yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
tile_object_handler.cc
Go to the documentation of this file.
2#include <algorithm>
3#include <array>
4#include <cmath>
5#include <iterator>
6#include <unordered_set>
7#include "absl/strings/str_format.h"
13#include "imgui/imgui.h"
14#include "util/i18n/tr.h"
15#include "util/log.h"
21
23
24namespace yaze::editor {
25
26namespace {
27constexpr size_t kMaxLayerBatchMutation = 128;
28constexpr int kGhostPreviewBufferSize = 512;
29
31 const gfx::Bitmap* sources[] = {
32 &room.bg1_buffer().bitmap(), &room.object_bg1_buffer().bitmap(),
33 &room.bg2_buffer().bitmap(), &room.object_bg2_buffer().bitmap()};
34 for (const gfx::Bitmap* source : sources) {
35 SDL_Surface* surface = source->surface();
36 SDL_Palette* palette = platform::GetSurfacePalette(surface);
37 if (!palette || palette->ncolors <= 0) {
38 continue;
39 }
40
41 std::vector<SDL_Color> colors(256, {0, 0, 0, 0});
42 const int color_count = std::min(palette->ncolors, 256);
43 std::copy_n(palette->colors, color_count, colors.begin());
44 colors[255] = {0, 0, 0, 0};
45 ghost.SetPalette(colors);
46 if (ghost.surface()) {
47 SDL_SetColorKey(ghost.surface(), SDL_TRUE, 255);
48 SDL_SetSurfaceBlendMode(ghost.surface(), SDL_BLENDMODE_BLEND);
49 }
50 return true;
51 }
52 return false;
53}
54
57 bool selected = false;
58};
59
61 return std::clamp(static_cast<int>(object.GetLayerValue()), 0, 2);
62}
63
64std::unordered_set<size_t> MakeValidIndexSet(const std::vector<size_t>& indices,
65 size_t object_count) {
66 std::unordered_set<size_t> index_set;
67 for (size_t index : indices) {
68 if (index < object_count) {
69 index_set.insert(index);
70 }
71 }
72 return index_set;
73}
74
75std::array<std::vector<LayerOrderEntry>, 3> BuildLayerBuckets(
76 const std::vector<zelda3::RoomObject>& objects,
77 const std::unordered_set<size_t>& selected_indices) {
78 std::array<std::vector<LayerOrderEntry>, 3> buckets;
79 for (size_t i = 0; i < objects.size(); ++i) {
80 buckets[LayerBucketIndex(objects[i])].push_back(
81 LayerOrderEntry{objects[i], selected_indices.count(i) > 0});
82 }
83 return buckets;
84}
85
87 ObjectSelection* selection,
88 const std::vector<size_t>& selected_indices_after_reorder) {
89 if (!selection) {
90 return;
91 }
92 selection->ClearSelection();
93 for (size_t index : selected_indices_after_reorder) {
95 }
96}
97
98void FlattenLayerBuckets(std::vector<zelda3::RoomObject>& objects,
99 std::array<std::vector<LayerOrderEntry>, 3>& buckets,
100 ObjectSelection* selection) {
101 std::vector<zelda3::RoomObject> reordered;
102 std::vector<size_t> selected_indices_after_reorder;
103 reordered.reserve(objects.size());
104 for (auto& bucket : buckets) {
105 for (auto& entry : bucket) {
106 if (entry.selected) {
107 selected_indices_after_reorder.push_back(reordered.size());
108 }
109 reordered.push_back(std::move(entry.object));
110 }
111 }
112 objects = std::move(reordered);
113 RestoreObjectSelection(selection, selected_indices_after_reorder);
114}
115
116struct GuideRect {
117 int left = 0;
118 int top = 0;
119 int right = 0;
120 int bottom = 0;
121 int center_x = 0;
122 int center_y = 0;
123};
124
126 auto [x, y, width, height] =
128 return GuideRect{x, y, x + width, y + height, x + width / 2, y + height / 2};
129}
130
131bool AddUniqueGuide(std::vector<int>& guides, int value) {
132 constexpr int kDuplicateTolerancePx = 2;
133 if (std::any_of(guides.begin(), guides.end(), [&](int guide) {
134 return std::abs(guide - value) <= kDuplicateTolerancePx;
135 })) {
136 return false;
137 }
138 guides.push_back(value);
139 return true;
140}
141
142void DrawDashedLine(ImDrawList* draw_list, ImVec2 start, ImVec2 end,
143 ImU32 color, float thickness) {
144 constexpr float kDash = 6.0f;
145 constexpr float kGap = 4.0f;
146 const bool vertical = std::abs(start.x - end.x) < 0.5f;
147 const float total =
148 vertical ? std::abs(end.y - start.y) : std::abs(end.x - start.x);
149 for (float offset = 0.0f; offset < total; offset += kDash + kGap) {
150 const float segment_end = std::min(offset + kDash, total);
151 if (vertical) {
152 const float y0 = start.y + offset;
153 const float y1 = start.y + segment_end;
154 draw_list->AddLine(ImVec2(start.x, y0), ImVec2(start.x, y1), color,
155 thickness);
156 } else {
157 const float x0 = start.x + offset;
158 const float x1 = start.x + segment_end;
159 draw_list->AddLine(ImVec2(x0, start.y), ImVec2(x1, start.y), color,
160 thickness);
161 }
162 }
163}
164
165} // namespace
166
169 if (!ctx_)
170 return GhostCapacityState::kNormal;
171 auto* room =
172 const_cast<TileObjectHandler*>(this)->GetRoom(ctx_->current_room_id);
173 const size_t current_obj_count = room ? room->GetTileObjects().size() : 0;
174 return GetPlacementCapacityState(current_obj_count, zelda3::kMaxTileObjects);
175}
176
178 if (!ctx_ || !ctx_->rooms)
179 return nullptr;
180 if (room_id < 0 || room_id >= static_cast<int>(ctx_->rooms->size())) {
181 return nullptr;
182 }
183 return &(*ctx_->rooms)[room_id];
184}
185
192
193// ========================================================================
194// BaseEntityHandler implementation
195// ========================================================================
196
201
205
206bool TileObjectHandler::HandleClick(int canvas_x, int canvas_y) {
207 if (!HasValidContext())
208 return false;
209
211 auto [room_x, room_y] = CanvasToRoom(canvas_x, canvas_y);
212 if (IsWithinBounds(canvas_x, canvas_y)) {
214 }
215 return true; // Placement click is handled even if outside bounds.
216 }
217
218 // Handle selection (click)
219 if (!ctx_ || !ctx_->selection)
220 return false;
221
222 auto hovered = GetEntityAtPosition(canvas_x, canvas_y);
223 if (!hovered.has_value())
224 return false;
225
226 const ImGuiIO& io = ImGui::GetIO();
227
228 // Alt-click is reserved for caller-specific behaviors (inspector/etc). Treat
229 // it as a handled click over an object without changing selection.
230 if (io.KeyAlt)
231 return true;
232
234 if (io.KeyShift) {
236 } else if (io.KeyCtrl || io.KeySuper) {
238 }
239
240 ctx_->selection->SelectObject(*hovered, mode);
241 return true;
242}
243
244void TileObjectHandler::InitDrag(const ImVec2& start_pos) {
245 is_dragging_ = true;
248 drag_last_dx_ = 0;
249 drag_last_dy_ = 0;
250 drag_has_duplicated_ = false;
252}
253
254void TileObjectHandler::BeginMarqueeSelection(const ImVec2& start_pos) {
255 if (!ctx_ || !ctx_->selection)
256 return;
257 ctx_->selection->BeginRectangleSelection(static_cast<int>(start_pos.x),
258 static_cast<int>(start_pos.y));
259}
260
262 const ImVec2& mouse_pos, bool mouse_left_down, bool mouse_left_released,
263 bool shift_down, bool toggle_down, bool alt_down, bool draw_box) {
264 (void)shift_down;
265 (void)toggle_down;
266 if (!ctx_ || !ctx_->selection)
267 return;
269 return;
270
271 // Update + draw while the drag is in progress.
272 if (mouse_left_down) {
273 ctx_->selection->UpdateRectangleSelection(static_cast<int>(mouse_pos.x),
274 static_cast<int>(mouse_pos.y));
275 if (draw_box) {
276 if (ctx_->canvas) {
278 }
279 }
280 }
281
282 // Finalize selection on release.
283 if (mouse_left_released) {
284 ctx_->selection->UpdateRectangleSelection(static_cast<int>(mouse_pos.x),
285 static_cast<int>(mouse_pos.y));
286
287 auto* room = GetRoom(ctx_->current_room_id);
288 if (!room) {
290 return;
291 }
292
293 constexpr int kMinRectPixels = 6;
294 if (alt_down || !ctx_->selection->IsRectangleLargeEnough(kMinRectPixels)) {
296 return;
297 }
298
300 room->GetTileObjects(), ObjectSelection::SelectionMode::Single);
301 }
302}
303
304void TileObjectHandler::HandleDrag(ImVec2 current_pos, ImVec2 delta) {
305 (void)delta;
306 if (!is_dragging_ || !ctx_ || !ctx_->selection)
307 return;
308
310
311 // Calculate total drag delta from start (snapped)
312 ImVec2 drag_delta =
314 drag_delta = ApplyDragModifiers(drag_delta);
315
316 const int tile_dx = static_cast<int>(drag_delta.x) / 8;
317 const int tile_dy = static_cast<int>(drag_delta.y) / 8;
318
319 // Calculate incremental move
320 const int inc_dx = tile_dx - drag_last_dx_;
321 const int inc_dy = tile_dy - drag_last_dy_;
322
323 if (inc_dx != 0 || inc_dy != 0) {
327 }
328
330 inc_dx, inc_dy,
331 /*notify_mutation=*/false);
332
333 drag_last_dx_ = tile_dx;
334 drag_last_dy_ = tile_dy;
335 }
336}
337
339 if (is_dragging_) {
340 const bool had_mutation = drag_mutation_started_;
341 is_dragging_ = false;
343 drag_has_duplicated_ = false;
344 // Drag operations mutate incrementally while the mouse is held down. The
345 // editor's undo capture wants to finalize after the drag ends (once the
346 // interaction mode has returned to Select), so emit one more invalidation
347 // on release if anything changed.
348 if (had_mutation && ctx_) {
350 }
351 }
352}
353
354ImVec2 TileObjectHandler::ApplyDragModifiers(const ImVec2& delta) const {
355 return delta;
356}
357
359 if (!HasValidContext() || !ctx_ || !ctx_->selection || delta == 0.0f)
360 return false;
361
362 auto indices = ctx_->selection->GetSelectedIndices();
363 if (indices.empty())
364 return false;
365
366 int resize_delta = (delta > 0.0f) ? 1 : -1;
367 ResizeObjects(ctx_->current_room_id, indices, resize_delta);
368 return true;
369}
370
373 return;
374
375 const auto pointer_screen_pos = GetPointerScreenPosition();
376 if (!pointer_screen_pos.has_value()) {
377 return;
378 }
379
380 const DungeonCanvasTransform transform = GetCanvasTransform();
381 const auto [canvas_x, canvas_y] =
382 transform.ScreenToRoomPixelCoordinates(*pointer_screen_pos);
383 auto [room_x, room_y] = CanvasToRoom(canvas_x, canvas_y);
384
385 if (!IsWithinBounds(canvas_x, canvas_y))
386 return;
387
388 auto [snap_canvas_x, snap_canvas_y] = RoomToCanvas(room_x, room_y);
389 const auto preview_geometry = CalculateGhostPreviewGeometry(preview_object_);
390
391 ImDrawList* draw_list = ImGui::GetWindowDrawList();
392 const ImVec2 preview_start = transform.RoomPixelsToScreen(ImVec2(
393 static_cast<float>(snap_canvas_x + preview_geometry.offset_x_tiles * 8),
394 static_cast<float>(snap_canvas_y + preview_geometry.offset_y_tiles * 8)));
395 const ImVec2 preview_size = transform.RoomSizeToScreen(
396 ImVec2(static_cast<float>(preview_geometry.width_pixels),
397 static_cast<float>(preview_geometry.height_pixels)));
398 const ImVec2 preview_end(preview_start.x + preview_size.x,
399 preview_start.y + preview_size.y);
400 const ImVec2 bitmap_start = transform.RoomPixelsToScreen(
401 ImVec2(static_cast<float>(snap_canvas_x -
402 preview_geometry.render_anchor_x_tiles * 8),
403 static_cast<float>(snap_canvas_y -
404 preview_geometry.render_anchor_y_tiles * 8)));
405
407 const size_t current_obj_count = room ? room->GetTileObjects().size() : 0;
408 const auto capacity_state = GetPlacementGhostCapacityState();
409
410 const auto& theme = AgentUI::GetTheme();
411 const ImVec4 outline_color = GetPlacementAccentColor(
412 theme, capacity_state, theme.dungeon_selection_primary);
413 bool drew_bitmap = false;
414
416 auto& bitmap = ghost_preview_buffer_->bitmap();
417 if (bitmap.texture()) {
418 const int crop_width =
419 std::min(preview_geometry.buffer_width_pixels, bitmap.width());
420 const int crop_height =
421 std::min(preview_geometry.buffer_height_pixels, bitmap.height());
422 const ImVec2 bitmap_size = transform.RoomSizeToScreen(ImVec2(
423 static_cast<float>(crop_width), static_cast<float>(crop_height)));
424 const ImVec2 bitmap_end(bitmap_start.x + bitmap_size.x,
425 bitmap_start.y + bitmap_size.y);
426 const ImVec2 uv_end(static_cast<float>(crop_width) / bitmap.width(),
427 static_cast<float>(crop_height) / bitmap.height());
428 ImVec4 tint = capacity_state == GhostCapacityState::kNormal
429 ? theme.text_primary
430 : GetPlacementAccentColor(theme, capacity_state,
431 theme.text_primary);
432 tint.w = 0.70f;
433 draw_list->AddImage((ImTextureID)(intptr_t)bitmap.texture(), bitmap_start,
434 bitmap_end, ImVec2(0, 0), uv_end,
435 ImGui::GetColorU32(tint));
436 draw_list->AddRect(preview_start, preview_end,
437 ImGui::GetColorU32(outline_color), 0.0f, 0, 2.0f);
438 drew_bitmap = true;
439 }
440 }
441
442 if (!drew_bitmap) {
443 draw_list->AddRectFilled(
444 preview_start, preview_end,
445 ImGui::GetColorU32(
446 ImVec4(outline_color.x, outline_color.y, outline_color.z, 0.25f)));
447 draw_list->AddRect(preview_start, preview_end,
448 ImGui::GetColorU32(outline_color), 0.0f, 0, 2.0f);
449 }
450
451 // ID label
452 std::string id_text = absl::StrFormat("0x%02X", preview_object_.id_);
453 draw_list->AddText(ImVec2(preview_start.x + 2, preview_start.y + 1),
454 ImGui::GetColorU32(theme.text_primary), id_text.c_str());
455
456 // Capacity tooltip while hovering — proactive warning before user clicks.
457 if (capacity_state != GhostCapacityState::kNormal &&
458 ImGui::IsMouseHoveringRect(preview_start, preview_end)) {
459 ImGui::SetTooltip(tr("Objects: %zu/%zu\n%s"), current_obj_count,
461 GetPlacementCapacityTooltipSuffix(capacity_state).data());
462 }
463
464 const std::string badge_text = absl::StrFormat(
465 "Objects %zu/%zu", current_obj_count, zelda3::kMaxTileObjects);
467 ImVec2(preview_start.x, preview_end.y + 6.0f),
468 theme, capacity_state, badge_text);
469}
470
472 if (!HasValidContext() || !ctx_->selection)
473 return;
474
475 auto* room = GetCurrentRoom();
476 if (!room)
477 return;
478
479 // Use ObjectSelection's rendering (handles pulsing border, corner handles)
481 ctx_->canvas, room->GetTileObjects(), [](const zelda3::RoomObject& obj) {
482 auto result = zelda3::DimensionService::Get().GetDimensions(obj);
483 return std::make_tuple(result.offset_x_tiles * 8,
484 result.offset_y_tiles * 8, result.width_pixels(),
485 result.height_pixels());
486 });
487
488 if (is_dragging_) {
489 DrawSmartGuides(room->GetTileObjects());
490 }
491}
492
494 int canvas_x, int canvas_y) const {
495 if (!HasValidContext() || !IsWithinBounds(canvas_x, canvas_y)) {
496 return std::nullopt;
497 }
498 auto* room =
499 const_cast<TileObjectHandler*>(this)->GetRoom(ctx_->current_room_id);
500 if (!room)
501 return std::nullopt;
502
503 const auto& objects = room->GetTileObjects();
504 for (size_t i = objects.size(); i > 0; --i) {
505 size_t index = i - 1;
506 const auto& object = objects[index];
507
508 // Respect layer filter if available in context
509 if (ctx_ && ctx_->selection &&
511 continue;
512 }
513
514 auto [obj_tile_x, obj_tile_y, width_tiles, height_tiles] =
516
517 int obj_px = obj_tile_x * 8;
518 int obj_py = obj_tile_y * 8;
519 int w_px = width_tiles * 8;
520 int h_px = height_tiles * 8;
521
522 if (canvas_x >= obj_px && canvas_x < obj_px + w_px && canvas_y >= obj_py &&
523 canvas_y < obj_py + h_px) {
524 return index;
525 }
526 }
527 return std::nullopt;
528}
529
531 const std::vector<zelda3::RoomObject>& objects) const {
532 if (!ctx_ || !ctx_->canvas || !ctx_->selection ||
534 return;
535 }
536
537 const auto selected = ctx_->selection->GetSelectedIndices();
538 if (selected.empty()) {
539 return;
540 }
541
542 constexpr int kAlignmentTolerancePx = 2;
543 constexpr size_t kMaxGuidesPerAxis = 8;
544 std::vector<int> vertical_guides;
545 std::vector<int> horizontal_guides;
546
547 for (size_t selected_index : selected) {
548 if (selected_index >= objects.size()) {
549 continue;
550 }
551 const GuideRect selected_rect = GetGuideRect(objects[selected_index]);
552 const std::array<int, 3> selected_x = {
553 selected_rect.left, selected_rect.center_x, selected_rect.right};
554 const std::array<int, 3> selected_y = {
555 selected_rect.top, selected_rect.center_y, selected_rect.bottom};
556
557 for (size_t other_index = 0; other_index < objects.size(); ++other_index) {
558 if (ctx_->selection->IsObjectSelected(other_index)) {
559 continue;
560 }
561 const GuideRect other_rect = GetGuideRect(objects[other_index]);
562 const std::array<int, 3> other_x = {other_rect.left, other_rect.center_x,
563 other_rect.right};
564 const std::array<int, 3> other_y = {other_rect.top, other_rect.center_y,
565 other_rect.bottom};
566
567 for (int sx : selected_x) {
568 for (int ox : other_x) {
569 if (std::abs(sx - ox) <= kAlignmentTolerancePx) {
570 AddUniqueGuide(vertical_guides, ox);
571 }
572 }
573 }
574 for (int sy : selected_y) {
575 for (int oy : other_y) {
576 if (std::abs(sy - oy) <= kAlignmentTolerancePx) {
577 AddUniqueGuide(horizontal_guides, oy);
578 }
579 }
580 }
581 if (vertical_guides.size() >= kMaxGuidesPerAxis &&
582 horizontal_guides.size() >= kMaxGuidesPerAxis) {
583 break;
584 }
585 }
586 }
587
588 if (vertical_guides.empty() && horizontal_guides.empty()) {
589 return;
590 }
591
592 const auto& theme = AgentUI::GetTheme();
593 ImVec4 guide_color = theme.accent_color;
594 guide_color.w = 0.78f;
595 const ImU32 color = ImGui::GetColorU32(guide_color);
596 ImDrawList* draw_list = ImGui::GetWindowDrawList();
597 const DungeonCanvasTransform transform = GetCanvasTransform();
598 const ImVec2 canvas_pos = transform.room_origin_screen();
599 const ImVec2 room_size = transform.RoomSizeToScreen(ImVec2(
601
602 const size_t vertical_count =
603 std::min(vertical_guides.size(), kMaxGuidesPerAxis);
604 for (size_t i = 0; i < vertical_count; ++i) {
605 const float x =
606 transform.RoomPixelsToScreen(ImVec2(vertical_guides[i], 0.0f)).x;
607 DrawDashedLine(draw_list, ImVec2(x, canvas_pos.y),
608 ImVec2(x, canvas_pos.y + room_size.y), color, 1.2f);
609 }
610
611 const size_t horizontal_count =
612 std::min(horizontal_guides.size(), kMaxGuidesPerAxis);
613 for (size_t i = 0; i < horizontal_count; ++i) {
614 const float y =
615 transform.RoomPixelsToScreen(ImVec2(0.0f, horizontal_guides[i])).y;
616 DrawDashedLine(draw_list, ImVec2(canvas_pos.x, y),
617 ImVec2(canvas_pos.x + room_size.x, y), color, 1.2f);
618 }
619}
620
621// ========================================================================
622// Mutation Logic
623// ========================================================================
624
626 const std::vector<size_t>& indices,
627 int delta_x, int delta_y,
628 bool notify_mutation) {
629 auto* room = GetRoom(room_id);
630 if (!room || indices.empty())
631 return;
632 if (notify_mutation && ctx_)
634
635 auto& objects = room->GetTileObjects();
636 for (size_t index : indices) {
637 if (index < objects.size()) {
638 objects[index].x_ =
639 std::clamp(static_cast<int>(objects[index].x_ + delta_x), 0, 63);
640 objects[index].y_ =
641 std::clamp(static_cast<int>(objects[index].y_ + delta_y), 0, 63);
642 }
643 }
644
645 NotifyChange(room);
646}
647
649 const std::vector<size_t>& indices,
650 int16_t new_id) {
651 auto* room = GetRoom(room_id);
652 if (!room || indices.empty())
653 return;
654 if (ctx_)
656
657 auto& objects = room->GetTileObjects();
658 for (size_t index : indices) {
659 if (index < objects.size()) {
660 // Use the setter so derived flags + tile caches stay coherent.
661 objects[index].set_id(new_id);
662 }
663 }
664 NotifyChange(room);
665}
666
668 const std::vector<size_t>& indices,
669 uint8_t new_size) {
670 auto* room = GetRoom(room_id);
671 if (!room || indices.empty())
672 return;
673 if (ctx_)
675
676 auto& objects = room->GetTileObjects();
677 for (size_t index : indices) {
678 if (index < objects.size()) {
679 objects[index].size_ = new_size;
680 objects[index].tiles_loaded_ = false;
681 }
682 }
683 NotifyChange(room);
684}
685
687 const std::vector<size_t>& indices,
688 int new_layer) {
689 auto* room = GetRoom(room_id);
690 if (!room || indices.empty())
691 return false;
692 if (new_layer < 0 || new_layer > 2) {
693 LOG_WARN("TileObjectHandler",
694 "Rejected layer update with invalid target layer: %d", new_layer);
695 return false;
696 }
697 auto& objects = room->GetTileObjects();
698 std::vector<size_t> deduped_indices;
699 deduped_indices.reserve(indices.size());
700 std::unordered_set<size_t> seen_indices;
701 for (size_t index : indices) {
702 if (index >= objects.size()) {
703 continue;
704 }
705 if (seen_indices.insert(index).second) {
706 deduped_indices.push_back(index);
707 }
708 }
709 if (deduped_indices.empty()) {
710 return false;
711 }
712
713 if (deduped_indices.size() > kMaxLayerBatchMutation) {
714 LOG_WARN("TileObjectHandler",
715 "Rejected layer batch mutation of %zu objects (max %zu)",
716 deduped_indices.size(), kMaxLayerBatchMutation);
717 return false;
718 }
719
720 auto candidate_objects = objects;
721 auto mutation = zelda3::ReassignObjectStorage(candidate_objects,
722 deduped_indices, new_layer);
723 if (!mutation.ok()) {
724 LOG_WARN("TileObjectHandler", "Rejected object stream mutation: %s",
725 std::string(mutation.status().message()).c_str());
726 return false;
727 }
728 if (!mutation->changed) {
729 return true;
730 }
731
732 if (ctx_)
734 objects = std::move(candidate_objects);
735 RestoreObjectSelection(ctx_ ? ctx_->selection : nullptr,
736 mutation->selected_indices);
737 NotifyChange(room);
738 return true;
739}
740
742 int room_id, const std::vector<size_t>& indices, int delta_x, int delta_y,
743 bool notify_mutation) {
744 auto* room = GetRoom(room_id);
745 if (!room || indices.empty())
746 return {};
747 if (notify_mutation && ctx_)
749
750 auto& objects = room->GetTileObjects();
751 std::vector<size_t> new_indices;
752
753 const size_t base_index = objects.size();
754 for (size_t index : indices) {
755 if (index < objects.size()) {
756 auto clone = objects[index].CopyForNewPlacement();
757 clone.x_ = std::clamp(static_cast<int>(clone.x_ + delta_x), 0, 63);
758 clone.y_ = std::clamp(static_cast<int>(clone.y_ + delta_y), 0, 63);
759 objects.push_back(clone);
760 new_indices.push_back(base_index + (new_indices.size()));
761 }
762 }
763
764 NotifyChange(room);
765 return new_indices;
766}
767
769 std::vector<size_t> indices) {
770 auto* room = GetRoom(room_id);
771 if (!room || indices.empty())
772 return;
773 if (ctx_)
775
776 std::sort(indices.rbegin(), indices.rend());
777 for (size_t index : indices) {
778 room->RemoveTileObject(index);
779 }
780
781 NotifyChange(room);
782}
783
785 auto* room = GetRoom(room_id);
786 if (!room)
787 return;
788 if (ctx_)
790 room->ClearTileObjects();
791 NotifyChange(room);
792}
793
795 const std::vector<size_t>& indices) {
796 auto* room = GetRoom(room_id);
797 if (!room || indices.empty())
798 return;
799 auto& objects = room->GetTileObjects();
800 auto selected_set = MakeValidIndexSet(indices, objects.size());
801 if (selected_set.empty()) {
802 return;
803 }
804 if (ctx_)
806 auto buckets = BuildLayerBuckets(objects, selected_set);
807 for (auto& bucket : buckets) {
808 std::vector<LayerOrderEntry> other;
809 std::vector<LayerOrderEntry> selected;
810 other.reserve(bucket.size());
811 selected.reserve(bucket.size());
812 for (auto& entry : bucket) {
813 if (entry.selected) {
814 selected.push_back(std::move(entry));
815 } else {
816 other.push_back(std::move(entry));
817 }
818 }
819 bucket = std::move(other);
820 bucket.insert(bucket.end(), std::make_move_iterator(selected.begin()),
821 std::make_move_iterator(selected.end()));
822 }
823 FlattenLayerBuckets(objects, buckets, ctx_ ? ctx_->selection : nullptr);
824 NotifyChange(room);
825}
826
828 const std::vector<size_t>& indices) {
829 auto* room = GetRoom(room_id);
830 if (!room || indices.empty())
831 return;
832 auto& objects = room->GetTileObjects();
833 auto selected_set = MakeValidIndexSet(indices, objects.size());
834 if (selected_set.empty()) {
835 return;
836 }
837 if (ctx_)
839 auto buckets = BuildLayerBuckets(objects, selected_set);
840 for (auto& bucket : buckets) {
841 std::vector<LayerOrderEntry> selected;
842 std::vector<LayerOrderEntry> other;
843 selected.reserve(bucket.size());
844 other.reserve(bucket.size());
845 for (auto& entry : bucket) {
846 if (entry.selected) {
847 selected.push_back(std::move(entry));
848 } else {
849 other.push_back(std::move(entry));
850 }
851 }
852 bucket = std::move(selected);
853 bucket.insert(bucket.end(), std::make_move_iterator(other.begin()),
854 std::make_move_iterator(other.end()));
855 }
856 FlattenLayerBuckets(objects, buckets, ctx_ ? ctx_->selection : nullptr);
857 NotifyChange(room);
858}
859
861 const std::vector<size_t>& indices) {
862 auto* room = GetRoom(room_id);
863 if (!room || indices.empty())
864 return;
865 auto& objects = room->GetTileObjects();
866 auto selected_set = MakeValidIndexSet(indices, objects.size());
867 if (selected_set.empty()) {
868 return;
869 }
870 if (ctx_)
872 auto buckets = BuildLayerBuckets(objects, selected_set);
873 for (auto& bucket : buckets) {
874 if (bucket.size() < 2) {
875 continue;
876 }
877 for (size_t i = bucket.size() - 1; i > 0; --i) {
878 const size_t previous = i - 1;
879 if (bucket[previous].selected && !bucket[i].selected) {
880 std::swap(bucket[previous], bucket[i]);
881 }
882 }
883 }
884 FlattenLayerBuckets(objects, buckets, ctx_ ? ctx_->selection : nullptr);
885 NotifyChange(room);
886}
887
889 const std::vector<size_t>& indices) {
890 auto* room = GetRoom(room_id);
891 if (!room || indices.empty())
892 return;
893 auto& objects = room->GetTileObjects();
894 auto selected_set = MakeValidIndexSet(indices, objects.size());
895 if (selected_set.empty()) {
896 return;
897 }
898 if (ctx_)
900 auto buckets = BuildLayerBuckets(objects, selected_set);
901 for (auto& bucket : buckets) {
902 if (bucket.size() < 2) {
903 continue;
904 }
905 for (size_t i = 1; i < bucket.size(); ++i) {
906 const size_t previous = i - 1;
907 if (bucket[i].selected && !bucket[previous].selected) {
908 std::swap(bucket[i], bucket[previous]);
909 }
910 }
911 }
912 FlattenLayerBuckets(objects, buckets, ctx_ ? ctx_->selection : nullptr);
913 NotifyChange(room);
914}
915
917 const std::vector<size_t>& indices,
918 int delta) {
919 auto* room = GetRoom(room_id);
920 if (!room || indices.empty())
921 return;
922 if (ctx_)
924 auto& objects = room->GetTileObjects();
925 for (size_t index : indices) {
926 if (index < objects.size()) {
927 int new_size =
928 std::clamp(static_cast<int>(objects[index].size_) + delta, 0, 15);
929 objects[index].size_ = static_cast<uint8_t>(new_size);
930 objects[index].tiles_loaded_ = false;
931 }
932 }
933 NotifyChange(room);
934}
935
937 const zelda3::RoomObject& object, int x,
938 int y) {
939 auto* room = GetRoom(room_id);
940 if (!room) {
942 return false;
943 }
944
945 // Hard-stop: enforce ROM object limit before committing placement.
946 if (room->GetTileObjects().size() >= zelda3::kMaxTileObjects) {
948 return false;
949 }
950
952 if (ctx_)
954 auto new_obj = object.CopyForNewPlacement();
955 new_obj.x_ = std::clamp(x, 0, 63);
956 new_obj.y_ = std::clamp(y, 0, 63);
957 room->AddTileObject(new_obj);
958 NotifyChange(room);
960 return true;
961}
962
969
972 if (!ctx_ || !ctx_->rom || !ctx_->rom->is_loaded())
973 return;
974
975 auto* room = GetRoom(ctx_->current_room_id);
976 if (!room || !room->IsLoaded())
977 return;
978
979 const auto preview_geometry = CalculateGhostPreviewGeometry(preview_object_);
980
981 // Keep one room-sized scratch texture for the handler lifetime. Replacing a
982 // textured bitmap during ImGui frame construction can invalidate draw-list
983 // texture handles, and repeatedly allocating previews leaks those handles.
985 ghost_preview_buffer_ = std::make_unique<gfx::BackgroundBuffer>(
986 kGhostPreviewBufferSize, kGhostPreviewBufferSize);
987 }
988 ghost_preview_buffer_->EnsureBitmapInitialized();
989 ghost_preview_buffer_->ClearBuffer();
990 auto& bitmap = ghost_preview_buffer_->bitmap();
991 ApplyRoomPaletteToGhost(*room, bitmap);
992 std::fill(bitmap.mutable_data().begin(), bitmap.mutable_data().end(), 255);
993 bitmap.set_modified(true);
994 const uint8_t* gfx_data = room->get_gfx_buffer().data();
995
996 zelda3::ObjectDrawer drawer(ctx_->rom, ctx_->current_room_id, gfx_data);
997 drawer.InitializeDrawRoutines();
998
999 // Replay at the same safe anchor ObjectGeometry uses for measurement so
1000 // routines that draw upward or leftward do not clip against buffer origin.
1001 auto render_object = preview_object_;
1002 render_object.x_ = preview_geometry.render_anchor_x_tiles;
1003 render_object.y_ = preview_geometry.render_anchor_y_tiles;
1004 // A placement preview is a flattened visual. Force the replay through BG1
1005 // so a sampled BG2-stream object cannot mask pixels in the same scratch
1006 // buffer that it just rendered.
1007 render_object.layer_ = zelda3::RoomObject::LayerType::BG1;
1008 auto status =
1009 drawer.DrawObject(render_object, *ghost_preview_buffer_,
1011 if (!status.ok()) {
1012 return;
1013 }
1014
1015 if (bitmap.size() > 0) {
1016 bitmap.UpdateSurfacePixels();
1017 if (bitmap.texture()) {
1021 } else if (!ghost_preview_create_queued_) {
1025 }
1027 if (bitmap.texture()) {
1029 }
1031 }
1032}
1033
1036 const zelda3::RoomObject& object) {
1037 const auto dimensions = zelda3::DimensionService::Get().GetDimensions(object);
1038 const auto [resolved_anchor_x, resolved_anchor_y] =
1039 zelda3::ObjectGeometry::Get().ResolveAnchor(object.id_, object.size_);
1040 // ResolveAnchor supplies routine-specific headroom. Dimension offsets cover
1041 // fallback/custom geometry too, so keep enough room for either source.
1042 const int anchor_x =
1043 std::max({0, resolved_anchor_x, -dimensions.offset_x_tiles});
1044 const int anchor_y =
1045 std::max({0, resolved_anchor_y, -dimensions.offset_y_tiles});
1046 const int leading_x_tiles = anchor_x + dimensions.offset_x_tiles;
1047 const int leading_y_tiles = anchor_y + dimensions.offset_y_tiles;
1048 return GhostPreviewGeometry{
1049 .render_anchor_x_tiles = anchor_x,
1050 .render_anchor_y_tiles = anchor_y,
1051 .offset_x_tiles = dimensions.offset_x_tiles,
1052 .offset_y_tiles = dimensions.offset_y_tiles,
1053 .width_pixels = dimensions.width_pixels(),
1054 .height_pixels = dimensions.height_pixels(),
1055 .buffer_width_pixels = (leading_x_tiles + dimensions.width_tiles) * 8,
1056 .buffer_height_pixels = (leading_y_tiles + dimensions.height_tiles) * 8,
1057 };
1058}
1059
1060// ========================================================================
1061// Clipboard Operations
1062// ========================================================================
1063
1065 int room_id, const std::vector<size_t>& indices) {
1066 auto* room = GetRoom(room_id);
1067 if (!room || indices.empty())
1068 return;
1069
1070 clipboard_.clear();
1071 const auto& objects = room->GetTileObjects();
1072
1073 for (size_t idx : indices) {
1074 if (idx < objects.size()) {
1075 clipboard_.push_back(objects[idx]);
1076 }
1077 }
1078}
1079
1080std::vector<size_t> TileObjectHandler::PasteFromClipboard(int room_id,
1081 int offset_x,
1082 int offset_y) {
1083 auto* room = GetRoom(room_id);
1084 if (!room || clipboard_.empty())
1085 return {};
1086 if (ctx_)
1088
1089 std::vector<size_t> new_indices;
1090 size_t base_index = room->GetTileObjects().size();
1091
1092 for (auto obj : clipboard_) {
1093 obj = obj.CopyForNewPlacement();
1094 obj.x_ = std::clamp(obj.x_ + offset_x, 0, 63);
1095 obj.y_ = std::clamp(obj.y_ + offset_y, 0, 63);
1096 obj.tiles_loaded_ = false;
1097 room->AddTileObject(obj);
1098 new_indices.push_back(base_index++);
1099 }
1100
1101 NotifyChange(room);
1102 return new_indices;
1103}
1104
1105std::vector<size_t> TileObjectHandler::PasteFromClipboardAt(int room_id,
1106 int target_x,
1107 int target_y) {
1108 if (clipboard_.empty())
1109 return {};
1110
1111 int offset_x = target_x - clipboard_[0].x_;
1112 int offset_y = target_y - clipboard_[0].y_;
1113
1114 return PasteFromClipboard(room_id, offset_x, offset_y);
1115}
1116
1117} // namespace yaze::editor
bool is_loaded() const
Definition rom.h:144
std::optional< ImVec2 > GetPointerScreenPosition() const
DungeonCanvasTransform GetCanvasTransform() const
std::pair< int, int > CanvasToRoom(int canvas_x, int canvas_y) const
Convert canvas pixel coordinates to room tile coordinates.
bool IsWithinBounds(int canvas_x, int canvas_y) const
Check if coordinates are within room bounds.
zelda3::Room * GetCurrentRoom() const
Get current room (convenience method)
bool HasValidContext() const
Check if context is valid.
std::pair< int, int > RoomToCanvas(int room_x, int room_y) const
Convert room tile coordinates to canvas pixel coordinates.
std::pair< int, int > ScreenToRoomPixelCoordinates(ImVec2 screen) const
ImVec2 RoomSizeToScreen(ImVec2 room_size) const
Manages object selection state and operations for the dungeon editor.
bool IsRectangleSelectionActive() const
Check if a rectangle selection is in progress.
void UpdateRectangleSelection(int canvas_x, int canvas_y)
Update rectangle selection endpoint.
void DrawSelectionHighlights(gui::Canvas *canvas, const std::vector< zelda3::RoomObject > &objects, std::function< std::tuple< int, int, int, int >(const zelda3::RoomObject &)> bounds_calculator)
Draw selection highlights for all selected objects.
bool IsObjectSelected(size_t index) const
Check if an object is selected.
std::vector< size_t > GetSelectedIndices() const
Get all selected object indices.
bool PassesLayerFilterForObject(const zelda3::RoomObject &object) const
Check if an object passes the current layer filter.
bool IsRectangleLargeEnough(int min_pixels) const
Check if rectangle selection exceeds a minimum pixel size.
void SelectObject(size_t index, SelectionMode mode=SelectionMode::Single)
Select a single object by index.
void ClearSelection()
Clear all selections.
void EndRectangleSelection(const std::vector< zelda3::RoomObject > &objects, SelectionMode mode=SelectionMode::Single)
Complete rectangle selection operation.
void BeginRectangleSelection(int canvas_x, int canvas_y)
Begin a rectangle selection operation.
void CancelRectangleSelection()
Cancel rectangle selection without modifying selection.
void DrawRectangleSelectionBox(gui::Canvas *canvas)
Draw the active rectangle selection box.
bool HasSelection() const
Check if any objects are selected.
Handles functional mutations and queries for tile objects.
void DrawGhostPreview() override
Draw ghost preview during placement.
void DrawSmartGuides(const std::vector< zelda3::RoomObject > &objects) const
std::vector< zelda3::RoomObject > clipboard_
void HandleMarqueeSelection(const ImVec2 &mouse_pos, bool mouse_left_down, bool mouse_left_released, bool shift_down, bool toggle_down, bool alt_down, bool draw_box=true)
void BeginPlacement() override
Begin placement mode.
bool HandleMouseWheel(float delta) override
std::unique_ptr< gfx::BackgroundBuffer > ghost_preview_buffer_
void UpdateObjectsSize(int room_id, const std::vector< size_t > &indices, uint8_t new_size)
void SendToFront(int room_id, const std::vector< size_t > &indices)
Reorder objects.
std::vector< size_t > DuplicateObjects(int room_id, const std::vector< size_t > &indices, int delta_x, int delta_y, bool notify_mutation=true)
Clone a set of objects and move them by a tile delta.
void DeleteAllObjects(int room_id)
Delete all objects in a room.
void MoveBackward(int room_id, const std::vector< size_t > &indices)
ImVec2 ApplyDragModifiers(const ImVec2 &delta) const
void HandleRelease() override
Handle mouse release.
void CopyObjectsToClipboard(int room_id, const std::vector< size_t > &indices)
Copy objects to internal clipboard.
void HandleDrag(ImVec2 current_pos, ImVec2 delta) override
Handle mouse drag.
std::vector< size_t > PasteFromClipboardAt(int room_id, int target_x, int target_y)
Paste objects from clipboard at target location. Use first clipboard item as origin.
void DrawSelectionHighlight() override
Draw selection highlight for selected entities.
bool UpdateObjectsLayer(int room_id, const std::vector< size_t > &indices, int new_layer)
bool PlaceObjectAt(int room_id, const zelda3::RoomObject &object, int x, int y)
Place a new object. Returns false if blocked by ROM limits.
void SendToBack(int room_id, const std::vector< size_t > &indices)
std::vector< size_t > PasteFromClipboard(int room_id, int offset_x, int offset_y)
Paste objects from clipboard with offset.
bool HandleClick(int canvas_x, int canvas_y) override
Handle mouse click at canvas position.
void SetPreviewObject(const zelda3::RoomObject &object)
Set object for placement.
void BeginMarqueeSelection(const ImVec2 &start_pos)
void MoveForward(int room_id, const std::vector< size_t > &indices)
void MoveObjects(int room_id, const std::vector< size_t > &indices, int delta_x, int delta_y, bool notify_mutation=true)
Move a set of objects by a tile delta.
void CancelPlacement() override
Cancel current placement.
PlacementBlockReason placement_block_reason_
void DeleteObjects(int room_id, std::vector< size_t > indices)
Delete objects by indices.
static GhostPreviewGeometry CalculateGhostPreviewGeometry(const zelda3::RoomObject &object)
Resolve the render anchor and visual extent used by placement previews.
void UpdateObjectsId(int room_id, const std::vector< size_t > &indices, int16_t new_id)
zelda3::Room * GetRoom(int room_id)
PlacementCapacityState GhostCapacityState
void NotifyChange(zelda3::Room *room)
GhostCapacityState GetPlacementGhostCapacityState() const
void ResizeObjects(int room_id, const std::vector< size_t > &indices, int delta)
Resize objects by a delta.
std::optional< size_t > GetEntityAtPosition(int canvas_x, int canvas_y) const override
Get entity at canvas position.
void InitDrag(const ImVec2 &start_pos)
void QueueTextureCommand(TextureCommandType type, Bitmap *bitmap)
Definition arena.cc:36
void ProcessTextureQueue(IRenderer *renderer)
Definition arena.cc:116
static Arena & Get()
Definition arena.cc:21
Represents a bitmap image optimized for SNES ROM hacking.
Definition bitmap.h:67
void SetPalette(const SnesPalette &palette)
Set the palette for the bitmap using SNES palette format.
Definition bitmap.cc:384
SDL_Surface * surface() const
Definition bitmap.h:400
static DimensionService & Get()
std::tuple< int, int, int, int > GetSelectionBoundsPixels(const RoomObject &obj) const
DimensionResult GetDimensions(const RoomObject &obj) const
std::tuple< int, int, int, int > GetHitTestBounds(const RoomObject &obj) const
Draws dungeon objects to background buffers using game patterns.
void InitializeDrawRoutines()
Initialize draw routine registry Must be called before drawing objects.
absl::Status DrawObject(const RoomObject &object, gfx::BackgroundBuffer &bg1, gfx::BackgroundBuffer &bg2, const gfx::PaletteGroup &palette_group, const DungeonState *state=nullptr, gfx::BackgroundBuffer *layout_bg1=nullptr)
Draw a room object to background buffers.
std::pair< int, int > ResolveAnchor(int16_t object_id, uint8_t size_byte) const
Resolve the canvas anchor (x, y) for a given object's draw routine.
static ObjectGeometry & Get()
void MarkTileObjectCollectionDirty()
Definition room.h:438
auto & object_bg2_buffer()
Definition room.h:981
auto & bg1_buffer()
Definition room.h:975
const std::vector< RoomObject > & GetTileObjects() const
Definition room.h:382
auto & object_bg1_buffer()
Definition room.h:979
auto & bg2_buffer()
Definition room.h:976
#define LOG_WARN(category, format,...)
Definition log.h:107
const AgentUITheme & GetTheme()
void DrawDashedLine(ImDrawList *draw_list, ImVec2 start, ImVec2 end, ImU32 color, float thickness)
void RestoreObjectSelection(ObjectSelection *selection, const std::vector< size_t > &selected_indices_after_reorder)
bool ApplyRoomPaletteToGhost(const zelda3::Room &room, gfx::Bitmap &ghost)
std::unordered_set< size_t > MakeValidIndexSet(const std::vector< size_t > &indices, size_t object_count)
GuideRect GetGuideRect(const zelda3::RoomObject &object)
void FlattenLayerBuckets(std::vector< zelda3::RoomObject > &objects, std::array< std::vector< LayerOrderEntry >, 3 > &buckets, ObjectSelection *selection)
bool AddUniqueGuide(std::vector< int > &guides, int value)
std::array< std::vector< LayerOrderEntry >, 3 > BuildLayerBuckets(const std::vector< zelda3::RoomObject > &objects, const std::unordered_set< size_t > &selected_indices)
ImVec2 SnapToTileGrid(const ImVec2 &point)
Snap a point to the 8px tile grid.
Editors are the view controllers for the application.
void DrawPlacementCapacityBadge(ImDrawList *draw_list, const ImVec2 &badge_min, const AgentUITheme &theme, PlacementCapacityState state, std::string_view primary_text)
ImVec4 GetPlacementAccentColor(const AgentUITheme &theme, PlacementCapacityState state, const ImVec4 &normal_color)
std::string_view GetPlacementCapacityTooltipSuffix(PlacementCapacityState state)
PlacementCapacityState GetPlacementCapacityState(size_t current_count, size_t max_count)
SDL_Palette * GetSurfacePalette(SDL_Surface *surface)
Get the palette attached to a surface.
Definition sdl_compat.h:392
constexpr size_t kMaxTileObjects
absl::StatusOr< ObjectStorageMutationResult > ReassignObjectStorage(std::vector< RoomObject > &objects, const std::vector< size_t > &indices, int target_value)
SDL2/SDL3 compatibility layer.
void NotifyInvalidateCache(MutationDomain domain=MutationDomain::kUnknown) const
Notify that cache invalidation is needed.
void NotifyMutation(MutationDomain domain=MutationDomain::kUnknown) const
Notify that a mutation is about to happen.