yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
object_tile_editor_panel.cc
Go to the documentation of this file.
2#include "util/i18n/tr.h"
3
4#include <algorithm>
5
6#include "absl/strings/str_format.h"
11#include "core/features.h"
12#include "imgui/imgui.h"
15
16namespace yaze {
17namespace editor {
18
19namespace {
20
21constexpr float kTileGridScale = 4.0f;
22
23void MixFingerprint(uint64_t* fingerprint, uint64_t value) {
24 constexpr uint64_t kFnvPrime = 1099511628211ULL;
25 *fingerprint ^= value;
26 *fingerprint *= kFnvPrime;
27}
28
29bool PathsReferToSameAsset(const std::filesystem::path& lhs,
30 const std::filesystem::path& rhs) {
31 if (lhs == rhs) {
32 return true;
33 }
34 std::error_code error;
35 const bool equivalent = std::filesystem::equivalent(lhs, rhs, error);
36 return !error && equivalent;
37}
38
39} // namespace
40
42 : renderer_(renderer), rom_(rom) {
43 tile_editor_ = std::make_unique<zelda3::ObjectTileEditor>(rom);
44}
45
49
56
61
63 std::string message) {
65 action_status_message_ = std::move(message);
66}
67
87
88absl::Status ObjectTileEditorPanel::OpenForObject(int16_t object_id,
89 int room_id,
90 DungeonRoomStore* rooms) {
91 return OpenForObject(object_id, room_id, rooms, current_palette_group_);
92}
93
95 int16_t object_id, int room_id, DungeonRoomStore* rooms,
96 const gfx::PaletteGroup& palette_group) {
98 return absl::UnimplementedError(
99 "Tile editing is not yet supported for this object layout");
100 }
101 if (rooms == nullptr) {
102 return absl::InvalidArgumentError(
103 "A dungeon room store is required to edit object tiles");
104 }
105 if (room_id < 0 || room_id >= static_cast<int>(rooms->size())) {
106 return absl::OutOfRangeError("Dungeon room id is outside the room store");
107 }
109 return absl::FailedPreconditionError(
110 "Apply, revert, or close the current Object Tile Editor session "
111 "before opening another object");
112 }
113
114 auto& room = (*rooms)[room_id];
115 auto layout_or =
116 tile_editor_->CaptureEditableObjectLayout(object_id, room, palette_group);
117 if (!layout_or.ok()) {
118 return layout_or.status();
119 }
120
121 // Do not disturb a live editor session until every fail-closed capture check
122 // has passed. In particular, a bad selection must not discard unsaved edits.
123 current_object_id_ = object_id;
124 current_room_id_ = room_id;
125 rooms_ = rooms;
126 current_palette_group_ = palette_group;
127 current_layout_ = std::move(*layout_or);
129 is_open_ = true;
131 return absl::OkStatus();
132}
133
135 int16_t object_id, int subtype, int room_id, DungeonRoomStore* rooms) {
136 return OpenForCustomObject(object_id, subtype, room_id, rooms,
138}
139
141 int16_t object_id, int subtype, int room_id, DungeonRoomStore* rooms,
142 const gfx::PaletteGroup& palette_group) {
143 if (rooms == nullptr) {
144 return absl::InvalidArgumentError(
145 "A dungeon room store is required to edit custom object tiles");
146 }
147 if (room_id < 0 || room_id >= static_cast<int>(rooms->size())) {
148 return absl::OutOfRangeError("Dungeon room id is outside the room store");
149 }
150 if (!core::FeatureFlags::get().kEnableCustomObjects) {
151 return absl::FailedPreconditionError(
152 "Custom Objects is disabled for the current project");
153 }
154 if (rooms->GetIfLoaded(room_id) == nullptr) {
155 return absl::FailedPreconditionError(
156 "Load the current dungeon room before editing custom object graphics");
157 }
159 return absl::FailedPreconditionError(
160 "Apply, revert, or explicitly discard the current Object Tile Editor "
161 "session before opening another custom object");
162 }
163
164 auto layout_or = tile_editor_->LoadCustomObjectLayout(object_id, subtype);
165 if (!layout_or.ok()) {
166 return layout_or.status();
167 }
168
169 current_object_id_ = object_id;
170 current_room_id_ = room_id;
171 rooms_ = rooms;
172 current_palette_group_ = palette_group;
173 current_layout_ = std::move(*layout_or);
176 is_open_ = true;
178 return absl::OkStatus();
179}
180
182 is_open_ = false;
183 current_layout_ = {};
184 current_room_id_ = -1;
186 rooms_ = nullptr;
188}
189
191 // Hiding a modified editor window must not silently throw away tile edits.
192 // The session remains available when the workspace window is reopened; the
193 // explicit action-bar close remains the user's discard action.
195 Close();
196 }
197}
198
205
207 int room_id, const gfx::PaletteGroup& group) {
208 if (is_open_ && current_room_id_ != room_id) {
209 return;
210 }
212}
213
216 return absl::StrFormat(ICON_MD_GRID_ON " Custom 0x%03X:%02X - %s",
219 }
220
221 return absl::StrFormat(ICON_MD_GRID_ON " Object 0x%03X - %s",
224}
225
237
240 return absl::FailedPreconditionError(
241 "Only an open custom-object session can add its first tile");
242 }
243 if (!current_layout_.cells.empty()) {
244 return absl::FailedPreconditionError(
245 "The custom-object layout already contains tiles");
246 }
247
248 return AppendCustomCell(
249 /*rel_x=*/0, /*rel_y=*/0,
250 gfx::TileInfo(/*id=*/0, /*palette=*/2, false, false, false));
251}
252
253absl::Status ObjectTileEditorPanel::AddCustomTileAt(int rel_x, int rel_y) {
255 return absl::FailedPreconditionError(
256 "Only an open custom-object session can add tiles; standard object "
257 "shapes are fixed");
258 }
259 if (rel_x < 0 || rel_x >= kCustomObjectBufferTiles || rel_y < 0 ||
260 rel_y >= kCustomObjectBufferTiles) {
261 return absl::OutOfRangeError(absl::StrFormat(
262 "Tile (%d, %d) is outside the %dx%d custom-object buffer; X and Y "
263 "must be 0-%d",
266 }
267 for (int index = 0; index < static_cast<int>(current_layout_.cells.size());
268 ++index) {
269 const auto& cell = current_layout_.cells[index];
270 if (cell.rel_x == rel_x && cell.rel_y == rel_y) {
271 selected_cell_index_ = index;
273 return absl::OkStatus();
274 }
275 }
276 if (selected_source_tile_ < 0) {
277 return absl::FailedPreconditionError(
278 "Select a source tile before adding a custom-object tile");
279 }
280
281 const uint16_t word = static_cast<uint16_t>((source_attributes_ & 0xE000) |
282 ((source_palette_ & 0x07) << 10) |
283 (selected_source_tile_ & 0x3FF));
284 return AppendCustomCell(rel_x, rel_y, gfx::WordToTileInfo(word));
285}
286
287absl::Status ObjectTileEditorPanel::AppendCustomCell(int rel_x, int rel_y,
288 gfx::TileInfo tile_info) {
289 // Validate the complete candidate with the publication codec before the
290 // live layout changes, so a rejected tile leaves the session untouched.
291 zelda3::CustomObject candidate;
292 candidate.tiles.reserve(current_layout_.cells.size() + 1);
293 for (const auto& cell : current_layout_.cells) {
294 candidate.tiles.push_back(
295 {cell.rel_x, cell.rel_y, gfx::TileInfoToWord(cell.tile_info)});
296 }
297 candidate.tiles.push_back({rel_x, rel_y, gfx::TileInfoToWord(tile_info)});
298 const auto encoded_or = zelda3::EncodeCustomObjectBinary(candidate);
299 if (!encoded_or.ok()) {
300 return encoded_or.status();
301 }
302
303 const int new_index = static_cast<int>(current_layout_.cells.size());
305 cell.rel_x = rel_x;
306 cell.rel_y = rel_y;
307 cell.tile_info = tile_info;
308 cell.original_word = 0;
309 cell.write_index = new_index;
310 cell.modified = true;
311 current_layout_.cells.push_back(cell);
312
313 const auto bounds = candidate.GetBoundingBox();
314 current_layout_.bounds_width = bounds.max_x + 1;
315 current_layout_.bounds_height = bounds.max_y + 1;
316 selected_cell_index_ = new_index;
318 preview_dirty_ = true;
319 atlas_dirty_ = true;
322 return absl::OkStatus();
323}
324
326 int rel_y) {
328 return absl::FailedPreconditionError(
329 "Only an open custom-object session can move tiles; standard object "
330 "shapes are fixed");
331 }
332 if (selected_cell_index_ < 0 ||
333 selected_cell_index_ >= static_cast<int>(current_layout_.cells.size())) {
334 return absl::FailedPreconditionError(
335 "Select a custom-object tile before moving it");
336 }
337 if (rel_x < 0 || rel_x >= kCustomObjectBufferTiles || rel_y < 0 ||
338 rel_y >= kCustomObjectBufferTiles) {
339 return absl::OutOfRangeError(absl::StrFormat(
340 "Tile (%d, %d) is outside the %dx%d custom-object buffer; X and Y "
341 "must be 0-%d",
344 }
345 const auto& selected = current_layout_.cells[selected_cell_index_];
346 if (selected.rel_x == rel_x && selected.rel_y == rel_y) {
347 return absl::OkStatus();
348 }
349 for (const auto& cell : current_layout_.cells) {
350 if (cell.rel_x == rel_x && cell.rel_y == rel_y) {
351 return absl::AlreadyExistsError(absl::StrFormat(
352 "Tile (%d, %d) is already occupied; move the selected tile to an "
353 "empty position",
354 rel_x, rel_y));
355 }
356 }
357
358 // Validate the complete candidate with the publication codec before the
359 // live layout changes, so a rejected move leaves the session untouched.
360 zelda3::CustomObject candidate;
361 candidate.tiles.reserve(current_layout_.cells.size());
362 for (int index = 0; index < static_cast<int>(current_layout_.cells.size());
363 ++index) {
364 const auto& cell = current_layout_.cells[index];
365 const bool is_selected = index == selected_cell_index_;
366 candidate.tiles.push_back({is_selected ? rel_x : cell.rel_x,
367 is_selected ? rel_y : cell.rel_y,
368 gfx::TileInfoToWord(cell.tile_info)});
369 }
370 const auto encoded_or = zelda3::EncodeCustomObjectBinary(candidate);
371 if (!encoded_or.ok()) {
372 return encoded_or.status();
373 }
374
376 cell.rel_x = rel_x;
377 cell.rel_y = rel_y;
378 cell.modified = true;
379
380 const auto bounds = candidate.GetBoundingBox();
381 current_layout_.bounds_width = bounds.max_x + 1;
382 current_layout_.bounds_height = bounds.max_y + 1;
383 // Moving geometry does not replace the user's current source brush.
384 preview_dirty_ = true;
387 return absl::OkStatus();
388}
389
397
407
427
428absl::StatusOr<ObjectTileEditorPanel::SourceImpactSnapshot>
431 uint64_t fingerprint = 1469598103934665603ULL;
432 MixFingerprint(&fingerprint,
433 static_cast<uint64_t>(shared_tile_data_usage_override_));
435 }
436
439 return absl::FailedPreconditionError(
440 "Custom object source-impact analysis requires its resolved asset "
441 "path");
442 }
443
444 auto& manager = zelda3::CustomObjectManager::Get();
445 uint64_t fingerprint = 1469598103934665603ULL;
446 MixFingerprint(&fingerprint, manager.asset_generation());
447 int consumer_count = 0;
448 std::vector<int> mapped_object_ids(
451 const auto manager_state = manager.SnapshotState();
452 for (const auto& mapping : manager_state.custom_file_map) {
453 const int object_id = mapping.first;
454 if (std::find(mapped_object_ids.begin(), mapped_object_ids.end(),
455 object_id) == mapped_object_ids.end()) {
456 mapped_object_ids.push_back(object_id);
457 }
458 }
459 std::sort(mapped_object_ids.begin(), mapped_object_ids.end());
460
461 for (const int object_id : mapped_object_ids) {
462 const int subtype_count = manager.GetSubtypeCount(object_id);
463 for (int subtype = 0; subtype < subtype_count; ++subtype) {
464 const std::string filename =
465 manager.ResolveFilename(object_id, subtype);
466 if (filename.empty()) {
467 continue;
468 }
469 auto resolved_or = zelda3::ResolveCustomObjectAssetPath(
470 manager.GetBasePath(), filename);
471 if (!resolved_or.ok()) {
472 return absl::FailedPreconditionError(absl::StrFormat(
473 "Could not resolve custom runtime slot 0x%02X:%02X: %s",
474 object_id, subtype, resolved_or.status().message()));
475 }
476 if (!PathsReferToSameAsset(*resolved_or,
478 continue;
479 }
480 ++consumer_count;
481 MixFingerprint(&fingerprint, static_cast<uint16_t>(object_id));
482 MixFingerprint(&fingerprint, static_cast<uint16_t>(subtype));
483 }
484 }
485 if (consumer_count == 0) {
486 return absl::AbortedError(
487 "Custom object mapping or project folder changed after this asset "
488 "was opened; edits were kept");
489 }
490 return SourceImpactSnapshot{consumer_count, fingerprint};
491 }
492 if (current_object_id_ < 0 ||
494 return absl::FailedPreconditionError(
495 "No matching standard object is selected for source-impact analysis");
496 }
497 if (rom_ == nullptr || !rom_->is_loaded()) {
498 return absl::FailedPreconditionError(
499 "A loaded ROM is required for source-impact analysis");
500 }
501
502 auto impact_or =
503 tile_editor_->AnalyzeStandardTileSourceImpact(current_layout_);
504 if (!impact_or.ok()) {
505 return impact_or.status();
506 }
507
508 uint64_t fingerprint = 1469598103934665603ULL;
509 MixFingerprint(&fingerprint, impact_or->affected_objects.size());
510 for (const auto& entry : impact_or->affected_objects) {
511 MixFingerprint(&fingerprint, 0xA11EC7EDULL);
512 MixFingerprint(&fingerprint, static_cast<uint16_t>(entry.object_id));
513 MixFingerprint(&fingerprint, entry.overlapping_ranges.size());
514 for (const auto& range : entry.overlapping_ranges) {
515 MixFingerprint(&fingerprint, range.begin);
516 MixFingerprint(&fingerprint, range.end);
517 }
518 }
519 MixFingerprint(&fingerprint, impact_or->runtime_consumers.size());
520 for (const auto& entry : impact_or->runtime_consumers) {
521 MixFingerprint(&fingerprint, 0x52754E71ULL);
522 MixFingerprint(&fingerprint, static_cast<uint64_t>(entry.consumer));
523 MixFingerprint(&fingerprint, entry.overlapping_ranges.size());
524 for (const auto& range : entry.overlapping_ranges) {
525 MixFingerprint(&fingerprint, range.begin);
526 MixFingerprint(&fingerprint, range.end);
527 }
528 }
529 return SourceImpactSnapshot{static_cast<int>(impact_or->consumer_count()),
530 fingerprint};
531}
532
534 auto snapshot_or = AnalyzeSourceImpactSnapshot();
535 if (!snapshot_or.ok()) {
536 return snapshot_or.status();
537 }
538 return snapshot_or->consumer_count;
539}
540
542 uint64_t fingerprint = 1469598103934665603ULL;
543 MixFingerprint(&fingerprint,
544 static_cast<uint16_t>(current_layout_.object_id));
545 MixFingerprint(&fingerprint,
546 static_cast<uint32_t>(current_layout_.tile_data_address));
547 MixFingerprint(&fingerprint, current_layout_.is_custom ? 1 : 0);
549 MixFingerprint(&fingerprint,
550 static_cast<uint32_t>(current_layout_.custom_subtype));
551 MixFingerprint(&fingerprint,
552 zelda3::CustomObjectManager::Get().asset_generation());
553 for (const unsigned char character :
554 current_layout_.custom_resolved_path.generic_string()) {
555 MixFingerprint(&fingerprint, character);
556 }
557 }
558 MixFingerprint(&fingerprint,
559 static_cast<uint32_t>(current_layout_.bounds_width));
560 MixFingerprint(&fingerprint,
561 static_cast<uint32_t>(current_layout_.bounds_height));
562 MixFingerprint(&fingerprint, current_layout_.cells.size());
563 for (const auto& cell : current_layout_.cells) {
564 MixFingerprint(&fingerprint, static_cast<uint32_t>(cell.rel_x));
565 MixFingerprint(&fingerprint, static_cast<uint32_t>(cell.rel_y));
566 MixFingerprint(&fingerprint, cell.original_word);
567 MixFingerprint(&fingerprint, cell.source_ref.has_value() ? 1 : 0);
568 if (cell.source_ref.has_value()) {
569 MixFingerprint(&fingerprint, cell.source_ref->span_index);
570 MixFingerprint(&fingerprint, cell.source_ref->word_index);
571 }
572 }
573 MixFingerprint(&fingerprint,
574 current_layout_.source_provenance.has_value() ? 1 : 0);
575 if (current_layout_.source_provenance.has_value()) {
576 const auto& provenance = *current_layout_.source_provenance;
577 MixFingerprint(&fingerprint, static_cast<uint16_t>(provenance.object_id));
578 MixFingerprint(&fingerprint, provenance.descriptor_pc_address);
579 MixFingerprint(&fingerprint, provenance.expected_descriptor_word);
580 MixFingerprint(&fingerprint, provenance.spans.size());
581 for (const auto& span : provenance.spans) {
582 MixFingerprint(&fingerprint, span.pc_address);
583 MixFingerprint(&fingerprint, span.expected_words.size());
584 for (const uint16_t word : span.expected_words) {
585 MixFingerprint(&fingerprint, word);
586 }
587 }
588 }
589 return fingerprint;
590}
591
592absl::StatusOr<int>
606
608 auto usage_count_or = GetSharedTileDataUsageCount();
609 if (!usage_count_or.ok()) {
610 return usage_count_or.status();
611 }
612 return *usage_count_or > 1;
613}
614
616 return rooms_ != nullptr &&
617 rooms_->GetIfLoaded(current_room_id_) != nullptr &&
618 !current_layout_.cells.empty();
619}
620
622 preview_dirty_ = true;
623 atlas_dirty_ = true;
624
626 return;
627 }
628
631}
632
633void ObjectTileEditorPanel::Draw(bool* p_open) {
634 if (!is_open_) {
635 if (p_open != nullptr) {
636 *p_open = false;
637 }
638 return;
639 }
640 if (p_open != nullptr && !*p_open) {
641 return;
642 }
643
644 const auto* room =
645 rooms_ != nullptr ? rooms_->GetIfLoaded(current_room_id_) : nullptr;
646 const uint64_t graphics_revision =
647 room != nullptr ? room->graphics_revision() : 0;
648 if (graphics_revision != room_graphics_revision_) {
649 room_graphics_revision_ = graphics_revision;
650 preview_dirty_ = true;
651 atlas_dirty_ = true;
652 }
653
654 const std::string session_title = BuildWindowTitle();
655 ImGui::TextUnformatted(session_title.c_str());
656 ImGui::TextDisabled(tr("Room 0x%03X"), current_room_id_);
657 ImGui::Separator();
658
659 if (current_layout_.cells.empty()) {
661 if (p_open != nullptr) {
662 *p_open = false;
663 }
664 return;
665 }
666 const auto& theme = gui::ThemeManager::Get().GetCurrentTheme();
667 ImGui::TextColored(gui::ConvertColorToImVec4(theme.warning),
668 ICON_MD_VISIBILITY_OFF " Empty runtime asset");
669 ImGui::TextWrapped(
670 "%s", tr("This fixed slot intentionally draws nothing. Add its first "
671 "tile to begin a graphics override."));
672 if (ImGui::Button(ICON_MD_ADD " Add First Tile")) {
673 const absl::Status status = AddFirstTileToEmptyCustomLayout();
674 if (!status.ok()) {
676 std::string(status.message()));
677 }
678 }
679 ImGui::Separator();
680 DrawActionBar(p_open);
681 return;
682 }
683
684 // Two-column layout: tile grid + source sheet
685 if (ImGui::BeginTable(
686 "##TileEditorLayout", 2,
687 ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInnerV)) {
688 ImGui::TableSetupColumn("Tile Grid", ImGuiTableColumnFlags_WidthFixed,
689 280.0f);
690 ImGui::TableSetupColumn("Source Sheet", ImGuiTableColumnFlags_WidthStretch);
691
692 ImGui::TableNextRow();
693 ImGui::TableNextColumn();
694 DrawTileGrid();
695
696 ImGui::TableNextColumn();
698
699 ImGui::EndTable();
700 }
701
702 ImGui::Separator();
704 ImGui::Separator();
705 DrawActionBar(p_open);
706
708
709 // Shared ROM data or project source-asset confirmation modal.
710 const char* shared_popup_title =
712 ? "Shared Custom Asset###SharedTileDataGlobalEdit"
713 : "Shared Tile Data (Global Edit)###SharedTileDataGlobalEdit";
715 ImGui::OpenPopup(shared_popup_title);
716 show_shared_confirm_ = false;
717 }
718 if (ImGui::BeginPopupModal(shared_popup_title, nullptr,
719 ImGuiWindowFlags_AlwaysAutoResize)) {
721 ImGui::Text(tr("This source asset is used by %d runtime slots."),
723 ImGui::Text(tr("Publishing will update all of those slots."));
724 ImGui::TextWrapped(
725 tr("This project source-asset edit is not covered by Dungeon Editor "
726 "Ctrl+Z."));
727 } else {
728 ImGui::Text(tr("This tile data is shared by %d consumers."),
730 ImGui::Text(tr("Changes will affect all of them."));
731 ImGui::TextWrapped(
732 tr("This shared tile edit is not covered by Dungeon Editor Ctrl+Z."));
733 }
734 ImGui::Spacing();
735 const char* apply_label =
737 ? tr("Publish Shared Asset")
738 : tr("Apply Global Edit");
739 if (ImGui::Button(apply_label, ImVec2(170, 0))) {
740 ApplyChanges(/*confirm_shared=*/false);
741 ImGui::CloseCurrentPopup();
742 }
743 ImGui::SameLine();
744 if (ImGui::Button(tr("Cancel"), ImVec2(120, 0))) {
747 ImGui::CloseCurrentPopup();
748 }
749 ImGui::EndPopup();
750 }
751}
752
754 auto* room =
755 rooms_ != nullptr ? rooms_->GetIfLoaded(current_room_id_) : nullptr;
756 if (room == nullptr) {
759 return;
760 }
761
762 auto status = tile_editor_->RenderLayoutToBitmap(
763 current_layout_, object_preview_bmp_, room->get_gfx_buffer().data(),
765 if (status.ok()) {
768 } else {
770 }
771 preview_dirty_ = false;
772 } else {
775 }
776}
777
779 auto* room =
780 rooms_ != nullptr ? rooms_->GetIfLoaded(current_room_id_) : nullptr;
781 if (room == nullptr) {
784 return;
785 }
786
787 auto status = tile_editor_->BuildTile8Atlas(
788 tile8_atlas_bmp_, room->get_gfx_buffer().data(), current_palette_group_,
791 ? std::optional<int16_t>(current_layout_.object_id)
792 : std::nullopt,
794 if (status.ok()) {
797 } else {
799 }
800 atlas_dirty_ = false;
801 } else {
804 }
805}
806
808 if (selected_cell_index_ < 0 ||
809 selected_cell_index_ >= static_cast<int>(current_layout_.cells.size())) {
811 return;
812 }
813
814 const auto& cell = current_layout_.cells[selected_cell_index_];
815 selected_source_tile_ = static_cast<int>(cell.tile_info.id_);
816
817 const int cell_palette = static_cast<int>(cell.tile_info.palette_);
818 if (source_palette_ != cell_palette) {
819 source_palette_ = cell_palette;
820 atlas_dirty_ = true;
821 }
823}
824
826 uint16_t attributes = 0;
828 selected_cell_index_ < static_cast<int>(current_layout_.cells.size())) {
829 attributes = gfx::TileInfoToWord(
831 0xE000;
832 }
833 if (source_attributes_ != attributes) {
834 source_attributes_ = attributes;
835 atlas_dirty_ = true;
836 }
837}
838
840 if (preview_dirty_) {
842 }
843
844 ImGui::Text(tr("Object Tiles (%dx%d)"), current_layout_.bounds_width,
846
848 // Custom patterns can span the full 64x64 buffer (2048px at this scale);
849 // scroll them inside the fixed grid column so every cell stays reachable.
850 constexpr float kMaxCustomGridHeight = 300.0f;
851 const ImGuiStyle& style = ImGui::GetStyle();
852 const float grid_height =
853 current_layout_.bounds_height * 8 * kTileGridScale;
854 const float child_height =
855 std::min(grid_height + style.WindowPadding.y * 2 + style.ScrollbarSize,
856 kMaxCustomGridHeight);
857 if (ImGui::BeginChild("##CustomTileGridScroll", ImVec2(0, child_height),
858 true, ImGuiWindowFlags_HorizontalScrollbar)) {
860 }
861 ImGui::EndChild();
862 } else {
864 }
865
866 // Show cell count
867 int modified_count = 0;
868 for (const auto& cell : current_layout_.cells) {
869 if (cell.modified)
870 ++modified_count;
871 }
872 ImGui::Text(tr("%zu tiles, %d modified"), current_layout_.cells.size(),
873 modified_count);
874
877 }
878}
879
881 constexpr float kScale = kTileGridScale;
882 float grid_width = current_layout_.bounds_width * 8 * kScale;
883 float grid_height = current_layout_.bounds_height * 8 * kScale;
884
885 ImVec2 canvas_pos = ImGui::GetCursorScreenPos();
886 ImVec2 canvas_size(grid_width, grid_height);
887
888 // Draw the preview bitmap as background
890 ImGui::Image((ImTextureID)(intptr_t)object_preview_bmp_.texture(),
891 canvas_size);
892 } else {
893 ImGui::Dummy(canvas_size);
894 }
895
896 ImDrawList* draw_list = ImGui::GetWindowDrawList();
897
898 // Draw 8px grid overlay
899 for (int gx = 0; gx <= current_layout_.bounds_width; ++gx) {
900 float line_x = canvas_pos.x + gx * 8 * kScale;
901 draw_list->AddLine(ImVec2(line_x, canvas_pos.y),
902 ImVec2(line_x, canvas_pos.y + grid_height),
903 IM_COL32(128, 128, 128, 80));
904 }
905 for (int gy = 0; gy <= current_layout_.bounds_height; ++gy) {
906 float line_y = canvas_pos.y + gy * 8 * kScale;
907 draw_list->AddLine(ImVec2(canvas_pos.x, line_y),
908 ImVec2(canvas_pos.x + grid_width, line_y),
909 IM_COL32(128, 128, 128, 80));
910 }
911
912 // Highlight selected cell
913 if (selected_cell_index_ >= 0 &&
914 selected_cell_index_ < static_cast<int>(current_layout_.cells.size())) {
915 const auto& cell = current_layout_.cells[selected_cell_index_];
916 ImVec2 cell_min(canvas_pos.x + cell.rel_x * 8 * kScale,
917 canvas_pos.y + cell.rel_y * 8 * kScale);
918 ImVec2 cell_max(cell_min.x + 8 * kScale, cell_min.y + 8 * kScale);
919 draw_list->AddRect(cell_min, cell_max, IM_COL32(255, 255, 0, 255), 0, 0,
920 2.0f);
921 }
922
923 // Handle clicks on the grid
924 if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(0)) {
925 ImVec2 mouse = ImGui::GetMousePos();
926 int click_tile_x =
927 static_cast<int>((mouse.x - canvas_pos.x) / (8 * kScale));
928 int click_tile_y =
929 static_cast<int>((mouse.y - canvas_pos.y) / (8 * kScale));
930
931 // Find the cell at this position
932 for (int idx = 0; idx < static_cast<int>(current_layout_.cells.size());
933 ++idx) {
934 if (current_layout_.cells[idx].rel_x == click_tile_x &&
935 current_layout_.cells[idx].rel_y == click_tile_y) {
938 break;
939 }
940 }
941 }
942}
943
945 constexpr int kMaxCoordinate = kCustomObjectBufferTiles - 1;
946 ImGui::TextUnformatted(tr("Destination (X/Y)"));
947 ImGui::SetNextItemWidth(90);
948 ImGui::SliderInt(tr("X##AddTileX"), &add_tile_x_, 0, kMaxCoordinate, "%d",
949 ImGuiSliderFlags_AlwaysClamp);
950 ImGui::SameLine();
951 ImGui::SetNextItemWidth(90);
952 ImGui::SliderInt(tr("Y##AddTileY"), &add_tile_y_, 0, kMaxCoordinate, "%d",
953 ImGuiSliderFlags_AlwaysClamp);
954 if (ImGui::Button(ICON_MD_ADD " Add Tile")) {
955 const absl::Status status = AddCustomTileAt(add_tile_x_, add_tile_y_);
956 if (!status.ok()) {
957 SetActionStatus(ActionStatusTone::kError, std::string(status.message()));
958 }
959 }
960 if (ImGui::IsItemHovered()) {
961 ImGui::SetItemTooltip(
962 "%s", tr("Adds the selected source tile with its palette and "
963 "flip/priority attributes. An occupied position is "
964 "selected instead."));
965 }
966 if (ImGui::Button(ICON_MD_OPEN_WITH " Move Selected Tile")) {
967 const absl::Status status =
969 if (!status.ok()) {
970 SetActionStatus(ActionStatusTone::kError, std::string(status.message()));
971 }
972 }
973 if (ImGui::IsItemHovered()) {
974 ImGui::SetItemTooltip(
975 "%s", tr("Moves the selected tile to X/Y, keeping its tile, palette, "
976 "and flip/priority attributes. An occupied position is "
977 "refused; tiles are never swapped or overwritten."));
978 }
979 ImGui::TextDisabled(
980 tr("Custom objects draw in a %dx%d tile buffer (X/Y 0-%d)."),
982}
983
985 if (atlas_dirty_) {
987 }
988
989 ImGui::Text(tr("Source Tiles (Palette %d)"), source_palette_);
990
991 // Palette selector
992 ImGui::SameLine();
993 ImGui::SetNextItemWidth(80);
994 if (ImGui::SliderInt("##SrcPal", &source_palette_, 0, 7)) {
995 atlas_dirty_ = true;
996 }
997
998 constexpr float kAtlasScale = 2.0f;
999 float display_width = zelda3::ObjectTileEditor::kAtlasWidthPx * kAtlasScale;
1000 float display_height = zelda3::ObjectTileEditor::kAtlasHeightPx * kAtlasScale;
1001
1002 ImVec2 atlas_pos = ImGui::GetCursorScreenPos();
1003 ImVec2 atlas_size(display_width, display_height);
1004
1005 // Scrollable child for the atlas
1006 ImGui::BeginChild("##AtlasScroll", ImVec2(display_width + 16, 300), true,
1007 ImGuiWindowFlags_HorizontalScrollbar);
1008
1009 atlas_pos = ImGui::GetCursorScreenPos();
1010
1012 ImGui::Image((ImTextureID)(intptr_t)tile8_atlas_bmp_.texture(), atlas_size);
1013 } else {
1014 ImGui::Dummy(atlas_size);
1015 }
1016
1017 ImDrawList* draw_list = ImGui::GetWindowDrawList();
1018
1019 // Draw 8px grid
1020 for (int gx = 0; gx <= zelda3::ObjectTileEditor::kAtlasTilesPerRow; ++gx) {
1021 float line_x = atlas_pos.x + gx * 8 * kAtlasScale;
1022 draw_list->AddLine(ImVec2(line_x, atlas_pos.y),
1023 ImVec2(line_x, atlas_pos.y + display_height),
1024 IM_COL32(64, 64, 64, 60));
1025 }
1026 for (int gy = 0; gy <= zelda3::ObjectTileEditor::kAtlasTileRows; ++gy) {
1027 float line_y = atlas_pos.y + gy * 8 * kAtlasScale;
1028 draw_list->AddLine(ImVec2(atlas_pos.x, line_y),
1029 ImVec2(atlas_pos.x + display_width, line_y),
1030 IM_COL32(64, 64, 64, 60));
1031 }
1032
1033 // Highlight selected source tile
1034 if (selected_source_tile_ >= 0) {
1035 int src_col =
1037 int src_row =
1039 ImVec2 sel_min(atlas_pos.x + src_col * 8 * kAtlasScale,
1040 atlas_pos.y + src_row * 8 * kAtlasScale);
1041 ImVec2 sel_max(sel_min.x + 8 * kAtlasScale, sel_min.y + 8 * kAtlasScale);
1042 draw_list->AddRect(sel_min, sel_max, IM_COL32(0, 255, 255, 255), 0, 0,
1043 2.0f);
1044 }
1045
1046 // Handle clicks on the atlas
1047 if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(0)) {
1048 ImVec2 mouse = ImGui::GetMousePos();
1049 int click_col =
1050 static_cast<int>((mouse.x - atlas_pos.x) / (8 * kAtlasScale));
1051 int click_row =
1052 static_cast<int>((mouse.y - atlas_pos.y) / (8 * kAtlasScale));
1053
1054 if (click_col >= 0 &&
1056 click_row >= 0 &&
1058 int tile_id =
1059 click_row * zelda3::ObjectTileEditor::kAtlasTilesPerRow + click_col;
1060 selected_source_tile_ = tile_id;
1061
1062 // If a cell is selected, replace its tile
1063 if (selected_cell_index_ >= 0 &&
1065 static_cast<int>(current_layout_.cells.size())) {
1067 cell.tile_info.id_ = static_cast<uint16_t>(tile_id);
1068 cell.tile_info.palette_ = static_cast<uint8_t>(source_palette_);
1069 cell.modified = true;
1070 preview_dirty_ = true;
1072 }
1073 }
1074 }
1075
1076 ImGui::EndChild();
1077
1078 if (selected_source_tile_ >= 0) {
1079 ImGui::Text(tr("Tile: 0x%03X"), selected_source_tile_);
1080 }
1081}
1082
1084 if (selected_cell_index_ < 0 ||
1085 selected_cell_index_ >= static_cast<int>(current_layout_.cells.size())) {
1086 ImGui::TextDisabled(tr("Select a tile cell to edit properties"));
1087 return;
1088 }
1089
1091 ImGui::Text(tr("Cell (%d, %d)"), cell.rel_x, cell.rel_y);
1092 ImGui::SameLine();
1093
1094 // Tile ID
1095 int tile_id = cell.tile_info.id_;
1096 ImGui::SetNextItemWidth(80);
1097 if (ImGui::InputInt(tr("ID"), &tile_id, 1, 16)) {
1098 cell.tile_info.id_ = static_cast<uint16_t>(tile_id & 0x3FF);
1099 cell.modified = true;
1100 preview_dirty_ = true;
1103 }
1104 ImGui::SameLine();
1105
1106 // Palette
1107 int pal = cell.tile_info.palette_;
1108 ImGui::SetNextItemWidth(60);
1109 if (ImGui::SliderInt(tr("Pal"), &pal, 0, 7)) {
1110 cell.tile_info.palette_ = static_cast<uint8_t>(pal);
1111 cell.modified = true;
1112 preview_dirty_ = true;
1115 }
1116 ImGui::SameLine();
1117
1118 // Flip flags
1119 if (ImGui::Checkbox(tr("H"), &cell.tile_info.horizontal_mirror_)) {
1120 cell.modified = true;
1121 preview_dirty_ = true;
1124 }
1125 ImGui::SameLine();
1126 if (ImGui::Checkbox(tr("V"), &cell.tile_info.vertical_mirror_)) {
1127 cell.modified = true;
1128 preview_dirty_ = true;
1131 }
1132 ImGui::SameLine();
1133 if (ImGui::Checkbox(tr("Pri"), &cell.tile_info.over_)) {
1134 cell.modified = true;
1135 preview_dirty_ = true;
1138 }
1139}
1140
1143 return tile_editor_->WriteBack(current_layout_);
1144 }
1145
1146 auto plan_or = tile_editor_->BuildStandardWritePlan(current_layout_);
1147 if (!plan_or.ok()) {
1148 return plan_or.status();
1149 }
1150
1151 const auto& plan = *plan_or;
1152 if (!plan.writes_pattern_source() && standard_write_preflight_ &&
1153 !plan.write_ranges().empty()) {
1154 const absl::Status preflight =
1155 standard_write_preflight_(plan.write_ranges());
1156 if (!preflight.ok()) {
1157 return preflight;
1158 }
1159 }
1160
1161 return tile_editor_->ApplyStandardWritePlan(plan);
1162}
1163
1164void ObjectTileEditorPanel::ApplyChanges(bool confirm_shared) {
1165 // Resolve the complete source impact before every write, including
1166 // the second call after the user accepts the shared-data confirmation. A
1167 // malformed or unresolved object family must block the write rather than be
1168 // interpreted as an unshared source.
1169 const auto impact_snapshot_or = AnalyzeSourceImpactSnapshot();
1170 if (!impact_snapshot_or.ok()) {
1171 show_shared_confirm_ = false;
1176 absl::StrFormat(ICON_MD_ERROR
1177 " Apply blocked: source impact could not be resolved: "
1178 "%s",
1179 impact_snapshot_or.status().message()));
1180 return;
1181 }
1182 const SourceImpactSnapshot impact_snapshot = *impact_snapshot_or;
1183 const int shared_count = impact_snapshot.consumer_count;
1184 if (confirm_shared && shared_count > 1) {
1185 shared_object_count_ = shared_count;
1186 pending_shared_confirmation_ = impact_snapshot;
1187 show_shared_confirm_ = true;
1191 absl::StrFormat(
1193 " Confirm shared asset publish: %d runtime slots use this "
1194 "file. This project-source edit is not covered by Ctrl+Z.",
1195 shared_count));
1196 } else {
1199 absl::StrFormat(ICON_MD_WARNING
1200 " Confirm global apply: %d consumers use this tile "
1201 "data. This edit is not covered by Ctrl+Z.",
1202 shared_count));
1203 }
1204 return;
1205 }
1206 const bool missing_required_confirmation =
1207 shared_count > 1 && !pending_shared_confirmation_.has_value();
1208 const bool confirmed_impact_changed =
1209 pending_shared_confirmation_.has_value() &&
1210 *pending_shared_confirmation_ != impact_snapshot;
1211 if (!confirm_shared &&
1212 (missing_required_confirmation || confirmed_impact_changed)) {
1213 shared_object_count_ = shared_count;
1214 pending_shared_confirmation_ = impact_snapshot;
1215 show_shared_confirm_ = true;
1219 absl::StrFormat(
1221 " Source impact changed; review and confirm the updated "
1222 "publish for %d runtime slots. This project-source edit is not "
1223 "covered by Ctrl+Z.",
1224 shared_count));
1225 } else {
1228 absl::StrFormat(
1230 " Source impact changed; review and confirm the updated global "
1231 "apply for %d consumers. This edit is not covered by Ctrl+Z.",
1232 shared_count));
1233 }
1234 return;
1235 }
1236
1237 show_shared_confirm_ = false;
1240
1241 auto status = WriteBackCurrentLayout();
1242 if (status.ok()) {
1244 const auto refreshed =
1246 if (refreshed.ok() && *refreshed) {
1247 current_layout_.pattern_source = *refreshed;
1248 }
1249 }
1250 if (rooms_ != nullptr) {
1252 [](int, zelda3::Room& room) { room.MarkObjectsDirty(); });
1253 }
1254 if (on_tiles_applied_) {
1256 }
1257
1258 // Re-render the current room immediately. Other materialized rooms were
1259 // marked dirty above and refresh lazily when their canvases draw.
1261 auto& room = (*rooms_)[current_room_id_];
1262 room.MarkObjectsDirty();
1263 room.RenderRoomGraphics();
1264 }
1265
1266 // Update both the cells and the authoritative source snapshot only after
1267 // the write succeeds. The next impact analysis reuses BuildStandardWritePlan
1268 // as its CAS gate, so leaving expected_words stale here would make every
1269 // later apply fail until the panel was reopened.
1270 for (auto& cell : current_layout_.cells) {
1271 if (cell.modified) {
1272 const uint16_t applied_word = gfx::TileInfoToWord(cell.tile_info);
1273 cell.original_word = applied_word;
1275 current_layout_.source_provenance.has_value() &&
1276 cell.source_ref.has_value()) {
1277 const auto& source_ref = *cell.source_ref;
1278 auto& spans = current_layout_.source_provenance->spans;
1279 if (source_ref.span_index < spans.size() &&
1280 source_ref.word_index <
1281 spans[source_ref.span_index].expected_words.size()) {
1282 spans[source_ref.span_index].expected_words[source_ref.word_index] =
1283 applied_word;
1284 }
1285 }
1286 cell.modified = false;
1287 }
1288 }
1291 }
1292
1294 if (shared_count > 1) {
1298 absl::StrFormat(
1300 " Published shared custom asset used by %d runtime slots.",
1301 shared_count));
1302 } else {
1305 absl::StrFormat(
1307 " Applied changes to shared tile data used by %d consumers.",
1308 shared_count));
1309 }
1310 } else {
1312 }
1313 return;
1314 }
1315
1318 absl::StrFormat(ICON_MD_ERROR " Apply failed: %s", status.message()));
1319}
1320
1322 const auto& theme = gui::ThemeManager::Get().GetCurrentTheme();
1323 int modified_count = 0;
1324 for (const auto& cell : current_layout_.cells) {
1325 if (cell.modified)
1326 ++modified_count;
1327 }
1328
1329 bool has_mods = modified_count > 0;
1330
1331 if (has_mods) {
1332 ImGui::Text(tr("%d tile(s) modified"), modified_count);
1333 ImGui::SameLine();
1334 }
1335
1336 // Shared tile data warning. Keep analysis errors visible here, but enforce
1337 // fail-closed behavior in ApplyChanges so the disabled-by-default standard
1338 // editor cannot silently turn an unknown impact into a write.
1339 const auto shared_count_or = GetDisplayedSharedTileDataUsageCount();
1340 if (!shared_count_or.ok()) {
1341 ImGui::TextColored(gui::ConvertColorToImVec4(theme.error),
1342 ICON_MD_ERROR " Source impact unavailable");
1343 if (ImGui::IsItemHovered()) {
1344 ImGui::SetItemTooltip("%s", shared_count_or.status().message().data());
1345 }
1346 ImGui::SameLine();
1347 } else if (*shared_count_or > 1) {
1348 const int shared_count = *shared_count_or;
1350 ImGui::TextColored(gui::ConvertColorToImVec4(theme.warning),
1351 ICON_MD_WARNING " Shared by %d runtime slots",
1352 shared_count);
1353 } else {
1354 ImGui::TextColored(gui::ConvertColorToImVec4(theme.warning),
1355 ICON_MD_WARNING " Shared by %d consumers",
1356 shared_count);
1357 }
1358 if (ImGui::IsItemHovered()) {
1360 ImGui::SetItemTooltip(
1361 tr("%d fixed runtime slots resolve to this source file.\n"
1362 "Publishing changes updates all of them.\nThis project "
1363 "source-asset edit is not covered by Ctrl+Z."),
1364 shared_count);
1365 } else {
1366 ImGui::SetItemTooltip(
1367 tr("This object reuses tile data with %d consumers.\nApplying "
1368 "changes will update every object or runtime consumer in that "
1369 "shared data group.\nThis shared tile edit is not covered by "
1370 "Ctrl+Z."),
1371 shared_count);
1372 }
1373 }
1374 ImGui::SameLine();
1375 }
1376
1377 // Apply button
1378 if (!has_mods)
1379 ImGui::BeginDisabled();
1380 const char* primary_action_label =
1382 ? ICON_MD_SAVE " Publish Asset"
1383 : ICON_MD_SAVE " Apply";
1384 if (ImGui::Button(primary_action_label)) {
1385 ApplyChanges();
1386 }
1387 if (!has_mods)
1388 ImGui::EndDisabled();
1389
1390 ImGui::SameLine();
1391
1392 // Revert button
1393 if (!has_mods)
1394 ImGui::BeginDisabled();
1395 if (ImGui::Button(ICON_MD_UNDO " Revert")) {
1397 }
1398 if (!has_mods)
1399 ImGui::EndDisabled();
1400
1401 ImGui::SameLine();
1402
1403 const char* close_label =
1404 has_mods ? ICON_MD_CLOSE " Discard & Close" : ICON_MD_CLOSE " Close";
1405 if (ImGui::Button(close_label)) {
1406 if (p_open != nullptr) {
1407 *p_open = false;
1408 }
1409 Close();
1410 return;
1411 }
1412 if (has_mods && ImGui::IsItemHovered()) {
1413 ImGui::SetItemTooltip(
1414 "%s", tr("Close the panel and discard all unapplied tile changes."));
1415 }
1416
1417 ImGui::Spacing();
1419 const auto& path = current_layout_.pattern_source->source_path;
1420 ImGui::Text("Source: %s",
1421 std::filesystem::path(path).filename().string().c_str());
1422 if (ImGui::IsItemHovered())
1423 ImGui::SetTooltip("%s", path.c_str());
1424 }
1425 ImGui::TextDisabled(
1427 ? ICON_MD_INFO
1428 " Project source-asset edit; not covered by Ctrl+Z."
1429 : ICON_MD_INFO " Global ROM tile edit; not covered by Ctrl+Z.");
1430
1432 !action_status_message_.empty()) {
1433 ImVec4 status_color = gui::ConvertColorToImVec4(theme.text_secondary);
1434 switch (action_status_tone_) {
1436 status_color = gui::ConvertColorToImVec4(theme.warning);
1437 break;
1439 status_color = gui::ConvertColorToImVec4(theme.success);
1440 break;
1442 status_color = gui::ConvertColorToImVec4(theme.error);
1443 break;
1445 break;
1446 }
1447
1448 ImGui::Spacing();
1449 gui::StyleColorGuard status_text(ImGuiCol_Text, status_color);
1450 ImGui::PushTextWrapPos(0.0f);
1451 ImGui::TextUnformatted(action_status_message_.c_str());
1452 ImGui::PopTextWrapPos();
1453 }
1454}
1455
1458 if (p_open != nullptr) {
1459 *p_open = false;
1460 }
1463 " Unapplied tile changes were kept. Reopen the panel to continue, or "
1464 "use Discard & Close to throw them away.");
1465 return;
1466 }
1467
1468 if (p_open != nullptr) {
1469 *p_open = false;
1470 }
1471 Close();
1472}
1473
1475 // Only handle shortcuts when this window is focused
1476 if (!ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows))
1477 return;
1478 if (current_layout_.cells.empty())
1479 return;
1480
1481 int cell_count = static_cast<int>(current_layout_.cells.size());
1482
1483 // Arrow keys: navigate selected cell by spatial position
1484 auto find_neighbor = [&](int dx, int dy) -> int {
1485 if (selected_cell_index_ < 0)
1486 return 0;
1487 const auto& cur = current_layout_.cells[selected_cell_index_];
1488 int target_x = cur.rel_x + dx;
1489 int target_y = cur.rel_y + dy;
1490 for (int i = 0; i < cell_count; ++i) {
1491 if (current_layout_.cells[i].rel_x == target_x &&
1492 current_layout_.cells[i].rel_y == target_y) {
1493 return i;
1494 }
1495 }
1496 return selected_cell_index_;
1497 };
1498
1499 if (ImGui::IsKeyPressed(ImGuiKey_LeftArrow, false)) {
1500 selected_cell_index_ = find_neighbor(-1, 0);
1502 }
1503 if (ImGui::IsKeyPressed(ImGuiKey_RightArrow, false)) {
1504 selected_cell_index_ = find_neighbor(1, 0);
1506 }
1507 if (ImGui::IsKeyPressed(ImGuiKey_UpArrow, false)) {
1508 selected_cell_index_ = find_neighbor(0, -1);
1510 }
1511 if (ImGui::IsKeyPressed(ImGuiKey_DownArrow, false)) {
1512 selected_cell_index_ = find_neighbor(0, 1);
1514 }
1515
1516 // Number keys 0-7: set palette on selected cell
1517 if (selected_cell_index_ >= 0 && selected_cell_index_ < cell_count) {
1519 for (int key = 0; key <= 7; ++key) {
1520 if (ImGui::IsKeyPressed(static_cast<ImGuiKey>(ImGuiKey_0 + key), false)) {
1521 cell.tile_info.palette_ = static_cast<uint8_t>(key);
1522 cell.modified = true;
1523 preview_dirty_ = true;
1526 }
1527 }
1528
1529 // H: toggle horizontal flip
1530 if (ImGui::IsKeyPressed(ImGuiKey_H, false)) {
1531 cell.tile_info.horizontal_mirror_ = !cell.tile_info.horizontal_mirror_;
1532 cell.modified = true;
1533 preview_dirty_ = true;
1536 }
1537
1538 // V: toggle vertical flip
1539 if (ImGui::IsKeyPressed(ImGuiKey_V, false)) {
1540 cell.tile_info.vertical_mirror_ = !cell.tile_info.vertical_mirror_;
1541 cell.modified = true;
1542 preview_dirty_ = true;
1545 }
1546
1547 // P: toggle priority
1548 if (ImGui::IsKeyPressed(ImGuiKey_P, false)) {
1549 cell.tile_info.over_ = !cell.tile_info.over_;
1550 cell.modified = true;
1551 preview_dirty_ = true;
1554 }
1555 }
1556
1557 // Escape: deselect or close
1558 if (ImGui::IsKeyPressed(ImGuiKey_Escape, false)) {
1559 if (selected_cell_index_ >= 0) {
1562 } else {
1563 RequestSafeWindowClose(p_open);
1564 }
1565 return;
1566 }
1567
1568 // Tab: cycle to next cell
1569 if (ImGui::IsKeyPressed(ImGuiKey_Tab, false)) {
1570 if (cell_count > 0) {
1571 selected_cell_index_ = (selected_cell_index_ + 1) % cell_count;
1573 }
1574 }
1575}
1576
1577} // namespace editor
1578} // namespace yaze
The Rom class is used to load, save, and modify Rom data. This is a generic SNES ROM container and do...
Definition rom.h:52
uint64_t object_tile_revision() const
Definition rom.h:208
bool is_loaded() const
Definition rom.h:199
static Flags & get()
Definition features.h:122
zelda3::Room * GetIfLoaded(int room_id)
absl::StatusOr< SourceImpactSnapshot > AnalyzeSourceImpactSnapshot() const
void ApplyChanges(bool confirm_shared=true)
void SetCurrentPaletteGroup(const gfx::PaletteGroup &group)
absl::Status AppendCustomCell(int rel_x, int rel_y, gfx::TileInfo tile_info)
void SetCurrentPaletteGroupForRoom(int room_id, const gfx::PaletteGroup &group)
absl::Status OpenForCustomObject(int16_t object_id, int subtype, int room_id, DungeonRoomStore *rooms)
ObjectTileEditorPanel(gfx::IRenderer *renderer, Rom *rom)
std::unique_ptr< zelda3::ObjectTileEditor > tile_editor_
std::optional< CustomLayoutBaseline > custom_baseline_
void OnClose() override
Called when panel is hidden.
absl::StatusOr< bool > HasSharedTileDataConflict() const
absl::Status AddCustomTileAt(int rel_x, int rel_y)
std::optional< SourceImpactDisplayCacheKey > source_impact_display_cache_key_
StandardWritePreflightCallback standard_write_preflight_
absl::Status MoveSelectedCustomTileTo(int rel_x, int rel_y)
absl::Status OpenForObject(int16_t object_id, int room_id, DungeonRoomStore *rooms)
std::optional< absl::StatusOr< int > > source_impact_display_cache_result_
void Draw(bool *p_open) override
Draw the panel content.
absl::StatusOr< int > GetDisplayedSharedTileDataUsageCount()
void SetActionStatus(ActionStatusTone tone, std::string message)
absl::StatusOr< int > GetSharedTileDataUsageCount() const
std::optional< SourceImpactSnapshot > pending_shared_confirmation_
void RetireBitmap(Bitmap &bitmap)
Explicitly retire resources owned by a bitmap that is being erased.
Definition arena.cc:63
static Arena & Get()
Definition arena.cc:24
Represents a bitmap image optimized for SNES ROM hacking.
Definition bitmap.h:70
const uint8_t * data() const
Definition bitmap.h:407
TextureHandle texture() const
Definition bitmap.h:410
void CreateTexture()
Creates the underlying SDL_Texture to be displayed.
Definition bitmap.cc:297
bool is_active() const
Definition bitmap.h:414
void UpdateTexture()
Updates the underlying SDL_Texture when it already exists.
Definition bitmap.cc:301
Defines an abstract interface for all rendering operations.
Definition irenderer.h:60
SNES 16-bit tile metadata container.
Definition snes_tile.h:52
RAII guard for ImGui style colors.
Definition style_guard.h:29
const Theme & GetCurrentTheme() const
static ThemeManager & Get()
static const std::array< int, 3 > & RuntimeObjectIds()
static CustomObjectManager & Get()
absl::StatusOr< std::shared_ptr< const DungeonTilePatterns > > GetTilePatterns()
static constexpr int kAtlasTilesPerRow
static bool IsEditableStandardObject(int16_t object_id)
void MarkObjectsDirty()
Definition room.h:471
uint64_t graphics_revision() const
Definition room.h:1125
#define ICON_MD_INFO
Definition icons.h:993
#define ICON_MD_WARNING
Definition icons.h:2123
#define ICON_MD_OPEN_WITH
Definition icons.h:1356
#define ICON_MD_ERROR
Definition icons.h:686
#define ICON_MD_GRID_ON
Definition icons.h:896
#define ICON_MD_VISIBILITY_OFF
Definition icons.h:2102
#define ICON_MD_ADD
Definition icons.h:86
#define ICON_MD_CHECK_CIRCLE
Definition icons.h:400
#define ICON_MD_SAVE
Definition icons.h:1644
#define ICON_MD_CLOSE
Definition icons.h:418
#define ICON_MD_UNDO
Definition icons.h:2039
bool PathsReferToSameAsset(const std::filesystem::path &lhs, const std::filesystem::path &rhs)
uint16_t TileInfoToWord(TileInfo tile_info)
Definition snes_tile.cc:362
TileInfo WordToTileInfo(uint16_t word)
Definition snes_tile.cc:379
ImVec4 ConvertColorToImVec4(const Color &color)
Definition color.h:140
absl::StatusOr< std::vector< uint8_t > > EncodeCustomObjectBinary(const CustomObject &object)
absl::StatusOr< fs::path > ResolveCustomObjectAssetPath(const std::string &custom_objects_folder, const std::string &filename)
std::string GetObjectName(int object_id)
Represents a group of palettes.
Represents a decoded custom object (from binary format)
BoundingBox GetBoundingBox() const
std::vector< TileMapEntry > tiles
std::shared_ptr< const DungeonTilePatterns > pattern_source
std::optional< ObjectTileSourceProvenance > source_provenance
std::filesystem::path custom_resolved_path