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