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"
24
26
27namespace yaze::editor {
28
29namespace {
30constexpr size_t kMaxLayerBatchMutation = 128;
31constexpr int kGhostPreviewBufferSize = 512;
32
34 const gfx::Bitmap* sources[] = {
35 &room.bg1_buffer().bitmap(), &room.object_bg1_buffer().bitmap(),
36 &room.bg2_buffer().bitmap(), &room.object_bg2_buffer().bitmap()};
37 for (const gfx::Bitmap* source : sources) {
38 SDL_Surface* surface = source->surface();
39 SDL_Palette* palette = platform::GetSurfacePalette(surface);
40 if (!palette || palette->ncolors <= 0) {
41 continue;
42 }
43
44 std::vector<SDL_Color> colors(256, {0, 0, 0, 0});
45 const int color_count = std::min(palette->ncolors, 256);
46 std::copy_n(palette->colors, color_count, colors.begin());
47 colors[255] = {0, 0, 0, 0};
48 ghost.SetPalette(colors);
49 if (ghost.surface()) {
50 SDL_SetColorKey(ghost.surface(), SDL_TRUE, 255);
51 SDL_SetSurfaceBlendMode(ghost.surface(), SDL_BLENDMODE_BLEND);
52 }
53 return true;
54 }
55 return false;
56}
57
60 bool selected = false;
61 size_t old_index = 0;
62};
63
65 return std::clamp(static_cast<int>(object.GetLayerValue()), 0, 2);
66}
67
68std::unordered_set<size_t> MakeValidIndexSet(const std::vector<size_t>& indices,
69 size_t object_count) {
70 std::unordered_set<size_t> index_set;
71 for (size_t index : indices) {
72 if (index < object_count) {
73 index_set.insert(index);
74 }
75 }
76 return index_set;
77}
78
79std::array<std::vector<LayerOrderEntry>, 3> BuildLayerBuckets(
80 const std::vector<zelda3::RoomObject>& objects,
81 const std::unordered_set<size_t>& selected_indices) {
82 std::array<std::vector<LayerOrderEntry>, 3> buckets;
83 for (size_t i = 0; i < objects.size(); ++i) {
84 buckets[LayerBucketIndex(objects[i])].push_back(
85 LayerOrderEntry{objects[i], selected_indices.count(i) > 0, i});
86 }
87 return buckets;
88}
89
91 ObjectSelection* selection,
92 const std::vector<size_t>& selected_indices_after_reorder) {
93 if (!selection) {
94 return;
95 }
96 if (selected_indices_after_reorder.empty()) {
97 selection->ClearSelection();
98 return;
99 }
100 selection->SelectObject(selected_indices_after_reorder.front(),
102 for (size_t ordinal = 1; ordinal < selected_indices_after_reorder.size();
103 ++ordinal) {
104 selection->SelectObject(selected_indices_after_reorder[ordinal],
106 }
107}
108
109void FlattenLayerBuckets(std::vector<zelda3::RoomObject>& objects,
110 std::array<std::vector<LayerOrderEntry>, 3>& buckets,
111 std::vector<std::optional<size_t>>& sources,
112 std::vector<size_t>& selected_indices_after_reorder) {
113 std::vector<zelda3::RoomObject> reordered;
114 reordered.reserve(objects.size());
115 sources.clear();
116 sources.reserve(objects.size());
117 for (auto& bucket : buckets) {
118 for (auto& entry : bucket) {
119 if (entry.selected) {
120 selected_indices_after_reorder.push_back(reordered.size());
121 }
122 sources.push_back(entry.old_index);
123 reordered.push_back(std::move(entry.object));
124 }
125 }
126 objects = std::move(reordered);
127}
128
129std::vector<std::optional<size_t>> IdentitySources(size_t count) {
130 std::vector<std::optional<size_t>> sources;
131 sources.reserve(count);
132 for (size_t index = 0; index < count; ++index) {
133 sources.push_back(index);
134 }
135 return sources;
136}
137
138bool SameChests(const std::vector<chest_data>& lhs,
139 const std::vector<chest_data>& rhs) {
140 return lhs.size() == rhs.size() &&
141 std::equal(lhs.begin(), lhs.end(), rhs.begin(), [](auto a, auto b) {
142 return a.id == b.id && a.size == b.size;
143 });
144}
145
147 const zelda3::RoomObject& b) {
148 return a.id_ == b.id_ && a.x_ == b.x_ && a.y_ == b.y_ && a.size_ == b.size_ &&
149 a.layer_ == b.layer_ && a.options_ == b.options_ &&
153}
154
155bool SameAuthoredObjects(const std::vector<zelda3::RoomObject>& lhs,
156 const std::vector<zelda3::RoomObject>& rhs) {
157 return lhs.size() == rhs.size() &&
158 std::equal(lhs.begin(), lhs.end(), rhs.begin(), SameAuthoredObject);
159}
160
161// Imported entries may need repair. Retain each unchanged original once, but
162// validate new copies and changed entries with the same rules as serialization.
163// This is pure preflight: drag increments must not run the command callback
164// that finishes an active gesture before capturing its next undo snapshot.
166 const std::vector<zelda3::RoomObject>& original,
167 const std::vector<zelda3::RoomObject>& candidate,
168 const std::vector<std::optional<size_t>>& sources) {
169 if (sources.size() != candidate.size()) {
170 return absl::InvalidArgumentError("Object source mapping is incomplete");
171 }
172 std::unordered_set<size_t> retained;
173 for (size_t i = 0; i < candidate.size(); ++i) {
174 if (sources[i]) {
175 const auto source = *sources[i];
176 if (source >= original.size()) {
177 return absl::InvalidArgumentError("Object source is unavailable");
178 }
179 if (retained.insert(source).second &&
180 SameAuthoredObject(original[source], candidate[i])) {
181 continue;
182 }
183 }
184 if (zelda3::UsesRoomObjectStream(candidate[i])) {
185 const auto status =
187 if (!status.ok()) {
188 return status;
189 }
190 }
191 }
192 return absl::OkStatus();
193}
194
195struct GuideRect {
196 int left = 0;
197 int top = 0;
198 int right = 0;
199 int bottom = 0;
200 int center_x = 0;
201 int center_y = 0;
202};
203
205 auto [x, y, width, height] =
207 return GuideRect{x, y, x + width, y + height, x + width / 2, y + height / 2};
208}
209
210bool AddUniqueGuide(std::vector<int>& guides, int value) {
211 constexpr int kDuplicateTolerancePx = 2;
212 if (std::any_of(guides.begin(), guides.end(), [&](int guide) {
213 return std::abs(guide - value) <= kDuplicateTolerancePx;
214 })) {
215 return false;
216 }
217 guides.push_back(value);
218 return true;
219}
220
221void DrawDashedLine(ImDrawList* draw_list, ImVec2 start, ImVec2 end,
222 ImU32 color, float thickness) {
223 constexpr float kDash = 6.0f;
224 constexpr float kGap = 4.0f;
225 const bool vertical = std::abs(start.x - end.x) < 0.5f;
226 const float total =
227 vertical ? std::abs(end.y - start.y) : std::abs(end.x - start.x);
228 for (float offset = 0.0f; offset < total; offset += kDash + kGap) {
229 const float segment_end = std::min(offset + kDash, total);
230 if (vertical) {
231 const float y0 = start.y + offset;
232 const float y1 = start.y + segment_end;
233 draw_list->AddLineV(start.x, y0, y1, color, thickness);
234 } else {
235 const float x0 = start.x + offset;
236 const float x1 = start.x + segment_end;
237 draw_list->AddLineH(x0, x1, start.y, color, thickness);
238 }
239 }
240}
241
242} // namespace
243
246 if (!ctx_)
247 return GhostCapacityState::kNormal;
248 auto* room =
249 const_cast<TileObjectHandler*>(this)->GetRoom(ctx_->current_room_id);
250 const size_t current_obj_count = room ? room->GetTileObjects().size() : 0;
251 return GetPlacementCapacityState(current_obj_count, zelda3::kMaxTileObjects);
252}
253
255 if (!ctx_ || !ctx_->rooms)
256 return nullptr;
257 if (room_id < 0 || room_id >= static_cast<int>(ctx_->rooms->size())) {
258 return nullptr;
259 }
260 return &(*ctx_->rooms)[room_id];
261}
262
269
270// ========================================================================
271// BaseEntityHandler implementation
272// ========================================================================
273
278
282
283bool TileObjectHandler::HandleClick(int canvas_x, int canvas_y) {
284 if (!HasValidContext())
285 return false;
286
288 auto [room_x, room_y] = CanvasToRoom(canvas_x, canvas_y);
289 if (IsWithinBounds(canvas_x, canvas_y)) {
291 }
292 return true; // Placement click is handled even if outside bounds.
293 }
294
295 // Handle selection (click)
296 if (!ctx_ || !ctx_->selection)
297 return false;
298
299 auto hovered = GetEntityAtPosition(canvas_x, canvas_y);
300 if (!hovered.has_value())
301 return false;
302
303 const ImGuiIO& io = ImGui::GetIO();
304
305 // Alt-click is reserved for caller-specific behaviors (inspector/etc). Treat
306 // it as a handled click over an object without changing selection.
307 if (io.KeyAlt)
308 return true;
309
311 if (io.KeyShift) {
313 } else if (io.KeyCtrl || io.KeySuper) {
315 }
316
317 ctx_->selection->SelectObject(*hovered, mode);
318 return true;
319}
320
321void TileObjectHandler::InitDrag(const ImVec2& start_pos) {
322 is_dragging_ = true;
325 drag_last_dx_ = 0;
326 drag_last_dy_ = 0;
327 drag_has_duplicated_ = false;
329 drag_rejected_delta_.reset();
330}
331
332void TileObjectHandler::BeginMarqueeSelection(const ImVec2& start_pos) {
333 if (!ctx_ || !ctx_->selection)
334 return;
335 ctx_->selection->BeginRectangleSelection(static_cast<int>(start_pos.x),
336 static_cast<int>(start_pos.y));
337}
338
340 const ImVec2& mouse_pos, bool mouse_left_down, bool mouse_left_released,
341 bool shift_down, bool toggle_down, bool alt_down, bool draw_box) {
342 (void)shift_down;
343 (void)toggle_down;
344 if (!ctx_ || !ctx_->selection)
345 return;
347 return;
348
349 // Update + draw while the drag is in progress.
350 if (mouse_left_down) {
351 ctx_->selection->UpdateRectangleSelection(static_cast<int>(mouse_pos.x),
352 static_cast<int>(mouse_pos.y));
353 if (draw_box) {
354 if (ctx_->canvas) {
356 }
357 }
358 }
359
360 // Finalize selection on release.
361 if (mouse_left_released) {
362 ctx_->selection->UpdateRectangleSelection(static_cast<int>(mouse_pos.x),
363 static_cast<int>(mouse_pos.y));
364
365 auto* room = GetRoom(ctx_->current_room_id);
366 if (!room) {
368 return;
369 }
370
371 constexpr int kMinRectPixels = 6;
372 if (alt_down || !ctx_->selection->IsRectangleLargeEnough(kMinRectPixels)) {
374 return;
375 }
376
378 room->GetTileObjects(), ObjectSelection::SelectionMode::Single,
379 [this](const zelda3::RoomObject& object) {
380 return ctx_->IsObjectVisibleForSelection(object);
381 });
382 }
383}
384
385void TileObjectHandler::HandleDrag(ImVec2 current_pos, ImVec2 delta) {
386 (void)delta;
387 if (!is_dragging_ || !ctx_ || !ctx_->selection)
388 return;
389
391
392 // Calculate total drag delta from start (snapped)
393 ImVec2 drag_delta =
395 drag_delta = ApplyDragModifiers(drag_delta);
396
397 const int tile_dx = static_cast<int>(drag_delta.x) / 8;
398 const int tile_dy = static_cast<int>(drag_delta.y) / 8;
399
400 // Calculate incremental move
401 const int inc_dx = tile_dx - drag_last_dx_;
402 const int inc_dy = tile_dy - drag_last_dy_;
403
404 const std::pair<int, int> requested_delta{tile_dx, tile_dy};
405 if (drag_rejected_delta_ == requested_delta) {
406 return;
407 }
408 drag_rejected_delta_.reset();
409 if (inc_dx != 0 || inc_dy != 0) {
411 ctx_->selection->GetSelectedIndices(), inc_dx, inc_dy,
412 /*notify_mutation=*/!drag_mutation_started_)) {
414 drag_last_dx_ = tile_dx;
415 drag_last_dy_ = tile_dy;
416 } else if (!mutation_status_.ok()) {
417 // A stationary invalid ghost must not produce one toast every frame.
418 drag_rejected_delta_ = requested_delta;
419 }
420 }
421}
422
424 if (is_dragging_) {
425 const bool had_mutation = drag_mutation_started_;
426 is_dragging_ = false;
428 drag_has_duplicated_ = false;
429 // Drag operations mutate incrementally while the mouse is held down. The
430 // editor's undo capture wants to finalize after the drag ends (once the
431 // interaction mode has returned to Select), so emit one more invalidation
432 // on release if anything changed.
433 if (had_mutation && ctx_) {
435 }
436 }
437}
438
439ImVec2 TileObjectHandler::ApplyDragModifiers(const ImVec2& delta) const {
440 return delta;
441}
442
444 if (!HasValidContext() || !ctx_ || delta == 0.0f)
445 return false;
446
447 const int resize_delta = (delta > 0.0f) ? 1 : -1;
448 const bool horizontal = ImGui::GetCurrentContext() && ImGui::GetIO().KeyShift;
450 const uint8_t size =
451 horizontal
454 resize_delta, true)
457 if (size == preview_object_.size_) {
458 return false;
459 }
460 return SetPreviewSize(size);
461 }
462 if (!ctx_->selection)
463 return false;
464 auto indices = ctx_->selection->GetSelectedIndices();
465 if (indices.empty())
466 return false;
467
468 return ResizeObjects(ctx_->current_room_id, indices, resize_delta, horizontal,
469 !horizontal);
470}
471
474 return;
475
476 const auto pointer_screen_pos = GetPointerScreenPosition();
477 if (!pointer_screen_pos.has_value()) {
478 return;
479 }
480
481 const DungeonCanvasTransform transform = GetCanvasTransform();
482 const auto [canvas_x, canvas_y] =
483 transform.ScreenToRoomPixelCoordinates(*pointer_screen_pos);
484 auto [room_x, room_y] = CanvasToRoom(canvas_x, canvas_y);
485
486 if (!IsWithinBounds(canvas_x, canvas_y))
487 return;
488
493 }
494
495 auto [snap_canvas_x, snap_canvas_y] = RoomToCanvas(room_x, room_y);
496 const auto preview_geometry = CalculateGhostPreviewGeometry(preview_object_);
497
498 ImDrawList* draw_list = ImGui::GetWindowDrawList();
499 const ImVec2 preview_start = transform.RoomPixelsToScreen(ImVec2(
500 static_cast<float>(snap_canvas_x + preview_geometry.offset_x_tiles * 8),
501 static_cast<float>(snap_canvas_y + preview_geometry.offset_y_tiles * 8)));
502 const ImVec2 preview_size = transform.RoomSizeToScreen(
503 ImVec2(static_cast<float>(preview_geometry.width_pixels),
504 static_cast<float>(preview_geometry.height_pixels)));
505 const ImVec2 preview_end(preview_start.x + preview_size.x,
506 preview_start.y + preview_size.y);
507 const ImVec2 bitmap_start = transform.RoomPixelsToScreen(
508 ImVec2(static_cast<float>(snap_canvas_x -
509 preview_geometry.render_anchor_x_tiles * 8),
510 static_cast<float>(snap_canvas_y -
511 preview_geometry.render_anchor_y_tiles * 8)));
512
513 const size_t current_obj_count = room ? room->GetTileObjects().size() : 0;
514 const auto capacity_state = GetPlacementGhostCapacityState();
515
516 const auto& theme = AgentUI::GetTheme();
517 const ImVec4 outline_color = GetPlacementAccentColor(
518 theme, capacity_state, theme.dungeon_selection_primary);
519 bool drew_bitmap = false;
520
522 auto& bitmap = ghost_preview_buffer_->bitmap();
523 if (bitmap.texture()) {
524 const int crop_width =
525 std::min(preview_geometry.buffer_width_pixels, bitmap.width());
526 const int crop_height =
527 std::min(preview_geometry.buffer_height_pixels, bitmap.height());
528 const ImVec2 bitmap_size = transform.RoomSizeToScreen(ImVec2(
529 static_cast<float>(crop_width), static_cast<float>(crop_height)));
530 const ImVec2 bitmap_end(bitmap_start.x + bitmap_size.x,
531 bitmap_start.y + bitmap_size.y);
532 const ImVec2 uv_end(static_cast<float>(crop_width) / bitmap.width(),
533 static_cast<float>(crop_height) / bitmap.height());
534 ImVec4 tint = capacity_state == GhostCapacityState::kNormal
535 ? theme.text_primary
536 : GetPlacementAccentColor(theme, capacity_state,
537 theme.text_primary);
538 tint.w = 0.70f;
539 draw_list->AddImage((ImTextureID)(intptr_t)bitmap.texture(), bitmap_start,
540 bitmap_end, ImVec2(0, 0), uv_end,
541 ImGui::GetColorU32(tint));
542 draw_list->AddRect(preview_start, preview_end,
543 ImGui::GetColorU32(outline_color), 0.0f, 2.0f);
544 drew_bitmap = true;
545 }
546 }
547
548 if (!drew_bitmap) {
549 draw_list->AddRectFilled(
550 preview_start, preview_end,
551 ImGui::GetColorU32(
552 ImVec4(outline_color.x, outline_color.y, outline_color.z, 0.25f)));
553 draw_list->AddRect(preview_start, preview_end,
554 ImGui::GetColorU32(outline_color), 0.0f, 2.0f);
555 }
556
557 // ID label
558 std::string id_text = absl::StrFormat("0x%02X", preview_object_.id_);
559 draw_list->AddText(ImVec2(preview_start.x + 2, preview_start.y + 1),
560 ImGui::GetColorU32(theme.text_primary), id_text.c_str());
561
562 // Capacity tooltip while hovering — proactive warning before user clicks.
563 if (capacity_state != GhostCapacityState::kNormal &&
564 ImGui::IsMouseHoveringRect(preview_start, preview_end)) {
565 ImGui::SetTooltip(tr("Objects: %zu/%zu\n%s"), current_obj_count,
567 GetPlacementCapacityTooltipSuffix(capacity_state).data());
568 }
569
570 const std::string badge_text = absl::StrFormat(
571 "Objects %zu/%zu", current_obj_count, zelda3::kMaxTileObjects);
573 ImVec2(preview_start.x, preview_end.y + 6.0f),
574 theme, capacity_state, badge_text);
575}
576
578 if (!HasValidContext() || !ctx_->selection)
579 return;
580
581 auto* room = GetCurrentRoom();
582 if (!room)
583 return;
584
585 // Use ObjectSelection's rendering (handles pulsing border, corner handles)
587 ctx_->canvas, room->GetTileObjects(), [](const zelda3::RoomObject& obj) {
588 auto result = zelda3::DimensionService::Get().GetDimensions(obj);
589 return std::make_tuple(result.offset_x_tiles * 8,
590 result.offset_y_tiles * 8, result.width_pixels(),
591 result.height_pixels());
592 });
593
594 if (is_dragging_) {
595 DrawSmartGuides(room->GetTileObjects());
596 }
597}
598
600 int canvas_x, int canvas_y) const {
601 if (!HasValidContext() || !IsWithinBounds(canvas_x, canvas_y)) {
602 return std::nullopt;
603 }
604 auto* room =
605 const_cast<TileObjectHandler*>(this)->GetRoom(ctx_->current_room_id);
606 if (!room)
607 return std::nullopt;
608
609 const auto& objects = room->GetTileObjects();
610 for (size_t i = objects.size(); i > 0; --i) {
611 size_t index = i - 1;
612 const auto& object = objects[index];
613
614 if (ctx_ && !ctx_->IsObjectVisibleForSelection(object)) {
615 continue;
616 }
617
618 // Respect layer filter if available in context
619 if (ctx_ && ctx_->selection &&
621 continue;
622 }
623
624 auto [obj_tile_x, obj_tile_y, width_tiles, height_tiles] =
626
627 int obj_px = obj_tile_x * 8;
628 int obj_py = obj_tile_y * 8;
629 int w_px = width_tiles * 8;
630 int h_px = height_tiles * 8;
631
632 if (canvas_x >= obj_px && canvas_x < obj_px + w_px && canvas_y >= obj_py &&
633 canvas_y < obj_py + h_px) {
634 return index;
635 }
636 }
637 return std::nullopt;
638}
639
641 const std::vector<zelda3::RoomObject>& objects) const {
642 if (!ctx_ || !ctx_->canvas || !ctx_->selection ||
644 return;
645 }
646
647 const auto selected = ctx_->selection->GetSelectedIndices();
648 if (selected.empty()) {
649 return;
650 }
651
652 constexpr int kAlignmentTolerancePx = 2;
653 constexpr size_t kMaxGuidesPerAxis = 8;
654 std::vector<int> vertical_guides;
655 std::vector<int> horizontal_guides;
656
657 for (size_t selected_index : selected) {
658 if (selected_index >= objects.size()) {
659 continue;
660 }
661 const GuideRect selected_rect = GetGuideRect(objects[selected_index]);
662 const std::array<int, 3> selected_x = {
663 selected_rect.left, selected_rect.center_x, selected_rect.right};
664 const std::array<int, 3> selected_y = {
665 selected_rect.top, selected_rect.center_y, selected_rect.bottom};
666
667 for (size_t other_index = 0; other_index < objects.size(); ++other_index) {
668 if (ctx_->selection->IsObjectSelected(other_index)) {
669 continue;
670 }
671 const GuideRect other_rect = GetGuideRect(objects[other_index]);
672 const std::array<int, 3> other_x = {other_rect.left, other_rect.center_x,
673 other_rect.right};
674 const std::array<int, 3> other_y = {other_rect.top, other_rect.center_y,
675 other_rect.bottom};
676
677 for (int sx : selected_x) {
678 for (int ox : other_x) {
679 if (std::abs(sx - ox) <= kAlignmentTolerancePx) {
680 AddUniqueGuide(vertical_guides, ox);
681 }
682 }
683 }
684 for (int sy : selected_y) {
685 for (int oy : other_y) {
686 if (std::abs(sy - oy) <= kAlignmentTolerancePx) {
687 AddUniqueGuide(horizontal_guides, oy);
688 }
689 }
690 }
691 if (vertical_guides.size() >= kMaxGuidesPerAxis &&
692 horizontal_guides.size() >= kMaxGuidesPerAxis) {
693 break;
694 }
695 }
696 }
697
698 if (vertical_guides.empty() && horizontal_guides.empty()) {
699 return;
700 }
701
702 const auto& theme = AgentUI::GetTheme();
703 ImVec4 guide_color = theme.accent_color;
704 guide_color.w = 0.78f;
705 const ImU32 color = ImGui::GetColorU32(guide_color);
706 ImDrawList* draw_list = ImGui::GetWindowDrawList();
707 const DungeonCanvasTransform transform = GetCanvasTransform();
708 const ImVec2 canvas_pos = transform.room_origin_screen();
709 const ImVec2 room_size = transform.RoomSizeToScreen(ImVec2(
711
712 const size_t vertical_count =
713 std::min(vertical_guides.size(), kMaxGuidesPerAxis);
714 for (size_t i = 0; i < vertical_count; ++i) {
715 const float x =
716 transform.RoomPixelsToScreen(ImVec2(vertical_guides[i], 0.0f)).x;
717 DrawDashedLine(draw_list, ImVec2(x, canvas_pos.y),
718 ImVec2(x, canvas_pos.y + room_size.y), color, 1.2f);
719 }
720
721 const size_t horizontal_count =
722 std::min(horizontal_guides.size(), kMaxGuidesPerAxis);
723 for (size_t i = 0; i < horizontal_count; ++i) {
724 const float y =
725 transform.RoomPixelsToScreen(ImVec2(0.0f, horizontal_guides[i])).y;
726 DrawDashedLine(draw_list, ImVec2(canvas_pos.x, y),
727 ImVec2(canvas_pos.x + room_size.x, y), color, 1.2f);
728 }
729}
730
731// ========================================================================
732// Mutation Logic
733// ========================================================================
734
735bool TileObjectHandler::RejectMutation(absl::Status status) {
736 mutation_status_ = std::move(status);
739 }
740 return false;
741}
742
744 int room_id, std::vector<zelda3::RoomObject> candidate,
745 const std::vector<std::optional<size_t>>& sources,
746 const std::vector<std::optional<chest_data>>& chest_overrides,
747 std::optional<std::vector<size_t>> selection) {
748 mutation_status_ = absl::OkStatus();
749 auto* room = GetRoom(room_id);
750 if (!room) {
751 return RejectMutation(absl::InvalidArgumentError("Room is unavailable"));
752 }
753 if (ctx_->current_room_id != room_id) {
754 return RejectMutation(absl::FailedPreconditionError(
755 "Select the destination room before changing its objects"));
756 }
757 if (candidate.size() > zelda3::kMaxTileObjects &&
758 candidate.size() > room->GetTileObjects().size()) {
759 return RejectMutation(
760 absl::ResourceExhaustedError("Room object limit reached"));
761 }
762 const auto admission =
763 ValidateObjectCandidates(room->GetTileObjects(), candidate, sources);
764 if (!admission.ok()) {
765 return RejectMutation(admission);
766 }
767 auto chests =
768 zelda3::PlanChestObjectEdit(room->GetTileObjects(), room->GetChests(),
769 candidate, sources, chest_overrides);
770 if (!chests.ok()) {
771 return RejectMutation(chests.status());
772 }
773 const bool chests_changed = !SameChests(room->GetChests(), *chests);
774 if (!chests_changed &&
775 SameAuthoredObjects(room->GetTileObjects(), candidate)) {
776 return true;
777 }
779 mutation_status_ = object_mutation_preflight_(room_id, candidate, *chests);
780 if (!mutation_status_.ok()) {
782 }
783 }
785 const auto* source_context = ctx_;
786 const auto before_objects = room->GetTileObjects();
787 const auto before_chests = room->GetChests();
789 // Finishing an earlier gesture may publish a preview or rebind the viewer.
790 // Never replace that newer state with the already planned candidate.
791 if (ctx_ != source_context || !ctx_ || ctx_->current_room_id != room_id ||
792 GetRoom(room_id) != room ||
793 !SameAuthoredObjects(room->GetTileObjects(), before_objects) ||
794 !SameChests(room->GetChests(), before_chests)) {
795 return RejectMutation(absl::FailedPreconditionError(
796 "Room objects changed while finishing the previous gesture; repeat "
797 "the edit"));
798 }
799 }
800 if (ctx_) {
802 }
803 // SetTileObjects marks domains from both snapshots, including removed special
804 // table objects. Both data planes are complete before invalidation can render.
805 room->SetTileObjects(candidate);
806 if (chests_changed) {
807 room->GetChests() = std::move(*chests);
808 room->MarkChestsDirty();
809 }
810 if (selection && ctx_) {
811 RestoreObjectSelection(ctx_->selection, *selection);
812 }
813 NotifyChange(room);
814 return true;
815}
816
818 const std::vector<size_t>& indices,
819 int delta_x, int delta_y,
820 bool notify_mutation) {
821 mutation_status_ = absl::OkStatus();
822 auto* room = GetRoom(room_id);
823 if (!room || indices.empty())
824 return false;
825 if (ctx_->current_room_id != room_id) {
826 return RejectMutation(absl::FailedPreconditionError(
827 "Select the destination room before changing its objects"));
828 }
829
830 auto& objects = room->GetTileObjects();
831 const auto before = objects;
832 auto candidate = before;
833 for (size_t index : indices) {
834 if (index < candidate.size()) {
835 candidate[index].x_ =
836 std::clamp(static_cast<int>(candidate[index].x_) + delta_x, 0, 63);
837 candidate[index].y_ =
838 std::clamp(static_cast<int>(candidate[index].y_) + delta_y, 0, 63);
839 }
840 }
841 if (SameAuthoredObjects(objects, candidate)) {
842 return false;
843 }
844 const auto admission = ValidateObjectCandidates(
845 objects, candidate, IdentitySources(objects.size()));
846 if (!admission.ok()) {
847 return RejectMutation(admission);
848 }
850 const auto* source_context = ctx_;
852 object_mutation_preflight_(room_id, candidate, room->GetChests());
853 if (!mutation_status_.ok()) {
855 }
856 if (ctx_ != source_context || !ctx_ || ctx_->current_room_id != room_id ||
857 GetRoom(room_id) != room ||
858 !SameAuthoredObjects(room->GetTileObjects(), before)) {
859 return RejectMutation(absl::FailedPreconditionError(
860 "Room objects changed during edit preflight; repeat the edit"));
861 }
862 }
863 if (notify_mutation && ctx_)
865 objects = std::move(candidate);
866 NotifyChange(room);
867 return true;
868}
869
871 const std::vector<size_t>& indices,
872 int16_t new_id) {
873 mutation_status_ = absl::OkStatus();
874 auto* room = GetRoom(room_id);
875 if (!room || indices.empty())
876 return;
877 auto candidate = room->GetTileObjects();
878 for (size_t index : indices) {
879 if (index >= candidate.size() || candidate[index].id_ == new_id) {
880 continue;
881 }
882 if (zelda3::UsesSpecialLayerSelector(candidate[index]) || new_id == 0xE00) {
883 RejectMutation(absl::FailedPreconditionError(
884 "Use the dedicated torch or pushable-block controls to change "
885 "special-table objects"));
886 return;
887 }
888 const uint8_t canonical_size =
889 zelda3::CanonicalRoomObjectSize(new_id, candidate[index].size_);
890 candidate[index].set_id(new_id);
891 candidate[index].set_size(canonical_size);
892 auto options = candidate[index].options() & ~zelda3::ObjectOption::Chest;
894 options = options | zelda3::ObjectOption::Chest;
895 }
896 candidate[index].set_options(options);
897 }
898 CommitCandidate(room_id, std::move(candidate),
899 IdentitySources(room->GetTileObjects().size()));
900}
901
903 const std::vector<size_t>& indices,
904 uint8_t new_size) {
905 mutation_status_ = absl::OkStatus();
906 auto* room = GetRoom(room_id);
907 if (!room || indices.empty())
908 return;
909
910 auto& objects = room->GetTileObjects();
911 const auto can_update_size = [&](const zelda3::RoomObject& object) {
912 if (!zelda3::IsRoomObjectSizeEditable(object.id_)) {
913 return false;
914 }
915 if (!zelda3::IsRoomObjectResizable(object.id_)) {
916 const auto& manager = zelda3::CustomObjectManager::Get();
917 return new_size <= 0x0F &&
918 new_size < manager.GetSubtypeCount(object.id_) &&
919 !manager.ResolveFilename(object.id_, new_size).empty();
920 }
921 return true;
922 };
923 const bool has_change =
924 std::any_of(indices.begin(), indices.end(), [&](size_t index) {
925 if (index >= objects.size() || !can_update_size(objects[index])) {
926 return false;
927 }
928 return objects[index].size_ !=
929 zelda3::CanonicalRoomObjectSize(objects[index].id_, new_size);
930 });
931 if (!has_change) {
932 return;
933 }
934 auto candidate = objects;
935
936 for (size_t index : indices) {
937 if (index < objects.size() && can_update_size(candidate[index])) {
938 const uint8_t canonical_size =
939 zelda3::CanonicalRoomObjectSize(candidate[index].id_, new_size);
940 if (candidate[index].size_ == canonical_size) {
941 continue;
942 }
943 candidate[index].size_ = canonical_size;
944 candidate[index].tiles_loaded_ = false;
945 }
946 }
947 const auto admission = ValidateObjectCandidates(
948 objects, candidate, IdentitySources(objects.size()));
949 if (!admission.ok()) {
950 RejectMutation(admission);
951 return;
952 }
953 if (ctx_)
954 ctx_->NotifyMutation(MutationDomain::kTileObjects);
955 objects = std::move(candidate);
956 NotifyChange(room);
957}
958
959bool TileObjectHandler::UpdateObjectsLayer(int room_id,
960 const std::vector<size_t>& indices,
961 int new_layer) {
962 mutation_status_ = absl::OkStatus();
963 auto* room = GetRoom(room_id);
964 if (!room || indices.empty())
965 return false;
966 if (new_layer < 0 || new_layer > 2) {
967 mutation_status_ =
968 absl::InvalidArgumentError("Invalid object stream target");
969 LOG_WARN("TileObjectHandler",
970 "Rejected layer update with invalid target layer: %d", new_layer);
971 return false;
972 }
973 auto& objects = room->GetTileObjects();
974 std::vector<size_t> deduped_indices;
975 deduped_indices.reserve(indices.size());
976 std::unordered_set<size_t> seen_indices;
977 for (size_t index : indices) {
978 if (index >= objects.size()) {
979 continue;
980 }
981 if (seen_indices.insert(index).second) {
982 deduped_indices.push_back(index);
983 }
984 }
985 if (deduped_indices.empty()) {
986 return false;
987 }
988
989 if (deduped_indices.size() > kMaxLayerBatchMutation) {
990 mutation_status_ =
991 absl::ResourceExhaustedError("Object stream batch is too large");
992 LOG_WARN("TileObjectHandler",
993 "Rejected layer batch mutation of %zu objects (max %zu)",
994 deduped_indices.size(), kMaxLayerBatchMutation);
995 return false;
996 }
997
998 auto candidate_objects = objects;
999 auto mutation = zelda3::ReassignObjectStorage(candidate_objects,
1000 deduped_indices, new_layer);
1001 if (!mutation.ok()) {
1002 mutation_status_ = mutation.status();
1003 LOG_WARN("TileObjectHandler", "Rejected object stream mutation: %s",
1004 std::string(mutation.status().message()).c_str());
1005 return false;
1006 }
1007 if (!mutation->changed) {
1008 return true;
1009 }
1010
1011 std::vector<std::optional<size_t>> sources(objects.size());
1012 for (size_t old_index = 0; old_index < objects.size(); ++old_index) {
1013 sources[mutation->old_to_new_index[old_index]] = old_index;
1014 }
1015 return CommitCandidate(room_id, std::move(candidate_objects), sources, {},
1016 mutation->selected_indices);
1017}
1018
1019std::vector<size_t> TileObjectHandler::DuplicateObjects(
1020 int room_id, const std::vector<size_t>& indices, int delta_x, int delta_y) {
1021 mutation_status_ = absl::OkStatus();
1022 auto* room = GetRoom(room_id);
1023 if (!room || indices.empty())
1024 return {};
1025 const auto& objects = room->GetTileObjects();
1026 auto candidate = objects;
1027 auto sources = IdentitySources(objects.size());
1028 std::vector<size_t> new_indices;
1029 std::unordered_set<size_t> seen;
1030 for (size_t index : indices) {
1031 if (index < objects.size() && seen.insert(index).second) {
1032 auto clone = objects[index].CopyForNewPlacement();
1033 if (zelda3::UsesRoomObjectStream(clone) &&
1035 clone.set_options(clone.options() | zelda3::ObjectOption::Chest);
1036 }
1037 clone.x_ = std::clamp(static_cast<int>(clone.x_ + delta_x), 0, 63);
1038 clone.y_ = std::clamp(static_cast<int>(clone.y_ + delta_y), 0, 63);
1039 new_indices.push_back(candidate.size());
1040 candidate.push_back(std::move(clone));
1041 sources.push_back(index);
1042 }
1043 }
1044 if (new_indices.empty() || !CommitCandidate(room_id, std::move(candidate),
1045 sources, {}, new_indices)) {
1046 return {};
1047 }
1048 return new_indices;
1049}
1050
1051bool TileObjectHandler::DeleteObjects(int room_id,
1052 std::vector<size_t> indices) {
1053 mutation_status_ = absl::OkStatus();
1054 auto* room = GetRoom(room_id);
1055 if (!room || indices.empty())
1056 return false;
1057 const auto& objects = room->GetTileObjects();
1058 const auto removed = MakeValidIndexSet(indices, objects.size());
1059 if (removed.empty()) {
1060 return false;
1061 }
1062 std::vector<zelda3::RoomObject> candidate;
1063 std::vector<std::optional<size_t>> sources;
1064 for (size_t index = 0; index < objects.size(); ++index) {
1065 if (!removed.contains(index)) {
1066 candidate.push_back(objects[index]);
1067 sources.push_back(index);
1068 }
1069 }
1070 return CommitCandidate(room_id, std::move(candidate), sources, {},
1071 std::vector<size_t>{});
1072}
1073
1074bool TileObjectHandler::DeleteAllObjects(int room_id) {
1075 return CommitCandidate(room_id, {}, {}, {}, std::vector<size_t>{});
1076}
1077
1078void TileObjectHandler::SendToFront(int room_id,
1079 const std::vector<size_t>& indices) {
1080 mutation_status_ = absl::OkStatus();
1081 auto* room = GetRoom(room_id);
1082 if (!room || indices.empty())
1083 return;
1084 auto objects = room->GetTileObjects();
1085 auto selected_set = MakeValidIndexSet(indices, objects.size());
1086 if (selected_set.empty()) {
1087 return;
1088 }
1089 auto buckets = BuildLayerBuckets(objects, selected_set);
1090 for (auto& bucket : buckets) {
1091 std::vector<LayerOrderEntry> other;
1092 std::vector<LayerOrderEntry> selected;
1093 other.reserve(bucket.size());
1094 selected.reserve(bucket.size());
1095 for (auto& entry : bucket) {
1096 if (entry.selected) {
1097 selected.push_back(std::move(entry));
1098 } else {
1099 other.push_back(std::move(entry));
1100 }
1101 }
1102 bucket = std::move(other);
1103 bucket.insert(bucket.end(), std::make_move_iterator(selected.begin()),
1104 std::make_move_iterator(selected.end()));
1105 }
1106 std::vector<std::optional<size_t>> sources;
1107 std::vector<size_t> selected_indices;
1108 FlattenLayerBuckets(objects, buckets, sources, selected_indices);
1109 CommitCandidate(room_id, std::move(objects), sources, {}, selected_indices);
1110}
1111
1112void TileObjectHandler::SendToBack(int room_id,
1113 const std::vector<size_t>& indices) {
1114 mutation_status_ = absl::OkStatus();
1115 auto* room = GetRoom(room_id);
1116 if (!room || indices.empty())
1117 return;
1118 auto objects = room->GetTileObjects();
1119 auto selected_set = MakeValidIndexSet(indices, objects.size());
1120 if (selected_set.empty()) {
1121 return;
1122 }
1123 auto buckets = BuildLayerBuckets(objects, selected_set);
1124 for (auto& bucket : buckets) {
1125 std::vector<LayerOrderEntry> selected;
1126 std::vector<LayerOrderEntry> other;
1127 selected.reserve(bucket.size());
1128 other.reserve(bucket.size());
1129 for (auto& entry : bucket) {
1130 if (entry.selected) {
1131 selected.push_back(std::move(entry));
1132 } else {
1133 other.push_back(std::move(entry));
1134 }
1135 }
1136 bucket = std::move(selected);
1137 bucket.insert(bucket.end(), std::make_move_iterator(other.begin()),
1138 std::make_move_iterator(other.end()));
1139 }
1140 std::vector<std::optional<size_t>> sources;
1141 std::vector<size_t> selected_indices;
1142 FlattenLayerBuckets(objects, buckets, sources, selected_indices);
1143 CommitCandidate(room_id, std::move(objects), sources, {}, selected_indices);
1144}
1145
1146void TileObjectHandler::MoveForward(int room_id,
1147 const std::vector<size_t>& indices) {
1148 mutation_status_ = absl::OkStatus();
1149 auto* room = GetRoom(room_id);
1150 if (!room || indices.empty())
1151 return;
1152 auto objects = room->GetTileObjects();
1153 auto selected_set = MakeValidIndexSet(indices, objects.size());
1154 if (selected_set.empty()) {
1155 return;
1156 }
1157 auto buckets = BuildLayerBuckets(objects, selected_set);
1158 for (auto& bucket : buckets) {
1159 if (bucket.size() < 2) {
1160 continue;
1161 }
1162 for (size_t i = bucket.size() - 1; i > 0; --i) {
1163 const size_t previous = i - 1;
1164 if (bucket[previous].selected && !bucket[i].selected) {
1165 std::swap(bucket[previous], bucket[i]);
1166 }
1167 }
1168 }
1169 std::vector<std::optional<size_t>> sources;
1170 std::vector<size_t> selected_indices;
1171 FlattenLayerBuckets(objects, buckets, sources, selected_indices);
1172 CommitCandidate(room_id, std::move(objects), sources, {}, selected_indices);
1173}
1174
1175void TileObjectHandler::MoveBackward(int room_id,
1176 const std::vector<size_t>& indices) {
1177 mutation_status_ = absl::OkStatus();
1178 auto* room = GetRoom(room_id);
1179 if (!room || indices.empty())
1180 return;
1181 auto objects = room->GetTileObjects();
1182 auto selected_set = MakeValidIndexSet(indices, objects.size());
1183 if (selected_set.empty()) {
1184 return;
1185 }
1186 auto buckets = BuildLayerBuckets(objects, selected_set);
1187 for (auto& bucket : buckets) {
1188 if (bucket.size() < 2) {
1189 continue;
1190 }
1191 for (size_t i = 1; i < bucket.size(); ++i) {
1192 const size_t previous = i - 1;
1193 if (bucket[i].selected && !bucket[previous].selected) {
1194 std::swap(bucket[i], bucket[previous]);
1195 }
1196 }
1197 }
1198 std::vector<std::optional<size_t>> sources;
1199 std::vector<size_t> selected_indices;
1200 FlattenLayerBuckets(objects, buckets, sources, selected_indices);
1201 CommitCandidate(room_id, std::move(objects), sources, {}, selected_indices);
1202}
1203
1204bool TileObjectHandler::ResizeObjects(int room_id,
1205 const std::vector<size_t>& indices,
1206 int delta, bool horizontal,
1207 bool uniform) {
1208 mutation_status_ = absl::OkStatus();
1209 auto* room = GetRoom(room_id);
1210 if (!room || indices.empty())
1211 return false;
1212 if (ctx_->current_room_id != room_id) {
1213 return RejectMutation(absl::FailedPreconditionError(
1214 "Select the destination room before changing its objects"));
1215 }
1216 auto& objects = room->GetTileObjects();
1217 const auto before = objects;
1218 const auto resized_size = [&](const zelda3::RoomObject& object) {
1220 object.id_, object.size_, delta)
1221 : zelda3::ResizeRoomObjectByDelta(object.id_, object.size_,
1222 delta, horizontal);
1223 };
1224 const bool has_change =
1225 std::any_of(indices.begin(), indices.end(), [&](size_t index) {
1226 return index < objects.size() &&
1227 zelda3::IsRoomObjectResizable(objects[index].id_) &&
1228 objects[index].size_ != resized_size(objects[index]);
1229 });
1230 if (!has_change) {
1231 return false;
1232 }
1233 auto candidate = objects;
1234
1235 for (size_t index : indices) {
1236 if (index < objects.size() &&
1237 zelda3::IsRoomObjectResizable(candidate[index].id_)) {
1238 const uint8_t new_size = resized_size(candidate[index]);
1239 if (candidate[index].size_ == new_size) {
1240 continue;
1241 }
1242 candidate[index].size_ = new_size;
1243 candidate[index].tiles_loaded_ = false;
1244 }
1245 }
1246 const auto admission = ValidateObjectCandidates(
1247 objects, candidate, IdentitySources(objects.size()));
1248 if (!admission.ok()) {
1249 RejectMutation(admission);
1250 return false;
1251 }
1252 if (object_mutation_preflight_) {
1253 const auto* source_context = ctx_;
1254 mutation_status_ =
1255 object_mutation_preflight_(room_id, candidate, room->GetChests());
1256 if (!mutation_status_.ok()) {
1257 return RejectMutation(mutation_status_);
1258 }
1259 if (ctx_ != source_context || !ctx_ || ctx_->current_room_id != room_id ||
1260 GetRoom(room_id) != room ||
1261 !SameAuthoredObjects(room->GetTileObjects(), before)) {
1262 return RejectMutation(absl::FailedPreconditionError(
1263 "Room objects changed during edit preflight; repeat the edit"));
1264 }
1265 }
1266 if (ctx_)
1267 ctx_->NotifyMutation(MutationDomain::kTileObjects);
1268 objects = std::move(candidate);
1269 NotifyChange(room);
1270 return true;
1271}
1272
1273bool TileObjectHandler::PlaceObjectAt(int room_id,
1274 const zelda3::RoomObject& object, int x,
1275 int y) {
1276 mutation_status_ = absl::OkStatus();
1277 auto* room = GetRoom(room_id);
1278 if (!room) {
1279 placement_block_reason_ = PlacementBlockReason::kInvalidRoom;
1280 return RejectMutation(absl::InvalidArgumentError("Room is unavailable"));
1281 }
1282
1283 // Hard-stop: enforce ROM object limit before committing placement.
1284 if (room->GetTileObjects().size() >= zelda3::kMaxTileObjects) {
1285 placement_block_reason_ = PlacementBlockReason::kObjectLimit;
1286 return RejectMutation(
1287 absl::ResourceExhaustedError("Room object limit reached"));
1288 }
1289
1290 placement_block_reason_ = PlacementBlockReason::kNone;
1291 auto new_obj = object.CopyForNewPlacement();
1292 if (zelda3::UsesRoomObjectStream(new_obj) &&
1293 zelda3::IsStatefulChestObjectId(new_obj.id_)) {
1294 new_obj.set_options(new_obj.options() | zelda3::ObjectOption::Chest);
1295 }
1296 new_obj.x_ = std::clamp(x, 0, 63);
1297 new_obj.y_ = std::clamp(y, 0, 63);
1298 const size_t placed_index = room->GetTileObjects().size();
1299 auto candidate = room->GetTileObjects();
1300 auto sources = IdentitySources(candidate.size());
1301 candidate.push_back(new_obj);
1302 sources.push_back(std::nullopt);
1303 if (!CommitCandidate(room_id, std::move(candidate), sources, {},
1304 std::vector<size_t>{placed_index})) {
1305 placement_block_reason_ = PlacementBlockReason::kChestValidation;
1306 return false;
1307 }
1308 if (placement_policy_ == PlacementPolicy::kOnce) {
1309 CancelPlacement();
1310 }
1311 if (ctx_ && ctx_->selection) {
1312 ctx_->NotifySelectionChanged();
1313 }
1314 TriggerSuccessToast();
1315 if (placement_callback_) {
1316 placement_callback_(new_obj);
1317 }
1318 return true;
1319}
1320
1321void TileObjectHandler::SetPreviewObject(const zelda3::RoomObject& object) {
1322 preview_object_ = object;
1323 RefreshPreviewGraphics();
1324}
1325
1326bool TileObjectHandler::SetPreviewSize(uint8_t size) {
1327 if (!object_placement_mode_ ||
1328 !zelda3::IsRoomObjectSizeEditable(preview_object_.id_)) {
1329 return false;
1330 }
1331 if (!zelda3::IsRoomObjectResizable(preview_object_.id_)) {
1332 const auto& manager = zelda3::CustomObjectManager::Get();
1333 if (size > 0x0F || size >= manager.GetSubtypeCount(preview_object_.id_) ||
1334 manager.ResolveFilename(preview_object_.id_, size).empty()) {
1335 return false;
1336 }
1337 }
1338 const uint8_t canonical_size =
1339 zelda3::CanonicalRoomObjectSize(preview_object_.id_, size);
1340 if (preview_object_.size_ != canonical_size) {
1341 preview_object_.size_ = canonical_size;
1342 preview_object_.tiles_loaded_ = false;
1343 RefreshPreviewGraphics();
1344 }
1345 return true;
1346}
1347
1348bool TileObjectHandler::SetPreviewLayer(int layer) {
1349 if (!object_placement_mode_ || layer < 0 || layer > 2 ||
1350 (layer == 2 && zelda3::UsesSpecialLayerSelector(preview_object_))) {
1351 return false;
1352 }
1353 if (preview_object_.GetLayerValue() != layer) {
1354 preview_object_.layer_ = static_cast<zelda3::RoomObject::LayerType>(layer);
1355 preview_object_.tiles_loaded_ = false;
1356 RefreshPreviewGraphics();
1357 }
1358 return true;
1359}
1360
1361void TileObjectHandler::RefreshPreviewGraphics() {
1362 if (object_placement_mode_) {
1363 RenderGhostPreviewBitmap();
1364 }
1365}
1366
1367void TileObjectHandler::RenderGhostPreviewBitmap() {
1368 ghost_preview_bitmap_ready_ = false;
1369 if (!ctx_ || !ctx_->rom || !ctx_->rom->is_loaded())
1370 return;
1371
1372 auto* room = GetRoom(ctx_->current_room_id);
1373 if (!room || !room->IsLoaded())
1374 return;
1375
1376 const auto preview_geometry = CalculateGhostPreviewGeometry(preview_object_);
1377
1378 // Keep one room-sized scratch texture for the handler lifetime. Replacing a
1379 // textured bitmap during ImGui frame construction can invalidate draw-list
1380 // texture handles, and repeatedly allocating previews leaks those handles.
1381 if (!ghost_preview_buffer_) {
1382 ghost_preview_buffer_ = std::make_unique<gfx::BackgroundBuffer>(
1383 kGhostPreviewBufferSize, kGhostPreviewBufferSize);
1384 }
1385 ghost_preview_buffer_->EnsureBitmapInitialized();
1386 ghost_preview_buffer_->ClearBuffer();
1387 auto& bitmap = ghost_preview_buffer_->bitmap();
1388 ApplyRoomPaletteToGhost(*room, bitmap);
1389 std::fill(bitmap.mutable_data().begin(), bitmap.mutable_data().end(), 255);
1390 bitmap.set_modified(true);
1391 const uint8_t* gfx_data = room->get_gfx_buffer().data();
1392
1393 zelda3::ObjectDrawer drawer(ctx_->rom, ctx_->current_room_id, gfx_data);
1394 drawer.InitializeDrawRoutines();
1395 drawer.SetRoomFloorGraphics(room->floor1(), room->floor2());
1396
1397 // Replay at the same safe anchor ObjectGeometry uses for measurement so
1398 // routines that draw upward or leftward do not clip against buffer origin.
1399 auto render_object = preview_object_;
1400 render_object.x_ = preview_geometry.render_anchor_x_tiles;
1401 render_object.y_ = preview_geometry.render_anchor_y_tiles;
1402 // A placement preview is a flattened visual. Force the replay through BG1
1403 // so a sampled BG2-stream object cannot mask pixels in the same scratch
1404 // buffer that it just rendered.
1405 render_object.layer_ = zelda3::RoomObject::LayerType::BG1;
1406 auto status =
1407 drawer.DrawObject(render_object, *ghost_preview_buffer_,
1408 *ghost_preview_buffer_, ctx_->current_palette_group);
1409 if (!status.ok()) {
1410 return;
1411 }
1412
1413 if (bitmap.size() > 0) {
1414 bitmap.UpdateSurfacePixels();
1415 if (bitmap.texture()) {
1416 ghost_preview_create_queued_ = false;
1419 } else if (!ghost_preview_create_queued_) {
1422 ghost_preview_create_queued_ = true;
1423 }
1425 if (bitmap.texture()) {
1426 ghost_preview_create_queued_ = false;
1427 }
1428 ghost_preview_bitmap_ready_ = true;
1429 ghost_preview_room_id_ = ctx_->current_room_id;
1430 ghost_preview_graphics_revision_ = room->graphics_revision();
1431 }
1432}
1433
1435TileObjectHandler::CalculateGhostPreviewGeometry(
1436 const zelda3::RoomObject& object) {
1437 const auto dimensions = zelda3::DimensionService::Get().GetDimensions(object);
1438 const auto [resolved_anchor_x, resolved_anchor_y] =
1439 zelda3::ObjectGeometry::Get().ResolveAnchor(object.id_, object.size_);
1440 // ResolveAnchor supplies routine-specific headroom. Dimension offsets cover
1441 // fallback/custom geometry too, so keep enough room for either source.
1442 const int anchor_x =
1443 std::max({0, resolved_anchor_x, -dimensions.offset_x_tiles});
1444 const int anchor_y =
1445 std::max({0, resolved_anchor_y, -dimensions.offset_y_tiles});
1446 const int leading_x_tiles = anchor_x + dimensions.offset_x_tiles;
1447 const int leading_y_tiles = anchor_y + dimensions.offset_y_tiles;
1448 return GhostPreviewGeometry{
1449 .render_anchor_x_tiles = anchor_x,
1450 .render_anchor_y_tiles = anchor_y,
1451 .offset_x_tiles = dimensions.offset_x_tiles,
1452 .offset_y_tiles = dimensions.offset_y_tiles,
1453 .width_pixels = dimensions.width_pixels(),
1454 .height_pixels = dimensions.height_pixels(),
1455 .buffer_width_pixels = (leading_x_tiles + dimensions.width_tiles) * 8,
1456 .buffer_height_pixels = (leading_y_tiles + dimensions.height_tiles) * 8,
1457 };
1458}
1459
1460// ========================================================================
1461// Clipboard Operations
1462// ========================================================================
1463
1464void TileObjectHandler::CopyObjectsToClipboard(
1465 int room_id, const std::vector<size_t>& indices) {
1466 auto* room = GetRoom(room_id);
1467 if (!room || indices.empty())
1468 return;
1469
1470 ClearClipboard();
1471 const auto& objects = room->GetTileObjects();
1472 const bool includes_chest =
1473 std::any_of(indices.begin(), indices.end(), [&](size_t index) {
1474 return index < objects.size() &&
1475 zelda3::IsStatefulChestObjectId(objects[index].id_);
1476 });
1477 if (includes_chest) {
1478 clipboard_status_ =
1479 zelda3::ValidateChestObjectMapping(objects, room->GetChests());
1480 }
1481 for (size_t index : indices) {
1482 if (index >= objects.size()) {
1483 continue;
1484 }
1485 clipboard_.push_back(objects[index]);
1486 std::optional<chest_data> chest;
1487 if (zelda3::IsStatefulChestObjectId(objects[index].id_)) {
1488 const auto chest_index = zelda3::ChestIndexForObject(objects, index);
1489 if (!chest_index || *chest_index >= room->GetChests().size() ||
1490 room->GetChests()[*chest_index].size !=
1491 (objects[index].id_ == 0xFB1)) {
1492 clipboard_status_ = absl::FailedPreconditionError(
1493 "Cannot copy a chest with missing or mismatched contents");
1494 } else {
1495 chest = room->GetChests()[*chest_index];
1496 }
1497 }
1498 clipboard_chests_.push_back(chest);
1499 }
1500}
1501
1502std::vector<size_t> TileObjectHandler::PasteFromClipboard(int room_id,
1503 int offset_x,
1504 int offset_y) {
1505 mutation_status_ = clipboard_status_;
1506 auto* room = GetRoom(room_id);
1507 if (!room || clipboard_.empty())
1508 return {};
1509 if (!mutation_status_.ok()) {
1510 RejectMutation(mutation_status_);
1511 return {};
1512 }
1513 auto candidate = room->GetTileObjects();
1514 auto sources = IdentitySources(candidate.size());
1515 std::vector<std::optional<chest_data>> overrides(candidate.size());
1516 std::vector<size_t> new_indices;
1517 for (size_t index = 0; index < clipboard_.size(); ++index) {
1518 auto object = clipboard_[index].CopyForNewPlacement();
1519 if (zelda3::UsesRoomObjectStream(object) &&
1520 zelda3::IsStatefulChestObjectId(object.id_)) {
1521 object.set_options(object.options() | zelda3::ObjectOption::Chest);
1522 }
1523 object.x_ = std::clamp(object.x_ + offset_x, 0, 63);
1524 object.y_ = std::clamp(object.y_ + offset_y, 0, 63);
1525 object.tiles_loaded_ = false;
1526 new_indices.push_back(candidate.size());
1527 candidate.push_back(std::move(object));
1528 sources.push_back(std::nullopt);
1529 overrides.push_back(clipboard_chests_[index]);
1530 }
1531 if (!CommitCandidate(room_id, std::move(candidate), sources, overrides,
1532 new_indices)) {
1533 return {};
1534 }
1535 return new_indices;
1536}
1537
1538std::vector<size_t> TileObjectHandler::PasteFromClipboardAt(int room_id,
1539 int target_x,
1540 int target_y) {
1541 if (clipboard_.empty())
1542 return {};
1543
1544 int offset_x = target_x - clipboard_[0].x_;
1545 int offset_y = target_y - clipboard_[0].y_;
1546
1547 return PasteFromClipboard(room_id, offset_x, offset_y);
1548}
1549
1550} // namespace yaze::editor
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.
void EndRectangleSelection(const std::vector< zelda3::RoomObject > &objects, SelectionMode mode=SelectionMode::Single, std::function< bool(const zelda3::RoomObject &)> is_object_visible={})
Complete rectangle selection operation.
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 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
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_
ObjectMutationPreflight object_mutation_preflight_
void UpdateObjectsSize(int room_id, const std::vector< size_t > &indices, uint8_t new_size)
bool 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.
bool CommitCandidate(int room_id, std::vector< zelda3::RoomObject > candidate, const std::vector< std::optional< size_t > > &sources, const std::vector< std::optional< chest_data > > &chest_overrides={}, std::optional< std::vector< size_t > > selection=std::nullopt)
ImVec2 ApplyDragModifiers(const ImVec2 &delta) const
void HandleRelease() override
Handle mouse release.
void HandleDrag(ImVec2 current_pos, ImVec2 delta) override
Handle mouse drag.
std::optional< std::pair< int, int > > drag_rejected_delta_
void DrawSelectionHighlight() override
Draw selection highlight for selected entities.
bool PlaceObjectAt(int room_id, const zelda3::RoomObject &object, int x, int y)
Place a new object. Returns false if blocked by ROM limits.
bool HandleClick(int canvas_x, int canvas_y) override
Handle mouse click at canvas position.
std::function< void()> before_object_command_callback_
void BeginMarqueeSelection(const ImVec2 &start_pos)
bool RejectMutation(absl::Status status)
void CancelPlacement() override
Cancel current placement.
static GhostPreviewGeometry CalculateGhostPreviewGeometry(const zelda3::RoomObject &object)
Resolve the render anchor and visual extent used by placement previews.
bool ResizeObjects(int room_id, const std::vector< size_t > &indices, int delta, bool horizontal=false, bool uniform=false)
Resize objects by a delta. Uniform changes both packed axes; otherwise horizontal selects packed-floo...
void UpdateObjectsId(int room_id, const std::vector< size_t > &indices, int16_t new_id)
zelda3::Room * GetRoom(int room_id)
PlacementCapacityState GhostCapacityState
std::function< void(const absl::Status &) mutation_error_callback_)
void NotifyChange(zelda3::Room *room)
GhostCapacityState GetPlacementGhostCapacityState() const
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:39
void ProcessTextureQueue(IRenderer *renderer)
Definition arena.cc:211
static Arena & Get()
Definition arena.cc:24
Represents a bitmap image optimized for SNES ROM hacking.
Definition bitmap.h:70
void SetPalette(const SnesPalette &palette)
Set the palette for the bitmap using SNES palette format.
Definition bitmap.cc:452
SDL_Surface * surface() const
Definition bitmap.h:409
static CustomObjectManager & Get()
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.
void SetRoomFloorGraphics(uint8_t floor1, uint8_t floor2)
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, gfx::BackgroundBuffer *layout_bg2=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:501
auto & object_bg2_buffer()
Definition room.h:1151
uint64_t graphics_revision() const
Definition room.h:1125
auto & bg1_buffer()
Definition room.h:1145
const std::vector< RoomObject > & GetTileObjects() const
Definition room.h:445
auto & object_bg1_buffer()
Definition room.h:1149
auto & bg2_buffer()
Definition room.h:1146
struct chest chest
Treasure chest.
#define LOG_WARN(category, format,...)
Definition log.h:138
const AgentUITheme & GetTheme()
absl::Status ValidateObjectCandidates(const std::vector< zelda3::RoomObject > &original, const std::vector< zelda3::RoomObject > &candidate, const std::vector< std::optional< size_t > > &sources)
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 SameAuthoredObject(const zelda3::RoomObject &a, const zelda3::RoomObject &b)
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)
void FlattenLayerBuckets(std::vector< zelda3::RoomObject > &objects, std::array< std::vector< LayerOrderEntry >, 3 > &buckets, std::vector< std::optional< size_t > > &sources, std::vector< size_t > &selected_indices_after_reorder)
GuideRect GetGuideRect(const zelda3::RoomObject &object)
std::vector< std::optional< size_t > > IdentitySources(size_t count)
bool AddUniqueGuide(std::vector< int > &guides, int value)
bool SameAuthoredObjects(const std::vector< zelda3::RoomObject > &lhs, const std::vector< zelda3::RoomObject > &rhs)
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:398
absl::Status ValidateChestObjectMapping(const std::vector< RoomObject > &objects, const std::vector< chest_data > &chests)
Definition chest_edit.cc:74
uint8_t ResizeRoomObjectUniformlyByDelta(int object_id, uint8_t size, int delta)
uint8_t ResizeRoomObjectByDelta(int object_id, uint8_t size, int delta, bool horizontal)
std::optional< size_t > ChestIndexForObject(const std::vector< RoomObject > &objects, size_t object_index)
Definition chest_edit.cc:63
bool IsRoomObjectSizeEditable(int object_id)
constexpr size_t kMaxTileObjects
uint8_t CanonicalRoomObjectSize(int object_id, uint8_t requested_size)
bool UsesRoomObjectStream(const RoomObject &object)
constexpr bool IsStatefulChestObjectId(int object_id)
bool UsesSpecialLayerSelector(const RoomObject &object)
absl::StatusOr< ObjectStorageMutationResult > ReassignObjectStorage(std::vector< RoomObject > &objects, const std::vector< size_t > &indices, int target_value)
absl::Status ValidateRoomObjectStreamEntryForSave(const RoomObject &object)
bool IsRoomObjectResizable(int object_id)
absl::StatusOr< std::vector< chest_data > > PlanChestObjectEdit(const std::vector< RoomObject > &before_objects, const std::vector< chest_data > &before_chests, const std::vector< RoomObject > &after_objects, const std::vector< std::optional< size_t > > &source_indices, const std::vector< std::optional< chest_data > > &chest_overrides)
Definition chest_edit.cc:94
SDL2/SDL3 compatibility layer.
Treasure chest.
Definition zelda.h:425
bool IsObjectVisibleForSelection(const zelda3::RoomObject &object) const
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.