yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
dungeon_editor_v2_undo.cc
Go to the documentation of this file.
1// Undo/redo, clipboard, and room-state restore implementation for
2// DungeonEditorV2. Split out of dungeon_editor_v2.cc to keep that
3// translation unit within the editor source-size guardrail. Class
4// declaration lives in dungeon_editor_v2.h.
5
6#include <algorithm>
7#include <cctype>
8#include <cstdio>
9#include <exception>
10#include <iterator>
11#include <memory>
12#include <string>
13#include <unordered_set>
14#include <utility>
15#include <vector>
16#include "absl/status/status.h"
17#include "absl/strings/str_format.h"
18#include "absl/types/span.h"
57#include "app/gui/core/icons.h"
59#include "core/features.h"
60#include "core/project.h"
61#include "dungeon_editor_v2.h"
62#include "imgui/imgui.h"
63#include "rom/snes.h"
64#include "util/log.h"
65#include "util/macro.h"
71#include "zelda3/dungeon/room.h"
74
75namespace yaze::editor {
76
77absl::Status DungeonEditorV2::Undo() {
78 // Finalize any in-progress edit before undoing.
79 if (pending_undo_.room_id >= 0) {
81 }
84 }
87 }
88 const std::string description = undo_manager_.GetUndoDescription();
90 auto status = undo_manager_.Undo();
91 if (status.ok()) {
93 if (auto* viewer = GetViewerForRoom(current_room_id_)) {
94 viewer->TriggerChangePing();
95 }
96 }
99 description.empty() ? "Undid last dungeon edit"
100 : absl::StrFormat("Undid: %s", description),
101 ToastType::kInfo, 2.0f);
102 }
103 }
105 return status;
106}
107
108absl::Status DungeonEditorV2::Redo() {
109 // Finalize any in-progress edit before redoing.
110 if (pending_undo_.room_id >= 0) {
112 }
115 }
118 }
119 const std::string description = undo_manager_.GetRedoDescription();
121 auto status = undo_manager_.Redo();
122 if (status.ok()) {
124 if (auto* viewer = GetViewerForRoom(current_room_id_)) {
125 viewer->TriggerChangePing();
126 }
127 }
130 description.empty() ? "Redid last dungeon edit"
131 : absl::StrFormat("Redid: %s", description),
132 ToastType::kInfo, 2.0f);
133 }
134 }
136 return status;
137}
138
139absl::Status DungeonEditorV2::Cut() {
140 if (auto* viewer = GetViewerForRoom(current_room_id_)) {
141 viewer->object_interaction().HandleCopySelected();
142 viewer->object_interaction().HandleDeleteSelected();
143 }
144 return absl::OkStatus();
145}
146
147absl::Status DungeonEditorV2::Copy() {
148 if (auto* viewer = GetViewerForRoom(current_room_id_)) {
149 viewer->object_interaction().HandleCopySelected();
150 }
151 return absl::OkStatus();
152}
153
155 if (auto* viewer = GetViewerForRoom(current_room_id_)) {
156 viewer->object_interaction().HandlePasteObjects();
157 }
158 return absl::OkStatus();
159}
160
162 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size()))
163 return;
164
165 // Detect leaked undo snapshots (double-Begin without Finalize).
166 if (has_pending_undo_) {
167 LOG_ERROR("DungeonEditor",
168 "BeginUndoSnapshot called twice without FinalizeUndoAction. "
169 "Previous snapshot for room %d is being leaked. Finalizing now.",
171 // Auto-finalize the leaked snapshot to prevent silent state loss.
172 if (pending_undo_.room_id >= 0) {
174 }
175 }
176
177 pending_undo_.room_id = room_id;
178 pending_undo_.before_objects = rooms_[room_id].GetTileObjects();
180 if (auto* viewer = GetViewerForRoom(room_id);
181 viewer &&
182 (!IsWorkbenchWorkflowEnabled() || viewer->current_room_id() == room_id)) {
184 viewer->object_interaction().GetSelectedObjectIndices();
185 }
186 has_pending_undo_ = true;
187}
188
190 if (pending_undo_.room_id < 0 || pending_undo_.room_id != room_id)
191 return;
192 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size()))
193 return;
194
195 auto after_objects = rooms_[room_id].GetTileObjects();
196 std::vector<size_t> after_selection;
197 if (auto* viewer = GetViewerForRoom(room_id);
198 viewer &&
199 (!IsWorkbenchWorkflowEnabled() || viewer->current_room_id() == room_id)) {
200 after_selection = viewer->object_interaction().GetSelectedObjectIndices();
201 }
202
203 auto action = std::make_unique<DungeonObjectsAction>(
204 room_id, std::move(pending_undo_.before_objects),
205 std::move(pending_undo_.before_selection), std::move(after_objects),
206 std::move(after_selection),
207 [this](int rid, const std::vector<zelda3::RoomObject>& objects,
208 const std::vector<size_t>& selected_indices) {
209 RestoreRoomObjects(rid, objects, selected_indices);
210 });
211 undo_manager_.Push(std::move(action));
214 }
215
219 has_pending_undo_ = false;
220}
221
223 int room_id, const std::vector<zelda3::RoomObject>& objects,
224 const std::vector<size_t>& selected_indices) {
225 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size()))
226 return;
227
228 auto& room = rooms_[room_id];
229 const auto previous_objects = room.GetTileObjects();
230 room.SetTileObjects(objects);
231 room.RenderRoomGraphics();
232 if (auto* viewer = GetViewerForRoom(room_id)) {
233 std::vector<size_t> valid_selection;
234 for (size_t index : selected_indices) {
235 if (index < objects.size()) {
236 valid_selection.push_back(index);
237 }
238 }
239 if (!IsWorkbenchWorkflowEnabled() || viewer->current_room_id() == room_id) {
240 viewer->object_interaction().SetSelectedObjects(valid_selection);
241 }
242 viewer->TriggerObjectChangePing(previous_objects, objects);
244 }
247 }
248}
249
251 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size()))
252 return;
253
256 }
257
259 pending_collision_undo_.before = rooms_[room_id].custom_collision();
260}
261
264 pending_collision_undo_.room_id != room_id) {
265 return;
266 }
267 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size()))
268 return;
269
270 auto after = rooms_[room_id].custom_collision();
271 if (pending_collision_undo_.before.has_data == after.has_data &&
272 pending_collision_undo_.before.tiles == after.tiles) {
275 return;
276 }
277
278 auto action = std::make_unique<DungeonCustomCollisionAction>(
279 room_id, std::move(pending_collision_undo_.before), std::move(after),
280 [this](int rid, const zelda3::CustomCollisionMap& map) {
281 RestoreRoomCustomCollision(rid, map);
282 });
283 undo_manager_.Push(std::move(action));
286 }
287
290}
291
293 int room_id, const zelda3::CustomCollisionMap& map) {
294 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size()))
295 return;
296
297 auto& room = rooms_[room_id];
298 room.custom_collision() = map;
299 room.MarkCustomCollisionDirty();
302 }
303}
304
305namespace {
306
308 return map.has_data || std::any_of(map.tiles.begin(), map.tiles.end(),
309 [](uint8_t tile) { return tile != 0; });
310}
311
313 const zelda3::CustomCollisionMap& rhs) {
314 return lhs.has_data == rhs.has_data && lhs.tiles == rhs.tiles;
315}
316
318 zelda3::Room* room = nullptr;
320 bool dirty = false;
321};
322
324 public:
325 explicit CollisionBatchRollback(std::vector<CollisionRollbackEntry> entries)
326 : entries_(std::move(entries)) {}
327
329 if (committed_) {
330 return;
331 }
332 for (const auto& entry : entries_) {
333 entry.room->custom_collision() = entry.map;
334 if (entry.dirty) {
335 entry.room->MarkCustomCollisionDirty();
336 } else {
337 entry.room->ClearCustomCollisionDirty();
338 }
339 }
340 }
341
342 void Commit() { committed_ = true; }
343
344 private:
345 std::vector<CollisionRollbackEntry> entries_;
346 bool committed_ = false;
347};
348
349} // namespace
350
352 const std::vector<zelda3::TrackCollisionResult>& preview,
353 const zelda3::GeneratorOptions& options) {
354 if (preview.empty()) {
355 return absl::InvalidArgumentError("Minecart collision preview is empty");
356 }
357
358 std::unordered_set<int> seen_room_ids;
359 std::vector<DungeonCustomCollisionSnapshot> before;
360 std::vector<DungeonCustomCollisionSnapshot> after;
361 std::vector<CollisionRollbackEntry> rollback_entries;
362 before.reserve(preview.size());
363 after.reserve(preview.size());
364 rollback_entries.reserve(preview.size());
365
366 // Validate the complete batch before changing rooms or undo history.
367 for (const auto& expected : preview) {
368 const int room_id = expected.room_id;
369 if (!IsValidRoomId(room_id)) {
370 return absl::OutOfRangeError(absl::StrFormat(
371 "Minecart collision room 0x%03X is out of range", room_id));
372 }
373 if (!seen_room_ids.insert(room_id).second) {
374 return absl::InvalidArgumentError(absl::StrFormat(
375 "Minecart collision preview contains duplicate room 0x%03X",
376 room_id));
377 }
378
379 zelda3::Room* room = rooms_.GetIfMaterialized(room_id);
380 if (room == nullptr) {
381 return absl::FailedPreconditionError(absl::StrFormat(
382 "Minecart collision room 0x%03X is not loaded", room_id));
383 }
384 if (HasAnyCustomCollision(room->custom_collision())) {
385 return absl::FailedPreconditionError(absl::StrFormat(
386 "Room 0x%03X already has custom collision; generation will not "
387 "replace it",
388 room_id));
389 }
390
391 ASSIGN_OR_RETURN(auto current,
392 zelda3::GenerateTrackCollision(room, options));
393 current.room_id = room_id;
394 if (!current.collision_map.has_data || current.tiles_generated <= 0) {
395 return absl::FailedPreconditionError(absl::StrFormat(
396 "Room 0x%03X no longer contains supported minecart track pieces",
397 room_id));
398 }
399 if (!CollisionMapsEqual(current.collision_map, expected.collision_map) ||
400 current.tiles_generated != expected.tiles_generated ||
401 current.stop_count != expected.stop_count ||
402 current.corner_count != expected.corner_count ||
403 current.switch_count != expected.switch_count) {
404 return absl::FailedPreconditionError(absl::StrFormat(
405 "Minecart collision preview for room 0x%03X is stale; preview "
406 "again before applying",
407 room_id));
408 }
409
410 before.push_back({room_id, room->custom_collision()});
411 after.push_back({room_id, current.collision_map});
412 rollback_entries.push_back(
413 {room, room->custom_collision(), room->custom_collision_dirty()});
414 }
415
418 return absl::FailedPreconditionError(
419 "Finish the current dungeon edit before applying minecart collision");
420 }
421
422 auto action = std::make_unique<DungeonCustomCollisionBatchAction>(
423 before, after,
424 [this](const std::vector<DungeonCustomCollisionSnapshot>& snapshots) {
425 return RestoreRoomCustomCollisionBatch(snapshots);
426 });
427
428 CollisionBatchRollback rollback(std::move(rollback_entries));
429 for (const auto& snapshot : after) {
430 auto* room = rooms_.GetIfMaterialized(snapshot.room_id);
431 room->custom_collision() = snapshot.map;
432 room->MarkCustomCollisionDirty();
433 }
434 try {
435 undo_manager_.Push(std::move(action));
436 } catch (const std::exception& error) {
437 return absl::InternalError(absl::StrFormat(
438 "Could not record minecart collision undo: %s", error.what()));
439 }
440 rollback.Commit();
443 }
444
445 for (const auto& snapshot : after) {
446 if (auto* viewer = GetViewerForRoom(snapshot.room_id)) {
447 viewer->TriggerChangePing();
448 }
449 }
450 return absl::OkStatus();
451}
452
454 const std::vector<DungeonCustomCollisionSnapshot>& snapshots) {
455 std::unordered_set<int> seen_room_ids;
456 for (const auto& snapshot : snapshots) {
457 if (!IsValidRoomId(snapshot.room_id)) {
458 return absl::OutOfRangeError("Collision undo room ID is out of range");
459 }
460 if (!seen_room_ids.insert(snapshot.room_id).second) {
461 return absl::InvalidArgumentError(
462 "Collision undo contains a duplicate room ID");
463 }
464 if (rooms_.GetIfMaterialized(snapshot.room_id) == nullptr) {
465 return absl::FailedPreconditionError(
466 "Collision undo target room is not loaded");
467 }
468 }
469
470 for (const auto& snapshot : snapshots) {
471 auto& room = *rooms_.GetIfMaterialized(snapshot.room_id);
472 room.custom_collision() = snapshot.map;
473 room.MarkCustomCollisionDirty();
474 if (auto* viewer = GetViewerForRoom(snapshot.room_id)) {
475 viewer->TriggerChangePing();
477 }
478 }
481 }
482 return absl::OkStatus();
483}
484
485namespace {
486
490
491 const auto& zone = room.water_fill_zone();
492 // Preserve deterministic ordering (ascending offsets) for stable diffs.
493 for (size_t i = 0; i < zone.tiles.size(); ++i) {
494 if (zone.tiles[i] != 0) {
495 snap.offsets.push_back(static_cast<uint16_t>(i));
496 }
497 }
498 return snap;
499}
500
501} // namespace
502
504 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size()))
505 return;
506
509 }
510
512 pending_water_fill_undo_.before = MakeWaterFillSnapshot(rooms_[room_id]);
513}
514
518 return;
519 }
520 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size()))
521 return;
522
523 auto after = MakeWaterFillSnapshot(rooms_[room_id]);
524 if (pending_water_fill_undo_.before.sram_bit_mask == after.sram_bit_mask &&
525 pending_water_fill_undo_.before.offsets == after.offsets) {
528 return;
529 }
530
531 auto action = std::make_unique<DungeonWaterFillAction>(
532 room_id, std::move(pending_water_fill_undo_.before), std::move(after),
533 [this](int rid, const WaterFillSnapshot& snap) {
534 RestoreRoomWaterFill(rid, snap);
535 });
536 undo_manager_.Push(std::move(action));
537
540}
541
543 const WaterFillSnapshot& snap) {
544 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size()))
545 return;
546
547 auto& room = rooms_[room_id];
548 room.ClearWaterFillZone();
549 room.set_water_fill_sram_bit_mask(snap.sram_bit_mask);
550 for (uint16_t off : snap.offsets) {
551 const int x = static_cast<int>(off % 64);
552 const int y = static_cast<int>(off / 64);
553 room.SetWaterFillTile(x, y, /*filled=*/true);
554 }
555 room.MarkWaterFillDirty();
556}
557
558} // namespace yaze::editor
class MinecartTrackEditorPanel * minecart_track_editor_panel_
void RestoreRoomWaterFill(int room_id, const WaterFillSnapshot &snap)
void RestoreRoomObjects(int room_id, const std::vector< zelda3::RoomObject > &objects, const std::vector< size_t > &selected_indices)
PendingCollisionUndo pending_collision_undo_
PendingWaterFillUndo pending_water_fill_undo_
static bool IsValidRoomId(int room_id)
DungeonCanvasViewer * GetViewerForRoom(int room_id)
absl::Status RestoreRoomCustomCollisionBatch(const std::vector< DungeonCustomCollisionSnapshot > &snapshots)
void RestoreRoomCustomCollision(int room_id, const zelda3::CustomCollisionMap &map)
absl::Status ApplyMinecartCollisionBatch(const std::vector< zelda3::TrackCollisionResult > &preview, const zelda3::GeneratorOptions &options)
zelda3::Room * GetIfMaterialized(int room_id)
UndoManager undo_manager_
Definition editor.h:339
EditorDependencies dependencies_
Definition editor.h:338
void Show(const std::string &message, ToastType type=ToastType::kInfo, float ttl_seconds=3.0f)
void Push(std::unique_ptr< UndoAction > action)
absl::Status Redo()
Redo the top action. Returns error if stack is empty.
std::string GetRedoDescription() const
Description of the action that would be redone (for UI)
std::string GetUndoDescription() const
Description of the action that would be undone (for UI)
absl::Status Undo()
Undo the top action. Returns error if stack is empty.
const CustomCollisionMap & custom_collision() const
Definition room.h:519
bool custom_collision_dirty() const
Definition room.h:545
const WaterFillZoneMap & water_fill_zone() const
Definition room.h:550
uint8_t water_fill_sram_bit_mask() const
Definition room.h:593
#define LOG_ERROR(category, format,...)
Definition log.h:110
#define ASSIGN_OR_RETURN(type_variable_name, expression)
Definition macro.h:62
bool CollisionMapsEqual(const zelda3::CustomCollisionMap &lhs, const zelda3::CustomCollisionMap &rhs)
bool HasAnyCustomCollision(const zelda3::CustomCollisionMap &map)
WaterFillSnapshot MakeWaterFillSnapshot(const zelda3::Room &room)
Editors are the view controllers for the application.
absl::StatusOr< TrackCollisionResult > GenerateTrackCollision(Room *room, const GeneratorOptions &options)
std::vector< zelda3::RoomObject > before_objects
std::array< uint8_t, 64 *64 > tiles