yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
dungeon_object_interaction.cc
Go to the documentation of this file.
1// Related header
3#include "absl/strings/str_format.h"
5
6// C++ standard library headers
7#include <algorithm>
8#include <cmath>
9
10// Third-party library headers
11#include "imgui/imgui.h"
12
13// Project headers
18#include "app/gui/core/icons.h"
20#include "core/features.h"
23
24namespace yaze::editor {
25
26namespace {
27
28float ObjectResizeWheelDelta(const ImGuiIO& io) {
29 // macOS converts Shift+vertical wheel into horizontal input before SDL
30 // delivers it. Other platforms retain the vertical delta. Prefer that delta
31 // when both axes are present so one wheel event only resizes once.
32 return io.MouseWheel != 0.0f || !io.KeyShift ? io.MouseWheel : io.MouseWheelH;
33}
34
35} // namespace
36
38 ImGuiIO& io = ImGui::GetIO();
39 const bool hovered = canvas_->IsMouseHovering();
40 const bool mouse_left_down = ImGui::IsMouseDown(ImGuiMouseButton_Left);
41 const bool mouse_left_released =
42 ImGui::IsMouseReleased(ImGuiMouseButton_Left);
43
44 // Keep processing drag/release if an interaction started on the canvas but
45 // the cursor left the bounds before the mouse button was released.
46 const bool has_active_marquee = selection_.IsRectangleSelectionActive();
47 const bool should_process_without_hover =
48 has_active_marquee ||
51 (mouse_left_down || mouse_left_released)) ||
52 mouse_left_released;
53
54 if (!hovered && !should_process_without_hover) {
55 return;
56 }
57
58 const ImVec2 canvas_mouse_pos =
60 const int canvas_mouse_x = static_cast<int>(std::floor(canvas_mouse_pos.x));
61 const int canvas_mouse_y = static_cast<int>(std::floor(canvas_mouse_pos.y));
62 const bool pointer_within_room =
63 dungeon_coords::IsWithinBounds(canvas_mouse_x, canvas_mouse_y);
64
65 // Handle Escape key to cancel any active placement mode
66 if (ImGui::IsKeyPressed(ImGuiKey_Escape) &&
69 return;
70 }
71
72 if (hovered) {
74 if (HandleKeyboardNudge()) {
75 return;
76 }
77 }
78
79 // Painting modes are exclusive; don't also select/drag/mutate entities.
80 if (hovered && mouse_left_down) {
81 const auto mode = mode_manager_.GetMode();
83 if (pointer_within_room) {
84 UpdateCollisionPainting(canvas_mouse_pos);
85 }
86 return;
87 }
89 if (pointer_within_room) {
90 UpdateWaterFillPainting(canvas_mouse_pos);
91 }
92 return;
93 }
94 }
95
96 if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
97 HandleLeftClick(canvas_mouse_pos);
98 }
99
100 // Dispatch drag to coordinator (handlers gate internally via drag state).
101 if (ImGui::IsMouseDragging(ImGuiMouseButton_Left)) {
102 entity_coordinator_.HandleDrag(canvas_mouse_pos, io.MouseDelta);
103 }
104
105 // Handle mouse release - complete drag operation
106 if (mouse_left_released) {
108 }
109}
110
111void DungeonObjectInteraction::HandleLeftClick(const ImVec2& canvas_mouse_pos) {
112 int canvas_x = static_cast<int>(std::floor(canvas_mouse_pos.x));
113 int canvas_y = static_cast<int>(std::floor(canvas_mouse_pos.y));
114
115 // Try to handle click via entity coordinator (handles placement, entity selection, and object selection)
116 const bool was_placing = entity_coordinator_.IsPlacementActive();
117 if (entity_coordinator_.HandleClick(canvas_x, canvas_y)) {
118 // If a selected room element was clicked, prime drag state. Plain clicks on
119 // selected mixed members preserve the whole selection; movement begins only
120 // if the mouse actually drags.
121 if (!was_placing && !entity_coordinator_.IsPlacementActive() &&
123 HandleObjectSelectionStart(canvas_mouse_pos);
124 }
125 return;
126 }
127
128 // The canvas viewport can contain blank space after panning. Overlay clicks
129 // are dispatched above, but blank space outside the translated room must not
130 // clear selection or start a marquee.
131 if (!dungeon_coords::IsWithinBounds(canvas_x, canvas_y)) {
132 return;
133 }
134
135 // Not an entity click or placement; handle empty space
136 HandleEmptySpaceClick(canvas_mouse_pos);
137}
138
140 const ImVec2& canvas_mouse_pos) {
141 auto [room_x, room_y] =
142 CanvasToRoomCoordinates(static_cast<int>(canvas_mouse_pos.x),
143 static_cast<int>(canvas_mouse_pos.y));
144 if (rooms_ && current_room_id_ >= 0 && current_room_id_ < 296) {
145 auto& room = (*rooms_)[current_room_id_];
146 auto& state = mode_manager_.GetModeState();
147
148 // Only set for valid interior tiles (0-63)
149 if (room_x >= 0 && room_x < 64 && room_y >= 0 && room_y < 64) {
150 // Start a paint stroke (single undo snapshot per stroke).
151 if (!state.is_painting) {
152 state.is_painting = true;
153 state.paint_mutation_started = false;
154 state.paint_last_tile_x = room_x;
155 state.paint_last_tile_y = room_y;
156 }
157
158 const int x0 =
159 (state.paint_last_tile_x >= 0) ? state.paint_last_tile_x : room_x;
160 const int y0 =
161 (state.paint_last_tile_y >= 0) ? state.paint_last_tile_y : room_y;
162
163 bool changed = false;
164 auto ensure_mutation = [&]() {
165 if (!state.paint_mutation_started) {
167 state.paint_mutation_started = true;
168 }
169 };
170
172 x0, y0, room_x, room_y, [&](int lx, int ly) {
174 lx, ly, state.paint_brush_radius,
175 /*min_x=*/0, /*min_y=*/0, /*max_x=*/63, /*max_y=*/63,
176 [&](int bx, int by) {
177 if (room.HasCollisionOverride(bx, by) &&
178 room.GetCollisionTile(bx, by) ==
179 state.paint_collision_value) {
180 return;
181 }
182 ensure_mutation();
183 room.SetCollisionTile(bx, by, state.paint_collision_value);
184 changed = true;
185 });
186 });
187
188 if (changed) {
191 }
192
193 state.paint_last_tile_x = room_x;
194 state.paint_last_tile_y = room_y;
195 }
196 }
197}
198
199void DungeonObjectInteraction::UpdateWaterFillPainting(
200 const ImVec2& canvas_mouse_pos) {
201 if (!core::FeatureFlags::get().dungeon.kSaveWaterFillZones) {
202 mode_manager_.SetMode(InteractionMode::Select);
203 return;
204 }
205
206 const ImGuiIO& io = ImGui::GetIO();
207 const bool erase = io.KeyAlt;
208
209 auto [room_x, room_y] =
210 CanvasToRoomCoordinates(static_cast<int>(canvas_mouse_pos.x),
211 static_cast<int>(canvas_mouse_pos.y));
212 if (rooms_ && current_room_id_ >= 0 && current_room_id_ < 296) {
213 auto& room = (*rooms_)[current_room_id_];
214 auto& state = mode_manager_.GetModeState();
215
216 // Only set for valid interior tiles (0-63)
217 if (room_x >= 0 && room_x < 64 && room_y >= 0 && room_y < 64) {
218 const bool new_val = !erase;
219 // Start a paint stroke (single undo snapshot per stroke).
220 if (!state.is_painting) {
221 state.is_painting = true;
222 state.paint_mutation_started = false;
223 state.paint_last_tile_x = room_x;
224 state.paint_last_tile_y = room_y;
225 }
226
227 const int x0 =
228 (state.paint_last_tile_x >= 0) ? state.paint_last_tile_x : room_x;
229 const int y0 =
230 (state.paint_last_tile_y >= 0) ? state.paint_last_tile_y : room_y;
231
232 bool changed = false;
233 auto ensure_mutation = [&]() {
234 if (!state.paint_mutation_started) {
235 interaction_context_.NotifyMutation(MutationDomain::kWaterFill);
236 state.paint_mutation_started = true;
237 }
238 };
239
240 paint_util::ForEachPointOnLine(
241 x0, y0, room_x, room_y, [&](int lx, int ly) {
242 paint_util::ForEachPointInSquareBrush(
243 lx, ly, state.paint_brush_radius,
244 /*min_x=*/0, /*min_y=*/0, /*max_x=*/63, /*max_y=*/63,
245 [&](int bx, int by) {
246 if (room.GetWaterFillTile(bx, by) == new_val) {
247 return;
248 }
249 ensure_mutation();
250 room.SetWaterFillTile(bx, by, new_val);
251 changed = true;
252 });
253 });
254
255 if (changed) {
256 interaction_context_.NotifyInvalidateCache(MutationDomain::kWaterFill);
257 }
258
259 state.paint_last_tile_x = room_x;
260 state.paint_last_tile_y = room_y;
261 }
262 }
263}
264
265void DungeonObjectInteraction::HandleObjectSelectionStart(
266 const ImVec2& canvas_mouse_pos) {
267 const bool has_object_selection = selection_.HasSelection();
268 const bool has_entity_selection = HasEntitySelection();
269 if (!has_object_selection && !has_entity_selection) {
270 return;
271 }
272
273 mode_manager_.SetMode(InteractionMode::DraggingObjects);
274 if (has_object_selection) {
275 entity_coordinator_.tile_handler().InitDrag(canvas_mouse_pos);
276 }
277 if (has_entity_selection) {
278 entity_coordinator_.BeginSelectionDrag(canvas_mouse_pos);
279 }
280}
281
282void DungeonObjectInteraction::HandleEmptySpaceClick(
283 const ImVec2& canvas_mouse_pos) {
284 const ImGuiIO& io = ImGui::GetIO();
285 const bool additive = io.KeyShift || io.KeyCtrl || io.KeySuper;
286 const bool had_selection =
287 selection_.HasSelection() || entity_coordinator_.HasEntitySelection();
288
289 // ZScream treats an empty click against an existing selection as a clear
290 // action. Rectangle selection starts from a clean canvas gesture.
291 if (!additive) {
292 ClearEntitySelection();
293 selection_.ClearSelection();
294 }
295
296 if (!had_selection) {
297 entity_coordinator_.tile_handler().BeginMarqueeSelection(canvas_mouse_pos);
298 }
299}
300
301void DungeonObjectInteraction::FinishPaintStroke() {
302 // End the stroke without deselecting its tool. This also runs before a room
303 // change, while invalidation still addresses the room that was painted.
304 const auto mode = mode_manager_.GetMode();
305 if (mode != InteractionMode::PaintCollision &&
306 mode != InteractionMode::PaintWaterFill) {
307 return;
308 }
309 auto& state = mode_manager_.GetModeState();
310 const bool had_mutation = state.paint_mutation_started;
311 state.is_painting = false;
312 state.paint_mutation_started = false;
313 state.paint_last_tile_x = -1;
314 state.paint_last_tile_y = -1;
315 if (had_mutation) {
316 interaction_context_.NotifyInvalidateCache(
317 (mode == InteractionMode::PaintCollision)
318 ? MutationDomain::kCustomCollision
319 : MutationDomain::kWaterFill);
320 }
321}
322
323void DungeonObjectInteraction::HandleMouseRelease() {
324 FinishPaintStroke();
325
326 if (mode_manager_.GetMode() == InteractionMode::DraggingObjects) {
327 mode_manager_.SetMode(InteractionMode::Select);
328 }
329 entity_coordinator_.HandleRelease();
330 // Marquee selection finalization is handled by TileObjectHandler via
331 // CheckForObjectSelection().
332}
333
334bool DungeonObjectInteraction::HandleKeyboardNudge() {
335 if (!rooms_ || current_room_id_ < 0 ||
336 current_room_id_ >= static_cast<int>(rooms_->size())) {
337 return false;
338 }
339
340 const ImGuiIO& io = ImGui::GetIO();
341 if (ImGui::IsAnyItemActive() || io.KeyCtrl || io.KeySuper || io.KeyAlt ||
342 ImGui::IsMouseDown(ImGuiMouseButton_Left) ||
343 entity_coordinator_.IsPlacementActive()) {
344 return false;
345 }
346
347 const auto mode = mode_manager_.GetMode();
348 if (mode == InteractionMode::DraggingObjects ||
349 mode == InteractionMode::PaintCollision ||
350 mode == InteractionMode::PaintWaterFill) {
351 return false;
352 }
353
354 int delta_x = 0;
355 int delta_y = 0;
356 if (ImGui::IsKeyPressed(ImGuiKey_LeftArrow, true)) {
357 delta_x = -1;
358 } else if (ImGui::IsKeyPressed(ImGuiKey_RightArrow, true)) {
359 delta_x = 1;
360 }
361 if (ImGui::IsKeyPressed(ImGuiKey_UpArrow, true)) {
362 delta_y = -1;
363 } else if (ImGui::IsKeyPressed(ImGuiKey_DownArrow, true)) {
364 delta_y = 1;
365 }
366
367 if (delta_x == 0 && delta_y == 0) {
368 return false;
369 }
370
371 return NudgeSelected(delta_x, delta_y);
372}
373
374bool DungeonObjectInteraction::NudgeSelected(int delta_x, int delta_y) {
375 return entity_coordinator_.NudgeSelected(delta_x, delta_y);
376}
377
378void DungeonObjectInteraction::CheckForObjectSelection() {
379 // Draw/update active marquee selection for tile objects (delegated).
380 const ImGuiIO& io = ImGui::GetIO();
381 const ImVec2 mouse_pos = GetCanvasTransform().ScreenToRoomPixels(io.MousePos);
382 const bool mouse_left_released =
383 ImGui::IsMouseReleased(ImGuiMouseButton_Left);
384
385 if (mouse_left_released && selection_.IsRectangleSelectionActive()) {
386 selection_.UpdateRectangleSelection(static_cast<int>(mouse_pos.x),
387 static_cast<int>(mouse_pos.y));
388 constexpr int kMinRectPixels = 6;
389 if (!io.KeyAlt && selection_.IsRectangleLargeEnough(kMinRectPixels)) {
390 entity_coordinator_.SelectEntitiesInRect(
391 selection_.GetRectangleSelectionBounds(),
392 /*additive=*/false,
393 /*toggle=*/false);
394 }
395 }
396
397 entity_coordinator_.tile_handler().HandleMarqueeSelection(
398 mouse_pos,
399 /*mouse_left_down=*/ImGui::IsMouseDown(ImGuiMouseButton_Left),
400 /*mouse_left_released=*/mouse_left_released,
401 /*shift_down=*/io.KeyShift,
402 /*toggle_down=*/io.KeyCtrl || io.KeySuper,
403 /*alt_down=*/io.KeyAlt);
404}
405
406void DungeonObjectInteraction::DrawSelectionHighlights() {
407 if (!rooms_ || current_room_id_ < 0 || current_room_id_ >= 296)
408 return;
409
410 auto& room = (*rooms_)[current_room_id_];
411 const auto& objects = room.GetTileObjects();
412
413 // Use ObjectSelection's rendering (handles pulsing border, corner handles)
414 selection_.DrawSelectionHighlights(
415 canvas_, objects, [](const zelda3::RoomObject& obj) {
416 auto result = zelda3::DimensionService::Get().GetDimensions(obj);
417 return std::make_tuple(result.offset_x_tiles * 8,
418 result.offset_y_tiles * 8, result.width_pixels(),
419 result.height_pixels());
420 });
421
422 // Enhanced hover tooltip showing object info (always visible on hover)
423 // Skip completely in exclusive entity mode (door/sprite/item selected)
424 if (entity_coordinator_.HasEntitySelection()) {
425 return; // Entity mode active - no object tooltips or hover
426 }
427
428 if (canvas_->IsMouseHovering()) {
429 // Also skip tooltip if cursor is over a door/sprite/item entity (not selected yet)
430 ImGuiIO& io = ImGui::GetIO();
431 const auto [cursor_x, cursor_y] =
432 GetCanvasTransform().ScreenToRoomPixelCoordinates(io.MousePos);
433 auto entity_at_cursor =
434 entity_coordinator_.GetEntityAtPosition(cursor_x, cursor_y);
435 if (entity_at_cursor.has_value()) {
436 // Entity has priority - skip object tooltip, DrawHoverHighlight will also skip
437 DrawHoverHighlight(objects);
438 return;
439 }
440
441 auto hovered_index = entity_coordinator_.tile_handler().GetEntityAtPosition(
442 cursor_x, cursor_y);
443 if (hovered_index.has_value() && *hovered_index < objects.size()) {
444 const auto& object = objects[*hovered_index];
445 std::string object_name = zelda3::GetObjectName(object.id_);
446 int subtype = zelda3::GetObjectSubtype(object.id_);
447 const int layer = object.GetLayerValue();
448
449 // Get subtype name
450 const char* subtype_names[] = {"Unknown", "Type 1", "Type 2", "Type 3"};
451 const char* subtype_name =
452 (subtype >= 0 && subtype <= 3) ? subtype_names[subtype] : "Unknown";
453
454 // Build informative tooltip
455 std::string tooltip;
456 tooltip += object_name;
457 tooltip += " (" + std::string(subtype_name) + ")";
458 tooltip += "\n";
459 tooltip += "ID: 0x" + absl::StrFormat("%03X", object.id_);
460 if (zelda3::UsesRoomObjectStream(object)) {
461 static constexpr const char* kStreamNames[] = {"Primary", "BG2 overlay",
462 "BG1 overlay"};
463 const char* stream_name =
464 (layer >= 0 && layer < 3) ? kStreamNames[layer] : "Unknown";
465 tooltip += " | Object stream: " + std::string(stream_name);
466 } else {
467 const char* layer_name =
468 layer == 0 ? "Upper layer (BG1)" : "Lower layer (BG2)";
469 tooltip += " | Special layer: " + std::string(layer_name);
470 }
471 tooltip += " | Pos: (" + std::to_string(object.x_) + ", " +
472 std::to_string(object.y_) + ")";
473 const int axis_step = zelda3::RoomObjectSizeAxisStep(object.id_);
474 if (axis_step > 0) {
475 tooltip += absl::StrFormat(
476 "\nSize: %d x %d tiles",
477 zelda3::RoomObjectSizeAxisTiles(object.id_, object.size_, true),
478 zelda3::RoomObjectSizeAxisTiles(object.id_, object.size_));
479 } else if (zelda3::IsRoomObjectResizable(object.id_)) {
480 tooltip += absl::StrFormat("\nSize: 0x%02X", object.size_);
481 }
482
483 if (selection_.IsObjectSelected(*hovered_index)) {
484 if (axis_step > 0) {
485 tooltip +=
486 "\n" ICON_MD_MOUSE " Wheel: grow/shrink | Shift+wheel: width";
487 } else if (zelda3::IsRoomObjectResizable(object.id_)) {
488 tooltip += "\n" ICON_MD_MOUSE " Scroll wheel to resize";
489 }
490 tooltip += "\n" ICON_MD_DRAG_INDICATOR " Drag to move";
491 } else {
492 tooltip += "\n" ICON_MD_TOUCH_APP " Click to select";
493 }
494
495 ImGui::SetTooltip("%s", tooltip.c_str());
496 }
497 }
498
499 // Draw hover highlight for non-selected objects
500 DrawHoverHighlight(objects);
501}
502
503void DungeonObjectInteraction::DrawHoverHighlight(
504 const std::vector<zelda3::RoomObject>& objects) {
505 if (!canvas_->IsMouseHovering())
506 return;
507
508 // Skip all object hover in exclusive entity mode (door/sprite/item selected)
509 if (entity_coordinator_.HasEntitySelection())
510 return;
511
512 // Don't show object hover highlight if cursor is over a door/sprite/item entity
513 // Entities take priority over objects for interaction
514 ImGuiIO& io = ImGui::GetIO();
515 const DungeonCanvasTransform transform = GetCanvasTransform();
516 const auto [cursor_canvas_x, cursor_canvas_y] =
517 transform.ScreenToRoomPixelCoordinates(io.MousePos);
518 auto entity_at_cursor =
519 entity_coordinator_.GetEntityAtPosition(cursor_canvas_x, cursor_canvas_y);
520 if (entity_at_cursor.has_value()) {
521 return; // Entity has priority - skip object hover highlight
522 }
523
524 auto hovered_index = entity_coordinator_.tile_handler().GetEntityAtPosition(
525 cursor_canvas_x, cursor_canvas_y);
526 if (!hovered_index.has_value() || *hovered_index >= objects.size()) {
527 return;
528 }
529 const auto& object = objects[*hovered_index];
530
531 // Don't draw hover highlight if object is already selected
532 if (selection_.IsObjectSelected(*hovered_index)) {
533 return;
534 }
535
536 const auto& theme = AgentUI::GetTheme();
537 ImDrawList* draw_list = ImGui::GetWindowDrawList();
538 // Calculate object position and dimensions
539 auto [sel_x_px, sel_y_px, pixel_width, pixel_height] =
541
542 ImVec2 obj_start = transform.RoomPixelsToScreen(
543 ImVec2(static_cast<float>(sel_x_px), static_cast<float>(sel_y_px)));
544 const ImVec2 obj_size = transform.RoomSizeToScreen(ImVec2(
545 static_cast<float>(pixel_width), static_cast<float>(pixel_height)));
546 ImVec2 obj_end(obj_start.x + obj_size.x, obj_start.y + obj_size.y);
547
548 // Expand slightly for visibility
549 constexpr float margin = 2.0f;
550 obj_start.x -= margin;
551 obj_start.y -= margin;
552 obj_end.x += margin;
553 obj_end.y += margin;
554
555 // Draw subtle hover highlight with unified theme color
556 ImVec4 hover_fill = theme.selection_hover;
557 hover_fill.w *= 0.5f; // Make it more subtle for hover fill
558
559 ImVec4 hover_border = theme.selection_hover;
560
561 // Draw filled background for better visibility
562 draw_list->AddRectFilled(obj_start, obj_end, ImGui::GetColorU32(hover_fill));
563
564 // Draw dashed-style border (simulated with thinner line)
565 draw_list->AddRect(obj_start, obj_end, ImGui::GetColorU32(hover_border), 0.0f,
566 0, 1.5f);
567}
568
569void DungeonObjectInteraction::PlaceObjectAtPosition(int room_x, int room_y) {
570 auto& tile_handler = entity_coordinator_.tile_handler();
571 if (!tile_handler.IsPlacementActive()) {
572 return;
573 }
574 auto object = tile_handler.GetPreviewObject().CopyForNewPlacement();
575 object.x_ = std::clamp(room_x, 0, 63);
576 object.y_ = std::clamp(room_y, 0, 63);
577 if (!tile_handler.PlaceObjectAt(interaction_context_.current_room_id, object,
578 room_x, room_y)) {
579 return;
580 }
581 if (object_placed_callback_) {
582 object_placed_callback_(object);
583 }
584}
585
586void DungeonObjectInteraction::CompleteObjectPlacement(
587 const zelda3::RoomObject& /*object*/) {
588 entity_coordinator_.ClearEntitySelection();
589 if (!entity_coordinator_.tile_handler().IsPlacementActive() &&
590 mode_manager_.GetMode() == InteractionMode::PlaceObject) {
591 mode_manager_.SetMode(InteractionMode::Select);
592 }
593}
594
595bool DungeonObjectInteraction::BeginPlacementFromSelection() {
596 const auto indices = selection_.GetSelectedIndices();
597 const auto* room = interaction_context_.GetCurrentRoomConst();
598 if (!room || indices.size() != 1 || HasEntitySelection() ||
599 indices.front() >= room->GetTileObjects().size()) {
600 return false;
601 }
602 const auto object =
603 room->GetTileObjects()[indices.front()].CopyForNewPlacement();
604 SetPreviewObject(object, true);
605 return true;
606}
607
608std::pair<int, int> DungeonObjectInteraction::RoomToCanvasCoordinates(
609 int room_x, int room_y) const {
610 // Dungeon tiles are 8x8 pixels, convert room coordinates (tiles) to pixels
611 return {room_x * 8, room_y * 8};
612}
613
614std::pair<int, int> DungeonObjectInteraction::CanvasToRoomCoordinates(
615 int canvas_x, int canvas_y) const {
616 // Convert canvas pixels back to room coordinates (tiles)
617 return {canvas_x / 8, canvas_y / 8};
618}
619
620bool DungeonObjectInteraction::IsWithinCanvasBounds(int canvas_x, int canvas_y,
621 int margin) const {
622 return dungeon_coords::IsWithinBounds(canvas_x, canvas_y, margin);
623}
624
625void DungeonObjectInteraction::SetCurrentRoom(DungeonRoomStore* rooms,
626 int room_id) {
627 // Selections are indices into the current room's lists. After a room change
628 // they would point at unrelated objects in the new room, where Delete,
629 // Duplicate and the inspector would act on them. Placement is not tied to a
630 // room, so it continues.
631 if (rooms != rooms_ || room_id != current_room_id_) {
632 FinishPaintStroke();
633 const auto mode = mode_manager_.GetMode();
634 if (mode == InteractionMode::DraggingObjects ||
635 mode == InteractionMode::DraggingEntity ||
636 mode == InteractionMode::RectangleSelect) {
637 mode_manager_.SetMode(InteractionMode::Select);
638 }
639 // Tile and group drags already changed the old room. Finish their undo
640 // actions before rebinding; single-entity drags are previews and are
641 // canceled by clearing their selections below.
642 entity_coordinator_.tile_handler().HandleRelease();
643 selection_.CancelRectangleSelection();
644 entity_coordinator_.ClearAllEntitySelections();
645 ClearSelection();
646 }
647 rooms_ = rooms;
648 current_room_id_ = room_id;
649 interaction_context_.rooms = rooms;
650 interaction_context_.current_room_id = room_id;
651 interaction_context_.selection = &selection_;
652 entity_coordinator_.SetContext(&interaction_context_);
653}
654
655void DungeonObjectInteraction::SetPreviewObject(
656 const zelda3::RoomObject& object, bool loaded) {
657 if (loaded && object.id_ >= 0) {
658 // Cancel other placement modes (doors/sprites/items) before entering object
659 // placement. We re-enable tile placement below.
660 entity_coordinator_.CancelCurrentMode();
661
662 // Enter object placement mode
663 mode_manager_.SetMode(InteractionMode::PlaceObject);
664
665 // Ensure tile placement mode is active so ghost preview can render and
666 // clicks place the object.
667 auto& tile_handler = entity_coordinator_.tile_handler();
668 tile_handler.SetPreviewObject(object);
669 if (!tile_handler.IsPlacementActive()) {
670 tile_handler.BeginPlacement();
671 }
672 } else {
673 // Exit placement mode if not loaded
674 if (mode_manager_.GetMode() == InteractionMode::PlaceObject) {
675 CancelPlacement();
676 }
677 }
678}
679
680void DungeonObjectInteraction::ClearSelection() {
681 selection_.ClearSelection();
682 if (mode_manager_.GetMode() == InteractionMode::DraggingObjects) {
683 mode_manager_.SetMode(InteractionMode::Select);
684 }
685}
686
687absl::Status DungeonObjectInteraction::HandleDeleteSelected() {
689 request.kind = DungeonSelectionEditKind::kDelete;
690 request.objects = selection_.GetSelectedIndices();
691 request.entities = entity_coordinator_.SelectedEntitiesForEdit();
692 return entity_coordinator_.CommitSelectionEdit(request);
693}
694
695void DungeonObjectInteraction::HandleDeleteAllObjects() {
696 if (entity_coordinator_.tile_handler().DeleteAllObjects(current_room_id_)) {
697 selection_.ClearSelection();
698 }
699}
700
701absl::Status DungeonObjectInteraction::HandleCopySelected() {
702 const auto* room = interaction_context_.GetCurrentRoomConst();
703 if (!room) {
704 return entity_coordinator_.ReportSelectionEditStatus(
705 absl::FailedPreconditionError("No room is available to copy"));
706 }
707 const auto objects = selection_.GetSelectedIndices();
708 const auto entities = entity_coordinator_.SelectedEntitiesForEdit();
709 if (objects.empty() && entities.empty())
710 return absl::OkStatus();
711 auto copied = CopyDungeonSelection(*room, objects, entities);
712 if (!copied.ok()) {
713 return entity_coordinator_.ReportSelectionEditStatus(copied.status());
714 }
715 // Only replace the clipboard after the complete selection is copyable.
716 clipboard() = std::move(*copied);
717 return entity_coordinator_.ReportSelectionEditStatus(absl::OkStatus());
718}
719
720absl::Status DungeonObjectInteraction::HandleDuplicateSelected() {
722 request.kind = DungeonSelectionEditKind::kDuplicate;
723 request.objects = selection_.GetSelectedIndices();
724 request.entities = entity_coordinator_.SelectedEntitiesForEdit();
725 const bool includes_door = std::any_of(
726 request.entities.begin(), request.entities.end(),
727 [](const auto entity) { return entity.type == EntityType::Door; });
728 // Doors occupy fixed wall slots: keep that group in place, then let the
729 // author choose a valid shared destination explicitly.
730 request.delta_x_pixels =
731 includes_door ? 0 : entity_coordinator_.SelectionMoveStepPixels();
732 request.delta_y_pixels = request.delta_x_pixels;
733 // Duplicate does not disturb the user's clipboard.
734 return entity_coordinator_.CommitSelectionEdit(request);
735}
736
737absl::Status DungeonObjectInteraction::HandlePasteAt(int pixel_x, int pixel_y) {
738 if (!HasClipboardData())
739 return absl::OkStatus();
740 if (!dungeon_coords::IsWithinBounds(pixel_x, pixel_y)) {
741 return entity_coordinator_.ReportSelectionEditStatus(
742 absl::OutOfRangeError("Paste anchor is outside the room"));
743 }
745 request.kind = DungeonSelectionEditKind::kPaste;
746 request.objects = selection_.GetSelectedIndices();
747 request.entities = entity_coordinator_.SelectedEntitiesForEdit();
748 auto& copied = clipboard();
749 request.clipboard = &copied;
750 request.delta_x_pixels = pixel_x - copied.origin_pixel_x;
751 request.delta_y_pixels = pixel_y - copied.origin_pixel_y;
752 return entity_coordinator_.CommitSelectionEdit(request);
753}
754
755absl::Status DungeonObjectInteraction::HandlePasteObjects() {
756 if (!HasClipboardData())
757 return absl::OkStatus();
758 const auto& copied = clipboard();
759 int target_x = copied.origin_pixel_x;
760 int target_y = copied.origin_pixel_y;
761 const int step = !copied.sprites.empty() || !copied.items.empty()
762 ? dungeon_coords::kSpriteTileSize
763 : dungeon_coords::kTileSize;
764 if (canvas_ && ImGui::GetCurrentContext()) {
765 const auto [mouse_x, mouse_y] =
766 GetCanvasTransform().ScreenToRoomPixelCoordinates(
767 ImGui::GetIO().MousePos);
768 if (dungeon_coords::IsWithinBounds(mouse_x, mouse_y)) {
769 // Snap the translation, rather than the target, so a copied group keeps
770 // each member's relative position on its original encoding grid.
771 target_x += (mouse_x - target_x) / step * step;
772 target_y += (mouse_y - target_y) / step * step;
773 return HandlePasteAt(target_x, target_y);
774 }
775 }
776 // With no canvas target, doors retain their exact source wall slots.
777 // An automatic diagonal offset cannot represent a valid wall destination.
778 const int fallback_offset = copied.doors.empty() ? step : 0;
779 return HandlePasteAt(target_x + fallback_offset, target_y + fallback_offset);
780}
781
782void DungeonObjectInteraction::DrawGhostPreview() {
783 entity_coordinator_.DrawGhostPreviews();
784}
785
786void DungeonObjectInteraction::HandleScrollWheelResize() {
787 const ImGuiIO& io = ImGui::GetIO();
788 entity_coordinator_.HandleMouseWheel(ObjectResizeWheelDelta(io));
789}
790
791bool DungeonObjectInteraction::SetObjectId(size_t index, int16_t id) {
792 entity_coordinator_.tile_handler().UpdateObjectsId(current_room_id_, {index},
793 id);
794 return true;
795}
796
797bool DungeonObjectInteraction::SetObjectSize(size_t index, uint8_t size) {
798 entity_coordinator_.tile_handler().UpdateObjectsSize(current_room_id_,
799 {index}, size);
800 return true;
801}
802
803bool DungeonObjectInteraction::SetObjectLayer(
804 size_t index, zelda3::RoomObject::LayerType layer) {
805 return entity_coordinator_.tile_handler().UpdateObjectsLayer(
806 current_room_id_, {index}, static_cast<int>(layer));
807}
808
809std::pair<int, int> DungeonObjectInteraction::CalculateObjectBounds(
810 const zelda3::RoomObject& object) {
812}
813
814bool DungeonObjectInteraction::CanAssignSelectedObjectsToLayer(
815 int target_layer) const {
816 if (!rooms_ || current_room_id_ < 0 ||
817 current_room_id_ >= static_cast<int>(rooms_->size()) ||
818 target_layer < 0 || target_layer > 2) {
819 return false;
820 }
821
822 const auto selected = selection_.GetSelectedIndices();
823 if (selected.empty()) {
824 return false;
825 }
826
827 const auto& objects = (*rooms_)[current_room_id_].GetTileObjects();
828 for (const size_t index : selected) {
829 if (index >= objects.size() ||
830 (target_layer == 2 && !zelda3::UsesRoomObjectStream(objects[index]))) {
831 return false;
832 }
833 }
834 return true;
835}
836
837bool DungeonObjectInteraction::SendSelectedToLayer(int target_layer) {
838 if (!CanAssignSelectedObjectsToLayer(target_layer)) {
839 return false;
840 }
841 return entity_coordinator_.tile_handler().UpdateObjectsLayer(
842 current_room_id_, selection_.GetSelectedIndices(), target_layer);
843}
844
845void DungeonObjectInteraction::SendSelectedToFront() {
846 entity_coordinator_.tile_handler().SendToFront(
847 current_room_id_, selection_.GetSelectedIndices());
848}
849
850void DungeonObjectInteraction::SendSelectedToBack() {
851 entity_coordinator_.tile_handler().SendToBack(
852 current_room_id_, selection_.GetSelectedIndices());
853}
854
855void DungeonObjectInteraction::BringSelectedForward() {
856 entity_coordinator_.tile_handler().MoveForward(
857 current_room_id_, selection_.GetSelectedIndices());
858}
859
860void DungeonObjectInteraction::SendSelectedBackward() {
861 entity_coordinator_.tile_handler().MoveBackward(
862 current_room_id_, selection_.GetSelectedIndices());
863}
864
865void DungeonObjectInteraction::HandleLayerKeyboardShortcuts() {
866 // Only process if we have selected objects
867 if (!selection_.HasSelection())
868 return;
869
870 // Only when not typing in a text field
871 if (ImGui::IsAnyItemActive())
872 return;
873
874 // Modified digits belong to editor/window shortcuts, not object placement.
875 // The third room stream is unavailable to torches/pushable blocks.
876 const auto& io = ImGui::GetIO();
877 if (io.KeyMods == ImGuiMod_None) {
878 if (ImGui::IsKeyPressed(ImGuiKey_1)) {
879 SendSelectedToLayer(0); // Primary stream / upper layer (BG1)
880 } else if (ImGui::IsKeyPressed(ImGuiKey_2)) {
881 SendSelectedToLayer(1); // BG2 overlay / lower layer (BG2)
882 } else if (ImGui::IsKeyPressed(ImGuiKey_3)) {
883 SendSelectedToLayer(2); // BG1 overlay stream
884 }
885 }
886
887 // Object ordering shortcuts
888 // Ctrl+Shift+] = Bring to Front, Ctrl+Shift+[ = Send to Back
889 // Ctrl+] = Bring Forward, Ctrl+[ = Send Backward
890 if (io.KeyCtrl && io.KeyShift) {
891 if (ImGui::IsKeyPressed(ImGuiKey_RightBracket)) {
892 SendSelectedToFront();
893 } else if (ImGui::IsKeyPressed(ImGuiKey_LeftBracket)) {
894 SendSelectedToBack();
895 }
896 } else if (io.KeyCtrl) {
897 if (ImGui::IsKeyPressed(ImGuiKey_RightBracket)) {
898 BringSelectedForward();
899 } else if (ImGui::IsKeyPressed(ImGuiKey_LeftBracket)) {
900 SendSelectedBackward();
901 }
902 }
903}
904
905// ============================================================================
906// Door Placement Methods
907// ============================================================================
908
909void DungeonObjectInteraction::SetDoorPlacementMode(bool enabled,
910 zelda3::DoorType type) {
911 if (enabled) {
912 // Dispatch follows handler state; the previous placement must end too.
913 CancelPlacement();
914 mode_manager_.SetMode(InteractionMode::PlaceDoor);
915 entity_coordinator_.door_handler().SetDoorType(type);
916 entity_coordinator_.door_handler().BeginPlacement();
917 } else {
918 entity_coordinator_.door_handler().CancelPlacement();
919 if (mode_manager_.GetMode() == InteractionMode::PlaceDoor)
920 mode_manager_.SetMode(InteractionMode::Select);
921 }
922}
923
924// ============================================================================
925// Sprite Placement Methods
926// ============================================================================
927
928void DungeonObjectInteraction::SetSpritePlacementMode(bool enabled,
929 uint8_t sprite_id) {
930 if (enabled) {
931 // Dispatch follows handler state; the previous placement must end too.
932 CancelPlacement();
933 mode_manager_.SetMode(InteractionMode::PlaceSprite);
934 entity_coordinator_.sprite_handler().SetSpriteId(sprite_id);
935 entity_coordinator_.sprite_handler().BeginPlacement();
936 } else {
937 entity_coordinator_.sprite_handler().CancelPlacement();
938 if (mode_manager_.GetMode() == InteractionMode::PlaceSprite)
939 mode_manager_.SetMode(InteractionMode::Select);
940 }
941}
942
943// ============================================================================
944// Item Placement Methods
945// ============================================================================
946
947void DungeonObjectInteraction::SetItemPlacementMode(bool enabled,
948 uint8_t item_id) {
949 if (enabled) {
950 // Dispatch follows handler state; the previous placement must end too.
951 CancelPlacement();
952 mode_manager_.SetMode(InteractionMode::PlaceItem);
953 entity_coordinator_.item_handler().SetItemId(item_id);
954 entity_coordinator_.item_handler().BeginPlacement();
955 } else {
956 entity_coordinator_.item_handler().CancelPlacement();
957 if (mode_manager_.GetMode() == InteractionMode::PlaceItem)
958 mode_manager_.SetMode(InteractionMode::Select);
959 }
960}
961
962// ============================================================================
963// Entity Selection Methods (Doors, Sprites, Items)
964// ============================================================================
965
966void DungeonObjectInteraction::SelectEntity(EntityType type, size_t index) {
967 selection_.ClearSelection();
968 entity_coordinator_.SelectEntity(type, index);
969}
970
971void DungeonObjectInteraction::ClearEntitySelection() {
972 entity_coordinator_.ClearEntitySelection();
973}
974
975void DungeonObjectInteraction::CancelPlacement() {
976 entity_coordinator_.CancelPlacement();
977 if (mode_manager_.IsPlacementActive()) {
978 mode_manager_.SetMode(InteractionMode::Select);
979 }
980}
981
982void DungeonObjectInteraction::DrawEntitySelectionHighlights() {
983 entity_coordinator_.DrawSelectionHighlights();
984 entity_coordinator_.DrawPostPlacementOverlays();
985}
986
987void DungeonObjectInteraction::DrawDoorSnapIndicators() {
988 // Door snap indicators are now managed by DoorInteractionHandler
989 // through the entity coordinator. No-op here for backward compatibility.
990}
991
992} // namespace yaze::editor
static Flags & get()
Definition features.h:119
std::pair< int, int > ScreenToRoomPixelCoordinates(ImVec2 screen) const
ImVec2 ScreenToRoomPixels(ImVec2 screen) const
ImVec2 RoomSizeToScreen(ImVec2 room_size) const
DungeonCanvasTransform GetCanvasTransform() const
void HandleObjectSelectionStart(const ImVec2 &canvas_mouse_pos)
void HandleLeftClick(const ImVec2 &canvas_mouse_pos)
void UpdateWaterFillPainting(const ImVec2 &canvas_mouse_pos)
std::pair< int, int > CanvasToRoomCoordinates(int canvas_x, int canvas_y) const
void HandleEmptySpaceClick(const ImVec2 &canvas_mouse_pos)
void UpdateCollisionPainting(const ImVec2 &canvas_mouse_pos)
bool IsPlacementActive() const
Check if any placement mode is active.
bool HandleClick(int canvas_x, int canvas_y)
Handle click at canvas position.
void HandleDrag(ImVec2 current_pos, ImVec2 delta)
Handle drag operation.
InteractionMode GetMode() const
Get current interaction mode.
ModeState & GetModeState()
Get mutable reference to mode state.
bool IsRectangleSelectionActive() const
Check if a rectangle selection is in progress.
bool HasSelection() const
Check if any objects are selected.
bool IsMouseHovering() const
Definition canvas.h:354
static DimensionService & Get()
std::tuple< int, int, int, int > GetSelectionBoundsPixels(const RoomObject &obj) const
DimensionResult GetDimensions(const RoomObject &obj) const
std::pair< int, int > GetPixelDimensions(const RoomObject &obj) const
#define ICON_MD_DRAG_INDICATOR
Definition icons.h:624
#define ICON_MD_TOUCH_APP
Definition icons.h:2000
#define ICON_MD_MOUSE
Definition icons.h:1251
bool IsWithinBounds(int canvas_x, int canvas_y, int margin=0)
Check if coordinates are within room bounds.
void ForEachPointInSquareBrush(int cx, int cy, int radius, int min_x, int min_y, int max_x, int max_y, Fn &&fn)
Definition paint_util.h:40
void ForEachPointOnLine(int x0, int y0, int x1, int y1, Fn &&fn)
Definition paint_util.h:13
Editors are the view controllers for the application.
absl::StatusOr< DungeonSelectionClipboard > CopyDungeonSelection(const zelda3::Room &room, const std::vector< size_t > &objects, const std::vector< SelectedEntity > &entities)
EntityType
Type of entity that can be selected in the dungeon editor.
DoorType
Door types from ALTTP.
Definition door_types.h:33
int GetObjectSubtype(int object_id)
int RoomObjectSizeAxisStep(int object_id)
bool UsesRoomObjectStream(const RoomObject &object)
std::string GetObjectName(int object_id)
bool IsRoomObjectResizable(int object_id)
int RoomObjectSizeAxisTiles(int object_id, uint8_t size, bool horizontal)
const DungeonSelectionClipboard * clipboard
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.