yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
interaction_coordinator.cc
Go to the documentation of this file.
3#include "util/i18n/tr.h"
4
5#include <algorithm>
6#include <cmath>
7#include <cstdlib>
8#include <functional>
9#include <optional>
10#include <tuple>
11
12// Third-party library headers
13#include "absl/strings/str_format.h"
14#include "imgui/imgui.h"
15
22
23namespace yaze::editor {
24
25namespace {
26
27constexpr double kCycleHudHoldSeconds = 1.25;
28constexpr size_t kCycleHudMaxLabelChars = 54;
29
30bool Intersects(int ax, int ay, int aw, int ah, int bx, int by, int bw,
31 int bh) {
32 return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
33}
34
35std::optional<std::tuple<int, int, int, int>> GetEntityBounds(
36 const zelda3::Room& room, SelectedEntity entity) {
37 switch (entity.type) {
38 case EntityType::Door: {
39 const auto& doors = room.GetDoors();
40 if (entity.index >= doors.size()) {
41 return std::nullopt;
42 }
43 return doors[entity.index].GetEditorBounds();
44 }
45 case EntityType::Sprite: {
46 const auto& sprites = room.GetSprites();
47 if (entity.index >= sprites.size()) {
48 return std::nullopt;
49 }
50 const auto& sprite = sprites[entity.index];
51 constexpr int kSize = dungeon_coords::kSpriteTileSize;
52 return std::make_tuple(sprite.x() * kSize, sprite.y() * kSize, kSize,
53 kSize);
54 }
55 case EntityType::Item: {
56 const auto& items = room.GetPotItems();
57 if (entity.index >= items.size()) {
58 return std::nullopt;
59 }
60 const auto& item = items[entity.index];
61 return std::make_tuple(item.GetPixelX(), item.GetPixelY(), 16, 16);
62 }
65 default:
66 return std::nullopt;
67 }
68}
69
70ImVec4 EntitySelectionColor(const AgentUITheme& theme, EntityType type) {
71 switch (type) {
73 return theme.status_warning;
75 return theme.status_success;
77 return theme.dungeon_selection_primary;
80 default:
81 return theme.accent_color;
82 }
83}
84
85bool IsCycleModifierHeld(const ImGuiIO& io) {
86 return io.KeyAlt && (io.KeyCtrl || io.KeySuper);
87}
88
89std::string TruncateCycleHudLabel(std::string label) {
90 if (label.size() <= kCycleHudMaxLabelChars) {
91 return label;
92 }
93 label.resize(kCycleHudMaxLabelChars - 3);
94 label += "...";
95 return label;
96}
97
98} // namespace
99
107
109 // Cancel current mode first
111
112 current_mode_ = mode;
113
114 // Activate the new mode
115 switch (mode) {
116 case Mode::PlaceDoor:
118 break;
121 break;
122 case Mode::PlaceItem:
124 break;
125 case Mode::Select:
126 // Nothing to activate
127 break;
128 }
129}
130
140
146
147bool InteractionCoordinator::HandleClick(int canvas_x, int canvas_y) {
148 // Check placement modes first
150 return door_handler_.HandleClick(canvas_x, canvas_y);
151 }
153 return sprite_handler_.HandleClick(canvas_x, canvas_y);
154 }
156 return item_handler_.HandleClick(canvas_x, canvas_y);
157 }
159 return tile_handler_.HandleClick(canvas_x, canvas_y);
160 }
161
162 if (door_handler_.HandleOverlayClick(canvas_x, canvas_y)) {
163 return true;
164 }
165
166 if (!dungeon_coords::IsWithinBounds(canvas_x, canvas_y)) {
167 return false;
168 }
169
170 // In select mode, only handle the click if the cursor is over an entity or object.
171 const auto hits = GetEntitiesAtPosition(canvas_x, canvas_y);
172 if (hits.empty()) {
173 return false;
174 }
175
176 const ImGuiIO& io = ImGui::GetIO();
177 const bool cycle_modifier = IsCycleModifierHeld(io);
178 if (io.KeyAlt && !cycle_modifier) {
179 const bool had_entity_selection = HasEntitySelection();
180 const bool had_object_selection =
183 if (ctx_ && ctx_->selection) {
185 }
186 cycle_last_hits_.clear();
189 if ((had_entity_selection || had_object_selection) && ctx_) {
191 }
192 return true;
193 }
194 if (cycle_modifier) {
195 if (!SameCycleTarget(canvas_x, canvas_y, hits)) {
197 }
198 const size_t selected_index = cycle_next_index_ % hits.size();
199 const SelectedEntity selected = hits[selected_index];
200 cycle_next_index_ = (cycle_next_index_ + 1) % hits.size();
201 cycle_last_x_ = canvas_x;
202 cycle_last_y_ = canvas_y;
203 cycle_active_index_ = selected_index;
204 cycle_hud_screen_pos_ = io.MousePos;
205 cycle_hud_start_time_ = ImGui::GetCurrentContext() ? ImGui::GetTime() : 0.0;
206 cycle_last_hits_ = hits;
207 return ApplySelection(selected);
208 }
209
210 const auto entity = hits.front();
211 const bool additive = io.KeyShift || io.KeyCtrl || io.KeySuper;
212 const bool hit_already_selected = IsSelectionHitSelected(entity);
213
214 // Cross-selection rules:
215 // 1. Plain clicks select one stack participant and clear the other family.
216 // 2. Shift/Ctrl/Cmd allow mixed object/entity selection.
217 // 3. Alt-click clears, matching ZScream muscle memory. Ctrl/Cmd+Alt keeps
218 // Yaze's overlap cycle affordance available without stealing Alt.
219
220 if (entity.type == EntityType::Object) {
221 const bool has_multi_object_selection =
223 if (!additive && hit_already_selected &&
224 (has_multi_object_selection || HasGroupDragSelection())) {
225 return true;
226 }
227 if (!additive) {
229 if (ctx_ && ctx_->selection) {
231 }
232 }
233 return tile_handler_.HandleClick(canvas_x, canvas_y);
234 }
235
236 const bool toggle = io.KeyCtrl || io.KeySuper;
237 if (additive) {
238 return UpdateEntitySelection(entity, io.KeyShift, toggle);
239 }
240
241 if (hit_already_selected && HasGroupDragSelection()) {
242 return true;
243 }
244
245 // Plain entity clicks preserve the existing handler drag affordance.
246 selected_entities_.clear();
247 selected_entities_.push_back(entity);
251 if (ctx_ && ctx_->selection) {
253 }
254 switch (entity.type) {
255 case EntityType::Door:
256 return door_handler_.HandleClick(canvas_x, canvas_y);
258 return sprite_handler_.HandleClick(canvas_x, canvas_y);
259 case EntityType::Item:
260 return item_handler_.HandleClick(canvas_x, canvas_y);
261 default:
262 selected_entities_.clear();
263 return false;
264 }
265}
266
268 if (type == EntityType::Object || type == EntityType::None) {
270 if (ctx_ && ctx_->selection) {
272 }
273 return;
274 }
275
277
278 selected_entities_.push_back(SelectedEntity{type, index});
279
280 switch (type) {
281 case EntityType::Door:
283 break;
286 break;
287 case EntityType::Item:
289 break;
291 case EntityType::None:
292 default:
293 break;
294 }
295}
296
298 std::vector<SelectedEntity> entities) {
302 selected_entities_.clear();
303
304 for (const auto entity : entities) {
305 if (entity.type != EntityType::Door && entity.type != EntityType::Sprite &&
306 entity.type != EntityType::Item) {
307 continue;
308 }
309 if (std::find(selected_entities_.begin(), selected_entities_.end(),
310 entity) == selected_entities_.end()) {
311 selected_entities_.push_back(entity);
312 }
313 }
314
315 if (selected_entities_.size() == 1) {
316 const SelectedEntity selected = selected_entities_.front();
317 switch (selected.type) {
318 case EntityType::Door:
320 break;
323 break;
324 case EntityType::Item:
326 break;
328 case EntityType::None:
329 default:
330 break;
331 }
332 return;
333 }
334
335 if (ctx_) {
337 }
338}
339
352
354 const bool had_selection = HasEntitySelection();
356 if (had_selection && ctx_) {
358 }
359}
360
367
369 int canvas_x, int canvas_y) const {
370 const auto hits = GetEntitiesAtPosition(canvas_x, canvas_y);
371 if (hits.empty()) {
372 return std::nullopt;
373 }
374 return hits.front();
375}
376
378 int canvas_x, int canvas_y) const {
379 std::vector<SelectedEntity> hits;
380 if (!dungeon_coords::IsWithinBounds(canvas_x, canvas_y)) {
381 return hits;
382 }
383 if (auto door = door_handler_.GetEntityAtPosition(canvas_x, canvas_y)) {
384 hits.push_back(SelectedEntity{EntityType::Door, *door});
385 }
386 if (auto sprite = sprite_handler_.GetEntityAtPosition(canvas_x, canvas_y)) {
387 hits.push_back(SelectedEntity{EntityType::Sprite, *sprite});
388 }
389 if (auto item = item_handler_.GetEntityAtPosition(canvas_x, canvas_y)) {
390 hits.push_back(SelectedEntity{EntityType::Item, *item});
391 }
392 if (auto object = tile_handler_.GetEntityAtPosition(canvas_x, canvas_y)) {
393 hits.push_back(SelectedEntity{EntityType::Object, *object});
394 }
395 return hits;
396}
397
399 if (!selected_entities_.empty()) {
400 return selected_entities_.front();
401 }
402 if (auto idx = door_handler_.GetSelectedIndex()) {
403 return SelectedEntity{EntityType::Door, *idx};
404 }
405 if (auto idx = sprite_handler_.GetSelectedIndex()) {
407 }
408 if (auto idx = item_handler_.GetSelectedIndex()) {
409 return SelectedEntity{EntityType::Item, *idx};
410 }
412}
413
414void InteractionCoordinator::HandleDrag(ImVec2 current_pos, ImVec2 delta) {
415 // Forward drag to handlers that have active selections
417 HandleEntityGroupDrag(current_pos);
418 } else if (door_handler_.HasSelection()) {
419 door_handler_.HandleDrag(current_pos, delta);
420 }
422 sprite_handler_.HandleDrag(current_pos, delta);
423 }
425 item_handler_.HandleDrag(current_pos, delta);
426 }
427
428 // Tile objects (managed by ObjectSelection)
431 tile_handler_.HandleDrag(current_pos, delta);
432 }
433}
434
442
454
479
495
497 if (selected_entities_.size() > 1) {
499 } else {
500 // Preserve the richer single-selection overlays (door pair badge, drag
501 // preview) for the common one-entity inspector workflow.
505 }
506
507 // Draw snap indicators for door placement
510 }
512}
513
515 // Render placement success toasts for all handlers unconditionally so they
516 // remain visible even after the user exits placement mode immediately.
521}
522
524 int canvas_y) {
525 // Clear all selections first
527
528 // Try to select in priority order: doors, sprites, items
529 // (matches original DungeonObjectInteraction behavior)
530 if (door_handler_.HandleClick(canvas_x, canvas_y)) {
531 if (auto index = door_handler_.GetSelectedIndex()) {
533 }
534 return true;
535 }
536 if (sprite_handler_.HandleClick(canvas_x, canvas_y)) {
537 if (auto index = sprite_handler_.GetSelectedIndex()) {
539 }
540 return true;
541 }
542 if (item_handler_.HandleClick(canvas_x, canvas_y)) {
543 if (auto index = item_handler_.GetSelectedIndex()) {
545 }
546 return true;
547 }
548
549 return false;
550}
551
556
558 int delta_x, int delta_y, bool defer_drag_notifications) {
559 auto* room = ctx_ ? ctx_->GetCurrentRoom() : nullptr;
560 if (!room) {
561 return false;
562 }
563
564 bool doors_changed = false;
565 bool sprites_changed = false;
566 bool items_changed = false;
567 bool door_mutation_notified = false;
568 bool sprite_mutation_notified = false;
569 bool item_mutation_notified = false;
570
571 auto notify_once = [&](MutationDomain domain, bool& immediate_flag,
572 bool& drag_flag) {
573 if (!ctx_) {
574 return;
575 }
576 if (defer_drag_notifications) {
577 if (!drag_flag) {
578 ctx_->NotifyMutation(domain);
579 drag_flag = true;
580 }
581 return;
582 }
583 if (!immediate_flag) {
584 ctx_->NotifyMutation(domain);
585 immediate_flag = true;
586 }
587 };
588
589 for (const auto entity : selected_entities_) {
590 switch (entity.type) {
591 case EntityType::Door: {
592 auto& doors = room->GetDoors();
593 if (entity.index >= doors.size()) {
594 break;
595 }
596 auto& door = doors[entity.index];
597 int position_delta = 0;
598 switch (door.direction) {
601 position_delta = delta_x;
602 break;
605 position_delta = delta_y;
606 break;
607 }
608 if (position_delta == 0) {
609 break;
610 }
611 const int next_position =
612 std::clamp(static_cast<int>(door.position) + position_delta, 0,
614 if (next_position == door.position ||
616 static_cast<uint8_t>(next_position), door.direction)) {
617 break;
618 }
619 notify_once(MutationDomain::kDoors, door_mutation_notified,
621 door.position = static_cast<uint8_t>(next_position);
622 auto [b1, b2] = door.EncodeBytes();
623 door.byte1 = b1;
624 door.byte2 = b2;
625 doors_changed = true;
626 break;
627 }
628 case EntityType::Sprite: {
629 auto& sprites = room->GetSprites();
630 if (entity.index >= sprites.size()) {
631 break;
632 }
633 auto& sprite = sprites[entity.index];
634 const int next_x = std::clamp(static_cast<int>(sprite.x()) + delta_x, 0,
636 const int next_y = std::clamp(static_cast<int>(sprite.y()) + delta_y, 0,
638 if (next_x == sprite.x() && next_y == sprite.y()) {
639 break;
640 }
641 notify_once(MutationDomain::kSprites, sprite_mutation_notified,
643 sprite.set_x(next_x);
644 sprite.set_y(next_y);
645 sprites_changed = true;
646 break;
647 }
648 case EntityType::Item: {
649 auto& items = room->GetPotItems();
650 if (entity.index >= items.size()) {
651 break;
652 }
653 auto& item = items[entity.index];
654 constexpr int kRoomPixelMax = 511;
655 constexpr int kItemHorizontalNudgePixels = 8;
656 constexpr int kItemVerticalNudgePixels = 16;
657 const int next_pixel_x =
658 std::clamp(item.GetPixelX() + delta_x * kItemHorizontalNudgePixels,
659 0, kRoomPixelMax);
660 const int next_pixel_y =
661 std::clamp(item.GetPixelY() + delta_y * kItemVerticalNudgePixels, 0,
662 kRoomPixelMax);
663 const int encoded_x = std::clamp(next_pixel_x / 4, 0, 255);
664 const int encoded_y = std::clamp(next_pixel_y / 16, 0, 255);
665 const uint16_t next_position =
666 static_cast<uint16_t>((encoded_y << 8) | encoded_x);
667 if (next_position == item.position) {
668 break;
669 }
670 notify_once(MutationDomain::kItems, item_mutation_notified,
672 item.position = next_position;
673 items_changed = true;
674 break;
675 }
677 case EntityType::None:
678 default:
679 break;
680 }
681 }
682
683 if (!doors_changed && !sprites_changed && !items_changed) {
684 return false;
685 }
686
687 if (doors_changed) {
688 room->MarkObjectsDirty();
689 if (defer_drag_notifications) {
691 } else {
693 }
694 }
695 if (sprites_changed) {
696 room->MarkSpritesDirty();
697 if (defer_drag_notifications) {
699 } else {
701 }
702 }
703 if (items_changed) {
704 room->MarkPotItemsDirty();
705 if (defer_drag_notifications) {
707 } else {
709 }
710 }
711 if (!defer_drag_notifications) {
713 }
714 return true;
715}
716
717bool InteractionCoordinator::NudgeSelected(int delta_x, int delta_y) {
718 if (!selected_entities_.empty()) {
719 return NudgeSelectedEntities(delta_x, delta_y,
720 /*defer_drag_notifications=*/false);
721 }
722
724 return door_handler_.NudgeSelected(delta_x, delta_y);
725 }
727 return sprite_handler_.NudgeSelected(delta_x, delta_y);
728 }
730 constexpr int kItemHorizontalNudgePixels = 8;
731 constexpr int kItemVerticalNudgePixels = 16;
732 return item_handler_.NudgeSelected(delta_x * kItemHorizontalNudgePixels,
733 delta_y * kItemVerticalNudgePixels);
734 }
735 return false;
736}
737
750
752 if (!selected_entities_.empty()) {
753 auto* room = ctx_ ? ctx_->GetCurrentRoom() : nullptr;
754 if (!room) {
755 return;
756 }
757
758 std::vector<size_t> doors;
759 std::vector<size_t> sprites;
760 std::vector<size_t> items;
761 for (const auto entity : selected_entities_) {
762 switch (entity.type) {
763 case EntityType::Door:
764 if (entity.index < room->GetDoors().size()) {
765 doors.push_back(entity.index);
766 }
767 break;
769 if (entity.index < room->GetSprites().size()) {
770 sprites.push_back(entity.index);
771 }
772 break;
773 case EntityType::Item:
774 if (entity.index < room->GetPotItems().size()) {
775 items.push_back(entity.index);
776 }
777 break;
779 case EntityType::None:
780 default:
781 break;
782 }
783 }
784
785 auto sort_unique_desc = [](std::vector<size_t>& indices) {
786 std::sort(indices.begin(), indices.end(), std::greater<size_t>());
787 indices.erase(std::unique(indices.begin(), indices.end()), indices.end());
788 };
789 sort_unique_desc(doors);
790 sort_unique_desc(sprites);
791 sort_unique_desc(items);
792
793 if (!doors.empty()) {
795 auto& room_doors = room->GetDoors();
796 for (size_t index : doors) {
797 room_doors.erase(room_doors.begin() + static_cast<ptrdiff_t>(index));
798 }
799 room->MarkObjectStreamDirty();
801 }
802 if (!sprites.empty()) {
804 auto& room_sprites = room->GetSprites();
805 for (size_t index : sprites) {
806 room_sprites.erase(room_sprites.begin() +
807 static_cast<ptrdiff_t>(index));
808 }
809 room->MarkSpritesDirty();
811 }
812 if (!items.empty()) {
814 auto& room_items = room->GetPotItems();
815 for (size_t index : items) {
816 room_items.erase(room_items.begin() + static_cast<ptrdiff_t>(index));
817 }
818 room->MarkPotItemsDirty();
820 }
821
822 if (!doors.empty() || !sprites.empty() || !items.empty()) {
825 }
826 return;
827 }
828
831 } else if (sprite_handler_.HasSelection()) {
833 } else if (item_handler_.HasSelection()) {
835 }
836}
837
839 const {
840 if (!selected_entities_.empty()) {
841 switch (selected_entities_.front().type) {
842 case EntityType::Door:
843 return Mode::PlaceDoor;
845 return Mode::PlaceSprite;
846 case EntityType::Item:
847 return Mode::PlaceItem;
849 case EntityType::None:
850 default:
851 break;
852 }
853 }
855 return Mode::PlaceDoor;
856 }
858 return Mode::PlaceSprite;
859 }
861 return Mode::PlaceItem;
862 }
863 return Mode::Select;
864}
865
867 switch (current_mode_) {
868 case Mode::PlaceDoor:
869 return &door_handler_;
871 return &sprite_handler_;
872 case Mode::PlaceItem:
873 return &item_handler_;
874 case Mode::Select:
875 default:
876 return nullptr;
877 }
878}
879
882
883 if (entity.type == EntityType::Object) {
884 if (!ctx_ || !ctx_->selection) {
885 return false;
886 }
890 return true;
891 }
892
893 if (ctx_ && ctx_->selection) {
895 }
896
897 switch (entity.type) {
898 case EntityType::Door:
899 selected_entities_.push_back(entity);
901 return true;
903 selected_entities_.push_back(entity);
905 return true;
906 case EntityType::Item:
907 selected_entities_.push_back(entity);
909 return true;
911 case EntityType::None:
912 default:
913 return false;
914 }
915}
916
918 bool additive, bool toggle) {
919 if (entity.type == EntityType::Object || entity.type == EntityType::None) {
920 return false;
921 }
922
926
927 if (!additive && !toggle) {
928 selected_entities_.clear();
929 }
930
931 const auto existing =
932 std::find(selected_entities_.begin(), selected_entities_.end(), entity);
933 if (toggle) {
934 if (existing != selected_entities_.end()) {
935 selected_entities_.erase(existing);
936 } else {
937 selected_entities_.push_back(entity);
938 }
939 } else if (existing == selected_entities_.end()) {
940 selected_entities_.push_back(entity);
941 }
942
943 if (selected_entities_.size() == 1) {
944 const auto selected = selected_entities_.front();
945 switch (selected.type) {
946 case EntityType::Door:
947 door_handler_.SelectDoor(selected.index);
948 break;
950 sprite_handler_.SelectSprite(selected.index);
951 break;
952 case EntityType::Item:
953 item_handler_.SelectItem(selected.index);
954 break;
956 case EntityType::None:
957 default:
958 break;
959 }
960 } else if (ctx_) {
962 }
963
964 return true;
965}
966
968 SelectedEntity entity) const {
969 if (entity.type == EntityType::Object) {
970 return ctx_ && ctx_->selection &&
972 }
973 return std::find(selected_entities_.begin(), selected_entities_.end(),
974 entity) != selected_entities_.end() ||
975 GetSelectedEntity() == entity;
976}
977
979 const bool has_object_selection =
981 return !selected_entities_.empty() &&
982 (selected_entities_.size() > 1 || has_object_selection);
983}
984
1003
1006 return;
1007 }
1008
1010 const ImVec2 drag_delta(
1013
1014 constexpr int kEntityDragStepPixels = dungeon_coords::kSpriteTileSize;
1015 const int drag_dx = static_cast<int>(drag_delta.x) / kEntityDragStepPixels;
1016 const int drag_dy = static_cast<int>(drag_delta.y) / kEntityDragStepPixels;
1017 const int inc_dx = drag_dx - entity_group_drag_last_dx_;
1018 const int inc_dy = drag_dy - entity_group_drag_last_dy_;
1019 if (inc_dx == 0 && inc_dy == 0) {
1020 return;
1021 }
1022
1023 if (NudgeSelectedEntities(inc_dx, inc_dy,
1024 /*defer_drag_notifications=*/true)) {
1027 }
1028}
1029
1031 const std::tuple<int, int, int, int>& bounds, bool additive, bool toggle) {
1032 auto* room = ctx_ ? ctx_->GetCurrentRoomConst() : nullptr;
1033 if (!room) {
1034 return;
1035 }
1036
1037 const auto [raw_min_x, raw_min_y, raw_max_x, raw_max_y] = bounds;
1038 const int min_x = std::min(raw_min_x, raw_max_x);
1039 const int max_x = std::max(raw_min_x, raw_max_x);
1040 const int min_y = std::min(raw_min_y, raw_max_y);
1041 const int max_y = std::max(raw_min_y, raw_max_y);
1042 const int rect_w = std::max(1, max_x - min_x);
1043 const int rect_h = std::max(1, max_y - min_y);
1044
1045 std::vector<SelectedEntity> hits;
1046 for (size_t i = 0; i < room->GetDoors().size(); ++i) {
1047 const SelectedEntity entity{EntityType::Door, i};
1048 if (auto entity_bounds = GetEntityBounds(*room, entity)) {
1049 auto [x, y, w, h] = *entity_bounds;
1050 if (Intersects(x, y, w, h, min_x, min_y, rect_w, rect_h)) {
1051 hits.push_back(entity);
1052 }
1053 }
1054 }
1055 for (size_t i = 0; i < room->GetSprites().size(); ++i) {
1056 const SelectedEntity entity{EntityType::Sprite, i};
1057 if (auto entity_bounds = GetEntityBounds(*room, entity)) {
1058 auto [x, y, w, h] = *entity_bounds;
1059 if (Intersects(x, y, w, h, min_x, min_y, rect_w, rect_h)) {
1060 hits.push_back(entity);
1061 }
1062 }
1063 }
1064 for (size_t i = 0; i < room->GetPotItems().size(); ++i) {
1065 const SelectedEntity entity{EntityType::Item, i};
1066 if (auto entity_bounds = GetEntityBounds(*room, entity)) {
1067 auto [x, y, w, h] = *entity_bounds;
1068 if (Intersects(x, y, w, h, min_x, min_y, rect_w, rect_h)) {
1069 hits.push_back(entity);
1070 }
1071 }
1072 }
1073
1077 if (!additive && !toggle) {
1078 selected_entities_.clear();
1079 }
1080
1081 for (const auto entity : hits) {
1082 auto existing =
1083 std::find(selected_entities_.begin(), selected_entities_.end(), entity);
1084 if (toggle) {
1085 if (existing != selected_entities_.end()) {
1086 selected_entities_.erase(existing);
1087 } else {
1088 selected_entities_.push_back(entity);
1089 }
1090 } else if (existing == selected_entities_.end()) {
1091 selected_entities_.push_back(entity);
1092 }
1093 }
1094
1095 if (selected_entities_.size() == 1) {
1096 UpdateEntitySelection(selected_entities_.front(), /*additive=*/false,
1097 /*toggle=*/false);
1098 } else if (ctx_) {
1100 }
1101}
1102
1104 int canvas_x, int canvas_y, const std::vector<SelectedEntity>& hits) const {
1105 constexpr int kSameSpotTolerancePx = 3;
1106 if (std::abs(canvas_x - cycle_last_x_) > kSameSpotTolerancePx ||
1107 std::abs(canvas_y - cycle_last_y_) > kSameSpotTolerancePx ||
1108 hits.size() != cycle_last_hits_.size()) {
1109 return false;
1110 }
1111
1112 for (size_t i = 0; i < hits.size(); ++i) {
1113 if (!(hits[i] == cycle_last_hits_[i])) {
1114 return false;
1115 }
1116 }
1117 return true;
1118}
1119
1121 const std::vector<SelectedEntity>& hits) const {
1122 for (size_t i = 0; i < hits.size(); ++i) {
1123 const SelectedEntity hit = hits[i];
1124 if (hit.type == EntityType::Object) {
1125 if (ctx_ && ctx_->selection &&
1127 return i;
1128 }
1129 continue;
1130 }
1131
1132 if (std::find(selected_entities_.begin(), selected_entities_.end(), hit) !=
1133 selected_entities_.end()) {
1134 return i;
1135 }
1136
1137 const SelectedEntity selected = GetSelectedEntity();
1138 if (selected == hit) {
1139 return i;
1140 }
1141 }
1142
1143 return std::nullopt;
1144}
1145
1147 if (!ctx_ || !ctx_->canvas || !ctx_->canvas->IsMouseHovering()) {
1148 return;
1149 }
1150
1151 const ImGuiIO& io = ImGui::GetIO();
1152 if (!IsCycleModifierHeld(io)) {
1153 return;
1154 }
1155
1156 const DungeonCanvasTransform transform(ctx_->canvas->zero_point(),
1157 ctx_->canvas->scrolling(),
1159 const auto [canvas_x, canvas_y] =
1160 transform.ScreenToRoomPixelCoordinates(io.MousePos);
1161 const auto hits = GetEntitiesAtPosition(canvas_x, canvas_y);
1162 if (hits.size() < 2) {
1163 cycle_last_hits_.clear();
1165 cycle_hud_start_time_ = -1.0;
1166 return;
1167 }
1168
1169 if (!SameCycleTarget(canvas_x, canvas_y, hits)) {
1171 }
1172
1173 cycle_last_x_ = canvas_x;
1174 cycle_last_y_ = canvas_y;
1175 cycle_hud_screen_pos_ = io.MousePos;
1176 cycle_hud_start_time_ = ImGui::GetCurrentContext() ? ImGui::GetTime() : 0.0;
1177 cycle_last_hits_ = hits;
1178 if (const auto selected_index = FindSelectedCycleIndex(hits)) {
1179 cycle_active_index_ = *selected_index;
1180 } else {
1181 cycle_active_index_ = cycle_next_index_ % hits.size();
1182 }
1183}
1184
1186 if (ImGui::GetCurrentContext()) {
1188 }
1189
1190 if (!ImGui::GetCurrentContext() || cycle_last_hits_.size() < 2 ||
1191 cycle_hud_start_time_ < 0.0) {
1192 return;
1193 }
1194
1195 const double elapsed = ImGui::GetTime() - cycle_hud_start_time_;
1196 const ImGuiIO& io = ImGui::GetIO();
1197 const bool cycle_modifier_held = IsCycleModifierHeld(io);
1198 if (!cycle_modifier_held && elapsed > kCycleHudHoldSeconds) {
1199 return;
1200 }
1201
1202 const float alpha =
1203 cycle_modifier_held
1204 ? 1.0f
1205 : std::max(0.0f,
1206 1.0f - static_cast<float>(elapsed / kCycleHudHoldSeconds));
1207 const auto& theme = AgentUI::GetTheme();
1208 ImVec4 bg = theme.panel_bg_darker;
1209 bg.w *= 0.92f * alpha;
1210 ImVec4 border = theme.panel_border_color;
1211 border.w *= alpha;
1212 ImVec4 text = theme.text_primary;
1213 text.w *= alpha;
1214 ImVec4 secondary = theme.text_secondary_color;
1215 secondary.w *= alpha;
1216 ImVec4 active = theme.accent_color;
1217 active.w *= alpha;
1218
1219 ImGui::SetNextWindowPos(
1220 ImVec2(cycle_hud_screen_pos_.x + 14.0f, cycle_hud_screen_pos_.y + 14.0f),
1221 ImGuiCond_Always);
1222 ImGui::SetNextWindowBgAlpha(bg.w);
1223 ImGui::PushStyleColor(ImGuiCol_WindowBg, bg);
1224 ImGui::PushStyleColor(ImGuiCol_Border, border);
1225 ImGui::PushStyleColor(ImGuiCol_Text, text);
1226 ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 6.0f));
1227 ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
1228 constexpr ImGuiWindowFlags kFlags =
1229 ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoSavedSettings |
1230 ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoNav |
1231 ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoInputs;
1232 if (ImGui::Begin("##DungeonSelectionCycleHud", nullptr, kFlags)) {
1233 ImGui::TextColored(secondary, tr("Cycle"));
1234 for (size_t i = 0; i < cycle_last_hits_.size(); ++i) {
1235 const char* marker = (i == cycle_active_index_) ? "[X]" : "[ ]";
1236 ImGui::TextColored(i == cycle_active_index_ ? active : secondary, "%s",
1237 marker);
1238 ImGui::SameLine(0.0f, 5.0f);
1239 ImGui::TextUnformatted(
1241 }
1242 }
1243 ImGui::End();
1244 ImGui::PopStyleVar(2);
1245 ImGui::PopStyleColor(3);
1246}
1247
1249 auto* room = ctx_ ? ctx_->GetCurrentRoomConst() : nullptr;
1250 if (!room || !ctx_ || !ctx_->canvas) {
1251 return;
1252 }
1253
1254 const auto& theme = AgentUI::GetTheme();
1255 ImDrawList* draw_list = ImGui::GetWindowDrawList();
1256 const DungeonCanvasTransform transform(ctx_->canvas->zero_point(),
1257 ctx_->canvas->scrolling(),
1259 const float scale = transform.scale();
1260 const float pulse =
1261 0.6f + 0.4f * std::sin(static_cast<float>(ImGui::GetTime()) * 6.0f);
1262
1263 for (size_t i = 0; i < selected_entities_.size(); ++i) {
1264 const auto entity = selected_entities_[i];
1265 const auto bounds = GetEntityBounds(*room, entity);
1266 if (!bounds.has_value()) {
1267 continue;
1268 }
1269
1270 auto [x, y, w, h] = *bounds;
1271 ImVec2 start = transform.RoomPixelsToScreen(
1272 ImVec2(static_cast<float>(x), static_cast<float>(y)));
1273 const ImVec2 size = transform.RoomSizeToScreen(
1274 ImVec2(static_cast<float>(w), static_cast<float>(h)));
1275 ImVec2 end(start.x + size.x, start.y + size.y);
1276 constexpr float kMargin = 2.0f;
1277 start.x -= kMargin;
1278 start.y -= kMargin;
1279 end.x += kMargin;
1280 end.y += kMargin;
1281
1282 ImVec4 base = EntitySelectionColor(theme, entity.type);
1283 ImVec4 fill = base;
1284 fill.w = 0.14f + 0.10f * pulse;
1285 ImVec4 border = base;
1286 border.w = (i == 0) ? 0.95f : 0.72f;
1287
1288 draw_list->AddRectFilled(start, end, ImGui::GetColorU32(fill));
1289 draw_list->AddRect(start, end, ImGui::GetColorU32(border), 0.0f, 0,
1290 (i == 0) ? 2.2f : 1.6f);
1291 draw_list->AddText(ImVec2(start.x, start.y - 14.0f * scale),
1292 ImGui::GetColorU32(theme.text_primary),
1293 DescribeEntity(entity).c_str());
1294 }
1295}
1296
1298 SelectedEntity entity) const {
1299 const zelda3::Room* room = ctx_ ? ctx_->GetCurrentRoomConst() : nullptr;
1300 switch (entity.type) {
1301 case EntityType::Door: {
1302 if (room && entity.index < room->GetDoors().size()) {
1303 const auto& door = room->GetDoors()[entity.index];
1304 const std::string direction_name(
1305 zelda3::GetDoorDirectionName(door.direction));
1306 return absl::StrFormat("Door (%s)", direction_name);
1307 }
1308 return "Door";
1309 }
1310 case EntityType::Sprite:
1311 if (room && entity.index < room->GetSprites().size()) {
1312 const auto& sprite = room->GetSprites()[entity.index];
1313 return absl::StrFormat("Sprite (0x%02X - %s)", sprite.id(),
1314 zelda3::ResolveSpriteName(sprite.id()));
1315 }
1316 return "Sprite";
1317 case EntityType::Item:
1318 if (room && entity.index < room->GetPotItems().size()) {
1319 const auto& item = room->GetPotItems()[entity.index];
1320 return absl::StrFormat("Item (0x%02X)", item.item);
1321 }
1322 return "Item";
1323 case EntityType::Object:
1324 if (room && entity.index < room->GetTileObjects().size()) {
1325 const auto& object = room->GetTileObjects()[entity.index];
1326 return absl::StrFormat("Object (0x%03X - %s)", object.id_,
1327 zelda3::GetObjectName(object.id_));
1328 }
1329 return "Object";
1330 case EntityType::None:
1331 default:
1332 return "None";
1333 }
1334}
1335
1337 SelectedEntity entity) const {
1338 return TruncateCycleHudLabel(DescribeEntity(entity));
1339}
1340
1341} // 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)
bool NudgeSelected(int delta_x, int delta_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 > 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.
Mode GetSelectedEntityType() const
Get the type of currently selected entity.
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.
void DeleteSelectedEntity()
Delete currently selected entity.
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.
void SetMode(Mode mode)
Set interaction mode.
void ClearAllEntitySelections()
Clear all entity selections.
void DrawGhostPreviews()
Draw ghost previews for active placement mode.
bool SameCycleTarget(int canvas_x, int canvas_y, const std::vector< SelectedEntity > &hits) const
bool NudgeSelectedEntities(int delta_x, int delta_y, bool defer_drag_notifications)
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.
bool NudgeSelected(int delta_pixel_x, int delta_pixel_y)
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.
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:397
auto zero_point() const
Definition canvas.h:348
bool IsMouseHovering() const
Definition canvas.h:338
auto scrolling() const
Definition canvas.h:350
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:350
const std::vector< zelda3::Sprite > & GetSprites() const
Definition room.h:253
const std::vector< RoomObject > & GetTileObjects() const
Definition room.h:382
const std::vector< PotItem > & GetPotItems() const
Definition room.h:366
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.
MutationDomain
Domain/type of mutation for undo + invalidation routing.
EntityType
Type of entity that can be selected in the dungeon editor.
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)
@ East
Right wall (vertical door, 3x4 tiles)
@ West
Left wall (vertical door, 3x4 tiles)
const char * ResolveSpriteName(uint16_t id)
Definition sprite.cc:284
Centralized theme colors for Agent UI components.
Definition agent_theme.h:19
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.
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.
zelda3::Room * GetCurrentRoom() const
Get pointer to current room.
Represents a selected entity in the dungeon editor.