218 if (ImGui::IsKeyPressed(ImGuiKey_Escape))
220 const bool is_lyra = (map_id >= Document::kFirstMap &&
221 map_id < Document::kFirstMap + Document::kMapCount);
224 const int map_index = map_id - Document::kFirstMap;
232 ImGui::PushID(
"LyraProperties");
233 ImGui::Text(
"Lyra Map Properties ($%02X)", map_id);
235 "Project draft; saved with project; build/runtime unavailable");
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();
242 "• Items & Bomb Doors: Disabled (pending SRAM event allocation in "
244 "• Exits: 1 free slot in vanilla table; multi-link requires ASM exit "
246 "• Entrances: Light-world trigger links v2 in development (Phase "
248 "• Sprites: Draft state only (requires actor compilation for ROM)");
250 ImGui::PopStyleColor();
255 const char* state_names[] = {
"State 0",
"State 1",
"State 2"};
256 int current_state = state;
257 if (current_state < 0)
259 if (current_state > 2)
261 if (ImGui::Combo(
"Game State", ¤t_state, state_names, 3)) {
262 state = current_state;
267 if (ImGui::InputInt(
"Main palette", &main_pal)) {
268 draft.
main_palette =
static_cast<uint8_t
>(std::clamp(main_pal, 0, 255));
272 "LyraMapProperties/input:MainPalette",
"input",
273 ImGui::GetID(
"Main palette"));
276 if (ImGui::InputInt(
"Area palette", &area_pal)) {
277 draft.
palette_set =
static_cast<uint8_t
>(std::clamp(area_pal, 0, 255));
281 ImGui::TextUnformatted(
282 "Background colors: project override (128 colors)");
284 "This map uses project colors. Native palettes and sprite colors "
286 if (ImGui::Button(
"Use native palette")) {
291 ImGui::TextUnformatted(
"Background colors: native palette");
296 if (ImGui::InputScalar(
"Animated GFX", ImGuiDataType_S32, &anim_sheet,
297 nullptr,
nullptr,
"%02X",
298 ImGuiInputTextFlags_CharsHexadecimal)) {
300 static_cast<uint8_t
>(std::clamp(anim_sheet, 0, 255));
304 if (ImGui::TreeNode(
"Custom GFX Slots")) {
305 for (
int i = 0; i < 8; ++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)) {
313 static_cast<uint8_t
>(std::clamp(sheet_val, 0, 255));
321 if (ImGui::TreeNode(
"Sprite Graphics & Palettes")) {
322 for (
int s = 0; s < 3; ++s) {
324 std::string gfx_label =
325 absl::StrFormat(
"Sprite set (State %d, -1 inherit)", s);
327 if (ImGui::InputInt(gfx_label.c_str(), &gfx_val)) {
331 std::string pal_label =
332 absl::StrFormat(
"Sprite palette (State %d, -1 inherit)", s);
334 if (ImGui::InputInt(pal_label.c_str(), &pal_val)) {
342 const bool music_open = ImGui::TreeNode(
"Music and message");
344 absl::StrFormat(
"LyraMapProperties/%02X/tree:MusicMessage", map_id),
345 "tree", ImGui::GetItemID());
347 for (
int i = 0; i < 4; ++i) {
349 int value = draft.
music[i];
350 if (ImGui::InputInt(
"Music", &value))
351 draft.
music[i] = std::clamp(value, 0, 255);
355 if (ImGui::InputScalar(
"Message ID", ImGuiDataType_S32, &value,
nullptr,
357 ImGuiInputTextFlags_CharsHexadecimal))
358 draft.
message_id = std::clamp(value, 0, 65535);
360 absl::StrFormat(
"LyraMapProperties/%02X/input_scalar:MessageId",
362 "input_scalar", ImGui::GetItemID());
367 if (ImGui::InputScalar(
"Native source map", ImGuiDataType_S32, ©_source,
368 nullptr,
nullptr,
"%02X",
369 ImGuiInputTextFlags_CharsHexadecimal)) {
376 if (ImGui::Button(
"Copy graphics settings")) {
381 draft = *std::move(copied);
383 "Copied $%02X (area parent $%02X) into the $%02X draft",
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.");
395 ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f),
"%s",
402 if (ImGui::Button(
"Apply Properties")) {
410 "LyraMapProperties/button:Apply",
"button", ImGui::GetItemID());
412 if (ImGui::Button(
"Revert Properties")) {
419 ImGui::PushTextWrapPos(0.0f);
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.",
428 ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.2f, 1.0f),
429 "$%02X has unapplied property edits.", map_id);
432 std::erase(other_pending, map_id);
433 if (!other_pending.empty()) {
435 "Unapplied properties on %s. Visit each map and Apply or Revert "
436 "before saving the project.",
441 ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f),
"%s",
444 ImGui::PopTextWrapPos();
449 ImGui::TextUnformatted(
"Entities");
453 ImVec4(1.0f, 0.9f, 0.2f, 1.0f),
454 "Placement mode active (Click canvas to place, Esc to cancel)");
458 if (ImGui::CollapsingHeader(
"Sprites", ImGuiTreeNodeFlags_DefaultOpen)) {
460 if (ImGui::Button(
"Add sprite")) {
462 const uint32_t new_id = AllocateSpriteId(next_state);
463 next_state.
maps[map_index].sprites.push_back({
468 .state = std::clamp(state, 0, 2),
473 "LyraMapProperties/button:AddSprite",
"button", ImGui::GetItemID());
475 if (ImGui::Button(
"Place sprite on canvas")) {
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())) {
490 auto& target = next_state.
maps[map_index].sprites[i];
491 bool changed =
false;
493 int spr_x = target.x;
494 if (ImGui::SliderInt(
"X", &spr_x, 0, 31)) {
498 int spr_y = target.y;
499 if (ImGui::SliderInt(
"Y", &spr_y, 0, 31)) {
503 int spr_type = target.sprite_id;
504 if (ImGui::InputScalar(
"Sprite ID", ImGuiDataType_S32, &spr_type,
505 nullptr,
nullptr,
"%02X",
506 ImGuiInputTextFlags_CharsHexadecimal)) {
508 static_cast<uint8_t
>(std::clamp(spr_type, 0, 255));
511 int picked_type = target.sprite_id;
512 if (DrawSpriteTypePicker(
"Sprite type", picked_type,
514 target.sprite_id =
static_cast<uint8_t
>(picked_type);
517 int spr_st = target.state;
518 if (ImGui::SliderInt(
"State", &spr_st, 0, 2)) {
519 target.state = spr_st;
523 if (ImGui::Button(
"Remove")) {
524 next_state.
maps[map_index].sprites.erase(
525 next_state.
maps[map_index].sprites.begin() + i);
542 if (ImGui::CollapsingHeader(
"Doors / Entrances",
543 ImGuiTreeNodeFlags_DefaultOpen)) {
544 if (ImGui::Button(
"Add entrance")) {
546 const uint32_t new_id = AllocateDoorId(next_state);
547 next_state.
maps[map_index].doors.push_back({
551 .entrance_id = std::nullopt,
556 "LyraMapProperties/button:AddEntrance",
"button", ImGui::GetItemID());
558 if (ImGui::Button(
"Place entrance on canvas")) {
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())) {
571 auto& target = next_state.
maps[map_index].doors[i];
572 bool changed =
false;
574 int door_x = target.x;
575 if (ImGui::SliderInt(
"X", &door_x, 0, 31)) {
579 int door_y = target.y;
580 if (ImGui::SliderInt(
"Y", &door_y, 0, 31)) {
584 int ent_id = target.entrance_id.value_or(-1);
585 if (ImGui::InputInt(
"Entrance ID (-1 unassigned)", &ent_id)) {
587 target.entrance_id = std::nullopt;
589 target.entrance_id = std::min(ent_id, 0xFE);
594 if (ImGui::Button(
"Remove")) {
595 next_state.
maps[map_index].doors.erase(
596 next_state.
maps[map_index].doors.begin() + i);
613 if (ImGui::CollapsingHeader(
"Exits", ImGuiTreeNodeFlags_DefaultOpen)) {
614 if (ImGui::Button(
"Add exit")) {
616 const uint32_t new_id = AllocateExitId(next_state);
617 next_state.
maps[map_index].exits.push_back({
622 .exit_row = std::nullopt,
627 "LyraMapProperties/button:AddExit",
"button", ImGui::GetItemID());
629 if (ImGui::Button(
"Place exit on canvas")) {
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())) {
642 auto& target = next_state.
maps[map_index].exits[i];
643 bool changed =
false;
645 int exit_x = target.x;
646 if (ImGui::SliderInt(
"X", &exit_x, 0, 31)) {
650 int exit_y = target.y;
651 if (ImGui::SliderInt(
"Y", &exit_y, 0, 31)) {
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);
662 int row_val = target.exit_row.value_or(-1);
663 if (ImGui::InputInt(
"Exit row (-1 unassigned)", &row_val)) {
665 target.exit_row = std::nullopt;
667 target.exit_row = std::min(row_val, 78);
672 if (ImGui::Button(
"Remove")) {
673 next_state.
maps[map_index].exits.erase(
674 next_state.
maps[map_index].exits.begin() + i);
694 if (ImGui::CollapsingHeader(
"Flight Points",
695 ImGuiTreeNodeFlags_DefaultOpen)) {
696 ImGui::PushID(
"FlightPointsSection");
698 if (ImGui::Button(
"Add flight point")) {
700 const uint32_t new_id = AllocateFlightPointId(next_state);
703 .map_id = std::clamp(map_id, 0, 0xBF),
706 .name =
"Flight point",
707 .runtime_slot = std::nullopt,
712 "LyraMapProperties/button:AddFlight",
"button", ImGui::GetItemID());
714 if (ImGui::Button(
"Place flight point on canvas")) {
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) {
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())) {
731 bool changed =
false;
733 ImGui::Text(
"ID: %u (read-only)", target.id);
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;
742 int f_map = target.map_id;
743 if (ImGui::InputScalar(
"Map ID", ImGuiDataType_S32, &f_map,
nullptr,
745 ImGuiInputTextFlags_CharsHexadecimal)) {
746 target.map_id = std::clamp(f_map, 0, 0xBF);
751 if (ImGui::SliderInt(
"X", &f_x, 0, 31)) {
757 if (ImGui::SliderInt(
"Y", &f_y, 0, 31)) {
762 if (ImGui::Button(
"Remove")) {
783 ImGui::TextWrapped(
"%s",
status_.ToString().c_str());
789 const auto& connections =
document_->authoring().edge_connections;
790 const auto connected = [&](
int other) {
791 return std::any_of(connections.begin(), connections.end(),
793 return c.map_a == std::min(map_id, other) &&
794 c.map_b == std::max(map_id, other);
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;
804 if (!connected(other))
805 open_neighbors.push_back(other);
808 !ImGui::CollapsingHeader(
"Connections", ImGuiTreeNodeFlags_DefaultOpen)) {
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");
818 const bool full = connections.size() >= Document::kMaxEdgeConnections;
819 const char* preview = full ?
"Connection limit reached"
820 : open_neighbors.empty() ?
"All neighbors drafted"
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())) {
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,
844 ImGui::EndDisabled();
846 "LyraMapProperties/combo:AddConnection",
"combo",
847 ImGui::GetID(
"##AddConnection"));
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)
855 ImGui::PushID(
static_cast<int>(connection.id));
857 ImGui::Text(
"$%02X %s ↔ $%02X %s", connection.map_a,
858 FacingEdge(connection.map_a, 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());
867 bool assigned = edited.
passage.has_value();
868 if (ImGui::Checkbox(
"Assign passage", &assigned)) {
869 edited.
passage = assigned ? std::optional<core::LyraEdgePassage>(
874 ImGui::TextUnformatted(
"Passage: unassigned (no opening drafted)");
876 auto& passage = *edited.
passage;
877 ImGui::Text(
"Passage: tiles %d–%d", passage.first_tile,
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();
893 if (edited != connection) {
901 if (ImGui::Button(
"Remove connection")) {
912 ImGui::TextDisabled(
"No connections drafted for $%02X.", map_id);
914 ImGui::PopTextWrapPos();
919 int world,
int state) {
926 const float scale = canvas.
scale;
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);
938 const float marker_size = 16.0f * scale;
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) {
946 const auto& map_auth =
document_->authoring().maps[map_idx];
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),
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),
969 for (
const auto& sprite : map_auth.sprites) {
970 if (sprite.state != state) {
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);
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");
984 for (
const auto& flight :
document_->authoring().flight_points) {
985 if (flight.map_id / 0x40 != world) {
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),
1001 if (ImGui::IsKeyPressed(ImGuiKey_Escape)) {
1009 if (!ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
1013 if (!std::isfinite(canvas.
scale) || canvas.
scale <= 0) {
1017 const ImVec2 mouse_pos = ImGui::GetIO().MousePos;
1018 const float world_x_px =
1020 const float world_y_px =
1023 if (!std::isfinite(world_x_px) || !std::isfinite(world_y_px)) {
1027 if (world_x_px < 0 || world_x_px >= 4096 || world_y_px < 0 ||
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));
1033 if (tile_x < 0 || tile_x > 255 || tile_y < 0 || tile_y > 255) {
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) {
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;
1052 if (explicit_map_id >= 0 && explicit_map_id <= 0xBF) {
1053 const uint32_t new_id = AllocateFlightPointId(next_state);
1056 .map_id = explicit_map_id,
1059 .name =
"Flight point",
1060 .runtime_slot = std::nullopt,
1062 absl::Status edit_status =
EditAuthoring(next_state, overworld, history);
1063 if (edit_status.ok()) {
1071 if (explicit_map_id < Document::kFirstMap ||
1072 explicit_map_id >= Document::kFirstMap + Document::kMapCount) {
1076 const int map_index = explicit_map_id - Document::kFirstMap;
1080 const uint32_t new_id = AllocateSpriteId(next_state);
1081 next_state.
maps[map_index].sprites.push_back({
1088 absl::Status edit_status =
EditAuthoring(next_state, overworld, history);
1089 if (edit_status.ok()) {
1094 const uint32_t new_id = AllocateDoorId(next_state);
1095 next_state.
maps[map_index].doors.push_back({
1099 .entrance_id = std::nullopt,
1101 absl::Status edit_status =
EditAuthoring(next_state, overworld, history);
1102 if (edit_status.ok()) {
1107 const uint32_t new_id = AllocateExitId(next_state);
1108 next_state.
maps[map_index].exits.push_back({
1113 .exit_row = std::nullopt,
1115 absl::Status edit_status =
EditAuthoring(next_state, overworld, history);
1116 if (edit_status.ok()) {