yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
lyra_map_authoring_ui.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <array>
5#include <cfloat>
6#include <cmath>
7#include <cstdint>
8#include <cstdio>
9#include <string>
10#include <unordered_set>
11#include <utility>
12#include <vector>
13
14#include "absl/strings/ascii.h"
15#include "absl/strings/str_format.h"
17#include "imgui/imgui.h"
20
21namespace yaze::editor {
22namespace {
23
25
26uint32_t AllocateStableId(const std::vector<uint32_t>& existing_ids) {
27 std::unordered_set<uint32_t> id_set(existing_ids.begin(), existing_ids.end());
28 uint32_t candidate = 1;
29 while (id_set.count(candidate) > 0) {
30 ++candidate;
31 }
32 return candidate;
33}
34
36 std::vector<uint32_t> ids;
37 for (const auto& map : state.maps) {
38 for (const auto& s : map.sprites) {
39 ids.push_back(s.id);
40 }
41 }
42 return AllocateStableId(ids);
43}
44
46 std::vector<uint32_t> ids;
47 for (const auto& map : state.maps) {
48 for (const auto& d : map.doors) {
49 ids.push_back(d.id);
50 }
51 }
52 return AllocateStableId(ids);
53}
54
56 std::vector<uint32_t> ids;
57 for (const auto& map : state.maps) {
58 for (const auto& e : map.exits) {
59 ids.push_back(e.id);
60 }
61 }
62 return AllocateStableId(ids);
63}
64
66 std::vector<uint32_t> ids;
67 for (const auto& f : state.flight_points) {
68 ids.push_back(f.id);
69 }
70 return AllocateStableId(ids);
71}
72
74 std::vector<uint32_t> ids;
75 for (const auto& c : state.edge_connections) {
76 ids.push_back(c.id);
77 }
78 return AllocateStableId(ids);
79}
80
81// Edge of `map_id` that faces an adjacent `other`.
82const char* FacingEdge(int map_id, int other) {
83 if (other == map_id - Document::kColumns)
84 return "north";
85 if (other == map_id + Document::kColumns)
86 return "south";
87 return other < map_id ? "west" : "east";
88}
89
90// Lyra maps have no vanilla names; only a project label names them.
91std::string MapName(int map_id) {
92 const auto& labels = zelda3::GetResourceLabels();
93 if (map_id >= Document::kFirstMap &&
94 !labels.HasProjectLabel(zelda3::ResourceType::kOverworldMap, map_id)) {
95 return {};
96 }
97 return labels.GetLabel(zelda3::ResourceType::kOverworldMap, map_id);
98}
99
100std::string NamedMap(int map_id) {
101 const std::string name = MapName(map_id);
102 return name.empty() ? absl::StrFormat("$%02X", map_id)
103 : absl::StrFormat("$%02X %s", map_id, name);
104}
105
106void DrawEdgeTilePicker(const char* label, int& value, int first, int last) {
107 const std::string preview = std::to_string(value);
108 if (!ImGui::BeginCombo(label, preview.c_str()))
109 return;
110 for (int tile = first; tile <= last; ++tile) {
111 const bool selected = tile == value;
112 const std::string option = std::to_string(tile);
113 if (ImGui::Selectable(option.c_str(), selected))
114 value = tile;
115 if (selected)
116 ImGui::SetItemDefaultFocus();
117 }
118 ImGui::EndCombo();
119}
120
121std::string SpriteTypeLabel(int type) {
122 // ResolveSpriteName returns thread-local storage reused by the next call.
123 const std::string name =
124 zelda3::ResolveSpriteName(static_cast<uint16_t>(type));
125 return absl::StrFormat("$%02X %s", type, name);
126}
127
128// Returns true only when the chosen type differs from the incoming one.
129bool DrawSpriteTypePicker(const char* label, int& type,
130 std::array<char, 64>& filter) {
131 const std::string preview = SpriteTypeLabel(type);
132 if (!ImGui::BeginCombo(label, preview.c_str(), ImGuiComboFlags_HeightLarge)) {
133 return false;
134 }
135 ImGui::SetNextItemWidth(-FLT_MIN);
136 ImGui::InputTextWithHint("##filter", "Search ID or name", filter.data(),
137 filter.size());
138 const std::string query = absl::AsciiStrToLower(filter.data());
139 bool changed = false;
140 for (int id = 0; id < 256; ++id) {
141 const std::string option = SpriteTypeLabel(id);
142 if (!query.empty() &&
143 absl::AsciiStrToLower(option).find(query) == std::string::npos) {
144 continue;
145 }
146 ImGui::PushID(id);
147 const bool selected = id == type;
148 if (ImGui::Selectable(option.c_str(), selected) && !selected) {
149 type = id;
150 changed = true;
151 }
152 if (selected)
153 ImGui::SetItemDefaultFocus();
154 ImGui::PopID();
155 }
156 ImGui::EndCombo();
157 return changed;
158}
159
160} // namespace
161
163 zelda3::Overworld& overworld,
164 UndoManager& history) {
165 if (!ImGui::CollapsingHeader("Gameplay area",
166 ImGuiTreeNodeFlags_DefaultOpen)) {
167 return;
168 }
169 // Keep a value copy: a selection below can replace the authoring vector.
170 const auto* stored = document_->GameplayAreaForMap(map_id);
171 const auto area = stored ? std::optional(*stored) : std::nullopt;
172 if (area) {
173 ImGui::Text("Parent: $%02X", area->parent_map_id);
174 ImGui::TextWrapped("Ordered pages: %s", FormatMapIds(area->maps).c_str());
175 if (ImGui::Button("Unassign whole area")) {
176 status_ = SetGameplayArea(map_id, std::nullopt, overworld, history);
177 }
178 } else {
179 ImGui::TextUnformatted("Parent: Unassigned");
180 }
181 const bool child = area && area->parent_map_id != map_id;
182 if (child) {
183 ImGui::TextWrapped("Select the parent page to change this area's size.");
184 }
185 ImGui::BeginDisabled(child);
186 if (ImGui::BeginCombo("Assign this page as parent", "Choose footprint")) {
187 using Size = core::LyraGameplayAreaSize;
188 const std::array<std::pair<Size, const char*>, 4> options = {{
189 {Size::kSmall, "Small (1 x 1 page)"},
190 {Size::kWide, "Wide (2 x 1 pages)"},
191 {Size::kTall, "Tall (1 x 2 pages)"},
192 {Size::kLarge, "Large (2 x 2 pages)"},
193 }};
194 for (const auto& [size, label] : options) {
195 const bool valid = core::LyraGameplayAreaFootprint(map_id, size).ok();
196 ImGui::BeginDisabled(!valid);
197 const bool selected = area && area->size == size;
198 if (ImGui::Selectable(label, selected) && !selected) {
199 status_ = SetGameplayArea(map_id, size, overworld, history);
200 }
201 ImGui::EndDisabled();
202 }
203 ImGui::EndCombo();
204 }
206 "LyraMapProperties/combo:GameplayFootprint", "combo",
207 ImGui::GetID("Assign this page as parent"));
208 ImGui::EndDisabled();
209}
210
211void LyraTerrainCanvas::DrawProperties(int map_id, int& state,
212 zelda3::Overworld& overworld,
213 UndoManager& history) {
214 if (!document_) {
215 return;
216 }
217
218 if (ImGui::IsKeyPressed(ImGuiKey_Escape))
219 placement_kind_ = 0;
220 const bool is_lyra = (map_id >= Document::kFirstMap &&
221 map_id < Document::kFirstMap + Document::kMapCount);
222
223 if (is_lyra) {
224 const int map_index = map_id - Document::kFirstMap;
225 if (property_map_ != map_id) {
226 property_map_ = map_id;
227 copy_message_.clear();
228 copy_failed_ = false;
229 }
230 core::LyraMapProperties& draft = PropertyDraftFor(map_index);
231
232 ImGui::PushID("LyraProperties");
233 ImGui::Text("Lyra Map Properties ($%02X)", map_id);
234 ImGui::TextWrapped(
235 "Project draft; saved with project; build/runtime unavailable");
236
237 ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.3f, 1.0f));
238 if (ImGui::CollapsingHeader(ICON_MD_INFO " Tail Map Limitations ($A0-$BF)",
239 ImGuiTreeNodeFlags_DefaultOpen)) {
240 ImGui::PopStyleColor();
241 ImGui::TextWrapped(
242 "• Items & Bomb Doors: Disabled (pending SRAM event allocation in "
243 "save file)\n"
244 "• Exits: 1 free slot in vanilla table; multi-link requires ASM exit "
245 "table v2\n"
246 "• Entrances: Light-world trigger links v2 in development (Phase "
247 "P2)\n"
248 "• Sprites: Draft state only (requires actor compilation for ROM)");
249 } else {
250 ImGui::PopStyleColor();
251 }
252 DrawGameplayArea(map_id, overworld, history);
253
254 // Game state selector combo 0..2
255 const char* state_names[] = {"State 0", "State 1", "State 2"};
256 int current_state = state;
257 if (current_state < 0)
258 current_state = 0;
259 if (current_state > 2)
260 current_state = 2;
261 if (ImGui::Combo("Game State", &current_state, state_names, 3)) {
262 state = current_state;
263 }
264
265 // Main palette (uint8_t)
266 int main_pal = draft.main_palette;
267 if (ImGui::InputInt("Main palette", &main_pal)) {
268 draft.main_palette = static_cast<uint8_t>(std::clamp(main_pal, 0, 255));
269 }
270
272 "LyraMapProperties/input:MainPalette", "input",
273 ImGui::GetID("Main palette"));
274 // Area palette (uint8_t)
275 int area_pal = draft.palette_set;
276 if (ImGui::InputInt("Area palette", &area_pal)) {
277 draft.palette_set = static_cast<uint8_t>(std::clamp(area_pal, 0, 255));
278 }
279
280 if (draft.background_palette) {
281 ImGui::TextUnformatted(
282 "Background colors: project override (128 colors)");
283 ImGui::TextWrapped(
284 "This map uses project colors. Native palettes and sprite colors "
285 "stay unchanged.");
286 if (ImGui::Button("Use native palette")) {
287 draft.background_palette.reset();
288 status_ = ApplyPropertyDraft(map_index, overworld, history);
289 }
290 } else {
291 ImGui::TextUnformatted("Background colors: native palette");
292 }
293
294 // Animated GFX (uint8_t hex)
295 int anim_sheet = draft.animated_sheet;
296 if (ImGui::InputScalar("Animated GFX", ImGuiDataType_S32, &anim_sheet,
297 nullptr, nullptr, "%02X",
298 ImGuiInputTextFlags_CharsHexadecimal)) {
299 draft.animated_sheet =
300 static_cast<uint8_t>(std::clamp(anim_sheet, 0, 255));
301 }
302
303 // Custom GFX slots 0..7
304 if (ImGui::TreeNode("Custom GFX Slots")) {
305 for (int i = 0; i < 8; ++i) {
306 ImGui::PushID(i);
307 int sheet_val = draft.graphics_sheets[i];
308 std::string label = absl::StrFormat("Custom GFX slot %d", i);
309 if (ImGui::InputScalar(label.c_str(), ImGuiDataType_S32, &sheet_val,
310 nullptr, nullptr, "%02X",
311 ImGuiInputTextFlags_CharsHexadecimal)) {
312 draft.graphics_sheets[i] =
313 static_cast<uint8_t>(std::clamp(sheet_val, 0, 255));
314 }
315 ImGui::PopID();
316 }
317 ImGui::TreePop();
318 }
319
320 // Sprite set / palette for states 0..2 (-1 inherit)
321 if (ImGui::TreeNode("Sprite Graphics & Palettes")) {
322 for (int s = 0; s < 3; ++s) {
323 ImGui::PushID(s);
324 std::string gfx_label =
325 absl::StrFormat("Sprite set (State %d, -1 inherit)", s);
326 int gfx_val = draft.sprite_graphics[s];
327 if (ImGui::InputInt(gfx_label.c_str(), &gfx_val)) {
328 draft.sprite_graphics[s] = std::clamp(gfx_val, -1, 255);
329 }
330
331 std::string pal_label =
332 absl::StrFormat("Sprite palette (State %d, -1 inherit)", s);
333 int pal_val = draft.sprite_palettes[s];
334 if (ImGui::InputInt(pal_label.c_str(), &pal_val)) {
335 draft.sprite_palettes[s] = std::clamp(pal_val, -1, 255);
336 }
337 ImGui::PopID();
338 }
339 ImGui::TreePop();
340 }
341
342 const bool music_open = ImGui::TreeNode("Music and message");
344 absl::StrFormat("LyraMapProperties/%02X/tree:MusicMessage", map_id),
345 "tree", ImGui::GetItemID());
346 if (music_open) {
347 for (int i = 0; i < 4; ++i) {
348 ImGui::PushID(i);
349 int value = draft.music[i];
350 if (ImGui::InputInt("Music", &value))
351 draft.music[i] = std::clamp(value, 0, 255);
352 ImGui::PopID();
353 }
354 int value = draft.message_id;
355 if (ImGui::InputScalar("Message ID", ImGuiDataType_S32, &value, nullptr,
356 nullptr, "%04X",
357 ImGuiInputTextFlags_CharsHexadecimal))
358 draft.message_id = std::clamp(value, 0, 65535);
360 absl::StrFormat("LyraMapProperties/%02X/input_scalar:MessageId",
361 map_id),
362 "input_scalar", ImGui::GetItemID());
363 ImGui::TreePop();
364 }
365
366 int copy_source = copy_source_map_;
367 if (ImGui::InputScalar("Native source map", ImGuiDataType_S32, &copy_source,
368 nullptr, nullptr, "%02X",
369 ImGuiInputTextFlags_CharsHexadecimal)) {
370 copy_source_map_ = std::clamp(copy_source, 0, 0x9F);
371 }
372 if (const auto* source = overworld.overworld_map(copy_source_map_)) {
373 ImGui::Text("Source $%02X, area parent $%02X", copy_source_map_,
374 source->parent());
375 }
376 if (ImGui::Button("Copy graphics settings")) {
377 auto copied =
379 copy_failed_ = !copied.ok();
380 if (copied.ok()) {
381 draft = *std::move(copied);
382 copy_message_ = absl::StrFormat(
383 "Copied $%02X (area parent $%02X) into the $%02X draft",
385 overworld.overworld_map(copy_source_map_)->parent(), map_id);
386 } else {
387 copy_message_ = copied.status().ToString();
388 }
389 }
390 ImGui::TextWrapped(
391 "Copies native sheets, palettes, animation and sprite sets. Background "
392 "colors, overlays and runtime graphics are not copied. Apply "
393 "Properties to keep these settings.");
394 if (copy_failed_) {
395 ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "%s",
396 copy_message_.c_str());
397 } else if (!copy_message_.empty()) {
398 ImGui::TextWrapped("%s", copy_message_.c_str());
399 }
400
401 // Apply and Revert buttons
402 if (ImGui::Button("Apply Properties")) {
403 status_ = ApplyPropertyDraft(map_index, overworld, history);
404 if (status_.ok()) {
405 copy_message_.clear();
406 copy_failed_ = false;
407 }
408 }
410 "LyraMapProperties/button:Apply", "button", ImGui::GetItemID());
411 ImGui::SameLine();
412 if (ImGui::Button("Revert Properties")) {
413 RevertPropertyDraft(map_index);
414 status_ = absl::OkStatus();
415 copy_message_.clear();
416 copy_failed_ = false;
417 }
418
419 ImGui::PushTextWrapPos(0.0f);
420 if (PropertyConflict(map_index)) {
421 ImGui::TextColored(
422 ImVec4(1.0f, 0.4f, 0.4f, 1.0f),
423 "$%02X properties changed after these edits began (for example by "
424 "Undo). Apply is refused; Revert Properties to load the current "
425 "values, then edit again.",
426 map_id);
427 } else if (PropertyPending(map_index)) {
428 ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.2f, 1.0f),
429 "$%02X has unapplied property edits.", map_id);
430 }
431 std::vector<int> other_pending = PendingPropertyMaps();
432 std::erase(other_pending, map_id);
433 if (!other_pending.empty()) {
434 ImGui::TextWrapped(
435 "Unapplied properties on %s. Visit each map and Apply or Revert "
436 "before saving the project.",
437 FormatMapIds(other_pending).c_str());
438 }
439
440 if (!status_.ok() && !PropertyConflict(map_index)) {
441 ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "%s",
442 status_.ToString().c_str());
443 }
444 ImGui::PopTextWrapPos();
445
446 ImGui::Separator();
447
448 // Entity section for Lyra maps
449 ImGui::TextUnformatted("Entities");
450
451 if (placement_kind_ != 0) {
452 ImGui::TextColored(
453 ImVec4(1.0f, 0.9f, 0.2f, 1.0f),
454 "Placement mode active (Click canvas to place, Esc to cancel)");
455 }
456
457 // Sprites
458 if (ImGui::CollapsingHeader("Sprites", ImGuiTreeNodeFlags_DefaultOpen)) {
459 DrawSpriteTypePicker("Sprite type", new_sprite_type_, sprite_filter_);
460 if (ImGui::Button("Add sprite")) {
461 core::LyraAuthoringState next_state = document_->authoring();
462 const uint32_t new_id = AllocateSpriteId(next_state);
463 next_state.maps[map_index].sprites.push_back({
464 .id = new_id,
465 .sprite_id = static_cast<uint8_t>(new_sprite_type_),
466 .x = 16,
467 .y = 16,
468 .state = std::clamp(state, 0, 2),
469 });
470 (void)EditAuthoring(next_state, overworld, history);
471 }
473 "LyraMapProperties/button:AddSprite", "button", ImGui::GetItemID());
474 ImGui::SameLine();
475 if (ImGui::Button("Place sprite on canvas")) {
476 placement_kind_ = 1;
478 placement_state_ = std::clamp(state, 0, 2);
479 }
480
481 const auto& sprites = document_->authoring().maps[map_index].sprites;
482 for (size_t i = 0; i < sprites.size(); ++i) {
483 const auto& s = sprites[i];
484 ImGui::PushID(static_cast<int>(s.id + 0x100000));
485 std::string header_label =
486 absl::StrFormat("Sprite #%u (ID: %u, State: %d) - %s", i, s.id,
487 s.state, SpriteTypeLabel(s.sprite_id));
488 if (ImGui::CollapsingHeader((header_label + "###entity").c_str())) {
489 core::LyraAuthoringState next_state = document_->authoring();
490 auto& target = next_state.maps[map_index].sprites[i];
491 bool changed = false;
492
493 int spr_x = target.x;
494 if (ImGui::SliderInt("X", &spr_x, 0, 31)) {
495 target.x = spr_x;
496 changed = true;
497 }
498 int spr_y = target.y;
499 if (ImGui::SliderInt("Y", &spr_y, 0, 31)) {
500 target.y = spr_y;
501 changed = true;
502 }
503 int spr_type = target.sprite_id;
504 if (ImGui::InputScalar("Sprite ID", ImGuiDataType_S32, &spr_type,
505 nullptr, nullptr, "%02X",
506 ImGuiInputTextFlags_CharsHexadecimal)) {
507 target.sprite_id =
508 static_cast<uint8_t>(std::clamp(spr_type, 0, 255));
509 changed = true;
510 }
511 int picked_type = target.sprite_id;
512 if (DrawSpriteTypePicker("Sprite type", picked_type,
514 target.sprite_id = static_cast<uint8_t>(picked_type);
515 changed = true;
516 }
517 int spr_st = target.state;
518 if (ImGui::SliderInt("State", &spr_st, 0, 2)) {
519 target.state = spr_st;
520 changed = true;
521 }
522
523 if (ImGui::Button("Remove")) {
524 next_state.maps[map_index].sprites.erase(
525 next_state.maps[map_index].sprites.begin() + i);
526 (void)EditAuthoring(next_state, overworld, history);
527 ImGui::PopID();
528 break;
529 }
530
531 if (changed) {
532 (void)EditAuthoring(next_state, overworld, history);
533 ImGui::PopID();
534 break;
535 }
536 }
537 ImGui::PopID();
538 }
539 }
540
541 // Doors / Entrances
542 if (ImGui::CollapsingHeader("Doors / Entrances",
543 ImGuiTreeNodeFlags_DefaultOpen)) {
544 if (ImGui::Button("Add entrance")) {
545 core::LyraAuthoringState next_state = document_->authoring();
546 const uint32_t new_id = AllocateDoorId(next_state);
547 next_state.maps[map_index].doors.push_back({
548 .id = new_id,
549 .x = 16,
550 .y = 16,
551 .entrance_id = std::nullopt,
552 });
553 (void)EditAuthoring(next_state, overworld, history);
554 }
556 "LyraMapProperties/button:AddEntrance", "button", ImGui::GetItemID());
557 ImGui::SameLine();
558 if (ImGui::Button("Place entrance on canvas")) {
559 placement_kind_ = 2;
560 placement_type_ = 0;
561 }
562
563 const auto& doors = document_->authoring().maps[map_index].doors;
564 for (size_t i = 0; i < doors.size(); ++i) {
565 const auto& d = doors[i];
566 ImGui::PushID(static_cast<int>(d.id + 0x200000));
567 std::string header_label =
568 absl::StrFormat("Entrance #%u (ID: %u)", i, d.id);
569 if (ImGui::CollapsingHeader((header_label + "###entity").c_str())) {
570 core::LyraAuthoringState next_state = document_->authoring();
571 auto& target = next_state.maps[map_index].doors[i];
572 bool changed = false;
573
574 int door_x = target.x;
575 if (ImGui::SliderInt("X", &door_x, 0, 31)) {
576 target.x = door_x;
577 changed = true;
578 }
579 int door_y = target.y;
580 if (ImGui::SliderInt("Y", &door_y, 0, 31)) {
581 target.y = door_y;
582 changed = true;
583 }
584 int ent_id = target.entrance_id.value_or(-1);
585 if (ImGui::InputInt("Entrance ID (-1 unassigned)", &ent_id)) {
586 if (ent_id < 0) {
587 target.entrance_id = std::nullopt;
588 } else {
589 target.entrance_id = std::min(ent_id, 0xFE);
590 }
591 changed = true;
592 }
593
594 if (ImGui::Button("Remove")) {
595 next_state.maps[map_index].doors.erase(
596 next_state.maps[map_index].doors.begin() + i);
597 (void)EditAuthoring(next_state, overworld, history);
598 ImGui::PopID();
599 break;
600 }
601
602 if (changed) {
603 (void)EditAuthoring(next_state, overworld, history);
604 ImGui::PopID();
605 break;
606 }
607 }
608 ImGui::PopID();
609 }
610 }
611
612 // Exits
613 if (ImGui::CollapsingHeader("Exits", ImGuiTreeNodeFlags_DefaultOpen)) {
614 if (ImGui::Button("Add exit")) {
615 core::LyraAuthoringState next_state = document_->authoring();
616 const uint32_t new_id = AllocateExitId(next_state);
617 next_state.maps[map_index].exits.push_back({
618 .id = new_id,
619 .room_id = 0,
620 .x = 16,
621 .y = 16,
622 .exit_row = std::nullopt,
623 });
624 (void)EditAuthoring(next_state, overworld, history);
625 }
627 "LyraMapProperties/button:AddExit", "button", ImGui::GetItemID());
628 ImGui::SameLine();
629 if (ImGui::Button("Place exit on canvas")) {
630 placement_kind_ = 3;
631 placement_type_ = 0;
632 }
633
634 const auto& exits = document_->authoring().maps[map_index].exits;
635 for (size_t i = 0; i < exits.size(); ++i) {
636 const auto& e = exits[i];
637 ImGui::PushID(static_cast<int>(e.id + 0x300000));
638 std::string header_label =
639 absl::StrFormat("Exit #%u (ID: %u)", i, e.id);
640 if (ImGui::CollapsingHeader((header_label + "###entity").c_str())) {
641 core::LyraAuthoringState next_state = document_->authoring();
642 auto& target = next_state.maps[map_index].exits[i];
643 bool changed = false;
644
645 int exit_x = target.x;
646 if (ImGui::SliderInt("X", &exit_x, 0, 31)) {
647 target.x = exit_x;
648 changed = true;
649 }
650 int exit_y = target.y;
651 if (ImGui::SliderInt("Y", &exit_y, 0, 31)) {
652 target.y = exit_y;
653 changed = true;
654 }
655 int room_id = target.room_id;
656 if (ImGui::InputScalar("Room ID", ImGuiDataType_S32, &room_id,
657 nullptr, nullptr, "%03X",
658 ImGuiInputTextFlags_CharsHexadecimal)) {
659 target.room_id = std::clamp(room_id, 0, 0x127);
660 changed = true;
661 }
662 int row_val = target.exit_row.value_or(-1);
663 if (ImGui::InputInt("Exit row (-1 unassigned)", &row_val)) {
664 if (row_val < 0) {
665 target.exit_row = std::nullopt;
666 } else {
667 target.exit_row = std::min(row_val, 78);
668 }
669 changed = true;
670 }
671
672 if (ImGui::Button("Remove")) {
673 next_state.maps[map_index].exits.erase(
674 next_state.maps[map_index].exits.begin() + i);
675 (void)EditAuthoring(next_state, overworld, history);
676 ImGui::PopID();
677 break;
678 }
679
680 if (changed) {
681 (void)EditAuthoring(next_state, overworld, history);
682 ImGui::PopID();
683 break;
684 }
685 }
686 ImGui::PopID();
687 }
688 }
689
690 ImGui::PopID();
691 }
692
693 // Flight points section for ANY map 0..BF when document is loaded
694 if (ImGui::CollapsingHeader("Flight Points",
695 ImGuiTreeNodeFlags_DefaultOpen)) {
696 ImGui::PushID("FlightPointsSection");
697
698 if (ImGui::Button("Add flight point")) {
699 core::LyraAuthoringState next_state = document_->authoring();
700 const uint32_t new_id = AllocateFlightPointId(next_state);
701 next_state.flight_points.push_back({
702 .id = new_id,
703 .map_id = std::clamp(map_id, 0, 0xBF),
704 .x = 16,
705 .y = 16,
706 .name = "Flight point",
707 .runtime_slot = std::nullopt,
708 });
709 (void)EditAuthoring(next_state, overworld, history);
710 }
712 "LyraMapProperties/button:AddFlight", "button", ImGui::GetItemID());
713 ImGui::SameLine();
714 if (ImGui::Button("Place flight point on canvas")) {
715 placement_kind_ = 4;
716 placement_type_ = 0;
717 }
718
719 const auto& flight_points = document_->authoring().flight_points;
720 for (size_t i = 0; i < flight_points.size(); ++i) {
721 const auto& f = flight_points[i];
722 if (f.map_id != map_id) {
723 continue;
724 }
725 ImGui::PushID(static_cast<int>(f.id + 0x400000));
726 std::string header_label =
727 absl::StrFormat("Flight Point #%u (%s)", i, f.name.c_str());
728 if (ImGui::CollapsingHeader((header_label + "###entity").c_str())) {
729 core::LyraAuthoringState next_state = document_->authoring();
730 auto& target = next_state.flight_points[i];
731 bool changed = false;
732
733 ImGui::Text("ID: %u (read-only)", target.id);
734
735 char name_buf[81] = {0};
736 std::snprintf(name_buf, sizeof(name_buf), "%s", target.name.c_str());
737 if (ImGui::InputText("Name", name_buf, sizeof(name_buf))) {
738 target.name = name_buf;
739 changed = true;
740 }
741
742 int f_map = target.map_id;
743 if (ImGui::InputScalar("Map ID", ImGuiDataType_S32, &f_map, nullptr,
744 nullptr, "%02X",
745 ImGuiInputTextFlags_CharsHexadecimal)) {
746 target.map_id = std::clamp(f_map, 0, 0xBF);
747 changed = true;
748 }
749
750 int f_x = target.x;
751 if (ImGui::SliderInt("X", &f_x, 0, 31)) {
752 target.x = f_x;
753 changed = true;
754 }
755
756 int f_y = target.y;
757 if (ImGui::SliderInt("Y", &f_y, 0, 31)) {
758 target.y = f_y;
759 changed = true;
760 }
761
762 if (ImGui::Button("Remove")) {
763 next_state.flight_points.erase(next_state.flight_points.begin() + i);
764 (void)EditAuthoring(next_state, overworld, history);
765 ImGui::PopID();
766 break;
767 }
768
769 if (changed) {
770 (void)EditAuthoring(next_state, overworld, history);
771 ImGui::PopID();
772 break;
773 }
774 }
775 ImGui::PopID();
776 }
777
778 ImGui::PopID();
779 }
780
781 DrawEdgeConnections(map_id, overworld, history);
782 if (!status_.ok())
783 ImGui::TextWrapped("%s", status_.ToString().c_str());
784}
785
787 zelda3::Overworld& overworld,
788 UndoManager& history) {
789 const auto& connections = document_->authoring().edge_connections;
790 const auto connected = [&](int other) {
791 return std::any_of(connections.begin(), connections.end(),
792 [&](const core::LyraEdgeConnection& c) {
793 return c.map_a == std::min(map_id, other) &&
794 c.map_b == std::max(map_id, other);
795 });
796 };
797 bool has_neighbor = false;
798 std::vector<int> open_neighbors;
799 for (const int delta : {-Document::kColumns, Document::kColumns, -1, 1}) {
800 const int other = map_id + delta;
801 if (!core::LyraEdgeMapsAdjacent(map_id, other))
802 continue;
803 has_neighbor = true;
804 if (!connected(other))
805 open_neighbors.push_back(other);
806 }
807 if (!has_neighbor ||
808 !ImGui::CollapsingHeader("Connections", ImGuiTreeNodeFlags_DefaultOpen)) {
809 return;
810 }
811
812 ImGui::PushID("EdgeConnectionsSection");
813 ImGui::PushTextWrapPos(0.0f);
814 ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.2f, 1.0f),
815 "Saved with project; does not open the route in the game");
816 ImGui::TextDisabled("Tile16 positions 0–31 along the edge");
817
818 const bool full = connections.size() >= Document::kMaxEdgeConnections;
819 const char* preview = full ? "Connection limit reached"
820 : open_neighbors.empty() ? "All neighbors drafted"
821 : "Choose neighbor";
822 ImGui::TextUnformatted("Add connection");
823 ImGui::BeginDisabled(full || open_neighbors.empty());
824 ImGui::SetNextItemWidth(-FLT_MIN);
825 if (ImGui::BeginCombo("##AddConnection", preview)) {
826 for (const int other : open_neighbors) {
827 ImGui::PushID(other);
828 const std::string option = absl::StrFormat(
829 "%s edge: %s", FacingEdge(map_id, other), NamedMap(other));
830 if (ImGui::Selectable(option.c_str())) {
831 core::LyraAuthoringState next_state = document_->authoring();
832 next_state.edge_connections.push_back({
833 .id = AllocateEdgeConnectionId(next_state),
834 .map_a = std::min(map_id, other),
835 .map_b = std::max(map_id, other),
836 .passage = std::nullopt,
837 });
838 (void)EditAuthoring(next_state, overworld, history);
839 }
840 ImGui::PopID();
841 }
842 ImGui::EndCombo();
843 }
844 ImGui::EndDisabled();
846 "LyraMapProperties/combo:AddConnection", "combo",
847 ImGui::GetID("##AddConnection"));
848
849 bool any = false;
850 for (size_t i = 0; i < connections.size(); ++i) {
851 const auto& connection = connections[i];
852 if (connection.map_a != map_id && connection.map_b != map_id)
853 continue;
854 any = true;
855 ImGui::PushID(static_cast<int>(connection.id));
856 ImGui::Separator();
857 ImGui::Text("$%02X %s ↔ $%02X %s", connection.map_a,
858 FacingEdge(connection.map_a, connection.map_b),
859 connection.map_b,
860 FacingEdge(connection.map_b, connection.map_a));
861 for (const int id : {connection.map_a, connection.map_b}) {
862 if (const std::string name = MapName(id); !name.empty())
863 ImGui::TextDisabled("$%02X: %s", id, name.c_str());
864 }
865
866 core::LyraEdgeConnection edited = connection;
867 bool assigned = edited.passage.has_value();
868 if (ImGui::Checkbox("Assign passage", &assigned)) {
869 edited.passage = assigned ? std::optional<core::LyraEdgePassage>(
871 : std::nullopt;
872 }
873 if (!edited.passage) {
874 ImGui::TextUnformatted("Passage: unassigned (no opening drafted)");
875 } else {
876 auto& passage = *edited.passage;
877 ImGui::Text("Passage: tiles %d–%d", passage.first_tile,
878 passage.last_tile);
879 ImGui::TextDisabled(
880 "%s; first and last included",
881 connection.map_b - connection.map_a == Document::kColumns
882 ? "Columns (left to right)"
883 : "Rows (top to bottom)");
884 ImGui::PushItemWidth(ImGui::GetFontSize() * 7.0f);
885 DrawEdgeTilePicker("First tile", passage.first_tile, 0,
886 Document::kMapSize - 1);
887 passage.last_tile = std::max(passage.first_tile, passage.last_tile);
888 DrawEdgeTilePicker("Last tile", passage.last_tile, 0,
889 Document::kMapSize - 1);
890 passage.first_tile = std::min(passage.first_tile, passage.last_tile);
891 ImGui::PopItemWidth();
892 }
893 if (edited != connection) {
894 core::LyraAuthoringState next_state = document_->authoring();
895 next_state.edge_connections[i] = std::move(edited);
896 (void)EditAuthoring(next_state, overworld, history);
897 ImGui::PopID();
898 break;
899 }
900
901 if (ImGui::Button("Remove connection")) {
902 core::LyraAuthoringState next_state = document_->authoring();
903 next_state.edge_connections.erase(next_state.edge_connections.begin() +
904 i);
905 (void)EditAuthoring(next_state, overworld, history);
906 ImGui::PopID();
907 break;
908 }
909 ImGui::PopID();
910 }
911 if (!any)
912 ImGui::TextDisabled("No connections drafted for $%02X.", map_id);
913
914 ImGui::PopTextWrapPos();
915 ImGui::PopID();
916}
917
919 int world, int state) {
920 if (!document_ || !canvas.draw_list || !std::isfinite(canvas.scale) ||
921 canvas.scale <= 0) {
922 return;
923 }
924
925 ImDrawList* draw = canvas.draw_list;
926 const float scale = canvas.scale;
927 const ImVec2 origin = canvas.canvas_p0;
928 const ImVec2 scroll = canvas.scrolling;
929
930 auto ToScreen = [&](int map_id, int x, int y) -> ImVec2 {
931 const int local_map = map_id % 64;
932 const int world_x = (local_map % 8) * 512 + x * 16;
933 const int world_y = (local_map / 8) * 512 + y * 16;
934 return ImVec2(origin.x + scroll.x + world_x * scale,
935 origin.y + scroll.y + world_y * scale);
936 };
937
938 const float marker_size = 16.0f * scale;
939
940 // Draw project map entities (sprites, doors, exits)
941 for (int map_idx = 0; map_idx < Document::kMapCount; ++map_idx) {
942 const int map_id = Document::kFirstMap + map_idx;
943 if (map_id / 0x40 != world) {
944 continue;
945 }
946 const auto& map_auth = document_->authoring().maps[map_idx];
947
948 // Exits (Distinct color: yellow / orange-yellow)
949 for (const auto& exit : map_auth.exits) {
950 const ImVec2 p_min = ToScreen(map_id, exit.x, exit.y);
951 const ImVec2 p_max(p_min.x + marker_size, p_min.y + marker_size);
952 draw->AddRectFilled(p_min, p_max, IM_COL32(230, 190, 40, 180));
953 draw->AddRect(p_min, p_max, IM_COL32(255, 255, 255, 220));
954 draw->AddText(ImVec2(p_min.x + 2, p_min.y + 2), IM_COL32(0, 0, 0, 255),
955 "exit");
956 }
957
958 // Doors / Entrances (Distinct color: green)
959 for (const auto& door : map_auth.doors) {
960 const ImVec2 p_min = ToScreen(map_id, door.x, door.y);
961 const ImVec2 p_max(p_min.x + marker_size, p_min.y + marker_size);
962 draw->AddRectFilled(p_min, p_max, IM_COL32(50, 190, 80, 180));
963 draw->AddRect(p_min, p_max, IM_COL32(255, 255, 255, 220));
964 draw->AddText(ImVec2(p_min.x + 2, p_min.y + 2), IM_COL32(0, 0, 0, 255),
965 "entrance");
966 }
967
968 // Sprites matching current state (Distinct color: red / magenta)
969 for (const auto& sprite : map_auth.sprites) {
970 if (sprite.state != state) {
971 continue;
972 }
973 DrawSpritePreview(canvas, map_id, sprite);
974 const ImVec2 p_min = ToScreen(map_id, sprite.x, sprite.y);
975 const ImVec2 p_max(p_min.x + marker_size, p_min.y + marker_size);
976
977 draw->AddRect(p_min, p_max, IM_COL32(255, 255, 255, 220));
978 draw->AddText(ImVec2(p_min.x + 2, p_min.y + 2),
979 IM_COL32(255, 255, 255, 255), "S");
980 }
981 }
982
983 // Flight points matching explicit map_id / 0x40 == world (Distinct color: cyan / blue)
984 for (const auto& flight : document_->authoring().flight_points) {
985 if (flight.map_id / 0x40 != world) {
986 continue;
987 }
988 const ImVec2 p_min = ToScreen(flight.map_id, flight.x, flight.y);
989 const ImVec2 p_max(p_min.x + marker_size, p_min.y + marker_size);
990 draw->AddRectFilled(p_min, p_max, IM_COL32(40, 160, 220, 180));
991 draw->AddRect(p_min, p_max, IM_COL32(255, 255, 255, 220));
992 draw->AddText(ImVec2(p_min.x + 2, p_min.y + 2), IM_COL32(0, 0, 0, 255),
993 "flight");
994 }
995}
996
998 int world,
999 zelda3::Overworld& overworld,
1000 UndoManager& history) {
1001 if (ImGui::IsKeyPressed(ImGuiKey_Escape)) {
1002 placement_kind_ = 0;
1003 }
1004
1005 if (placement_kind_ == 0 || !canvas.hovered || !document_) {
1006 return;
1007 }
1008
1009 if (!ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
1010 return;
1011 }
1012
1013 if (!std::isfinite(canvas.scale) || canvas.scale <= 0) {
1014 return;
1015 }
1016
1017 const ImVec2 mouse_pos = ImGui::GetIO().MousePos;
1018 const float world_x_px =
1019 (mouse_pos.x - canvas.canvas_p0.x - canvas.scrolling.x) / canvas.scale;
1020 const float world_y_px =
1021 (mouse_pos.y - canvas.canvas_p0.y - canvas.scrolling.y) / canvas.scale;
1022
1023 if (!std::isfinite(world_x_px) || !std::isfinite(world_y_px)) {
1024 return;
1025 }
1026
1027 if (world_x_px < 0 || world_x_px >= 4096 || world_y_px < 0 ||
1028 world_y_px >= 4096)
1029 return;
1030 const int tile_x = static_cast<int>(std::floor(world_x_px / 16.0f));
1031 const int tile_y = static_cast<int>(std::floor(world_y_px / 16.0f));
1032
1033 if (tile_x < 0 || tile_x > 255 || tile_y < 0 || tile_y > 255) {
1034 return;
1035 }
1036
1037 const int map_col = tile_x / 32;
1038 const int map_row = tile_y / 32;
1039 if (map_col < 0 || map_col >= 8 || map_row < 0 || map_row >= 8) {
1040 return;
1041 }
1042
1043 const int local_map = map_row * 8 + map_col;
1044 const int explicit_map_id = world * 0x40 + local_map;
1045 const int local_x = tile_x % 32;
1046 const int local_y = tile_y % 32;
1047
1048 core::LyraAuthoringState next_state = document_->authoring();
1049
1050 if (placement_kind_ == 4) {
1051 // Flight point (allowed on any map 0..BF)
1052 if (explicit_map_id >= 0 && explicit_map_id <= 0xBF) {
1053 const uint32_t new_id = AllocateFlightPointId(next_state);
1054 next_state.flight_points.push_back({
1055 .id = new_id,
1056 .map_id = explicit_map_id,
1057 .x = local_x,
1058 .y = local_y,
1059 .name = "Flight point",
1060 .runtime_slot = std::nullopt,
1061 });
1062 absl::Status edit_status = EditAuthoring(next_state, overworld, history);
1063 if (edit_status.ok()) {
1064 placement_kind_ = 0;
1065 }
1066 }
1067 return;
1068 }
1069
1070 // Sprites, doors, exits: only maps A0..BF
1071 if (explicit_map_id < Document::kFirstMap ||
1072 explicit_map_id >= Document::kFirstMap + Document::kMapCount) {
1073 return;
1074 }
1075
1076 const int map_index = explicit_map_id - Document::kFirstMap;
1077
1078 if (placement_kind_ == 1) {
1079 // Sprite
1080 const uint32_t new_id = AllocateSpriteId(next_state);
1081 next_state.maps[map_index].sprites.push_back({
1082 .id = new_id,
1083 .sprite_id = static_cast<uint8_t>(placement_type_),
1084 .x = local_x,
1085 .y = local_y,
1086 .state = std::clamp(placement_state_, 0, 2),
1087 });
1088 absl::Status edit_status = EditAuthoring(next_state, overworld, history);
1089 if (edit_status.ok()) {
1090 placement_kind_ = 0;
1091 }
1092 } else if (placement_kind_ == 2) {
1093 // Door / Entrance
1094 const uint32_t new_id = AllocateDoorId(next_state);
1095 next_state.maps[map_index].doors.push_back({
1096 .id = new_id,
1097 .x = local_x,
1098 .y = local_y,
1099 .entrance_id = std::nullopt,
1100 });
1101 absl::Status edit_status = EditAuthoring(next_state, overworld, history);
1102 if (edit_status.ok()) {
1103 placement_kind_ = 0;
1104 }
1105 } else if (placement_kind_ == 3) {
1106 // Exit
1107 const uint32_t new_id = AllocateExitId(next_state);
1108 next_state.maps[map_index].exits.push_back({
1109 .id = new_id,
1110 .room_id = 0,
1111 .x = local_x,
1112 .y = local_y,
1113 .exit_row = std::nullopt,
1114 });
1115 absl::Status edit_status = EditAuthoring(next_state, overworld, history);
1116 if (edit_status.ok()) {
1117 placement_kind_ = 0;
1118 }
1119 }
1120}
1121
1122} // namespace yaze::editor
void DrawEntities(const gui::CanvasRuntime &canvas, int world, int state)
void DrawProperties(int map_id, int &state, zelda3::Overworld &overworld, UndoManager &history)
bool PropertyConflict(int map_index) const
bool PropertyPending(int map_index) const
absl::Status SetGameplayArea(int map_id, std::optional< core::LyraGameplayAreaSize > size, zelda3::Overworld &overworld, UndoManager &history)
void HandleEntityInput(const gui::CanvasRuntime &canvas, int world, zelda3::Overworld &overworld, UndoManager &history)
std::shared_ptr< core::LyraTerrainDocument > document_
std::vector< int > PendingPropertyMaps() const
void DrawEdgeConnections(int map_id, zelda3::Overworld &overworld, UndoManager &history)
absl::Status EditAuthoring(core::LyraAuthoringState after, zelda3::Overworld &overworld, UndoManager &history)
static std::string FormatMapIds(const std::vector< int > &map_ids)
std::array< char, 64 > sprite_filter_
void DrawGameplayArea(int map_id, zelda3::Overworld &overworld, UndoManager &history)
void DrawSpritePreview(const gui::CanvasRuntime &canvas, int map_id, const core::LyraSpritePlacement &sprite)
absl::StatusOr< core::LyraMapProperties > CopyNativeGraphicsSettings(zelda3::Overworld &overworld, int source_map, core::LyraMapProperties candidate)
absl::Status ApplyPropertyDraft(int map_index, zelda3::Overworld &overworld, UndoManager &history)
core::LyraMapProperties & PropertyDraftFor(int map_index)
Manages undo/redo stacks for a single editor.
void RegisterWidget(const std::string &full_path, const std::string &type, ImGuiID imgui_id, const std::string &description="", const WidgetMetadata &metadata=WidgetMetadata())
static WidgetIdRegistry & Instance()
auto overworld_map(int i) const
Definition overworld.h:770
#define ICON_MD_INFO
Definition icons.h:993
absl::StatusOr< std::vector< int > > LyraGameplayAreaFootprint(int parent_map_id, LyraGameplayAreaSize size)
bool LyraEdgeMapsAdjacent(int map_a, int map_b)
bool DrawSpriteTypePicker(const char *label, int &type, std::array< char, 64 > &filter)
uint32_t AllocateSpriteId(const core::LyraAuthoringState &state)
uint32_t AllocateDoorId(const core::LyraAuthoringState &state)
uint32_t AllocateEdgeConnectionId(const core::LyraAuthoringState &state)
uint32_t AllocateStableId(const std::vector< uint32_t > &existing_ids)
uint32_t AllocateFlightPointId(const core::LyraAuthoringState &state)
void DrawEdgeTilePicker(const char *label, int &value, int first, int last)
uint32_t AllocateExitId(const core::LyraAuthoringState &state)
Editors are the view controllers for the application.
const char * ResolveSpriteName(uint16_t id)
Definition sprite.cc:287
ResourceLabelProvider & GetResourceLabels()
Get the global ResourceLabelProvider instance.
std::array< LyraMapAuthoring, 32 > maps
std::vector< LyraFlightPoint > flight_points
std::vector< LyraEdgeConnection > edge_connections
std::optional< LyraEdgePassage > passage
std::optional< std::array< uint16_t, 128 > > background_palette
std::array< uint8_t, 8 > graphics_sheets