yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
interaction_coordinator.cc
Go to the documentation of this file.
4#include "util/i18n/tr.h"
5
6#include <algorithm>
7#include <cmath>
8#include <cstdlib>
9#include <functional>
10#include <optional>
11#include <tuple>
12
13// Third-party library headers
14#include "absl/strings/str_format.h"
15#include "imgui/imgui.h"
16
24
25namespace yaze::editor {
26
27namespace {
28
29constexpr double kCycleHudHoldSeconds = 1.25;
30constexpr size_t kCycleHudMaxLabelChars = 54;
31
32bool Intersects(int ax, int ay, int aw, int ah, int bx, int by, int bw,
33 int bh) {
34 return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
35}
36
37std::optional<std::tuple<int, int, int, int>> GetEntityBounds(
38 const zelda3::Room& room, SelectedEntity entity) {
39 switch (entity.type) {
40 case EntityType::Door: {
41 const auto& doors = room.GetDoors();
42 if (entity.index >= doors.size()) {
43 return std::nullopt;
44 }
45 return doors[entity.index].GetEditorBounds();
46 }
47 case EntityType::Sprite: {
48 const auto& sprites = room.GetSprites();
49 if (entity.index >= sprites.size()) {
50 return std::nullopt;
51 }
52 const auto& sprite = sprites[entity.index];
53 constexpr int kSize = dungeon_coords::kSpriteTileSize;
54 return std::make_tuple(sprite.x() * kSize, sprite.y() * kSize, kSize,
55 kSize);
56 }
57 case EntityType::Item: {
58 const auto& items = room.GetPotItems();
59 if (entity.index >= items.size()) {
60 return std::nullopt;
61 }
62 const auto& item = items[entity.index];
63 return std::make_tuple(item.GetPixelX(), item.GetPixelY(), 16, 16);
64 }
65 case EntityType::Object:
66 case EntityType::None:
67 default:
68 return std::nullopt;
69 }
70}
71
72ImVec4 EntitySelectionColor(const AgentUITheme& theme, EntityType type) {
73 switch (type) {
74 case EntityType::Door:
75 return theme.status_warning;
76 case EntityType::Sprite:
77 return theme.status_success;
78 case EntityType::Item:
79 return theme.dungeon_selection_primary;
80 case EntityType::Object:
81 case EntityType::None:
82 default:
83 return theme.accent_color;
84 }
85}
86
87bool IsCycleModifierHeld(const ImGuiIO& io) {
88 return io.KeyAlt && (io.KeyCtrl || io.KeySuper);
89}
90
91std::string TruncateCycleHudLabel(std::string label) {
92 if (label.size() <= kCycleHudMaxLabelChars) {
93 return label;
94 }
95 label.resize(kCycleHudMaxLabelChars - 3);
96 label += "...";
97 return label;
98}
99
100} // namespace
101
109
111 // Cancel current mode first
113
114 current_mode_ = mode;
115
116 // Activate the new mode
117 switch (mode) {
118 case Mode::PlaceDoor:
120 break;
123 break;
124 case Mode::PlaceItem:
126 break;
127 case Mode::Select:
128 // Nothing to activate
129 break;
130 }
131}
132
142
148
149bool InteractionCoordinator::HandleClick(int canvas_x, int canvas_y) {
150 // Check placement modes first
152 return door_handler_.HandleClick(canvas_x, canvas_y);
153 }
155 return sprite_handler_.HandleClick(canvas_x, canvas_y);
156 }
158 return item_handler_.HandleClick(canvas_x, canvas_y);
159 }
161 return tile_handler_.HandleClick(canvas_x, canvas_y);
162 }
163
164 if (door_handler_.HandleOverlayClick(canvas_x, canvas_y)) {
165 return true;
166 }
167
168 if (!dungeon_coords::IsWithinBounds(canvas_x, canvas_y)) {
169 return false;
170 }
171
172 // In select mode, only handle the click if the cursor is over an entity or object.
173 const auto hits = GetEntitiesAtPosition(canvas_x, canvas_y);
174 if (hits.empty()) {
175 return false;
176 }
177
178 const ImGuiIO& io = ImGui::GetIO();
179 const bool cycle_modifier = IsCycleModifierHeld(io);
180 if (io.KeyAlt && !cycle_modifier) {
181 const bool had_entity_selection = HasEntitySelection();
182 const bool had_object_selection =
185 if (ctx_ && ctx_->selection) {
187 }
188 cycle_last_hits_.clear();
191 if ((had_entity_selection || had_object_selection) && ctx_) {
193 }
194 return true;
195 }
196 if (cycle_modifier) {
197 if (!SameCycleTarget(canvas_x, canvas_y, hits)) {
199 }
200 const size_t selected_index = cycle_next_index_ % hits.size();
201 const SelectedEntity selected = hits[selected_index];
202 cycle_next_index_ = (cycle_next_index_ + 1) % hits.size();
203 cycle_last_x_ = canvas_x;
204 cycle_last_y_ = canvas_y;
205 cycle_active_index_ = selected_index;
206 cycle_hud_screen_pos_ = io.MousePos;
207 cycle_hud_start_time_ = ImGui::GetCurrentContext() ? ImGui::GetTime() : 0.0;
208 cycle_last_hits_ = hits;
209 return ApplySelection(selected);
210 }
211
212 const auto entity = hits.front();
213 const bool additive = io.KeyShift || io.KeyCtrl || io.KeySuper;
214 const bool hit_already_selected = IsSelectionHitSelected(entity);
215
216 // Cross-selection rules:
217 // 1. Plain clicks select one stack participant and clear the other family.
218 // 2. Shift/Ctrl/Cmd allow mixed object/entity selection.
219 // 3. Alt-click clears, matching ZScream muscle memory. Ctrl/Cmd+Alt keeps
220 // Yaze's overlap cycle affordance available without stealing Alt.
221
222 if (entity.type == EntityType::Object) {
223 const bool has_multi_object_selection =
225 if (!additive && hit_already_selected &&
226 (has_multi_object_selection || HasGroupDragSelection())) {
227 return true;
228 }
229 if (!additive) {
231 if (ctx_ && ctx_->selection) {
233 }
234 }
235 return tile_handler_.HandleClick(canvas_x, canvas_y);
236 }
237
238 const bool toggle = io.KeyCtrl || io.KeySuper;
239 if (additive) {
240 return UpdateEntitySelection(entity, io.KeyShift, toggle);
241 }
242
243 if (hit_already_selected && HasGroupDragSelection()) {
244 return true;
245 }
246
247 // Plain entity clicks preserve the existing handler drag affordance.
248 selected_entities_.clear();
249 selected_entities_.push_back(entity);
253 if (ctx_ && ctx_->selection) {
255 }
256 switch (entity.type) {
257 case EntityType::Door:
258 return door_handler_.HandleClick(canvas_x, canvas_y);
259 case EntityType::Sprite:
260 return sprite_handler_.HandleClick(canvas_x, canvas_y);
261 case EntityType::Item:
262 return item_handler_.HandleClick(canvas_x, canvas_y);
263 default:
264 selected_entities_.clear();
265 return false;
266 }
267}
268
270 if (type == EntityType::Object || type == EntityType::None) {
272 if (ctx_ && ctx_->selection) {
274 }
275 return;
276 }
277
279
280 selected_entities_.push_back(SelectedEntity{type, index});
281
282 switch (type) {
283 case EntityType::Door:
285 break;
286 case EntityType::Sprite:
288 break;
289 case EntityType::Item:
291 break;
292 case EntityType::Object:
293 case EntityType::None:
294 default:
295 break;
296 }
297}
298
300 std::vector<SelectedEntity> entities) {
304 selected_entities_.clear();
305
306 for (const auto entity : entities) {
307 if (entity.type != EntityType::Door && entity.type != EntityType::Sprite &&
308 entity.type != EntityType::Item) {
309 continue;
310 }
311 if (std::find(selected_entities_.begin(), selected_entities_.end(),
312 entity) == selected_entities_.end()) {
313 selected_entities_.push_back(entity);
314 }
315 }
316
317 if (selected_entities_.size() == 1) {
318 const SelectedEntity selected = selected_entities_.front();
319 switch (selected.type) {
320 case EntityType::Door:
322 break;
323 case EntityType::Sprite:
325 break;
326 case EntityType::Item:
328 break;
329 case EntityType::Object:
330 case EntityType::None:
331 default:
332 break;
333 }
334 return;
335 }
336
337 if (ctx_) {
339 }
340}
341
354
356 const bool had_selection = HasEntitySelection();
358 if (had_selection && ctx_) {
360 }
361}
362
368 if (selected_entities_.size() == 1) {
369 const auto& selected = selected_entities_.front();
370 switch (selected.type) {
371 case EntityType::Door:
372 door_handler_.SelectDoor(selected.index);
373 break;
374 case EntityType::Sprite:
375 sprite_handler_.SelectSprite(selected.index);
376 break;
377 case EntityType::Item:
378 item_handler_.SelectItem(selected.index);
379 break;
380 case EntityType::Object:
381 case EntityType::None:
382 default:
383 break;
384 }
385 }
386}
387
389 int canvas_x, int canvas_y) const {
390 const auto hits = GetEntitiesAtPosition(canvas_x, canvas_y);
391 if (hits.empty()) {
392 return std::nullopt;
393 }
394 return hits.front();
395}
396
398 int canvas_x, int canvas_y) const {
399 std::vector<SelectedEntity> hits;
400 if (!dungeon_coords::IsWithinBounds(canvas_x, canvas_y)) {
401 return hits;
402 }
403 if (auto door = door_handler_.GetEntityAtPosition(canvas_x, canvas_y)) {
404 hits.push_back(SelectedEntity{EntityType::Door, *door});
405 }
406 if (auto sprite = sprite_handler_.GetEntityAtPosition(canvas_x, canvas_y)) {
407 hits.push_back(SelectedEntity{EntityType::Sprite, *sprite});
408 }
409 if (auto item = item_handler_.GetEntityAtPosition(canvas_x, canvas_y)) {
410 hits.push_back(SelectedEntity{EntityType::Item, *item});
411 }
412 if (auto object = tile_handler_.GetEntityAtPosition(canvas_x, canvas_y)) {
413 hits.push_back(SelectedEntity{EntityType::Object, *object});
414 }
415 return hits;
416}
417
419 if (!selected_entities_.empty()) {
420 return selected_entities_.front();
421 }
422 if (auto idx = door_handler_.GetSelectedIndex()) {
423 return SelectedEntity{EntityType::Door, *idx};
424 }
425 if (auto idx = sprite_handler_.GetSelectedIndex()) {
426 return SelectedEntity{EntityType::Sprite, *idx};
427 }
428 if (auto idx = item_handler_.GetSelectedIndex()) {
429 return SelectedEntity{EntityType::Item, *idx};
430 }
431 return SelectedEntity{EntityType::None, 0};
432}
433
434void InteractionCoordinator::HandleDrag(ImVec2 current_pos, ImVec2 delta) {
435 // Forward drag to handlers that have active selections
437 HandleEntityGroupDrag(current_pos);
438 return;
439 } else if (door_handler_.HasSelection()) {
440 door_handler_.HandleDrag(current_pos, delta);
441 }
443 sprite_handler_.HandleDrag(current_pos, delta);
444 }
446 item_handler_.HandleDrag(current_pos, delta);
447 }
448
449 // Tile objects (managed by ObjectSelection)
452 tile_handler_.HandleDrag(current_pos, delta);
453 }
454}
455
463
469
471 const bool was_active = entity_group_drag_active_;
472 // Clear first: finalization can restore selection or flush other gestures.
474 if (was_active && ctx_ && ctx_->on_selection_edit_finished) {
476 }
477}
478
482
498
500 if (selected_entities_.size() > 1) {
502 } else {
503 // Preserve the richer single-selection overlays (door pair badge, drag
504 // preview) for the common one-entity inspector workflow.
508 }
509
510 // Draw snap indicators for door placement
513 }
515}
516
518 // Render placement success toasts for all handlers unconditionally so they
519 // remain visible even after the user exits placement mode immediately.
524}
525
527 int canvas_y) {
528 // Clear all selections first
530
531 // Try to select in priority order: doors, sprites, items
532 // (matches original DungeonObjectInteraction behavior)
533 if (door_handler_.HandleClick(canvas_x, canvas_y)) {
534 if (auto index = door_handler_.GetSelectedIndex()) {
535 selected_entities_ = {SelectedEntity{EntityType::Door, *index}};
536 }
537 return true;
538 }
539 if (sprite_handler_.HandleClick(canvas_x, canvas_y)) {
540 if (auto index = sprite_handler_.GetSelectedIndex()) {
541 selected_entities_ = {SelectedEntity{EntityType::Sprite, *index}};
542 }
543 return true;
544 }
545 if (item_handler_.HandleClick(canvas_x, canvas_y)) {
546 if (auto index = item_handler_.GetSelectedIndex()) {
547 selected_entities_ = {SelectedEntity{EntityType::Item, *index}};
548 }
549 return true;
550 }
551
552 return false;
553}
554
559
561 const {
562 if (!selected_entities_.empty()) {
563 return selected_entities_;
564 }
565 const auto selected = GetSelectedEntity();
566 return selected.type == EntityType::None
567 ? std::vector<SelectedEntity>{}
568 : std::vector<SelectedEntity>{selected};
569}
570
572 const auto entities = SelectedEntitiesForEdit();
573 return std::any_of(entities.begin(), entities.end(),
574 [](const auto entity) {
575 return entity.type == EntityType::Sprite;
576 })
579}
580
582 absl::Status status) {
583 const bool new_error = !status.ok() && status != selection_edit_status_;
584 selection_edit_status_ = std::move(status);
585 if (new_error && ctx_ && ctx_->on_selection_edit_error) {
587 }
589}
590
592 const DungeonSelectionEditRequest& request, bool continuous) {
593 auto* room = ctx_ ? ctx_->GetCurrentRoom() : nullptr;
594 if (!room) {
595 return ReportSelectionEditStatus(absl::FailedPreconditionError(
596 "No room is available for selection editing"));
597 }
598 auto planned = PlanDungeonSelectionEdit(*room, request);
599 if (!planned.ok()) {
600 return ReportSelectionEditStatus(planned.status());
601 }
602 return CommitPlannedSelectionEdit(*planned, continuous);
603}
604
605absl::Status InteractionCoordinator::CompleteDoorTrim(size_t door_index) {
607 auto* room = ctx_ ? ctx_->GetCurrentRoom() : nullptr;
608 if (!room) {
610 absl::FailedPreconditionError("No room is available for door trim"));
611 }
612 auto plan = PlanDungeonDoorTrim(*room, door_index);
613 if (!plan.ok()) {
614 return ReportSelectionEditStatus(plan.status());
615 }
616 if (ctx_->selection) {
617 plan->before.selected_objects = ctx_->selection->GetSelectedIndices();
618 }
619 plan->before.selected_entities = SelectedEntitiesForEdit();
620 return CommitPlannedSelectionEdit(*plan, false);
621}
622
624 const DungeonSelectionEditPlan& plan, bool continuous) {
625 auto* room = ctx_ ? ctx_->GetCurrentRoom() : nullptr;
626 if (!room || room->id() != plan.room_id) {
628 absl::FailedPreconditionError("Selection room is no longer available"));
629 }
630 if (!plan.changed() && plan.kind != DungeonSelectionEditKind::kDoorTrim) {
631 return ReportSelectionEditStatus(absl::OkStatus());
632 }
633 if (plan.changed() && ctx_->on_selection_edit) {
634 const auto status = ctx_->on_selection_edit(plan, continuous);
635 if (!status.ok()) {
636 return ReportSelectionEditStatus(status);
637 }
638 } else if (plan.changed()) {
639 // Standalone handlers retain legacy notifications; the editor callback
640 // stages global constraints and contributes one shared undo action.
641 if (!continuous)
643 for (const auto [mask, domain] :
648 if (plan.domains & mask)
649 ctx_->NotifyMutation(domain);
650 }
652 }
653 // A drag keeps its indices and active state. Re-selecting handlers during
654 // each increment would end their drag and could recursively finalize undo.
655 if (!continuous) {
656 if (ctx_->selection) {
658 for (const auto index : plan.after.selected_objects) {
661 }
662 }
664 }
665 if (plan.changed() && !ctx_->on_selection_edit) {
666 for (const auto [mask, domain] :
671 if (plan.domains & mask)
673 }
675 }
676 return ReportSelectionEditStatus(absl::OkStatus());
677}
678
679bool InteractionCoordinator::NudgeSelected(int delta_x, int delta_y) {
682 if (ctx_ && ctx_->selection)
685 if (request.objects.empty() && request.entities.empty())
686 return false;
687 if (request.objects.empty() && request.entities.size() == 1 &&
688 request.entities.front().type == EntityType::Door) {
689 // A door-only arrow command advances its authored wall slot. Mixed moves
690 // use one physical displacement and may not silently detach the door.
691 const auto* room = ctx_ ? ctx_->GetCurrentRoomConst() : nullptr;
692 const size_t index = request.entities.front().index;
693 if (!room || index >= room->GetDoors().size()) {
694 return CommitSelectionEdit(request).ok();
695 }
696 const auto& door = room->GetDoors()[index];
697 const bool horizontal = door.direction == zelda3::DoorDirection::North ||
698 door.direction == zelda3::DoorDirection::South;
699 const int64_t next_position =
700 static_cast<int64_t>(door.position) + (horizontal ? delta_x : delta_y);
701 if (next_position == door.position)
702 return false;
703 if (next_position < 0 ||
706 static_cast<uint8_t>(next_position), door.direction)) {
708 absl::OutOfRangeError("No door slot in that direction"));
709 return false;
710 }
711 auto next = door;
712 next.position = static_cast<uint8_t>(next_position);
713 const auto [old_x, old_y] = door.GetPixelCoords();
714 const auto [new_x, new_y] = next.GetPixelCoords();
715 request.delta_x_pixels = new_x - old_x;
716 request.delta_y_pixels = new_y - old_y;
717 return CommitSelectionEdit(request).ok();
718 }
719 const int step = SelectionMoveStepPixels();
720 const int64_t dx = static_cast<int64_t>(delta_x) * step;
721 const int64_t dy = static_cast<int64_t>(delta_y) * step;
722 if (dx < -512 || dx > 512 || dy < -512 || dy > 512) {
724 absl::OutOfRangeError("Selection movement leaves the room"));
725 return false;
726 }
727 request.delta_x_pixels = static_cast<int>(dx);
728 request.delta_y_pixels = static_cast<int>(dy);
729 return CommitSelectionEdit(request).ok();
730}
731
744
751
753 const {
754 if (!selected_entities_.empty()) {
755 switch (selected_entities_.front().type) {
756 case EntityType::Door:
757 return Mode::PlaceDoor;
758 case EntityType::Sprite:
759 return Mode::PlaceSprite;
760 case EntityType::Item:
761 return Mode::PlaceItem;
762 case EntityType::Object:
763 case EntityType::None:
764 default:
765 break;
766 }
767 }
769 return Mode::PlaceDoor;
770 }
772 return Mode::PlaceSprite;
773 }
775 return Mode::PlaceItem;
776 }
777 return Mode::Select;
778}
779
781 switch (current_mode_) {
782 case Mode::PlaceDoor:
783 return &door_handler_;
785 return &sprite_handler_;
786 case Mode::PlaceItem:
787 return &item_handler_;
788 case Mode::Select:
789 default:
790 return nullptr;
791 }
792}
793
796
797 if (entity.type == EntityType::Object) {
798 if (!ctx_ || !ctx_->selection) {
799 return false;
800 }
804 return true;
805 }
806
807 if (ctx_ && ctx_->selection) {
809 }
810
811 switch (entity.type) {
812 case EntityType::Door:
813 selected_entities_.push_back(entity);
815 return true;
816 case EntityType::Sprite:
817 selected_entities_.push_back(entity);
819 return true;
820 case EntityType::Item:
821 selected_entities_.push_back(entity);
823 return true;
824 case EntityType::Object:
825 case EntityType::None:
826 default:
827 return false;
828 }
829}
830
832 bool additive, bool toggle) {
833 if (entity.type == EntityType::Object || entity.type == EntityType::None) {
834 return false;
835 }
836
840
841 if (!additive && !toggle) {
842 selected_entities_.clear();
843 }
844
845 const auto existing =
846 std::find(selected_entities_.begin(), selected_entities_.end(), entity);
847 if (toggle) {
848 if (existing != selected_entities_.end()) {
849 selected_entities_.erase(existing);
850 } else {
851 selected_entities_.push_back(entity);
852 }
853 } else if (existing == selected_entities_.end()) {
854 selected_entities_.push_back(entity);
855 }
856
857 if (selected_entities_.size() == 1) {
858 const auto selected = selected_entities_.front();
859 switch (selected.type) {
860 case EntityType::Door:
861 door_handler_.SelectDoor(selected.index);
862 break;
863 case EntityType::Sprite:
864 sprite_handler_.SelectSprite(selected.index);
865 break;
866 case EntityType::Item:
867 item_handler_.SelectItem(selected.index);
868 break;
869 case EntityType::Object:
870 case EntityType::None:
871 default:
872 break;
873 }
874 } else if (ctx_) {
876 }
877
878 return true;
879}
880
882 SelectedEntity entity) const {
883 if (entity.type == EntityType::Object) {
884 return ctx_ && ctx_->selection &&
886 }
887 return std::find(selected_entities_.begin(), selected_entities_.end(),
888 entity) != selected_entities_.end() ||
889 GetSelectedEntity() == entity;
890}
891
893 const bool has_object_selection =
895 return !selected_entities_.empty() &&
896 (selected_entities_.size() > 1 || has_object_selection);
897}
898
911
914 return;
915 }
916
918 const ImVec2 drag_delta(
921
922 const int step = SelectionMoveStepPixels();
923 const int drag_dx = static_cast<int>(drag_delta.x) / step * step;
924 const int drag_dy = static_cast<int>(drag_delta.y) / step * step;
925 const int inc_dx = drag_dx - entity_group_drag_last_dx_;
926 const int inc_dy = drag_dy - entity_group_drag_last_dy_;
927 if (inc_dx == 0 && inc_dy == 0)
928 return;
929
932 if (ctx_ && ctx_->selection)
935 request.delta_x_pixels = inc_dx;
936 request.delta_y_pixels = inc_dy;
937 if (CommitSelectionEdit(request, true).ok()) {
940 }
941}
942
944 const std::tuple<int, int, int, int>& bounds, bool additive, bool toggle) {
945 auto* room = ctx_ ? ctx_->GetCurrentRoomConst() : nullptr;
946 if (!room) {
947 return;
948 }
949
950 const auto [raw_min_x, raw_min_y, raw_max_x, raw_max_y] = bounds;
951 const int min_x = std::min(raw_min_x, raw_max_x);
952 const int max_x = std::max(raw_min_x, raw_max_x);
953 const int min_y = std::min(raw_min_y, raw_max_y);
954 const int max_y = std::max(raw_min_y, raw_max_y);
955 const int rect_w = std::max(1, max_x - min_x);
956 const int rect_h = std::max(1, max_y - min_y);
957
958 std::vector<SelectedEntity> hits;
959 for (size_t i = 0; i < room->GetDoors().size(); ++i) {
960 const SelectedEntity entity{EntityType::Door, i};
961 if (auto entity_bounds = GetEntityBounds(*room, entity)) {
962 auto [x, y, w, h] = *entity_bounds;
963 if (Intersects(x, y, w, h, min_x, min_y, rect_w, rect_h)) {
964 hits.push_back(entity);
965 }
966 }
967 }
968 for (size_t i = 0; i < room->GetSprites().size(); ++i) {
969 const SelectedEntity entity{EntityType::Sprite, i};
970 if (auto entity_bounds = GetEntityBounds(*room, entity)) {
971 auto [x, y, w, h] = *entity_bounds;
972 if (Intersects(x, y, w, h, min_x, min_y, rect_w, rect_h)) {
973 hits.push_back(entity);
974 }
975 }
976 }
977 for (size_t i = 0; i < room->GetPotItems().size(); ++i) {
978 const SelectedEntity entity{EntityType::Item, i};
979 if (auto entity_bounds = GetEntityBounds(*room, entity)) {
980 auto [x, y, w, h] = *entity_bounds;
981 if (Intersects(x, y, w, h, min_x, min_y, rect_w, rect_h)) {
982 hits.push_back(entity);
983 }
984 }
985 }
986
990 if (!additive && !toggle) {
991 selected_entities_.clear();
992 }
993
994 for (const auto entity : hits) {
995 auto existing =
996 std::find(selected_entities_.begin(), selected_entities_.end(), entity);
997 if (toggle) {
998 if (existing != selected_entities_.end()) {
999 selected_entities_.erase(existing);
1000 } else {
1001 selected_entities_.push_back(entity);
1002 }
1003 } else if (existing == selected_entities_.end()) {
1004 selected_entities_.push_back(entity);
1005 }
1006 }
1007
1008 if (selected_entities_.size() == 1) {
1009 UpdateEntitySelection(selected_entities_.front(), /*additive=*/false,
1010 /*toggle=*/false);
1011 } else if (ctx_) {
1013 }
1014}
1015
1017 int canvas_x, int canvas_y, const std::vector<SelectedEntity>& hits) const {
1018 constexpr int kSameSpotTolerancePx = 3;
1019 if (std::abs(canvas_x - cycle_last_x_) > kSameSpotTolerancePx ||
1020 std::abs(canvas_y - cycle_last_y_) > kSameSpotTolerancePx ||
1021 hits.size() != cycle_last_hits_.size()) {
1022 return false;
1023 }
1024
1025 for (size_t i = 0; i < hits.size(); ++i) {
1026 if (!(hits[i] == cycle_last_hits_[i])) {
1027 return false;
1028 }
1029 }
1030 return true;
1031}
1032
1034 const std::vector<SelectedEntity>& hits) const {
1035 for (size_t i = 0; i < hits.size(); ++i) {
1036 const SelectedEntity hit = hits[i];
1037 if (hit.type == EntityType::Object) {
1038 if (ctx_ && ctx_->selection &&
1040 return i;
1041 }
1042 continue;
1043 }
1044
1045 if (std::find(selected_entities_.begin(), selected_entities_.end(), hit) !=
1046 selected_entities_.end()) {
1047 return i;
1048 }
1049
1050 const SelectedEntity selected = GetSelectedEntity();
1051 if (selected == hit) {
1052 return i;
1053 }
1054 }
1055
1056 return std::nullopt;
1057}
1058
1060 if (!ctx_ || !ctx_->canvas || !ctx_->canvas->IsMouseHovering()) {
1061 return;
1062 }
1063
1064 const ImGuiIO& io = ImGui::GetIO();
1065 if (!IsCycleModifierHeld(io)) {
1066 return;
1067 }
1068
1069 const DungeonCanvasTransform transform(ctx_->canvas->zero_point(),
1070 ctx_->canvas->scrolling(),
1072 const auto [canvas_x, canvas_y] =
1073 transform.ScreenToRoomPixelCoordinates(io.MousePos);
1074 const auto hits = GetEntitiesAtPosition(canvas_x, canvas_y);
1075 if (hits.size() < 2) {
1076 cycle_last_hits_.clear();
1078 cycle_hud_start_time_ = -1.0;
1079 return;
1080 }
1081
1082 if (!SameCycleTarget(canvas_x, canvas_y, hits)) {
1084 }
1085
1086 cycle_last_x_ = canvas_x;
1087 cycle_last_y_ = canvas_y;
1088 cycle_hud_screen_pos_ = io.MousePos;
1089 cycle_hud_start_time_ = ImGui::GetCurrentContext() ? ImGui::GetTime() : 0.0;
1090 cycle_last_hits_ = hits;
1091 if (const auto selected_index = FindSelectedCycleIndex(hits)) {
1092 cycle_active_index_ = *selected_index;
1093 } else {
1094 cycle_active_index_ = cycle_next_index_ % hits.size();
1095 }
1096}
1097
1099 if (ImGui::GetCurrentContext()) {
1101 }
1102
1103 if (!ImGui::GetCurrentContext() || cycle_last_hits_.size() < 2 ||
1104 cycle_hud_start_time_ < 0.0) {
1105 return;
1106 }
1107
1108 const double elapsed = ImGui::GetTime() - cycle_hud_start_time_;
1109 const ImGuiIO& io = ImGui::GetIO();
1110 const bool cycle_modifier_held = IsCycleModifierHeld(io);
1111 if (!cycle_modifier_held && elapsed > kCycleHudHoldSeconds) {
1112 return;
1113 }
1114
1115 const float alpha =
1116 cycle_modifier_held
1117 ? 1.0f
1118 : std::max(0.0f,
1119 1.0f - static_cast<float>(elapsed / kCycleHudHoldSeconds));
1120 const auto& theme = AgentUI::GetTheme();
1121 ImVec4 bg = theme.panel_bg_darker;
1122 bg.w *= 0.92f * alpha;
1123 ImVec4 border = theme.panel_border_color;
1124 border.w *= alpha;
1125 ImVec4 text = theme.text_primary;
1126 text.w *= alpha;
1127 ImVec4 secondary = theme.text_secondary_color;
1128 secondary.w *= alpha;
1129 ImVec4 active = theme.accent_color;
1130 active.w *= alpha;
1131
1132 ImGui::SetNextWindowPos(
1133 ImVec2(cycle_hud_screen_pos_.x + 14.0f, cycle_hud_screen_pos_.y + 14.0f),
1134 ImGuiCond_Always);
1135 ImGui::SetNextWindowBgAlpha(bg.w);
1136 gui::StyleColorGuard hud_colors({{ImGuiCol_WindowBg, bg},
1137 {ImGuiCol_Border, border},
1138 {ImGuiCol_Text, text}});
1139 gui::StyleVarGuard hud_vars(
1140 {{ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 6.0f)},
1141 {ImGuiStyleVar_WindowRounding, 4.0f}});
1142 constexpr ImGuiWindowFlags kFlags =
1143 ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoSavedSettings |
1144 ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoNav |
1145 ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoInputs;
1146 if (ImGui::Begin("##DungeonSelectionCycleHud", nullptr, kFlags)) {
1147 ImGui::TextColored(secondary, tr("Cycle"));
1148 for (size_t i = 0; i < cycle_last_hits_.size(); ++i) {
1149 const char* marker = (i == cycle_active_index_) ? "[X]" : "[ ]";
1150 ImGui::TextColored(i == cycle_active_index_ ? active : secondary, "%s",
1151 marker);
1152 ImGui::SameLine(0.0f, 5.0f);
1153 ImGui::TextUnformatted(
1155 }
1156 }
1157 ImGui::End();
1158}
1159
1161 auto* room = ctx_ ? ctx_->GetCurrentRoomConst() : nullptr;
1162 if (!room || !ctx_ || !ctx_->canvas) {
1163 return;
1164 }
1165
1166 const auto& theme = AgentUI::GetTheme();
1167 ImDrawList* draw_list = ImGui::GetWindowDrawList();
1168 const DungeonCanvasTransform transform(ctx_->canvas->zero_point(),
1169 ctx_->canvas->scrolling(),
1171 const float scale = transform.scale();
1172 const float pulse =
1173 0.6f + 0.4f * std::sin(static_cast<float>(ImGui::GetTime()) * 6.0f);
1174
1175 for (size_t i = 0; i < selected_entities_.size(); ++i) {
1176 const auto entity = selected_entities_[i];
1177 const auto bounds = GetEntityBounds(*room, entity);
1178 if (!bounds.has_value()) {
1179 continue;
1180 }
1181
1182 auto [x, y, w, h] = *bounds;
1183 ImVec2 start = transform.RoomPixelsToScreen(
1184 ImVec2(static_cast<float>(x), static_cast<float>(y)));
1185 const ImVec2 size = transform.RoomSizeToScreen(
1186 ImVec2(static_cast<float>(w), static_cast<float>(h)));
1187 ImVec2 end(start.x + size.x, start.y + size.y);
1188 constexpr float kMargin = 2.0f;
1189 start.x -= kMargin;
1190 start.y -= kMargin;
1191 end.x += kMargin;
1192 end.y += kMargin;
1193
1194 ImVec4 base = EntitySelectionColor(theme, entity.type);
1195 ImVec4 fill = base;
1196 fill.w = 0.14f + 0.10f * pulse;
1197 ImVec4 border = base;
1198 border.w = (i == 0) ? 0.95f : 0.72f;
1199
1200 draw_list->AddRectFilled(start, end, ImGui::GetColorU32(fill));
1201 draw_list->AddRect(start, end, ImGui::GetColorU32(border), 0.0f, 0,
1202 (i == 0) ? 2.2f : 1.6f);
1203 draw_list->AddText(ImVec2(start.x, start.y - 14.0f * scale),
1204 ImGui::GetColorU32(theme.text_primary),
1205 DescribeEntity(entity).c_str());
1206 }
1207}
1208
1210 SelectedEntity entity) const {
1211 const zelda3::Room* room = ctx_ ? ctx_->GetCurrentRoomConst() : nullptr;
1212 switch (entity.type) {
1213 case EntityType::Door: {
1214 if (room && entity.index < room->GetDoors().size()) {
1215 const auto& door = room->GetDoors()[entity.index];
1216 const std::string direction_name(
1217 zelda3::GetDoorDirectionName(door.direction));
1218 return absl::StrFormat("Door (%s)", direction_name);
1219 }
1220 return "Door";
1221 }
1222 case EntityType::Sprite:
1223 if (room && entity.index < room->GetSprites().size()) {
1224 const auto& sprite = room->GetSprites()[entity.index];
1225 return absl::StrFormat("Sprite (0x%02X - %s)", sprite.id(),
1226 zelda3::ResolveSpriteName(sprite.id()));
1227 }
1228 return "Sprite";
1229 case EntityType::Item:
1230 if (room && entity.index < room->GetPotItems().size()) {
1231 const auto& item = room->GetPotItems()[entity.index];
1232 return absl::StrFormat("Item (0x%02X)", item.item);
1233 }
1234 return "Item";
1235 case EntityType::Object:
1236 if (room && entity.index < room->GetTileObjects().size()) {
1237 const auto& object = room->GetTileObjects()[entity.index];
1238 return absl::StrFormat("Object (0x%03X - %s)", object.id_,
1239 zelda3::GetObjectName(object.id_));
1240 }
1241 return "Object";
1242 case EntityType::None:
1243 default:
1244 return "None";
1245 }
1246}
1247
1249 SelectedEntity entity) const {
1250 return TruncateCycleHudLabel(DescribeEntity(entity));
1251}
1252
1253} // namespace yaze::editor
Abstract base class for entity interaction handlers.
virtual bool HandleMouseWheel(float delta)
void SetContext(InteractionContext *ctx)
Set the interaction context.
bool HandleOverlayClick(int canvas_x, int canvas_y)
void DrawSelectionHighlight() override
Draw selection highlight for selected entities.
void DrawSnapIndicators()
Draw snap position indicators during door drag.
void SelectDoor(size_t index)
Select door at index.
void DrawGhostPreview() override
Draw ghost preview during placement.
void HandleDrag(ImVec2 current_pos, ImVec2 delta) override
Handle mouse drag.
void CancelPlacement() override
Cancel current placement.
void BeginPlacement() override
Begin placement mode.
void HandleRelease() override
Handle mouse release.
bool HandleClick(int canvas_x, int canvas_y) override
Handle mouse click at canvas position.
std::optional< size_t > GetSelectedIndex() const
Get selected door index.
bool IsPlacementActive() const override
Check if placement mode is active.
std::optional< size_t > GetEntityAtPosition(int canvas_x, int canvas_y) const override
Get entity at canvas position.
bool HasSelection() const
Check if a door is selected.
std::pair< int, int > ScreenToRoomPixelCoordinates(ImVec2 screen) const
ImVec2 RoomSizeToScreen(ImVec2 room_size) const
std::vector< SelectedEntity > SelectedEntitiesForEdit() const
absl::Status ReportSelectionEditStatus(absl::Status status)
std::vector< SelectedEntity > GetEntitiesAtPosition(int canvas_x, int canvas_y) const
bool IsSelectionHitSelected(SelectedEntity entity) const
bool TrySelectEntityAtCursor(int canvas_x, int canvas_y)
Try to select entity at cursor position.
absl::Status CompleteDoorTrim(size_t door_index)
Mode GetSelectedEntityType() const
Get the type of currently selected entity.
absl::Status CommitPlannedSelectionEdit(const DungeonSelectionEditPlan &plan, bool continuous)
std::string DescribeCycleHudEntity(SelectedEntity entity) const
void SelectEntitiesInRect(const std::tuple< int, int, int, int > &bounds, bool additive, bool toggle)
void CancelCurrentMode()
Cancel current mode and return to select mode.
void SetContext(InteractionContext *ctx)
Set the shared interaction context.
std::vector< SelectedEntity > selected_entities_
bool UpdateEntitySelection(SelectedEntity entity, bool additive, bool toggle)
std::optional< size_t > FindSelectedCycleIndex(const std::vector< SelectedEntity > &hits) const
BaseEntityHandler * GetActiveHandler()
Get active handler based on current mode.
bool IsPlacementActive() const
Check if any placement mode is active.
bool HandleClick(int canvas_x, int canvas_y)
Handle click at canvas position.
void SetSelectedEntities(std::vector< SelectedEntity > entities)
std::string DescribeEntity(SelectedEntity entity) const
void SelectEntity(EntityType type, size_t index)
void DrawPostPlacementOverlays()
Draw post-placement success toasts for all handlers (unconditional)
bool NudgeSelected(int delta_x, int delta_y)
void DrawSelectionHighlights()
Draw selection highlights for all entity types.
absl::Status DeleteSelectedEntity()
Delete currently selected entity.
void SetMode(Mode mode)
Set interaction mode.
void ClearAllEntitySelections()
Clear all entity selections.
absl::Status CommitSelectionEdit(const DungeonSelectionEditRequest &request, bool continuous=false)
void DrawGhostPreviews()
Draw ghost previews for active placement mode.
bool SameCycleTarget(int canvas_x, int canvas_y, const std::vector< SelectedEntity > &hits) const
std::vector< SelectedEntity > cycle_last_hits_
std::optional< SelectedEntity > GetEntityAtPosition(int canvas_x, int canvas_y) const
void HandleDrag(ImVec2 current_pos, ImVec2 delta)
Handle drag operation.
void DrawSelectionHighlight() override
Draw selection highlight for selected entities.
bool HandleClick(int canvas_x, int canvas_y) override
Handle mouse click at canvas position.
bool IsPlacementActive() const override
Check if placement mode is active.
void DrawGhostPreview() override
Draw ghost preview during placement.
void BeginPlacement() override
Begin placement mode.
void HandleDrag(ImVec2 current_pos, ImVec2 delta) override
Handle mouse drag.
std::optional< size_t > GetEntityAtPosition(int canvas_x, int canvas_y) const override
Get entity at canvas position.
bool HasSelection() const
Check if an item is selected.
void HandleRelease() override
Handle mouse release.
std::optional< size_t > GetSelectedIndex() const
Get selected item index.
void SelectItem(size_t index)
Select item at index.
void CancelPlacement() override
Cancel current placement.
bool IsObjectSelected(size_t index) const
Check if an object is selected.
std::vector< size_t > GetSelectedIndices() const
Get all selected object indices.
size_t GetSelectionCount() const
Get the number of selected objects.
void SelectObject(size_t index, SelectionMode mode=SelectionMode::Single)
Select a single object by index.
void ClearSelection()
Clear all selections.
bool HasSelection() const
Check if any objects are selected.
bool IsPlacementActive() const override
Check if placement mode is active.
void CancelPlacement() override
Cancel current placement.
void HandleRelease() override
Handle mouse release.
void SelectSprite(size_t index)
Select sprite at index.
bool HasSelection() const
Check if a sprite is selected.
std::optional< size_t > GetEntityAtPosition(int canvas_x, int canvas_y) const override
Get entity at canvas position.
void DrawGhostPreview() override
Draw ghost preview during placement.
void DrawSelectionHighlight() override
Draw selection highlight for selected entities.
void HandleDrag(ImVec2 current_pos, ImVec2 delta) override
Handle mouse drag.
bool HandleClick(int canvas_x, int canvas_y) override
Handle mouse click at canvas position.
std::optional< size_t > GetSelectedIndex() const
Get selected sprite index.
void BeginPlacement() override
Begin placement mode.
void DrawGhostPreview() override
Draw ghost preview during placement.
bool HandleMouseWheel(float delta) override
void HandleRelease() override
Handle mouse release.
void HandleDrag(ImVec2 current_pos, ImVec2 delta) override
Handle mouse drag.
bool IsPlacementActive() const override
Check if placement mode is active.
bool HandleClick(int canvas_x, int canvas_y) override
Handle mouse click at canvas position.
void CancelPlacement() override
Cancel current placement.
std::optional< size_t > GetEntityAtPosition(int canvas_x, int canvas_y) const override
Get entity at canvas position.
auto global_scale() const
Definition canvas.h:413
auto zero_point() const
Definition canvas.h:364
bool IsMouseHovering() const
Definition canvas.h:354
auto scrolling() const
Definition canvas.h:366
RAII guard for ImGui style colors.
Definition style_guard.h:29
RAII guard for ImGui style vars.
Definition style_guard.h:71
static constexpr int kMaxDoorPositions
static bool IsValidPosition(uint8_t position, DoorDirection direction)
Check if a position is valid for door placement.
const std::vector< Door > & GetDoors() const
Definition room.h:413
const std::vector< zelda3::Sprite > & GetSprites() const
Definition room.h:316
const std::vector< RoomObject > & GetTileObjects() const
Definition room.h:445
const std::vector< PotItem > & GetPotItems() const
Definition room.h:429
const AgentUITheme & GetTheme()
std::optional< std::tuple< int, int, int, int > > GetEntityBounds(const zelda3::Room &room, SelectedEntity entity)
ImVec4 EntitySelectionColor(const AgentUITheme &theme, EntityType type)
bool Intersects(int ax, int ay, int aw, int ah, int bx, int by, int bw, int bh)
bool IsWithinBounds(int canvas_x, int canvas_y, int margin=0)
Check if coordinates are within room bounds.
ImVec2 SnapToTileGrid(const ImVec2 &point)
Snap a point to the 8px tile grid.
Editors are the view controllers for the application.
absl::StatusOr< DungeonSelectionEditPlan > PlanDungeonSelectionEdit(const zelda3::Room &room, const DungeonSelectionEditRequest &request)
absl::StatusOr< DungeonSelectionEditPlan > PlanDungeonDoorTrim(const zelda3::Room &room, size_t door_index)
constexpr uint8_t kSelectionSprites
constexpr uint8_t kSelectionObjects
constexpr uint8_t kSelectionDoors
void ApplyDungeonSelectionEditState(zelda3::Room &room, const DungeonSelectionEditState &state, uint8_t domains)
EntityType
Type of entity that can be selected in the dungeon editor.
constexpr uint8_t kSelectionItems
constexpr std::string_view GetDoorDirectionName(DoorDirection dir)
Get human-readable name for door direction.
Definition door_types.h:204
std::string GetObjectName(int object_id)
@ South
Bottom wall (horizontal door, 4x3 tiles)
@ North
Top wall (horizontal door, 4x3 tiles)
const char * ResolveSpriteName(uint16_t id)
Definition sprite.cc:287
Centralized theme colors for Agent UI components.
Definition agent_theme.h:19
std::vector< SelectedEntity > selected_entities
Shared context for all interaction handlers.
const zelda3::Room * GetCurrentRoomConst() const
Get const pointer to current room.
void NotifyEntityChanged() const
Notify that entity has changed.
std::function< void(const absl::Status &) on_selection_edit_error)
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.
std::function< void()> on_selection_edit_finished
zelda3::Room * GetCurrentRoom() const
Get pointer to current room.
std::function< absl::Status(const DungeonSelectionEditPlan &, bool continuous) on_selection_edit)
Represents a selected entity in the dungeon editor.