diff --git a/ImguiMapEditor/Domain/ItemType.h b/ImguiMapEditor/Domain/ItemType.h index b9b4cfb..b9ebf4b 100644 --- a/ImguiMapEditor/Domain/ItemType.h +++ b/ImguiMapEditor/Domain/ItemType.h @@ -251,6 +251,9 @@ class ItemType { bool is_wall = false; bool is_locked = false; // Door key lock status (for highlight_locked_doors) + // Lying object (multi-tile corpses) — drawn before creatures + bool is_lying_object = false; + // Sprite IDs for rendering std::vector sprite_ids; diff --git a/ImguiMapEditor/Rendering/Animation/ItemAnimation.cpp b/ImguiMapEditor/Rendering/Animation/ItemAnimation.cpp index fbdcdd9..3bfa1dc 100644 --- a/ImguiMapEditor/Rendering/Animation/ItemAnimation.cpp +++ b/ImguiMapEditor/Rendering/Animation/ItemAnimation.cpp @@ -25,23 +25,94 @@ int ItemAnimation::getPhaseFromFrames(int frames, int64_t global_ms, return tick % frames; } +int ItemAnimation::getPingPongPhase(int raw_phase, int frames) { + if (frames <= 1) return 0; + int cycle = frames * 2 - 2; + int mod = raw_phase % cycle; + return mod >= frames ? cycle - mod : mod; +} + int ItemAnimation::getPhase(const Domain::ItemType &item, int64_t global_ms, int tile_x, int tile_y, int tile_z) { int frames = std::max(item.frames, 1); if (frames <= 1) return 0; - bool per_instance = !item.frame_durations.empty() && item.animation_mode == 0; - int tile_offset = per_instance ? (tile_x * 17 + tile_y * 31 + tile_z * 7) : 0; + bool is_async = !item.frame_durations.empty() && item.animation_mode == 0; + int tile_offset = is_async ? (tile_x * 17 + tile_y * 31 + tile_z * 7) : 0; + + int start_phase = 0; + if (item.start_frame == 255 && is_async) { + start_phase = (tile_x * 17 + tile_y * 31 + tile_z * 7) % frames; + } else if (item.start_frame > 0) { + start_phase = item.start_frame % frames; + } + + if (item.loop_count == -1) { + // Ping-pong: apply start_phase to raw phase BEFORE reflection + if (item.frame_durations.empty()) { + int tick = static_cast(global_ms / 500); + return getPingPongPhase(tick + tile_offset + start_phase, frames); + } + + // Build explicit ping-pong timeline from per-phase durations + const int num_phases = static_cast(item.frame_durations.size()); + if (num_phases == 0) return start_phase; + + // Per-phase durations (stack allocation — clamp to prevent overflow) + constexpr int MAX_PHASES = 32; + const int effective_phases = std::min(num_phases, MAX_PHASES); + int phase_durs[MAX_PHASES]; + int cycle_length = 0; + for (int i = 0; i < effective_phases; ++i) { + phase_durs[i] = getPhaseDuration(item, i); + cycle_length += phase_durs[i]; + } + + // Ping-pong timeline: [0, 1, ..., N-1, N-2, ..., 1] (omit duplicate endpoints) + constexpr int MAX_TIMELINE = MAX_PHASES * 2 - 2; + int timeline[MAX_TIMELINE]; + int timeline_len = 0; + for (int i = 0; i < effective_phases; ++i) + timeline[timeline_len++] = i; + for (int i = effective_phases - 2; i >= 1; --i) + timeline[timeline_len++] = i; + + // Backward portion cycle length + int backward_length = 0; + for (int i = effective_phases - 2; i >= 1; --i) + backward_length += phase_durs[i]; + cycle_length += backward_length; + if (cycle_length <= 0) return start_phase; + + // Start offset: sum durations of first start_phase phases + int start_offset = 0; + for (int i = 0; i < start_phase && i < effective_phases; ++i) + start_offset += phase_durs[i]; + + int elapsed = static_cast( + (global_ms + static_cast(tile_offset) * 500 + start_offset) % cycle_length); + + // Walk the timeline to find the active phase + for (int i = 0; i < timeline_len; ++i) { + int dur = phase_durs[timeline[i]]; + if (elapsed < dur) + return timeline[i] % frames; + elapsed -= dur; + } + return 0; + } + + // Forward loop if (item.frame_durations.empty()) { int tick = static_cast(global_ms / 500); - return (tick + tile_offset) % frames; + return (tick + tile_offset + start_phase) % frames; } int total = static_cast(item.total_duration); if (total <= 0) - return 0; + return start_phase; int64_t elapsed_ms = global_ms + static_cast(tile_offset) * 500; int elapsed = static_cast(elapsed_ms % total); @@ -49,18 +120,18 @@ int ItemAnimation::getPhase(const Domain::ItemType &item, int64_t global_ms, for (int phase = 0; phase < static_cast(item.frame_durations.size()); ++phase) { int dur = getPhaseDuration(item, phase); if (elapsed < dur) - return phase % frames; + return (phase + start_phase) % frames; elapsed -= dur; } - return 0; + return start_phase; } int ItemAnimation::getPhaseDuration(const Domain::ItemType &item, int phase) { if (phase < 0 || phase >= static_cast(item.frame_durations.size())) return 500; const auto &d = item.frame_durations[phase]; - return (d.first + d.second) / 2; + return static_cast((d.first + d.second) / 2); } } // namespace Rendering diff --git a/ImguiMapEditor/Rendering/Animation/ItemAnimation.h b/ImguiMapEditor/Rendering/Animation/ItemAnimation.h index bcdf276..c77cc54 100644 --- a/ImguiMapEditor/Rendering/Animation/ItemAnimation.h +++ b/ImguiMapEditor/Rendering/Animation/ItemAnimation.h @@ -16,6 +16,7 @@ struct ItemAnimation { private: static int getPhaseDuration(const Domain::ItemType &item, int phase); + static int getPingPongPhase(int raw_phase, int frames); }; } // namespace Rendering diff --git a/ImguiMapEditor/Rendering/Map/TileRenderer.cpp b/ImguiMapEditor/Rendering/Map/TileRenderer.cpp index 54a6b90..d86ce6d 100644 --- a/ImguiMapEditor/Rendering/Map/TileRenderer.cpp +++ b/ImguiMapEditor/Rendering/Map/TileRenderer.cpp @@ -189,15 +189,14 @@ void TileRenderer::queueTile(const Domain::Tile &tile, int tile_x, int tile_y, } } - // Queue all items - delegate to ItemRenderer - // Pass cached items with pre-resolved types + // Queue all items item_renderer_.queueAll(render_cache_, screen_x, screen_y, size, tile_x, tile_y, tile_z, anim_ticks, ground_color, item_alpha, isItemSelected, view_settings_, missing_sprites, &accumulated_elevation, tile_has_hook_south, tile_has_hook_east, check_tooltips, &tile_needs_tooltip); - // CREATURE RENDERING - Per-tile immediate (OTClient parity) + // Creature rendering (per-tile for correct isometric depth) // With diagonal tile iteration, creatures must be rendered PER-TILE to // maintain correct isometric depth. NW tiles (drawn first) should have // creatures that can be overlapped by SE tile items (drawn later). Deferring diff --git a/ImguiMapEditor/Rendering/Tile/CreatureRenderer.cpp b/ImguiMapEditor/Rendering/Tile/CreatureRenderer.cpp index f703143..9cdccb0 100644 --- a/ImguiMapEditor/Rendering/Tile/CreatureRenderer.cpp +++ b/ImguiMapEditor/Rendering/Tile/CreatureRenderer.cpp @@ -19,67 +19,36 @@ void CreatureRenderer::queue(const Domain::Creature *creature, float screen_x, const TileColor &ground_color, float alpha, uint8_t direction, int animation_frame, std::vector &missing_sprites) { - if (!creature || !client_data_) { + if (!creature || !client_data_) return; - } - // Look up creature type to get outfit const Domain::CreatureType *creature_type = client_data_->getCreatureType(creature->name); if (!creature_type || creature_type->outfit.lookType == 0) { - // No creature type found - render magenta placeholder - const AtlasRegion *white_region = - sprite_manager_.getAtlasManager().getWhitePixel(); - if (white_region) { - emitter_.emit(screen_x, screen_y, size, size, *white_region, - Config::Colors::INVALID_CREATURE_R, - Config::Colors::INVALID_CREATURE_G, - Config::Colors::INVALID_CREATURE_B, alpha * 0.7f); - } + emitPlaceholder(screen_x, screen_y, size, alpha); return; } const Domain::Outfit &outfit = creature_type->outfit; - - // Get outfit data from client const IO::ClientItem *outfit_data = client_data_->getOutfitData(outfit.lookType); if (!outfit_data || outfit_data->sprite_ids.empty()) { - // No outfit data - render magenta placeholder - const AtlasRegion *white_region = - sprite_manager_.getAtlasManager().getWhitePixel(); - if (white_region) { - emitter_.emit(screen_x, screen_y, size, size, *white_region, - Config::Colors::INVALID_CREATURE_R, - Config::Colors::INVALID_CREATURE_G, - Config::Colors::INVALID_CREATURE_B, alpha * 0.7f); - } + emitPlaceholder(screen_x, screen_y, size, alpha); return; } - // Calculate color modulation (lighting + selection) TileColor creature_color = ground_color; - - // Apply selection dimming if (creature->isSelected()) { creature_color.r *= 0.5f; creature_color.g *= 0.5f; creature_color.b *= 0.5f; } - const int width = std::max(1, outfit_data->width); - const int height = std::max(1, outfit_data->height); - const int layers = std::max(1, outfit_data->layers); const int pattern_x = std::max(1, outfit_data->pattern_x); + int dir = direction % pattern_x; - // Map direction to pattern_x - int dir = 2 % pattern_x; // Default to South - if (direction < pattern_x) { - dir = direction; - } - - // Choose sprite source based on frame groups and walking state - const std::vector* sprites = &outfit_data->sprite_ids; + // Select sprite source and animation frame + const std::vector *sprites = &outfit_data->sprite_ids; int frame = 0; if (outfit_data->has_frame_groups && animation_frame == 0) { if (!outfit_data->idle_sprite_ids.empty()) { @@ -96,92 +65,178 @@ void CreatureRenderer::queue(const Domain::Creature *creature, float screen_x, } } else if (animation_frame > 0 && !outfit_data->walk_sprite_ids.empty()) { sprites = &outfit_data->walk_sprite_ids; - int walk_frames = std::max(1, outfit_data->walk_frames); - frame = animation_frame % walk_frames; + frame = animation_frame % std::max(1, outfit_data->walk_frames); } else { int f = std::max(1, outfit_data->frames); frame = (f > 1) ? (animation_frame % f) : 0; } - // For each tile part of a multi-tile creature - // RME formula: draw at (screenx - cx * TileSize, screeny - cy * TileSize) - // cx=0,cy=0 is at anchor (screenx, screeny) - // cx increases leftward, cy increases upward - for (int cy = 0; cy < height; ++cy) { - for (int cx = 0; cx < width; ++cx) { - // Get the base sprite index - // RME uses: getHardwareID(cx, cy, dir, pattern_y, pattern_z, outfit, - // frame) Which maps to getSpriteIndex(w, h, layer, pattern_x, pattern_y, - // pattern_z, frame) - uint32_t base_sprite_idx = Utils::SpriteUtils::getSpriteIndex( - outfit_data, cx, cy, // width/height position (NOT reversed!) - 0, // layer 0 = base - dir, // pattern_x = direction - 0, // pattern_y = addon (0 = base outfit) - 0, // pattern_z = mount (0 = no mount) - frame); - - if (base_sprite_idx >= sprites->size()) { - continue; + // Draw mount before rider + int z_pattern = 0; + if (outfit.lookMount > 0 && outfit_data->pattern_z > 1) { + z_pattern = 1; + drawMount(outfit, dir, animation_frame, screen_x, screen_y, size, + creature_color, alpha, anim_ticks, missing_sprites); + } + + // Draw outfit + addons (yPattern iteration) + const int width = std::max(1, outfit_data->width); + const int height = std::max(1, outfit_data->height); + const int layers = std::max(1, outfit_data->layers); + const int pattern_y_count = std::max(1, outfit_data->pattern_y); + uint16_t addons = outfit.lookAddons; + + for (int yPattern = 0; yPattern < pattern_y_count; ++yPattern) { + if (yPattern > 0 && !(addons & (1 << (yPattern - 1)))) + continue; + + for (int cy = 0; cy < height; ++cy) { + for (int cx = 0; cx < width; ++cx) { + uint32_t base_idx = Utils::SpriteUtils::getSpriteIndex( + outfit_data, cx, cy, 0, dir, yPattern, z_pattern, frame); + if (base_idx >= sprites->size()) + continue; + + uint32_t base_id = (*sprites)[base_idx]; + if (base_id == 0) + continue; + + uint32_t template_id = 0; + if (layers >= 2) { + uint32_t t_idx = Utils::SpriteUtils::getSpriteIndex( + outfit_data, cx, cy, 1, dir, yPattern, z_pattern, frame); + if (t_idx < sprites->size()) + template_id = (*sprites)[t_idx]; + } + + const AtlasRegion *region = + sprite_manager_.getCreatureSpriteService().getColorizedOutfitRegion( + base_id, template_id, + static_cast(outfit.lookHead), + static_cast(outfit.lookBody), + static_cast(outfit.lookLegs), + static_cast(outfit.lookFeet)); + if (!region) { + missing_sprites.push_back(base_id); + if (template_id != 0) + missing_sprites.push_back(template_id); + continue; + } + + float scale = size / TILE_SIZE; + float dx = screen_x - static_cast(cx) * size - + (outfit_data->has_offset ? outfit_data->offset_x : 0) * scale; + float dy = screen_y - static_cast(cy) * size - + (outfit_data->has_offset ? outfit_data->offset_y : 0) * scale; + + emitter_.emit(dx, dy, size, size, *region, creature_color.r, + creature_color.g, creature_color.b, alpha); } + } + } +} + +void CreatureRenderer::drawMount(const Domain::Outfit &outfit, int dir, + int animation_frame, float screen_x, + float screen_y, float size, + const TileColor &color, float alpha, + const AnimationTicks &anim_ticks, + std::vector &missing_sprites) { + const IO::ClientItem *mount_data = + client_data_->getOutfitData(outfit.lookMount); + if (!mount_data || mount_data->sprite_ids.empty()) + return; + + const std::vector *mount_sprites = &mount_data->sprite_ids; + int mount_dir = dir % std::max(1, mount_data->pattern_x); + + // Select sprite source based on rider state (idle vs walk) + if (animation_frame > 0 && !mount_data->walk_sprite_ids.empty()) { + mount_sprites = &mount_data->walk_sprite_ids; + } else if (mount_data->has_frame_groups && !mount_data->idle_sprite_ids.empty()) { + mount_sprites = &mount_data->idle_sprite_ids; + } - uint32_t base_sprite_id = (*sprites)[base_sprite_idx]; - if (base_sprite_id == 0) { + int mount_frame = selectMountFrame(mount_data, animation_frame, + anim_ticks.global_ms); + + int mw = std::max(1, mount_data->width); + int mh = std::max(1, mount_data->height); + int ml = std::max(1, mount_data->layers); + float scale = size / TILE_SIZE; + + for (int cy = 0; cy < mh; ++cy) { + for (int cx = 0; cx < mw; ++cx) { + uint32_t base_idx = Utils::SpriteUtils::getSpriteIndex( + mount_data, cx, cy, 0, mount_dir, 0, 0, mount_frame); + if (base_idx >= mount_sprites->size()) + continue; + uint32_t base_id = (*mount_sprites)[base_idx]; + if (base_id == 0) continue; - } - // Get template sprite for colorization - uint32_t template_sprite_id = 0; - if (layers >= 2) { - uint32_t template_sprite_idx = Utils::SpriteUtils::getSpriteIndex( - outfit_data, cx, cy, 1, dir, 0, 0, frame); - if (template_sprite_idx < sprites->size()) { - template_sprite_id = (*sprites)[template_sprite_idx]; - } + uint32_t template_id = 0; + if (ml >= 2) { + uint32_t t_idx = Utils::SpriteUtils::getSpriteIndex( + mount_data, cx, cy, 1, mount_dir, 0, 0, mount_frame); + if (t_idx < mount_sprites->size()) + template_id = (*mount_sprites)[t_idx]; } - // Get the colorized outfit sprite region from the atlas const AtlasRegion *region = sprite_manager_.getCreatureSpriteService().getColorizedOutfitRegion( - base_sprite_id, template_sprite_id, - static_cast(outfit.lookHead), - static_cast(outfit.lookBody), - static_cast(outfit.lookLegs), - static_cast(outfit.lookFeet)); - + base_id, template_id, + static_cast(outfit.lookMountHead), + static_cast(outfit.lookMountBody), + static_cast(outfit.lookMountLegs), + static_cast(outfit.lookMountFeet)); if (!region) { - // Queue for async load - missing_sprites.push_back(base_sprite_id); - if (template_sprite_id != 0) { - missing_sprites.push_back(template_sprite_id); - } + missing_sprites.push_back(base_id); + if (template_id != 0) + missing_sprites.push_back(template_id); continue; } - // Calculate draw position - SIMPLE RME FORMULA + DISPLACEMENT - // screenx - cx * TileSize - offsetX, screeny - cy * TileSize - offsetY - // The displacement offset centers creatures on the tile (OTClient - // parity). This puts cx=0,cy=0 at anchor, and expands left/up for larger - // creatures - float displacement_x = outfit_data->has_offset - ? static_cast(outfit_data->offset_x) - : 0.0f; - float displacement_y = outfit_data->has_offset - ? static_cast(outfit_data->offset_y) - : 0.0f; - // Scale displacement by (size / TILE_SIZE) for zoom - float scale = size / TILE_SIZE; - float draw_x = - screen_x - static_cast(cx) * size - displacement_x * scale; - float draw_y = - screen_y - static_cast(cy) * size - displacement_y * scale; - - // Queue to sprite batch - emitter_.emit(draw_x, draw_y, size, size, *region, creature_color.r, - creature_color.g, creature_color.b, alpha); + float dx = screen_x - static_cast(cx) * size - + (mount_data->has_offset ? mount_data->offset_x : 0) * scale; + float dy = screen_y - static_cast(cy) * size - + (mount_data->has_offset ? mount_data->offset_y : 0) * scale; + + emitter_.emit(dx, dy, size, size, *region, color.r, color.g, color.b, alpha); } } } +int CreatureRenderer::selectMountFrame(const IO::ClientItem *mount_data, + int animation_frame, int64_t global_ms) { + if (animation_frame > 0 && !mount_data->walk_sprite_ids.empty()) { + return animation_frame % std::max(1, mount_data->walk_frames); + } + if (mount_data->has_frame_groups && !mount_data->idle_sprite_ids.empty()) { + return ItemAnimation::getPhaseFromFrames( + static_cast(mount_data->idle_frames), global_ms, + mount_data->has_animation_data ? &mount_data->frame_durations : nullptr, + mount_data->total_duration); + } + if (mount_data->frames > 1) { + return ItemAnimation::getPhaseFromFrames( + static_cast(mount_data->frames), global_ms, + mount_data->has_animation_data ? &mount_data->frame_durations : nullptr, + mount_data->total_duration); + } + return 0; +} + +void CreatureRenderer::emitPlaceholder(float screen_x, float screen_y, + float size, float alpha) { + const AtlasRegion *white = sprite_manager_.getAtlasManager().getWhitePixel(); + if (white) { + emitter_.emit(screen_x, screen_y, size, size, *white, + Config::Colors::INVALID_CREATURE_R, + Config::Colors::INVALID_CREATURE_G, + Config::Colors::INVALID_CREATURE_B, alpha * 0.7f); + } +} + } // namespace Rendering } // namespace MapEditor diff --git a/ImguiMapEditor/Rendering/Tile/CreatureRenderer.h b/ImguiMapEditor/Rendering/Tile/CreatureRenderer.h index a718712..bc82df2 100644 --- a/ImguiMapEditor/Rendering/Tile/CreatureRenderer.h +++ b/ImguiMapEditor/Rendering/Tile/CreatureRenderer.h @@ -61,6 +61,16 @@ class CreatureRenderer { std::vector& missing_sprites); private: + static int selectMountFrame(const IO::ClientItem *mount_data, + int animation_frame, int64_t global_ms); + + void drawMount(const Domain::Outfit &outfit, int dir, int animation_frame, + float screen_x, float screen_y, float size, + const TileColor &color, float alpha, + const AnimationTicks &anim_ticks, + std::vector &missing_sprites); + void emitPlaceholder(float screen_x, float screen_y, float size, float alpha); + SpriteEmitter& emitter_; Services::SpriteManager& sprite_manager_; const Services::ClientDataService* client_data_; diff --git a/ImguiMapEditor/Rendering/Tile/ItemRenderer.h b/ImguiMapEditor/Rendering/Tile/ItemRenderer.h index d5305e5..2cf036b 100644 --- a/ImguiMapEditor/Rendering/Tile/ItemRenderer.h +++ b/ImguiMapEditor/Rendering/Tile/ItemRenderer.h @@ -195,29 +195,23 @@ void ItemRenderer::queueAll( // ============================================================ // MULTI-PASS RENDERING (OTClient Painter Algorithm) - // ============================================================ - // Pass 1: OnBottom items (walls, pillars) - FORWARD order - // Pass 2: Common items (furniture, decorations) - REVERSE order - // NOTE: OnTop items (P3) are rendered immediately per-tile in - // TileRenderer::queueTile() AFTER creatures for correct depth. + // Pass 1: OnBottom (walls, pillars) — forward + // Pass 2: Common items — forward (bottom-to-top, painter's algorithm) + // OnTop items rendered after creatures in TileRenderer // ============================================================ - // PASS 1: OnBottom items (priority 2) - FORWARD + // PASS 1: OnBottom items — forward for (const auto &entry : items) { if (entry.type && entry.type->is_on_bottom) { renderItem(entry); } } - // PASS 2: Common items (priority 5) - FORWARD (like RME) - // Items at the end of the vector (topmost) must be drawn LAST to appear on top + // PASS 2: Common items — forward (painter's algorithm: draw bottom first) for (const auto &entry : items) { - if (entry.type && !entry.type->is_on_bottom && !entry.type->is_on_top) { - renderItem(entry); - } else if (!entry.type) { - // Items without type info are treated as common items - renderItem(entry); - } + if (entry.type && entry.type->is_on_bottom) continue; + if (entry.type && entry.type->is_on_top) continue; + renderItem(entry); } // NOTE: OnTop items rendered per-tile in TileRenderer::queueTile() diff --git a/ImguiMapEditor/Services/ClientDataService.cpp b/ImguiMapEditor/Services/ClientDataService.cpp index 92ac636..5ccfc72 100644 --- a/ImguiMapEditor/Services/ClientDataService.cpp +++ b/ImguiMapEditor/Services/ClientDataService.cpp @@ -401,6 +401,9 @@ void ClientDataService::mergeOtbWithDat( merged.total_duration += (d.first + d.second) / 2; } + // Lying object (multi-tile corpses) + merged.is_lying_object = dat->is_lying_object; + // Frame groups (10.57+ creatures) merged.idle_sprite_ids = dat->idle_sprite_ids; merged.walk_sprite_ids = dat->walk_sprite_ids; diff --git a/ImguiMapEditor/Services/ItemCompositor.cpp b/ImguiMapEditor/Services/ItemCompositor.cpp index f8173a5..4a0681f 100644 --- a/ImguiMapEditor/Services/ItemCompositor.cpp +++ b/ImguiMapEditor/Services/ItemCompositor.cpp @@ -19,22 +19,20 @@ ItemCompositor::getCompositedItemTexture(const Domain::ItemType *type) { const uint16_t client_id = type->client_id; - // Check cache first auto it = cache_.find(client_id); if (it != cache_.end()) { return it->second.get(); } - // For single-tile items (1x1), load directly and cache - if (type->width == 1 && type->height == 1) { + // Single-tile single-layer: load directly + if (type->width == 1 && type->height == 1 && type->layers <= 1 && + !type->sprite_ids.empty()) { uint32_t sprite_id = type->sprite_ids[0]; auto sprite_data = Utils::SpriteUtils::loadDecodedSprite(spr_reader_, sprite_id); if (sprite_data.empty()) { return nullptr; } - - // Create and cache texture auto texture = std::make_unique(32, 32, sprite_data.data()); Rendering::Texture *ptr = texture.get(); @@ -42,51 +40,40 @@ ItemCompositor::getCompositedItemTexture(const Domain::ItemType *type) { return ptr; } - // Multi-tile item: need to composite all parts - const uint8_t width = type->width; - const uint8_t height = type->height; - const int composite_size = std::max(width, height) * 32; + // Multi-tile or multi-layer: composite all parts + const int w = std::max(1, type->width); + const int h = std::max(1, type->height); + const int layers = std::max(1, type->layers); + const int composite_size = std::max(w, h) * 32; - // Create RGBA buffer for the composited image std::vector composite_rgba(composite_size * composite_size * 4, 0); - // Background color (same as RME's ICON_BACKGROUND setting - gray) - constexpr uint8_t BG_SHADE = 48; - for (size_t i = 0; i < composite_rgba.size(); i += 4) { - composite_rgba[i + 0] = BG_SHADE; - composite_rgba[i + 1] = BG_SHADE; - composite_rgba[i + 2] = BG_SHADE; - composite_rgba[i + 3] = 255; - } - - // Composite each sprite part - for (uint8_t h = 0; h < height; ++h) { - for (uint8_t w = 0; w < width; ++w) { - size_t sprite_index = static_cast(h) * width + w; - - if (sprite_index >= type->sprite_ids.size()) { - continue; + // Iterate layers, then tile grid (matches map renderer order) + for (int layer = 0; layer < layers; ++layer) { + for (int cy = 0; cy < h; ++cy) { + for (int cx = 0; cx < w; ++cx) { + size_t sprite_index = Utils::SpriteUtils::getSpriteIndex( + type, cx, cy, layer, 0, 0, 0, 0); + if (sprite_index >= type->sprite_ids.size()) + continue; + + uint32_t sprite_id = type->sprite_ids[sprite_index]; + auto sprite_data = + Utils::SpriteUtils::loadDecodedSprite(spr_reader_, sprite_id); + if (sprite_data.size() < 32 * 32 * 4) + continue; + + // Reversed position (matches OTClient/RME: anchor at bottom-right) + int dest_x = (w - cx - 1) * 32; + int dest_y = (h - cy - 1) * 32; + + Utils::ImageBlending::blendSpriteTile(sprite_data.data(), + composite_rgba.data(), + composite_size, dest_x, dest_y); } - - uint32_t sprite_id = type->sprite_ids[sprite_index]; - auto sprite_data = - Utils::SpriteUtils::loadDecodedSprite(spr_reader_, sprite_id); - if (sprite_data.size() < 32 * 32 * 4) { - continue; - } - - // Calculate destination position (same as RME: width-w-1, height-h-1) - int dest_x = (width - w - 1) * 32; - int dest_y = (height - h - 1) * 32; - - // Blend sprite tile onto composite canvas - Utils::ImageBlending::blendSpriteTile(sprite_data.data(), - composite_rgba.data(), - composite_size, dest_x, dest_y); } } - // Create texture at full composite resolution auto texture = std::make_unique( composite_size, composite_size, composite_rgba.data()); Rendering::Texture *ptr = texture.get(); diff --git a/ImguiMapEditor/UI/Utils/PreviewUtils.cpp b/ImguiMapEditor/UI/Utils/PreviewUtils.cpp index b02510e..7eca8e5 100644 --- a/ImguiMapEditor/UI/Utils/PreviewUtils.cpp +++ b/ImguiMapEditor/UI/Utils/PreviewUtils.cpp @@ -3,6 +3,7 @@ #include "Services/ClientDataService.h" #include "Rendering/Tile/CreatureSpriteHelper.h" #include "Rendering/Core/Texture.h" +#include namespace MapEditor::UI::Utils { @@ -41,8 +42,41 @@ CreaturePreviewResult GetCreaturePreview(Services::ClientDataService& clientData CreaturePreviewResult GetCreaturePreview(Services::ClientDataService& clientData, Services::SpriteManager& spriteManager, const Domain::Outfit& outfit) { - // Basic validity check for outfit could go here, but helper handles it. return GetCreaturePreviewImpl(clientData, spriteManager, outfit); } +bool RenderPreviewCard(Rendering::Texture* texture, float size, + bool is_selected, float rounding, float padding) { + ImVec2 min = ImGui::GetCursorScreenPos(); + ImGui::Dummy(ImVec2(size, size)); + bool is_hovered = ImGui::IsItemHovered(); + bool is_clicked = ImGui::IsItemClicked(); + + ImDrawList* dl = ImGui::GetWindowDrawList(); + ImVec2 max(min.x + size, min.y + size); + + ImU32 bg_col = ImGui::GetColorU32(ImGuiCol_FrameBg); + ImU32 border_col = ImGui::GetColorU32(ImGuiCol_Border); + + if (is_selected) { + bg_col = ImGui::GetColorU32(ImGuiCol_Header); + border_col = IM_COL32(255, 200, 0, 255); + } else if (is_hovered) { + bg_col = ImGui::GetColorU32(ImGuiCol_HeaderHovered); + } + + dl->AddRectFilled(min, max, bg_col, rounding); + dl->AddRect(min, max, border_col, rounding); + + if (texture) { + ImVec2 img_min(min.x + padding, min.y + padding); + ImVec2 img_max(max.x - padding, max.y - padding); + dl->AddImageRounded( + (void*)(intptr_t)texture->id(), img_min, img_max, + ImVec2(0, 0), ImVec2(1, 1), IM_COL32_WHITE, rounding); + } + + return is_clicked; +} + } // namespace MapEditor::UI::Utils diff --git a/ImguiMapEditor/UI/Utils/PreviewUtils.hpp b/ImguiMapEditor/UI/Utils/PreviewUtils.hpp index db682e4..3c29be3 100644 --- a/ImguiMapEditor/UI/Utils/PreviewUtils.hpp +++ b/ImguiMapEditor/UI/Utils/PreviewUtils.hpp @@ -16,6 +16,8 @@ namespace MapEditor::Rendering { class Texture; } // namespace MapEditor::Rendering +struct ImVec2; + namespace MapEditor::UI::Utils { // Retrieves a preview texture for an item. Returns nullptr if not available. @@ -40,4 +42,10 @@ CreaturePreviewResult GetCreaturePreview(Services::ClientDataService& clientData Services::SpriteManager& spriteManager, const Domain::Outfit& outfit); +// Renders a preview texture inside a rounded card with optional selection/hover state. +// Advances the cursor by `size` pixels. Returns true if clicked. +bool RenderPreviewCard(Rendering::Texture* texture, float size, + bool is_selected, float rounding = 4.0f, + float padding = 2.0f); + } // namespace MapEditor::UI::Utils diff --git a/ImguiMapEditor/UI/Widgets/Properties/PropertyPanelRenderer.cpp b/ImguiMapEditor/UI/Widgets/Properties/PropertyPanelRenderer.cpp index 9dcf7fd..51325fe 100644 --- a/ImguiMapEditor/UI/Widgets/Properties/PropertyPanelRenderer.cpp +++ b/ImguiMapEditor/UI/Widgets/Properties/PropertyPanelRenderer.cpp @@ -7,6 +7,7 @@ #include "Domain/ChunkedMap.h" #include "Services/SpriteManager.h" #include "Rendering/Core/Texture.h" +#include "UI/Utils/PreviewUtils.hpp" #include "../../ext/fontawesome6/IconsFontAwesome6.h" #include #include @@ -319,8 +320,9 @@ void PropertyPanelRenderer::renderContainerSection() { if (slot_item && sprite_manager_) { const Domain::ItemType* slot_type = slot_item->getType(); if (slot_type) { - if (auto* tex = sprite_manager_->getItemCompositor().getCompositedItemTexture(slot_type)) { - ImGui::Image((void*)(intptr_t)tex->id(), ImVec2(SLOT_SIZE, SLOT_SIZE)); + if (auto* tex = Utils::GetItemPreview(*sprite_manager_, slot_type)) { + ImGui::SetCursorScreenPos(pos); + Utils::RenderPreviewCard(tex, SLOT_SIZE, false); // Tooltip on hover if (ImGui::IsItemHovered()) { diff --git a/ImguiMapEditor/UI/Widgets/TilesetGridWidget.cpp b/ImguiMapEditor/UI/Widgets/TilesetGridWidget.cpp index ed8e971..0859ce2 100644 --- a/ImguiMapEditor/UI/Widgets/TilesetGridWidget.cpp +++ b/ImguiMapEditor/UI/Widgets/TilesetGridWidget.cpp @@ -59,7 +59,7 @@ void TilesetGridWidget::setTileset(const std::string &tilesetName) { } } -void *TilesetGridWidget::getBrushTextureId(const Brushes::IBrush *brush) const { +Rendering::Texture *TilesetGridWidget::getBrushTexture(const Brushes::IBrush *brush) const { if (!clientData_ || !spriteManager_ || !brush) { return nullptr; } @@ -69,24 +69,62 @@ void *TilesetGridWidget::getBrushTextureId(const Brushes::IBrush *brush) const { const auto *itemType = clientData_->getItemTypeByServerId( static_cast(rawBrush->getItemId())); if (auto *tex = Utils::GetItemPreview(*spriteManager_, itemType)) { - return reinterpret_cast(static_cast(tex->id())); + return tex; } } - // Try CreatureBrush - use existing utility + // Try CreatureBrush else if (auto *creatureBrush = dynamic_cast(brush)) { const auto &outfit = creatureBrush->getOutfit(); auto preview = Utils::GetCreaturePreview(*clientData_, *spriteManager_, outfit); if (preview.texture) { - return reinterpret_cast( - static_cast(preview.texture->id())); + return preview.texture; } } return nullptr; } +void TilesetGridWidget::renderBrushCard(ImVec2 cursorPos, ImVec2 size, + Rendering::Texture *tex, + bool isSelected, bool isHovered, + bool isPulsing, float pulseElapsed) { + ImDrawList *dl = ImGui::GetWindowDrawList(); + constexpr float ROUNDING = 4.0f; + constexpr float PADDING = 2.0f; + ImVec2 rectMax(cursorPos.x + size.x, cursorPos.y + size.y); + + ImU32 bgCol = ImGui::GetColorU32(ImGuiCol_FrameBg); + if (isSelected) { + bgCol = ImGui::GetColorU32(ImGuiCol_Header); + } else if (isHovered) { + bgCol = ImGui::GetColorU32(ImGuiCol_HeaderHovered); + } + dl->AddRectFilled(cursorPos, rectMax, bgCol, ROUNDING); + + if (tex) { + ImVec2 imgMin(cursorPos.x + PADDING, cursorPos.y + PADDING); + ImVec2 imgMax(rectMax.x - PADDING, rectMax.y - PADDING); + dl->AddImageRounded((void *)(intptr_t)tex->id(), imgMin, imgMax, + ImVec2(0, 0), ImVec2(1, 1), IM_COL32_WHITE, ROUNDING); + } + + if (isSelected) { + if (isPulsing && pulseElapsed < PULSE_DURATION) { + float pulse = 0.5f + 0.5f * std::sin(pulseElapsed * 8.0f); + ImU32 pulseCol = IM_COL32(static_cast(50 * (1 - pulse)), + static_cast(220 * pulse + 35), + static_cast(80 * pulse), 255); + float thickness = 2.0f + pulse * 2.0f; + dl->AddRect(cursorPos, rectMax, pulseCol, ROUNDING, 0, thickness); + } else { + dl->AddRect(cursorPos, rectMax, IM_COL32(100, 180, 255, 255), ROUNDING, + 0, 2.0f); + } + } +} + void TilesetGridWidget::render() { if (tilesetName_.empty()) { ImGui::TextDisabled(ICON_FA_BOX_OPEN " No tileset selected"); @@ -286,34 +324,16 @@ void TilesetGridWidget::renderBrushGrid() { ImGui::PushID(static_cast(i)); - // Render brush tile + Rendering::Texture *tex = getBrushTexture(brush); ImVec2 tileSize(getIconSize(), getIconSize()); ImVec2 cursorPos = ImGui::GetCursorScreenPos(); + ImGui::Dummy(tileSize); - void *textureId = getBrushTextureId(brush); - - ImGui::InvisibleButton("##tile", tileSize); - bool isHovered = ImGui::IsItemHovered(); - bool isClicked = ImGui::IsItemClicked(); - - ImDrawList *dl = ImGui::GetWindowDrawList(); - - // Background - ImU32 bgColor = - isHovered ? IM_COL32(80, 80, 80, 255) : IM_COL32(40, 40, 40, 255); - dl->AddRectFilled( - cursorPos, ImVec2(cursorPos.x + tileSize.x, cursorPos.y + tileSize.y), - bgColor); - - // Sprite - if (textureId) { - dl->AddImage( - textureId, cursorPos, - ImVec2(cursorPos.x + tileSize.x, cursorPos.y + tileSize.y)); - } + bool isCrossSelected = brush->getName() == selectedBrushName_; + renderBrushCard(cursorPos, tileSize, tex, isCrossSelected, + ImGui::IsItemHovered()); - // Tooltip - if (isHovered) { + if (ImGui::IsItemHovered()) { ImGui::BeginTooltip(); ImGui::Text("%s", brush->getName().c_str()); ImGui::TextDisabled("From: %s", bws.sourceTileset.c_str()); @@ -321,15 +341,13 @@ void TilesetGridWidget::renderBrushGrid() { ImGui::EndTooltip(); } - // Double-click handling if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) { if (onBrushDoubleClicked_) { onBrushDoubleClicked_(bws.sourceTileset, brush->getName()); } } - // Click handling - if (isClicked) { + if (ImGui::IsItemClicked()) { selectedBrushName_ = brush->getName(); if (brushController_) { brushController_->setBrush(const_cast(brush)); @@ -411,7 +429,7 @@ void TilesetGridWidget::renderBrushGrid() { ImVec2 tileSize(getIconSize(), getIconSize()); ImVec2 cursorPos = ImGui::GetCursorScreenPos(); - void *textureId = getBrushTextureId(brush); + Rendering::Texture *tex = getBrushTexture(brush); // Drag source ImGui::InvisibleButton("##tile", tileSize); @@ -440,7 +458,6 @@ void TilesetGridWidget::renderBrushGrid() { ImGui::AcceptDragDropPayload("TILESET_ENTRY")) { size_t sourceIdx = *static_cast(payload->Data); if (sourceIdx != fe.originalIndex && tilesetRegistry_) { - // Perform move if (auto *ts = tilesetRegistry_->getTileset(tilesetName_)) { ts->moveEntry(sourceIdx, fe.originalIndex); filterDirty_ = true; @@ -453,65 +470,23 @@ void TilesetGridWidget::renderBrushGrid() { ImGui::EndDragDropTarget(); } - ImDrawList *dl = ImGui::GetWindowDrawList(); - - // Background - ImU32 bgColor = IM_COL32(40, 40, 40, 255); - if (isSelected) { - bgColor = IM_COL32(60, 100, 160, 255); - } else if (isHovered) { - bgColor = IM_COL32(80, 80, 80, 255); - } - dl->AddRectFilled( - cursorPos, ImVec2(cursorPos.x + tileSize.x, cursorPos.y + tileSize.y), - bgColor); - - // Sprite - if (textureId) { - dl->AddImage( - textureId, cursorPos, - ImVec2(cursorPos.x + tileSize.x, cursorPos.y + tileSize.y)); - } - - // Selection border (with optional pulse animation) - if (isSelected) { - bool isPulsing = - !pulseBrushName_.empty() && brush->getName() == pulseBrushName_; - - if (isPulsing) { - float currentTime = ImGui::GetTime(); - if (pulseStartTime_ < 0) { - pulseStartTime_ = currentTime; - } - - float elapsed = currentTime - pulseStartTime_; - if (elapsed < PULSE_DURATION) { - // Pulsing green border - float pulse = 0.5f + 0.5f * std::sin(elapsed * 8.0f); - ImU32 pulseColor = IM_COL32(static_cast(50 * (1 - pulse)), - static_cast(220 * pulse + 35), - static_cast(80 * pulse), 255); - float thickness = 2.0f + pulse * 2.0f; - dl->AddRect( - cursorPos, - ImVec2(cursorPos.x + tileSize.x, cursorPos.y + tileSize.y), - pulseColor, 0, 0, thickness); - } else { - // Pulse ended - clear state and show normal border - pulseBrushName_.clear(); - pulseStartTime_ = -1.0f; - dl->AddRect( - cursorPos, - ImVec2(cursorPos.x + tileSize.x, cursorPos.y + tileSize.y), - IM_COL32(100, 180, 255, 255), 0, 0, 2.0f); - } - } else { - dl->AddRect( - cursorPos, - ImVec2(cursorPos.x + tileSize.x, cursorPos.y + tileSize.y), - IM_COL32(100, 180, 255, 255), 0, 0, 2.0f); + // Render card with pulse animation support + bool isPulsing = isSelected && !pulseBrushName_.empty() && + brush->getName() == pulseBrushName_; + float pulseElapsed = 0.0f; + if (isPulsing) { + float currentTime = ImGui::GetTime(); + if (pulseStartTime_ < 0) + pulseStartTime_ = currentTime; + pulseElapsed = currentTime - pulseStartTime_; + if (pulseElapsed >= PULSE_DURATION) { + pulseBrushName_.clear(); + pulseStartTime_ = -1.0f; + isPulsing = false; } } + renderBrushCard(cursorPos, tileSize, tex, isSelected, isHovered, + isPulsing, pulseElapsed); // Tooltip if (isHovered) { diff --git a/ImguiMapEditor/UI/Widgets/TilesetGridWidget.h b/ImguiMapEditor/UI/Widgets/TilesetGridWidget.h index d7016a3..3a7afa5 100644 --- a/ImguiMapEditor/UI/Widgets/TilesetGridWidget.h +++ b/ImguiMapEditor/UI/Widgets/TilesetGridWidget.h @@ -16,6 +16,10 @@ struct AppSettings; namespace MapEditor { +namespace Rendering { +class Texture; +} + namespace Services { class ClientDataService; class SpriteManager; @@ -173,10 +177,18 @@ class TilesetGridWidget { void applyFilter(); /** - * Get OpenGL texture ID for a brush (RawBrush or CreatureBrush). + * Render a rounded card with sprite and optional selection/pulse border. + * Used by both cross-tileset and single-tileset rendering paths. + */ + void renderBrushCard(ImVec2 cursorPos, ImVec2 size, Rendering::Texture *tex, + bool isSelected, bool isHovered, + bool isPulsing = false, float pulseElapsed = 0.0f); + + /** + * Get preview texture for a brush (RawBrush or CreatureBrush). * Uses PreviewUtils for consistent texture retrieval. */ - void *getBrushTextureId(const Brushes::IBrush *brush) const; + Rendering::Texture *getBrushTexture(const Brushes::IBrush *brush) const; // Services (non-owning) Services::ClientDataService *clientData_ = nullptr; diff --git a/ImguiMapEditor/UI/Widgets/TilesetWidget.cpp b/ImguiMapEditor/UI/Widgets/TilesetWidget.cpp index 4a3fb12..cb908fe 100644 --- a/ImguiMapEditor/UI/Widgets/TilesetWidget.cpp +++ b/ImguiMapEditor/UI/Widgets/TilesetWidget.cpp @@ -266,8 +266,7 @@ void TilesetWidget::renderItemGrid() { if (spriteManager_) { if (auto *texture = Utils::GetItemPreview(*spriteManager_, itemType)) { - clicked = Utils::RenderGridItem((void *)(intptr_t)texture->id(), - iconSize_, isSelected); + clicked = Utils::RenderPreviewCard(texture, iconSize_, isSelected); rendered = true; } } diff --git a/ImguiMapEditor/UI/Windows/BrowseTile/ItemsListRenderer.cpp b/ImguiMapEditor/UI/Windows/BrowseTile/ItemsListRenderer.cpp index 19d46f4..ca9a897 100644 --- a/ImguiMapEditor/UI/Windows/BrowseTile/ItemsListRenderer.cpp +++ b/ImguiMapEditor/UI/Windows/BrowseTile/ItemsListRenderer.cpp @@ -7,6 +7,7 @@ #include "Domain/Tile.h" #include "Rendering/Core/Texture.h" #include "Services/SpriteManager.h" +#include "UI/Utils/PreviewUtils.hpp" #include "../../ext/fontawesome6/IconsFontAwesome6.h" #include @@ -76,8 +77,9 @@ void ItemsListRenderer::renderItemRow(const Domain::Item *item, bool image_rendered = false; if (sprite_manager_ && type) { - if (auto *texture = sprite_manager_->getItemCompositor().getCompositedItemTexture(type)) { - ImGui::Image((void *)(intptr_t)texture->id(), ImVec2(32, 32)); + if (auto *texture = Utils::GetItemPreview(*sprite_manager_, type)) { + bool is_sel = (selected_index == display_index); + Utils::RenderPreviewCard(texture, 32.0f, is_sel); image_rendered = true; } } diff --git a/ImguiMapEditor/Utils/SpriteUtils.cpp b/ImguiMapEditor/Utils/SpriteUtils.cpp index 481e48f..8cb66b3 100644 --- a/ImguiMapEditor/Utils/SpriteUtils.cpp +++ b/ImguiMapEditor/Utils/SpriteUtils.cpp @@ -18,8 +18,32 @@ uint32_t SpriteUtils::getSpriteIndex(const IO::ClientItem *item, int w, int h, const int pattern_z_count = std::max(1, item->pattern_z); const int frame_count = std::max(1, item->frames); - // RME sprite index formula (no modulo on pattern inputs - they are already in - // range) + uint32_t index = 0; + index = (frame % frame_count); + index = index * pattern_z_count + pattern_z; + index = index * pattern_y_count + pattern_y; + index = index * pattern_x_count + pattern_x; + index = index * layers + layer; + index = index * height + h; + index = index * width + w; + + return index; +} + +uint32_t SpriteUtils::getSpriteIndex(const Domain::ItemType *item, int w, + int h, int layer, int pattern_x, + int pattern_y, int pattern_z, int frame) { + if (!item) + return 0; + + const int width = std::max(1, item->width); + const int height = std::max(1, item->height); + const int layers = std::max(1, item->layers); + const int pattern_x_count = std::max(1, item->pattern_x); + const int pattern_y_count = std::max(1, item->pattern_y); + const int pattern_z_count = std::max(1, item->pattern_z); + const int frame_count = std::max(1, item->frames); + uint32_t index = 0; index = (frame % frame_count); index = index * pattern_z_count + pattern_z; diff --git a/ImguiMapEditor/Utils/SpriteUtils.h b/ImguiMapEditor/Utils/SpriteUtils.h index 912d85d..4a64790 100644 --- a/ImguiMapEditor/Utils/SpriteUtils.h +++ b/ImguiMapEditor/Utils/SpriteUtils.h @@ -1,6 +1,7 @@ #pragma once #include "IO/Readers/DatReaderBase.h" #include "IO/SprReader.h" +#include "Domain/ItemType.h" #include #include #include @@ -15,29 +16,25 @@ namespace Utils { class SpriteUtils { public: /** - * Calculate sprite index for given pattern position. + * Calculate sprite index for given pattern position (ClientItem). * RME formula: * ((((((frame%frames)*pZ+pZ)*pY+pY)*pX+pX)*layers+layer)*height+h)*width+w - * @param item ClientItem with sprite dimensions - * @param w Width index (0 to width-1) - * @param h Height index (0 to height-1) - * @param layer Layer index (0 for base, 1 for template) - * @param pattern_x Pattern X index - * @param pattern_y Pattern Y index - * @param pattern_z Pattern Z index - * @param frame Animation frame - * @return Sprite index in sprite_ids array */ static uint32_t getSpriteIndex(const IO::ClientItem *item, int w, int h, int layer, int pattern_x, int pattern_y, int pattern_z, int frame); + /** + * Calculate sprite index for given pattern position (ItemType). + * Same formula as ClientItem overload. + */ + static uint32_t getSpriteIndex(const Domain::ItemType *item, int w, int h, + int layer, int pattern_x, int pattern_y, + int pattern_z, int frame); + /** * Load and decode a sprite from SprReader. * Returns decoded RGBA data, or empty vector on failure. - * @param spr_reader SprReader to load from - * @param sprite_id Sprite ID to load - * @return Decoded RGBA pixel data (32x32x4 bytes), empty on failure */ static std::vector loadDecodedSprite(const std::shared_ptr &spr_reader,