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->AddLine(ImVec2(start.x, y0), ImVec2(start.x, y1), color,
234 thickness);
235 } else {
236 const float x0 = start.x + offset;
237 const float x1 = start.x + segment_end;
238 draw_list->AddLine(ImVec2(x0, start.y), ImVec2(x1, start.y), color,
239 thickness);
240 }
241 }
242}
243
244} // namespace
245
248 if (!ctx_)
249 return GhostCapacityState::kNormal;
250 auto* room =
251 const_cast<TileObjectHandler*>(this)->GetRoom(ctx_->current_room_id);
252 const size_t current_obj_count = room ? room->GetTileObjects().size() : 0;
253 return GetPlacementCapacityState(current_obj_count, zelda3::kMaxTileObjects);
254}
255
257 if (!ctx_ || !ctx_->rooms)
258 return nullptr;
259 if (room_id < 0 || room_id >= static_cast<int>(ctx_->rooms->size())) {
260 return nullptr;
261 }
262 return &(*ctx_->rooms)[room_id];
263}
264
271
272// ========================================================================
273// BaseEntityHandler implementation
274// ========================================================================
275
280
284
285bool TileObjectHandler::HandleClick(int canvas_x, int canvas_y) {
286 if (!HasValidContext())
287 return false;
288
290 auto [room_x, room_y] = CanvasToRoom(canvas_x, canvas_y);
291 if (IsWithinBounds(canvas_x, canvas_y)) {
293 }
294 return true; // Placement click is handled even if outside bounds.
295 }
296
297 // Handle selection (click)
298 if (!ctx_ || !ctx_->selection)
299 return false;
300
301 auto hovered = GetEntityAtPosition(canvas_x, canvas_y);
302 if (!hovered.has_value())
303 return false;
304
305 const ImGuiIO& io = ImGui::GetIO();
306
307 // Alt-click is reserved for caller-specific behaviors (inspector/etc). Treat
308 // it as a handled click over an object without changing selection.
309 if (io.KeyAlt)
310 return true;
311
313 if (io.KeyShift) {
315 } else if (io.KeyCtrl || io.KeySuper) {
317 }
318
319 ctx_->selection->SelectObject(*hovered, mode);
320 return true;
321}
322
323void TileObjectHandler::InitDrag(const ImVec2& start_pos) {
324 is_dragging_ = true;
327 drag_last_dx_ = 0;
328 drag_last_dy_ = 0;
329 drag_has_duplicated_ = false;
331 drag_rejected_delta_.reset();
332}
333
334void TileObjectHandler::BeginMarqueeSelection(const ImVec2& start_pos) {
335 if (!ctx_ || !ctx_->selection)
336 return;
337 ctx_->selection->BeginRectangleSelection(static_cast<int>(start_pos.x),
338 static_cast<int>(start_pos.y));
339}
340
342 const ImVec2& mouse_pos, bool mouse_left_down, bool mouse_left_released,
343 bool shift_down, bool toggle_down, bool alt_down, bool draw_box) {
344 (void)shift_down;
345 (void)toggle_down;
346 if (!ctx_ || !ctx_->selection)
347 return;
349 return;
350
351 // Update + draw while the drag is in progress.
352 if (mouse_left_down) {
353 ctx_->selection->UpdateRectangleSelection(static_cast<int>(mouse_pos.x),
354 static_cast<int>(mouse_pos.y));
355 if (draw_box) {
356 if (ctx_->canvas) {
358 }
359 }
360 }
361
362 // Finalize selection on release.
363 if (mouse_left_released) {
364 ctx_->selection->UpdateRectangleSelection(static_cast<int>(mouse_pos.x),
365 static_cast<int>(mouse_pos.y));
366
367 auto* room = GetRoom(ctx_->current_room_id);
368 if (!room) {
370 return;
371 }
372
373 constexpr int kMinRectPixels = 6;
374 if (alt_down || !ctx_->selection->IsRectangleLargeEnough(kMinRectPixels)) {
376 return;
377 }
378
380 room->GetTileObjects(), ObjectSelection::SelectionMode::Single,
381 [this](const zelda3::RoomObject& object) {
382 return ctx_->IsObjectVisibleForSelection(object);
383 });
384 }
385}
386
387void TileObjectHandler::HandleDrag(ImVec2 current_pos, ImVec2 delta) {
388 (void)delta;
389 if (!is_dragging_ || !ctx_ || !ctx_->selection)
390 return;
391
393
394 // Calculate total drag delta from start (snapped)
395 ImVec2 drag_delta =
397 drag_delta = ApplyDragModifiers(drag_delta);
398
399 const int tile_dx = static_cast<int>(drag_delta.x) / 8;
400 const int tile_dy = static_cast<int>(drag_delta.y) / 8;
401
402 // Calculate incremental move
403 const int inc_dx = tile_dx - drag_last_dx_;
404 const int inc_dy = tile_dy - drag_last_dy_;
405
406 const std::pair<int, int> requested_delta{tile_dx, tile_dy};
407 if (drag_rejected_delta_ == requested_delta) {
408 return;
409 }
410 drag_rejected_delta_.reset();
411 if (inc_dx != 0 || inc_dy != 0) {
413 ctx_->selection->GetSelectedIndices(), inc_dx, inc_dy,
414 /*notify_mutation=*/!drag_mutation_started_)) {
416 drag_last_dx_ = tile_dx;
417 drag_last_dy_ = tile_dy;
418 } else if (!mutation_status_.ok()) {
419 // A stationary invalid ghost must not produce one toast every frame.
420 drag_rejected_delta_ = requested_delta;
421 }
422 }
423}
424
426 if (is_dragging_) {
427 const bool had_mutation = drag_mutation_started_;
428 is_dragging_ = false;
430 drag_has_duplicated_ = false;
431 // Drag operations mutate incrementally while the mouse is held down. The
432 // editor's undo capture wants to finalize after the drag ends (once the
433 // interaction mode has returned to Select), so emit one more invalidation
434 // on release if anything changed.
435 if (had_mutation && ctx_) {
437 }
438 }
439}
440
441ImVec2 TileObjectHandler::ApplyDragModifiers(const ImVec2& delta) const {
442 return delta;
443}
444
446 if (!HasValidContext() || !ctx_ || delta == 0.0f)
447 return false;
448
449 const int resize_delta = (delta > 0.0f) ? 1 : -1;
450 const bool horizontal = ImGui::GetCurrentContext() && ImGui::GetIO().KeyShift;
452 const uint8_t size =
453 horizontal
456 resize_delta, true)
459 if (size == preview_object_.size_) {
460 return false;
461 }
462 return SetPreviewSize(size);
463 }
464 if (!ctx_->selection)
465 return false;
466 auto indices = ctx_->selection->GetSelectedIndices();
467 if (indices.empty())
468 return false;
469
470 return ResizeObjects(ctx_->current_room_id, indices, resize_delta, horizontal,
471 !horizontal);
472}
473
476 return;
477
478 const auto pointer_screen_pos = GetPointerScreenPosition();
479 if (!pointer_screen_pos.has_value()) {
480 return;
481 }
482
483 const DungeonCanvasTransform transform = GetCanvasTransform();
484 const auto [canvas_x, canvas_y] =
485 transform.ScreenToRoomPixelCoordinates(*pointer_screen_pos);
486 auto [room_x, room_y] = CanvasToRoom(canvas_x, canvas_y);
487
488 if (!IsWithinBounds(canvas_x, canvas_y))
489 return;
490
495 }
496
497 auto [snap_canvas_x, snap_canvas_y] = RoomToCanvas(room_x, room_y);
498 const auto preview_geometry = CalculateGhostPreviewGeometry(preview_object_);
499
500 ImDrawList* draw_list = ImGui::GetWindowDrawList();
501 const ImVec2 preview_start = transform.RoomPixelsToScreen(ImVec2(
502 static_cast<float>(snap_canvas_x + preview_geometry.offset_x_tiles * 8),
503 static_cast<float>(snap_canvas_y + preview_geometry.offset_y_tiles * 8)));
504 const ImVec2 preview_size = transform.RoomSizeToScreen(
505 ImVec2(static_cast<float>(preview_geometry.width_pixels),
506 static_cast<float>(preview_geometry.height_pixels)));
507 const ImVec2 preview_end(preview_start.x + preview_size.x,
508 preview_start.y + preview_size.y);
509 const ImVec2 bitmap_start = transform.RoomPixelsToScreen(
510 ImVec2(static_cast<float>(snap_canvas_x -
511 preview_geometry.render_anchor_x_tiles * 8),
512 static_cast<float>(snap_canvas_y -
513 preview_geometry.render_anchor_y_tiles * 8)));
514
515 const size_t current_obj_count = room ? room->GetTileObjects().size() : 0;
516 const auto capacity_state = GetPlacementGhostCapacityState();
517
518 const auto& theme = AgentUI::GetTheme();
519 const ImVec4 outline_color = GetPlacementAccentColor(
520 theme, capacity_state, theme.dungeon_selection_primary);
521 bool drew_bitmap = false;
522
524 auto& bitmap = ghost_preview_buffer_->bitmap();
525 if (bitmap.texture()) {
526 const int crop_width =
527 std::min(preview_geometry.buffer_width_pixels, bitmap.width());
528 const int crop_height =
529 std::min(preview_geometry.buffer_height_pixels, bitmap.height());
530 const ImVec2 bitmap_size = transform.RoomSizeToScreen(ImVec2(
531 static_cast<float>(crop_width), static_cast<float>(crop_height)));
532 const ImVec2 bitmap_end(bitmap_start.x + bitmap_size.x,
533 bitmap_start.y + bitmap_size.y);
534 const ImVec2 uv_end(static_cast<float>(crop_width) / bitmap.width(),
535 static_cast<float>(crop_height) / bitmap.height());
536 ImVec4 tint = capacity_state == GhostCapacityState::kNormal
537 ? theme.text_primary
538 : GetPlacementAccentColor(theme, capacity_state,
539 theme.text_primary);
540 tint.w = 0.70f;
541 draw_list->AddImage((ImTextureID)(intptr_t)bitmap.texture(), bitmap_start,
542 bitmap_end, ImVec2(0, 0), uv_end,
543 ImGui::GetColorU32(tint));
544 draw_list->AddRect(preview_start, preview_end,
545 ImGui::GetColorU32(outline_color), 0.0f, 0, 2.0f);
546 drew_bitmap = true;
547 }
548 }
549
550 if (!drew_bitmap) {
551 draw_list->AddRectFilled(
552 preview_start, preview_end,
553 ImGui::GetColorU32(
554 ImVec4(outline_color.x, outline_color.y, outline_color.z, 0.25f)));
555 draw_list->AddRect(preview_start, preview_end,
556 ImGui::GetColorU32(outline_color), 0.0f, 0, 2.0f);
557 }
558
559 // ID label
560 std::string id_text = absl::StrFormat("0x%02X", preview_object_.id_);
561 draw_list->AddText(ImVec2(preview_start.x + 2, preview_start.y + 1),
562 ImGui::GetColorU32(theme.text_primary), id_text.c_str());
563
564 // Capacity tooltip while hovering — proactive warning before user clicks.
565 if (capacity_state != GhostCapacityState::kNormal &&
566 ImGui::IsMouseHoveringRect(preview_start, preview_end)) {
567 ImGui::SetTooltip(tr("Objects: %zu/%zu\n%s"), current_obj_count,
569 GetPlacementCapacityTooltipSuffix(capacity_state).data());
570 }
571
572 const std::string badge_text = absl::StrFormat(
573 "Objects %zu/%zu", current_obj_count, zelda3::kMaxTileObjects);
575 ImVec2(preview_start.x, preview_end.y + 6.0f),
576 theme, capacity_state, badge_text);
577}
578
580 if (!HasValidContext() || !ctx_->selection)
581 return;
582
583 auto* room = GetCurrentRoom();
584 if (!room)
585 return;
586
587 // Use ObjectSelection's rendering (handles pulsing border, corner handles)
589 ctx_->canvas, room->GetTileObjects(), [](const zelda3::RoomObject& obj) {
590 auto result = zelda3::DimensionService::Get().GetDimensions(obj);
591 return std::make_tuple(result.offset_x_tiles * 8,
592 result.offset_y_tiles * 8, result.width_pixels(),
593 result.height_pixels());
594 });
595
596 if (is_dragging_) {
597 DrawSmartGuides(room->GetTileObjects());
598 }
599}
600
602 int canvas_x, int canvas_y) const {
603 if (!HasValidContext() || !IsWithinBounds(canvas_x, canvas_y)) {
604 return std::nullopt;
605 }
606 auto* room =
607 const_cast<TileObjectHandler*>(this)->GetRoom(ctx_->current_room_id);
608 if (!room)
609 return std::nullopt;
610
611 const auto& objects = room->GetTileObjects();
612 for (size_t i = objects.size(); i > 0; --i) {
613 size_t index = i - 1;
614 const auto& object = objects[index];
615
616 if (ctx_ && !ctx_->IsObjectVisibleForSelection(object)) {
617 continue;
618 }
619
620 // Respect layer filter if available in context
621 if (ctx_ && ctx_->selection &&
623 continue;
624 }
625
626 auto [obj_tile_x, obj_tile_y, width_tiles, height_tiles] =
628
629 int obj_px = obj_tile_x * 8;
630 int obj_py = obj_tile_y * 8;
631 int w_px = width_tiles * 8;
632 int h_px = height_tiles * 8;
633
634 if (canvas_x >= obj_px && canvas_x < obj_px + w_px && canvas_y >= obj_py &&
635 canvas_y < obj_py + h_px) {
636 return index;
637 }
638 }
639 return std::nullopt;
640}
641
643 const std::vector<zelda3::RoomObject>& objects) const {
644 if (!ctx_ || !ctx_->canvas || !ctx_->selection ||
646 return;
647 }
648
649 const auto selected = ctx_->selection->GetSelectedIndices();
650 if (selected.empty()) {
651 return;
652 }
653
654 constexpr int kAlignmentTolerancePx = 2;
655 constexpr size_t kMaxGuidesPerAxis = 8;
656 std::vector<int> vertical_guides;
657 std::vector<int> horizontal_guides;
658
659 for (size_t selected_index : selected) {
660 if (selected_index >= objects.size()) {
661 continue;
662 }
663 const GuideRect selected_rect = GetGuideRect(objects[selected_index]);
664 const std::array<int, 3> selected_x = {
665 selected_rect.left, selected_rect.center_x, selected_rect.right};
666 const std::array<int, 3> selected_y = {
667 selected_rect.top, selected_rect.center_y, selected_rect.bottom};
668
669 for (size_t other_index = 0; other_index < objects.size(); ++other_index) {
670 if (ctx_->selection->IsObjectSelected(other_index)) {
671 continue;
672 }
673 const GuideRect other_rect = GetGuideRect(objects[other_index]);
674 const std::array<int, 3> other_x = {other_rect.left, other_rect.center_x,
675 other_rect.right};
676 const std::array<int, 3> other_y = {other_rect.top, other_rect.center_y,
677 other_rect.bottom};
678
679 for (int sx : selected_x) {
680 for (int ox : other_x) {
681 if (std::abs(sx - ox) <= kAlignmentTolerancePx) {
682 AddUniqueGuide(vertical_guides, ox);
683 }
684 }
685 }
686 for (int sy : selected_y) {
687 for (int oy : other_y) {
688 if (std::abs(sy - oy) <= kAlignmentTolerancePx) {
689 AddUniqueGuide(horizontal_guides, oy);
690 }
691 }
692 }
693 if (vertical_guides.size() >= kMaxGuidesPerAxis &&
694 horizontal_guides.size() >= kMaxGuidesPerAxis) {
695 break;
696 }
697 }
698 }
699
700 if (vertical_guides.empty() && horizontal_guides.empty()) {
701 return;
702 }
703
704 const auto& theme = AgentUI::GetTheme();
705 ImVec4 guide_color = theme.accent_color;
706 guide_color.w = 0.78f;
707 const ImU32 color = ImGui::GetColorU32(guide_color);
708 ImDrawList* draw_list = ImGui::GetWindowDrawList();
709 const DungeonCanvasTransform transform = GetCanvasTransform();
710 const ImVec2 canvas_pos = transform.room_origin_screen();
711 const ImVec2 room_size = transform.RoomSizeToScreen(ImVec2(
713
714 const size_t vertical_count =
715 std::min(vertical_guides.size(), kMaxGuidesPerAxis);
716 for (size_t i = 0; i < vertical_count; ++i) {
717 const float x =
718 transform.RoomPixelsToScreen(ImVec2(vertical_guides[i], 0.0f)).x;
719 DrawDashedLine(draw_list, ImVec2(x, canvas_pos.y),
720 ImVec2(x, canvas_pos.y + room_size.y), color, 1.2f);
721 }
722
723 const size_t horizontal_count =
724 std::min(horizontal_guides.size(), kMaxGuidesPerAxis);
725 for (size_t i = 0; i < horizontal_count; ++i) {
726 const float y =
727 transform.RoomPixelsToScreen(ImVec2(0.0f, horizontal_guides[i])).y;
728 DrawDashedLine(draw_list, ImVec2(canvas_pos.x, y),
729 ImVec2(canvas_pos.x + room_size.x, y), color, 1.2f);
730 }
731}
732
733// ========================================================================
734// Mutation Logic
735// ========================================================================
736
737bool TileObjectHandler::RejectMutation(absl::Status status) {
738 mutation_status_ = std::move(status);
741 }
742 return false;
743}
744
746 int room_id, std::vector<zelda3::RoomObject> candidate,
747 const std::vector<std::optional<size_t>>& sources,
748 const std::vector<std::optional<chest_data>>& chest_overrides,
749 std::optional<std::vector<size_t>> selection) {
750 mutation_status_ = absl::OkStatus();
751 auto* room = GetRoom(room_id);
752 if (!room) {
753 return RejectMutation(absl::InvalidArgumentError("Room is unavailable"));
754 }
755 if (ctx_->current_room_id != room_id) {
756 return RejectMutation(absl::FailedPreconditionError(
757 "Select the destination room before changing its objects"));
758 }
759 if (candidate.size() > zelda3::kMaxTileObjects &&
760 candidate.size() > room->GetTileObjects().size()) {
761 return RejectMutation(
762 absl::ResourceExhaustedError("Room object limit reached"));
763 }
764 const auto admission =
765 ValidateObjectCandidates(room->GetTileObjects(), candidate, sources);
766 if (!admission.ok()) {
767 return RejectMutation(admission);
768 }
769 auto chests =
770 zelda3::PlanChestObjectEdit(room->GetTileObjects(), room->GetChests(),
771 candidate, sources, chest_overrides);
772 if (!chests.ok()) {
773 return RejectMutation(chests.status());
774 }
775 const bool chests_changed = !SameChests(room->GetChests(), *chests);
776 if (!chests_changed &&
777 SameAuthoredObjects(room->GetTileObjects(), candidate)) {
778 return true;
779 }
781 mutation_status_ = object_mutation_preflight_(room_id, candidate, *chests);
782 if (!mutation_status_.ok()) {
784 }
785 }
787 const auto* source_context = ctx_;
788 const auto before_objects = room->GetTileObjects();
789 const auto before_chests = room->GetChests();
791 // Finishing an earlier gesture may publish a preview or rebind the viewer.
792 // Never replace that newer state with the already planned candidate.
793 if (ctx_ != source_context || !ctx_ || ctx_->current_room_id != room_id ||
794 GetRoom(room_id) != room ||
795 !SameAuthoredObjects(room->GetTileObjects(), before_objects) ||
796 !SameChests(room->GetChests(), before_chests)) {
797 return RejectMutation(absl::FailedPreconditionError(
798 "Room objects changed while finishing the previous gesture; repeat "
799 "the edit"));
800 }
801 }
802 if (ctx_) {
804 }
805 // SetTileObjects marks domains from both snapshots, including removed special
806 // table objects. Both data planes are complete before invalidation can render.
807 room->SetTileObjects(candidate);
808 if (chests_changed) {
809 room->GetChests() = std::move(*chests);
810 room->MarkChestsDirty();
811 }
812 if (selection && ctx_) {
813 RestoreObjectSelection(ctx_->selection, *selection);
814 }
815 NotifyChange(room);
816 return true;
817}
818
820 const std::vector<size_t>& indices,
821 int delta_x, int delta_y,
822 bool notify_mutation) {
823 mutation_status_ = absl::OkStatus();
824 auto* room = GetRoom(room_id);
825 if (!room || indices.empty())
826 return false;
827 if (ctx_->current_room_id != room_id) {
828 return RejectMutation(absl::FailedPreconditionError(
829 "Select the destination room before changing its objects"));
830 }
831
832 auto& objects = room->GetTileObjects();
833 const auto before = objects;
834 auto candidate = before;
835 for (size_t index : indices) {
836 if (index < candidate.size()) {
837 candidate[index].x_ =
838 std::clamp(static_cast<int>(candidate[index].x_) + delta_x, 0, 63);
839 candidate[index].y_ =
840 std::clamp(static_cast<int>(candidate[index].y_) + delta_y, 0, 63);
841 }
842 }
843 if (SameAuthoredObjects(objects, candidate)) {
844 return false;
845 }
846 const auto admission = ValidateObjectCandidates(
847 objects, candidate, IdentitySources(objects.size()));
848 if (!admission.ok()) {
849 return RejectMutation(admission);
850 }
852 const auto* source_context = ctx_;
854 object_mutation_preflight_(room_id, candidate, room->GetChests());
855 if (!mutation_status_.ok()) {
857 }
858 if (ctx_ != source_context || !ctx_ || ctx_->current_room_id != room_id ||
859 GetRoom(room_id) != room ||
860 !SameAuthoredObjects(room->GetTileObjects(), before)) {
861 return RejectMutation(absl::FailedPreconditionError(
862 "Room objects changed during edit preflight; repeat the edit"));
863 }
864 }
865 if (notify_mutation && ctx_)
867 objects = std::move(candidate);
868 NotifyChange(room);
869 return true;
870}
871
873 const std::vector<size_t>& indices,
874 int16_t new_id) {
875 mutation_status_ = absl::OkStatus();
876 auto* room = GetRoom(room_id);
877 if (!room || indices.empty())
878 return;
879 auto candidate = room->GetTileObjects();
880 for (size_t index : indices) {
881 if (index >= candidate.size() || candidate[index].id_ == new_id) {
882 continue;
883 }
884 if (zelda3::UsesSpecialLayerSelector(candidate[index]) || new_id == 0xE00) {
885 RejectMutation(absl::FailedPreconditionError(
886 "Use the dedicated torch or pushable-block controls to change "
887 "special-table objects"));
888 return;
889 }
890 const uint8_t canonical_size =
891 zelda3::CanonicalRoomObjectSize(new_id, candidate[index].size_);
892 candidate[index].set_id(new_id);
893 candidate[index].set_size(canonical_size);
894 auto options = candidate[index].options() & ~zelda3::ObjectOption::Chest;
896 options = options | zelda3::ObjectOption::Chest;
897 }
898 candidate[index].set_options(options);
899 }
900 CommitCandidate(room_id, std::move(candidate),
901 IdentitySources(room->GetTileObjects().size()));
902}
903
905 const std::vector<size_t>& indices,
906 uint8_t new_size) {
907 mutation_status_ = absl::OkStatus();
908 auto* room = GetRoom(room_id);
909 if (!room || indices.empty())
910 return;
911
912 auto& objects = room->GetTileObjects();
913 const auto can_update_size = [&](const zelda3::RoomObject& object) {
914 if (!zelda3::IsRoomObjectSizeEditable(object.id_)) {
915 return false;
916 }
917 if (!zelda3::IsRoomObjectResizable(object.id_)) {
918 const auto& manager = zelda3::CustomObjectManager::Get();
919 return new_size <= 0x0F &&
920 new_size < manager.GetSubtypeCount(object.id_) &&
921 !manager.ResolveFilename(object.id_, new_size).empty();
922 }
923 return true;
924 };
925 const bool has_change =
926 std::any_of(indices.begin(), indices.end(), [&](size_t index) {
927 if (index >= objects.size() || !can_update_size(objects[index])) {
928 return false;
929 }
930 return objects[index].size_ !=
931 zelda3::CanonicalRoomObjectSize(objects[index].id_, new_size);
932 });
933 if (!has_change) {
934 return;
935 }
936 auto candidate = objects;
937
938 for (size_t index : indices) {
939 if (index < objects.size() && can_update_size(candidate[index])) {
940 const uint8_t canonical_size =
941 zelda3::CanonicalRoomObjectSize(candidate[index].id_, new_size);
942 if (candidate[index].size_ == canonical_size) {
943 continue;
944 }
945 candidate[index].size_ = canonical_size;
946 candidate[index].tiles_loaded_ = false;
947 }
948 }
949 const auto admission = ValidateObjectCandidates(
950 objects, candidate, IdentitySources(objects.size()));
951 if (!admission.ok()) {
952 RejectMutation(admission);
953 return;
954 }
955 if (ctx_)
956 ctx_->NotifyMutation(MutationDomain::kTileObjects);
957 objects = std::move(candidate);
958 NotifyChange(room);
959}
960
961bool TileObjectHandler::UpdateObjectsLayer(int room_id,
962 const std::vector<size_t>& indices,
963 int new_layer) {
964 mutation_status_ = absl::OkStatus();
965 auto* room = GetRoom(room_id);
966 if (!room || indices.empty())
967 return false;
968 if (new_layer < 0 || new_layer > 2) {
969 mutation_status_ =
970 absl::InvalidArgumentError("Invalid object stream target");
971 LOG_WARN("TileObjectHandler",
972 "Rejected layer update with invalid target layer: %d", new_layer);
973 return false;
974 }
975 auto& objects = room->GetTileObjects();
976 std::vector<size_t> deduped_indices;
977 deduped_indices.reserve(indices.size());
978 std::unordered_set<size_t> seen_indices;
979 for (size_t index : indices) {
980 if (index >= objects.size()) {
981 continue;
982 }
983 if (seen_indices.insert(index).second) {
984 deduped_indices.push_back(index);
985 }
986 }
987 if (deduped_indices.empty()) {
988 return false;
989 }
990
991 if (deduped_indices.size() > kMaxLayerBatchMutation) {
992 mutation_status_ =
993 absl::ResourceExhaustedError("Object stream batch is too large");
994 LOG_WARN("TileObjectHandler",
995 "Rejected layer batch mutation of %zu objects (max %zu)",
996 deduped_indices.size(), kMaxLayerBatchMutation);
997 return false;
998 }
999
1000 auto candidate_objects = objects;
1001 auto mutation = zelda3::ReassignObjectStorage(candidate_objects,
1002 deduped_indices, new_layer);
1003 if (!mutation.ok()) {
1004 mutation_status_ = mutation.status();
1005 LOG_WARN("TileObjectHandler", "Rejected object stream mutation: %s",
1006 std::string(mutation.status().message()).c_str());
1007 return false;
1008 }
1009 if (!mutation->changed) {
1010 return true;
1011 }
1012
1013 std::vector<std::optional<size_t>> sources(objects.size());
1014 for (size_t old_index = 0; old_index < objects.size(); ++old_index) {
1015 sources[mutation->old_to_new_index[old_index]] = old_index;
1016 }
1017 return CommitCandidate(room_id, std::move(candidate_objects), sources, {},
1018 mutation->selected_indices);
1019}
1020
1021std::vector<size_t> TileObjectHandler::DuplicateObjects(
1022 int room_id, const std::vector<size_t>& indices, int delta_x, int delta_y) {
1023 mutation_status_ = absl::OkStatus();
1024 auto* room = GetRoom(room_id);
1025 if (!room || indices.empty())
1026 return {};
1027 const auto& objects = room->GetTileObjects();
1028 auto candidate = objects;
1029 auto sources = IdentitySources(objects.size());
1030 std::vector<size_t> new_indices;
1031 std::unordered_set<size_t> seen;
1032 for (size_t index : indices) {
1033 if (index < objects.size() && seen.insert(index).second) {
1034 auto clone = objects[index].CopyForNewPlacement();
1035 if (zelda3::UsesRoomObjectStream(clone) &&
1037 clone.set_options(clone.options() | zelda3::ObjectOption::Chest);
1038 }
1039 clone.x_ = std::clamp(static_cast<int>(clone.x_ + delta_x), 0, 63);
1040 clone.y_ = std::clamp(static_cast<int>(clone.y_ + delta_y), 0, 63);
1041 new_indices.push_back(candidate.size());
1042 candidate.push_back(std::move(clone));
1043 sources.push_back(index);
1044 }
1045 }
1046 if (new_indices.empty() || !CommitCandidate(room_id, std::move(candidate),
1047 sources, {}, new_indices)) {
1048 return {};
1049 }
1050 return new_indices;
1051}
1052
1053bool TileObjectHandler::DeleteObjects(int room_id,
1054 std::vector<size_t> indices) {
1055 mutation_status_ = absl::OkStatus();
1056 auto* room = GetRoom(room_id);
1057 if (!room || indices.empty())
1058 return false;
1059 const auto& objects = room->GetTileObjects();
1060 const auto removed = MakeValidIndexSet(indices, objects.size());
1061 if (removed.empty()) {
1062 return false;
1063 }
1064 std::vector<zelda3::RoomObject> candidate;
1065 std::vector<std::optional<size_t>> sources;
1066 for (size_t index = 0; index < objects.size(); ++index) {
1067 if (!removed.contains(index)) {
1068 candidate.push_back(objects[index]);
1069 sources.push_back(index);
1070 }
1071 }
1072 return CommitCandidate(room_id, std::move(candidate), sources, {},
1073 std::vector<size_t>{});
1074}
1075
1076bool TileObjectHandler::DeleteAllObjects(int room_id) {
1077 return CommitCandidate(room_id, {}, {}, {}, std::vector<size_t>{});
1078}
1079
1080void TileObjectHandler::SendToFront(int room_id,
1081 const std::vector<size_t>& indices) {
1082 mutation_status_ = absl::OkStatus();
1083 auto* room = GetRoom(room_id);
1084 if (!room || indices.empty())
1085 return;
1086 auto objects = room->GetTileObjects();
1087 auto selected_set = MakeValidIndexSet(indices, objects.size());
1088 if (selected_set.empty()) {
1089 return;
1090 }
1091 auto buckets = BuildLayerBuckets(objects, selected_set);
1092 for (auto& bucket : buckets) {
1093 std::vector<LayerOrderEntry> other;
1094 std::vector<LayerOrderEntry> selected;
1095 other.reserve(bucket.size());
1096 selected.reserve(bucket.size());
1097 for (auto& entry : bucket) {
1098 if (entry.selected) {
1099 selected.push_back(std::move(entry));
1100 } else {
1101 other.push_back(std::move(entry));
1102 }
1103 }
1104 bucket = std::move(other);
1105 bucket.insert(bucket.end(), std::make_move_iterator(selected.begin()),
1106 std::make_move_iterator(selected.end()));
1107 }
1108 std::vector<std::optional<size_t>> sources;
1109 std::vector<size_t> selected_indices;
1110 FlattenLayerBuckets(objects, buckets, sources, selected_indices);
1111 CommitCandidate(room_id, std::move(objects), sources, {}, selected_indices);
1112}
1113
1114void TileObjectHandler::SendToBack(int room_id,
1115 const std::vector<size_t>& indices) {
1116 mutation_status_ = absl::OkStatus();
1117 auto* room = GetRoom(room_id);
1118 if (!room || indices.empty())
1119 return;
1120 auto objects = room->GetTileObjects();
1121 auto selected_set = MakeValidIndexSet(indices, objects.size());
1122 if (selected_set.empty()) {
1123 return;
1124 }
1125 auto buckets = BuildLayerBuckets(objects, selected_set);
1126 for (auto& bucket : buckets) {
1127 std::vector<LayerOrderEntry> selected;
1128 std::vector<LayerOrderEntry> other;
1129 selected.reserve(bucket.size());
1130 other.reserve(bucket.size());
1131 for (auto& entry : bucket) {
1132 if (entry.selected) {
1133 selected.push_back(std::move(entry));
1134 } else {
1135 other.push_back(std::move(entry));
1136 }
1137 }
1138 bucket = std::move(selected);
1139 bucket.insert(bucket.end(), std::make_move_iterator(other.begin()),
1140 std::make_move_iterator(other.end()));
1141 }
1142 std::vector<std::optional<size_t>> sources;
1143 std::vector<size_t> selected_indices;
1144 FlattenLayerBuckets(objects, buckets, sources, selected_indices);
1145 CommitCandidate(room_id, std::move(objects), sources, {}, selected_indices);
1146}
1147
1148void TileObjectHandler::MoveForward(int room_id,
1149 const std::vector<size_t>& indices) {
1150 mutation_status_ = absl::OkStatus();
1151 auto* room = GetRoom(room_id);
1152 if (!room || indices.empty())
1153 return;
1154 auto objects = room->GetTileObjects();
1155 auto selected_set = MakeValidIndexSet(indices, objects.size());
1156 if (selected_set.empty()) {
1157 return;
1158 }
1159 auto buckets = BuildLayerBuckets(objects, selected_set);
1160 for (auto& bucket : buckets) {
1161 if (bucket.size() < 2) {
1162 continue;
1163 }
1164 for (size_t i = bucket.size() - 1; i > 0; --i) {
1165 const size_t previous = i - 1;
1166 if (bucket[previous].selected && !bucket[i].selected) {
1167 std::swap(bucket[previous], bucket[i]);
1168 }
1169 }
1170 }
1171 std::vector<std::optional<size_t>> sources;
1172 std::vector<size_t> selected_indices;
1173 FlattenLayerBuckets(objects, buckets, sources, selected_indices);
1174 CommitCandidate(room_id, std::move(objects), sources, {}, selected_indices);
1175}
1176
1177void TileObjectHandler::MoveBackward(int room_id,
1178 const std::vector<size_t>& indices) {
1179 mutation_status_ = absl::OkStatus();
1180 auto* room = GetRoom(room_id);
1181 if (!room || indices.empty())
1182 return;
1183 auto objects = room->GetTileObjects();
1184 auto selected_set = MakeValidIndexSet(indices, objects.size());
1185 if (selected_set.empty()) {
1186 return;
1187 }
1188 auto buckets = BuildLayerBuckets(objects, selected_set);
1189 for (auto& bucket : buckets) {
1190 if (bucket.size() < 2) {
1191 continue;
1192 }
1193 for (size_t i = 1; i < bucket.size(); ++i) {
1194 const size_t previous = i - 1;
1195 if (bucket[i].selected && !bucket[previous].selected) {
1196 std::swap(bucket[i], bucket[previous]);
1197 }
1198 }
1199 }
1200 std::vector<std::optional<size_t>> sources;
1201 std::vector<size_t> selected_indices;
1202 FlattenLayerBuckets(objects, buckets, sources, selected_indices);
1203 CommitCandidate(room_id, std::move(objects), sources, {}, selected_indices);
1204}
1205
1206bool TileObjectHandler::ResizeObjects(int room_id,
1207 const std::vector<size_t>& indices,
1208 int delta, bool horizontal,
1209 bool uniform) {
1210 mutation_status_ = absl::OkStatus();
1211 auto* room = GetRoom(room_id);
1212 if (!room || indices.empty())
1213 return false;
1214 if (ctx_->current_room_id != room_id) {
1215 return RejectMutation(absl::FailedPreconditionError(
1216 "Select the destination room before changing its objects"));
1217 }
1218 auto& objects = room->GetTileObjects();
1219 const auto before = objects;
1220 const auto resized_size = [&](const zelda3::RoomObject& object) {
1222 object.id_, object.size_, delta)
1223 : zelda3::ResizeRoomObjectByDelta(object.id_, object.size_,
1224 delta, horizontal);
1225 };
1226 const bool has_change =
1227 std::any_of(indices.begin(), indices.end(), [&](size_t index) {
1228 return index < objects.size() &&
1229 zelda3::IsRoomObjectResizable(objects[index].id_) &&
1230 objects[index].size_ != resized_size(objects[index]);
1231 });
1232 if (!has_change) {
1233 return false;
1234 }
1235 auto candidate = objects;
1236
1237 for (size_t index : indices) {
1238 if (index < objects.size() &&
1239 zelda3::IsRoomObjectResizable(candidate[index].id_)) {
1240 const uint8_t new_size = resized_size(candidate[index]);
1241 if (candidate[index].size_ == new_size) {
1242 continue;
1243 }
1244 candidate[index].size_ = new_size;
1245 candidate[index].tiles_loaded_ = false;
1246 }
1247 }
1248 const auto admission = ValidateObjectCandidates(
1249 objects, candidate, IdentitySources(objects.size()));
1250 if (!admission.ok()) {
1251 RejectMutation(admission);
1252 return false;
1253 }
1254 if (object_mutation_preflight_) {
1255 const auto* source_context = ctx_;
1256 mutation_status_ =
1257 object_mutation_preflight_(room_id, candidate, room->GetChests());
1258 if (!mutation_status_.ok()) {
1259 return RejectMutation(mutation_status_);
1260 }
1261 if (ctx_ != source_context || !ctx_ || ctx_->current_room_id != room_id ||
1262 GetRoom(room_id) != room ||
1263 !SameAuthoredObjects(room->GetTileObjects(), before)) {
1264 return RejectMutation(absl::FailedPreconditionError(
1265 "Room objects changed during edit preflight; repeat the edit"));
1266 }
1267 }
1268 if (ctx_)
1269 ctx_->NotifyMutation(MutationDomain::kTileObjects);
1270 objects = std::move(candidate);
1271 NotifyChange(room);
1272 return true;
1273}
1274
1275bool TileObjectHandler::PlaceObjectAt(int room_id,
1276 const zelda3::RoomObject& object, int x,
1277 int y) {
1278 mutation_status_ = absl::OkStatus();
1279 auto* room = GetRoom(room_id);
1280 if (!room) {
1281 placement_block_reason_ = PlacementBlockReason::kInvalidRoom;
1282 return RejectMutation(absl::InvalidArgumentError("Room is unavailable"));
1283 }
1284
1285 // Hard-stop: enforce ROM object limit before committing placement.
1286 if (room->GetTileObjects().size() >= zelda3::kMaxTileObjects) {
1287 placement_block_reason_ = PlacementBlockReason::kObjectLimit;
1288 return RejectMutation(
1289 absl::ResourceExhaustedError("Room object limit reached"));
1290 }
1291
1292 placement_block_reason_ = PlacementBlockReason::kNone;
1293 auto new_obj = object.CopyForNewPlacement();
1294 if (zelda3::UsesRoomObjectStream(new_obj) &&
1295 zelda3::IsStatefulChestObjectId(new_obj.id_)) {
1296 new_obj.set_options(new_obj.options() | zelda3::ObjectOption::Chest);
1297 }
1298 new_obj.x_ = std::clamp(x, 0, 63);
1299 new_obj.y_ = std::clamp(y, 0, 63);
1300 const size_t placed_index = room->GetTileObjects().size();
1301 auto candidate = room->GetTileObjects();
1302 auto sources = IdentitySources(candidate.size());
1303 candidate.push_back(new_obj);
1304 sources.push_back(std::nullopt);
1305 if (!CommitCandidate(room_id, std::move(candidate), sources, {},
1306 std::vector<size_t>{placed_index})) {
1307 placement_block_reason_ = PlacementBlockReason::kChestValidation;
1308 return false;
1309 }
1310 if (placement_policy_ == PlacementPolicy::kOnce) {
1311 CancelPlacement();
1312 }
1313 if (ctx_ && ctx_->selection) {
1314 ctx_->NotifySelectionChanged();
1315 }
1316 TriggerSuccessToast();
1317 if (placement_callback_) {
1318 placement_callback_(new_obj);
1319 }
1320 return true;
1321}
1322
1323void TileObjectHandler::SetPreviewObject(const zelda3::RoomObject& object) {
1324 preview_object_ = object;
1325 RefreshPreviewGraphics();
1326}
1327
1328bool TileObjectHandler::SetPreviewSize(uint8_t size) {
1329 if (!object_placement_mode_ ||
1330 !zelda3::IsRoomObjectSizeEditable(preview_object_.id_)) {
1331 return false;
1332 }
1333 if (!zelda3::IsRoomObjectResizable(preview_object_.id_)) {
1334 const auto& manager = zelda3::CustomObjectManager::Get();
1335 if (size > 0x0F || size >= manager.GetSubtypeCount(preview_object_.id_) ||
1336 manager.ResolveFilename(preview_object_.id_, size).empty()) {
1337 return false;
1338 }
1339 }
1340 const uint8_t canonical_size =
1341 zelda3::CanonicalRoomObjectSize(preview_object_.id_, size);
1342 if (preview_object_.size_ != canonical_size) {
1343 preview_object_.size_ = canonical_size;
1344 preview_object_.tiles_loaded_ = false;
1345 RefreshPreviewGraphics();
1346 }
1347 return true;
1348}
1349
1350bool TileObjectHandler::SetPreviewLayer(int layer) {
1351 if (!object_placement_mode_ || layer < 0 || layer > 2 ||
1352 (layer == 2 && zelda3::UsesSpecialLayerSelector(preview_object_))) {
1353 return false;
1354 }
1355 if (preview_object_.GetLayerValue() != layer) {
1356 preview_object_.layer_ = static_cast<zelda3::RoomObject::LayerType>(layer);
1357 preview_object_.tiles_loaded_ = false;
1358 RefreshPreviewGraphics();
1359 }
1360 return true;
1361}
1362
1363void TileObjectHandler::RefreshPreviewGraphics() {
1364 if (object_placement_mode_) {
1365 RenderGhostPreviewBitmap();
1366 }
1367}
1368
1369void TileObjectHandler::RenderGhostPreviewBitmap() {
1370 ghost_preview_bitmap_ready_ = false;
1371 if (!ctx_ || !ctx_->rom || !ctx_->rom->is_loaded())
1372 return;
1373
1374 auto* room = GetRoom(ctx_->current_room_id);
1375 if (!room || !room->IsLoaded())
1376 return;
1377
1378 const auto preview_geometry = CalculateGhostPreviewGeometry(preview_object_);
1379
1380 // Keep one room-sized scratch texture for the handler lifetime. Replacing a
1381 // textured bitmap during ImGui frame construction can invalidate draw-list
1382 // texture handles, and repeatedly allocating previews leaks those handles.
1383 if (!ghost_preview_buffer_) {
1384 ghost_preview_buffer_ = std::make_unique<gfx::BackgroundBuffer>(
1385 kGhostPreviewBufferSize, kGhostPreviewBufferSize);
1386 }
1387 ghost_preview_buffer_->EnsureBitmapInitialized();
1388 ghost_preview_buffer_->ClearBuffer();
1389 auto& bitmap = ghost_preview_buffer_->bitmap();
1390 ApplyRoomPaletteToGhost(*room, bitmap);
1391 std::fill(bitmap.mutable_data().begin(), bitmap.mutable_data().end(), 255);
1392 bitmap.set_modified(true);
1393 const uint8_t* gfx_data = room->get_gfx_buffer().data();
1394
1395 zelda3::ObjectDrawer drawer(ctx_->rom, ctx_->current_room_id, gfx_data);
1396 drawer.InitializeDrawRoutines();
1397 drawer.SetRoomFloorGraphics(room->floor1(), room->floor2());
1398
1399 // Replay at the same safe anchor ObjectGeometry uses for measurement so
1400 // routines that draw upward or leftward do not clip against buffer origin.
1401 auto render_object = preview_object_;
1402 render_object.x_ = preview_geometry.render_anchor_x_tiles;
1403 render_object.y_ = preview_geometry.render_anchor_y_tiles;
1404 // A placement preview is a flattened visual. Force the replay through BG1
1405 // so a sampled BG2-stream object cannot mask pixels in the same scratch
1406 // buffer that it just rendered.
1407 render_object.layer_ = zelda3::RoomObject::LayerType::BG1;
1408 auto status =
1409 drawer.DrawObject(render_object, *ghost_preview_buffer_,
1410 *ghost_preview_buffer_, ctx_->current_palette_group);
1411 if (!status.ok()) {
1412 return;
1413 }
1414
1415 if (bitmap.size() > 0) {
1416 bitmap.UpdateSurfacePixels();
1417 if (bitmap.texture()) {
1418 ghost_preview_create_queued_ = false;
1421 } else if (!ghost_preview_create_queued_) {
1424 ghost_preview_create_queued_ = true;
1425 }
1427 if (bitmap.texture()) {
1428 ghost_preview_create_queued_ = false;
1429 }
1430 ghost_preview_bitmap_ready_ = true;
1431 ghost_preview_room_id_ = ctx_->current_room_id;
1432 ghost_preview_graphics_revision_ = room->graphics_revision();
1433 }
1434}
1435
1437TileObjectHandler::CalculateGhostPreviewGeometry(
1438 const zelda3::RoomObject& object) {
1439 const auto dimensions = zelda3::DimensionService::Get().GetDimensions(object);
1440 const auto [resolved_anchor_x, resolved_anchor_y] =
1441 zelda3::ObjectGeometry::Get().ResolveAnchor(object.id_, object.size_);
1442 // ResolveAnchor supplies routine-specific headroom. Dimension offsets cover
1443 // fallback/custom geometry too, so keep enough room for either source.
1444 const int anchor_x =
1445 std::max({0, resolved_anchor_x, -dimensions.offset_x_tiles});
1446 const int anchor_y =
1447 std::max({0, resolved_anchor_y, -dimensions.offset_y_tiles});
1448 const int leading_x_tiles = anchor_x + dimensions.offset_x_tiles;
1449 const int leading_y_tiles = anchor_y + dimensions.offset_y_tiles;
1450 return GhostPreviewGeometry{
1451 .render_anchor_x_tiles = anchor_x,
1452 .render_anchor_y_tiles = anchor_y,
1453 .offset_x_tiles = dimensions.offset_x_tiles,
1454 .offset_y_tiles = dimensions.offset_y_tiles,
1455 .width_pixels = dimensions.width_pixels(),
1456 .height_pixels = dimensions.height_pixels(),
1457 .buffer_width_pixels = (leading_x_tiles + dimensions.width_tiles) * 8,
1458 .buffer_height_pixels = (leading_y_tiles + dimensions.height_tiles) * 8,
1459 };
1460}
1461
1462// ========================================================================
1463// Clipboard Operations
1464// ========================================================================
1465
1466void TileObjectHandler::CopyObjectsToClipboard(
1467 int room_id, const std::vector<size_t>& indices) {
1468 auto* room = GetRoom(room_id);
1469 if (!room || indices.empty())
1470 return;
1471
1472 ClearClipboard();
1473 const auto& objects = room->GetTileObjects();
1474 const bool includes_chest =
1475 std::any_of(indices.begin(), indices.end(), [&](size_t index) {
1476 return index < objects.size() &&
1477 zelda3::IsStatefulChestObjectId(objects[index].id_);
1478 });
1479 if (includes_chest) {
1480 clipboard_status_ =
1481 zelda3::ValidateChestObjectMapping(objects, room->GetChests());
1482 }
1483 for (size_t index : indices) {
1484 if (index >= objects.size()) {
1485 continue;
1486 }
1487 clipboard_.push_back(objects[index]);
1488 std::optional<chest_data> chest;
1489 if (zelda3::IsStatefulChestObjectId(objects[index].id_)) {
1490 const auto chest_index = zelda3::ChestIndexForObject(objects, index);
1491 if (!chest_index || *chest_index >= room->GetChests().size() ||
1492 room->GetChests()[*chest_index].size !=
1493 (objects[index].id_ == 0xFB1)) {
1494 clipboard_status_ = absl::FailedPreconditionError(
1495 "Cannot copy a chest with missing or mismatched contents");
1496 } else {
1497 chest = room->GetChests()[*chest_index];
1498 }
1499 }
1500 clipboard_chests_.push_back(chest);
1501 }
1502}
1503
1504std::vector<size_t> TileObjectHandler::PasteFromClipboard(int room_id,
1505 int offset_x,
1506 int offset_y) {
1507 mutation_status_ = clipboard_status_;
1508 auto* room = GetRoom(room_id);
1509 if (!room || clipboard_.empty())
1510 return {};
1511 if (!mutation_status_.ok()) {
1512 RejectMutation(mutation_status_);
1513 return {};
1514 }
1515 auto candidate = room->GetTileObjects();
1516 auto sources = IdentitySources(candidate.size());
1517 std::vector<std::optional<chest_data>> overrides(candidate.size());
1518 std::vector<size_t> new_indices;
1519 for (size_t index = 0; index < clipboard_.size(); ++index) {
1520 auto object = clipboard_[index].CopyForNewPlacement();
1521 if (zelda3::UsesRoomObjectStream(object) &&
1522 zelda3::IsStatefulChestObjectId(object.id_)) {
1523 object.set_options(object.options() | zelda3::ObjectOption::Chest);
1524 }
1525 object.x_ = std::clamp(object.x_ + offset_x, 0, 63);
1526 object.y_ = std::clamp(object.y_ + offset_y, 0, 63);
1527 object.tiles_loaded_ = false;
1528 new_indices.push_back(candidate.size());
1529 candidate.push_back(std::move(object));
1530 sources.push_back(std::nullopt);
1531 overrides.push_back(clipboard_chests_[index]);
1532 }
1533 if (!CommitCandidate(room_id, std::move(candidate), sources, overrides,
1534 new_indices)) {
1535 return {};
1536 }
1537 return new_indices;
1538}
1539
1540std::vector<size_t> TileObjectHandler::PasteFromClipboardAt(int room_id,
1541 int target_x,
1542 int target_y) {
1543 if (clipboard_.empty())
1544 return {};
1545
1546 int offset_x = target_x - clipboard_[0].x_;
1547 int offset_y = target_y - clipboard_[0].y_;
1548
1549 return PasteFromClipboard(room_id, offset_x, offset_y);
1550}
1551
1552} // 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.