7c37a0dc2f
Implements the in-game editor's shared, data-native core and a Control-based UI on top of it, per docs/architecture/editor.md §7.1/§7.2, scoped to 2D per §8's own risk mitigation (the default world is flat, CHUNK_SIZE_Z=1 -- no 3D gizmos here). EditorCore (scripts/editor/): - ChunkEditorModel wraps a ChunkDataDecoder.ChunkPayload directly (no parallel data model) and adds a TileLayerData.set_tile() mutator alongside the existing get_tile(). - EditorUndoRedoStack wraps Godot's built-in UndoRedo (game-usable, not the editor-only EditorUndoRedoManager) so every mutation is a real, revertible do/undo Callable pair over ChunkEditorModel's raw setters. - SelectionManager tracks the selected object id as UI state, deliberately outside the undo/redo stack. - ToolRegistry + 5 tools (BrushTool, ObjectPlaceTool, SelectTool, EraseTool, TransformTool -- the 5th covers move/rotate/scale, which the doc's tool list doesn't literally name but the task requires) is the single choke point every mutation goes through. - EditorObjectCatalog (assets/editor/object_catalog.json) is a new, small "placeable object" catalog -- AvatarCatalog and SpawnCatalog were checked first and are both scoped to other concerns, so this mirrors their loading convention rather than reusing either. InGameEditor UI (scripts/editor/ui/): EditorViewport (2D top-down tile grid + object dots), EditorToolbar, PalettePanel, InspectorPanel, wired together in InGameEditor. Built from Control nodes in code (matching game.gd's existing dynamic-UI convention) rather than a hand-authored .tscn. Entry point: game.gd gates build mode behind a new toggle_build_mode input action (F2), freezing the player and opening a fresh empty ChunkEditorModel. Loading a *real* chunk from the backend for editing, and saving edits back, are explicitly out of scope -- a sibling task owns the batch-PUT/conflict-handling save path (world_persistence_impl.md). Verification: scripts/smoke_test_editor_core.gd decodes a real backend fixture (backend/tests/fixtures/chunks/simple_tile_chunk.bin), drives place/move/rotate/scale/delete/paint/erase entirely through EditorCore.use_tool()/ToolRegistry (never touching ChunkEditorModel's raw setters directly), undoes and redoes every step, and round-trips the result through encode_binary()/decode_binary() to prove the edited model stays a valid, byte-compatible ChunkPayload. 69/69 assertions pass. Full existing Godot smoke suite (16 other scripts/smoke_test_*.gd, backend + dedicated-server-dependent ones included) still passes with no regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
261 lines
12 KiB
GDScript
261 lines
12 KiB
GDScript
extends SceneTree
|
|
## Real exercise of the in-game editor's data-native core
|
|
## (docs/architecture/editor.md §7.1/§7.2, scoped to 2D per §8).
|
|
##
|
|
## Loads a real chunk (the backend's golden fixture
|
|
## backend/tests/fixtures/chunks/simple_tile_chunk.bin -- the same file
|
|
## scripts/smoke_test_chunk_codec_interop.gd proves decodes correctly),
|
|
## wraps it in a ChunkEditorModel, and drives every operation
|
|
## (place/move/rotate/scale/delete an object, paint/erase a tile) through
|
|
## the actual EditorCore.use_tool()/ToolRegistry path -- never by calling
|
|
## ChunkEditorModel's raw setters directly, which would bypass the tool
|
|
## layer this task was scoped to build. Then it undoes every single one of
|
|
## those operations and checks the chunk is back to exactly its starting
|
|
## state, redoes them all back, and finally round-trips the result through
|
|
## encode_binary()/decode_binary() to prove the model stays a valid,
|
|
## byte-compatible ChunkPayload throughout -- the contract the sibling
|
|
## save-path task depends on.
|
|
##
|
|
## Run with:
|
|
## ~/godot/godot --headless --path . --script res://scripts/smoke_test_editor_core.gd
|
|
|
|
const FIXTURE_PATH := "res://backend/tests/fixtures/chunks/simple_tile_chunk.bin"
|
|
|
|
var passed := 0
|
|
var failed := 0
|
|
|
|
|
|
func _check(label: String, cond: bool) -> void:
|
|
if cond:
|
|
passed += 1
|
|
print(" PASS: ", label)
|
|
else:
|
|
failed += 1
|
|
printerr(" FAIL: ", label)
|
|
|
|
|
|
func _initialize() -> void:
|
|
_run()
|
|
|
|
|
|
func _run() -> void:
|
|
await _test_full_editing_loop()
|
|
_test_catalog_rejects_unknown_id()
|
|
_test_selection_is_not_undoable()
|
|
|
|
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
|
quit(0 if failed == 0 else 1)
|
|
|
|
|
|
func _read_fixture() -> PackedByteArray:
|
|
var f := FileAccess.open(FIXTURE_PATH, FileAccess.READ)
|
|
if f == null:
|
|
push_error("Could not open fixture: " + FIXTURE_PATH)
|
|
return PackedByteArray()
|
|
return f.get_buffer(f.get_length())
|
|
|
|
|
|
func _test_full_editing_loop() -> void:
|
|
print("--- EditorCore/ToolRegistry over a real decoded chunk (simple_tile_chunk.bin) ---")
|
|
|
|
var original_bytes := _read_fixture()
|
|
_check("fixture file read (non-empty)", original_bytes.size() > 0)
|
|
if original_bytes.is_empty():
|
|
return
|
|
|
|
var model := ChunkEditorModel.from_binary(original_bytes)
|
|
_check("ChunkEditorModel.from_binary decoded the fixture", model != null)
|
|
if model == null:
|
|
return
|
|
|
|
# Baseline sanity on the loaded real chunk before touching anything.
|
|
_check("loaded chunk has 1 tile layer", model.payload.tile_layers.size() == 1)
|
|
_check("loaded chunk is 4x4", model.get_layer(0).width == 4 and model.get_layer(0).height == 4)
|
|
var baseline_tiles := (model.get_layer(0).tiles as PackedInt32Array).duplicate()
|
|
var baseline_object_count := model.payload.objects.size()
|
|
|
|
var catalog := EditorObjectCatalog.new()
|
|
var cat_err := catalog.load_catalog()
|
|
_check("object catalog loaded", cat_err == OK)
|
|
|
|
var core := EditorCore.new(model, catalog)
|
|
_check("EditorCore registers all 5 tools", core.tools.get_tool_ids().size() == 5)
|
|
for expected_tool in ["brush", "object_place", "select", "erase", "transform"]:
|
|
_check("ToolRegistry has tool '%s'" % expected_tool, core.tools.has_tool(expected_tool))
|
|
|
|
# ── 1. Paint a tile via the brush tool (through EditorCore/ToolRegistry) ──
|
|
_check("tile (0,0) starts as 0 in the fixture", model.get_tile_raw(0, 0, 0) == 0)
|
|
var painted := core.paint_tile(0, 0, 0, 1)
|
|
_check("core.paint_tile (brush tool) applied", painted)
|
|
_check("tile (0,0) is now 1 after paint", model.get_tile_raw(0, 0, 0) == 1)
|
|
|
|
# ── 2. Place a catalog object (through EditorCore/ToolRegistry) ──
|
|
var obj_id := core.place_object("crate_small", Vector3(1.5, 1.5, 0.0))
|
|
_check("core.place_object (object_place tool) returned a real id", obj_id != "")
|
|
_check("placed object exists in the model", model.find_object(obj_id) != null)
|
|
_check("placed object got the catalog's asset_id", model.find_object(obj_id).asset_id == "props/crate_small.glb")
|
|
_check("placing auto-selected the new object", core.selection.get_selected() == obj_id)
|
|
|
|
# ── 3. Select it explicitly via the select tool ──
|
|
_check("core.select_object (select tool)", core.select_object(obj_id))
|
|
_check("selection reflects it", core.selection.get_selected() == obj_id)
|
|
|
|
# ── 4. Move / rotate / scale via the transform tool ──
|
|
var moved := core.move_object(obj_id, Vector3(5.0, 6.0, 0.0))
|
|
_check("core.move_object (transform tool) applied", moved)
|
|
_check("object position updated", model.find_object(obj_id).position.is_equal_approx(Vector3(5.0, 6.0, 0.0)))
|
|
|
|
var new_rot := Vector4(0.0, 0.7071068, 0.0, 0.7071068) # 90 deg yaw
|
|
var rotated := core.rotate_object(obj_id, new_rot)
|
|
_check("core.rotate_object (transform tool) applied", rotated)
|
|
_check("object rotation updated", model.find_object(obj_id).rotation.is_equal_approx(new_rot))
|
|
|
|
var scaled := core.scale_object(obj_id, Vector3(2.0, 2.0, 2.0))
|
|
_check("core.scale_object (transform tool) applied", scaled)
|
|
_check("object scale updated", model.find_object(obj_id).scale.is_equal_approx(Vector3(2.0, 2.0, 2.0)))
|
|
|
|
# ── 5. Erase a different tile (the fixture already has a nonzero tile at
|
|
# (1,0) -- world_id 12's palette makes it a real, non-empty tile id, so
|
|
# erase has something genuine to clear without needing a manual seed). ──
|
|
var original_tile_1_0 := model.get_tile_raw(0, 1, 0)
|
|
_check("tile (1,0) is nonzero in the fixture (something real to erase)", original_tile_1_0 != 0)
|
|
var erased_tile := core.erase_tile(0, 1, 0)
|
|
_check("core.erase_tile (erase tool) applied", erased_tile)
|
|
_check("tile (1,0) is 0 after erase", model.get_tile_raw(0, 1, 0) == 0)
|
|
|
|
# ── 6. Delete the object via the erase tool ──
|
|
var deleted := core.delete_object(obj_id)
|
|
_check("core.delete_object (erase tool) applied", deleted)
|
|
_check("object is gone from the model", model.find_object(obj_id) == null)
|
|
_check("deleting the selected object cleared the selection", not core.selection.has_selection())
|
|
|
|
# ── 7. Undo every operation (6 ops) and verify each actually reverts ──
|
|
print("--- undo/redo actually revert each step ---")
|
|
_check("undo #1 (re-adds deleted object)", core.undo())
|
|
_check("object exists again after undo", model.find_object(obj_id) != null)
|
|
|
|
_check("undo #2 (un-erases tile)", core.undo())
|
|
_check("tile (1,0) is back to its original value after undo",
|
|
model.get_tile_raw(0, 1, 0) == original_tile_1_0)
|
|
|
|
_check("undo #3 (un-scales)", core.undo())
|
|
_check("scale reverted", model.find_object(obj_id).scale.is_equal_approx(Vector3.ONE))
|
|
|
|
_check("undo #4 (un-rotates)", core.undo())
|
|
_check("rotation reverted", model.find_object(obj_id).rotation.is_equal_approx(Vector4(0, 0, 0, 1)))
|
|
|
|
_check("undo #5 (un-moves)", core.undo())
|
|
_check("position reverted", model.find_object(obj_id).position.is_equal_approx(Vector3(1.5, 1.5, 0.0)))
|
|
|
|
_check("undo #6 (un-places, object removed)", core.undo())
|
|
_check("placed object is gone after undo", model.find_object(obj_id) == null)
|
|
|
|
_check("undo #7 (un-paints tile)", core.undo())
|
|
_check("tile (0,0) is back to 0 after undo", model.get_tile_raw(0, 0, 0) == 0)
|
|
|
|
_check("no more undo history", not core.undo_redo.can_undo())
|
|
_check("tiles exactly match the original fixture after full undo",
|
|
model.get_layer(0).tiles == baseline_tiles)
|
|
_check("object count matches the original fixture after full undo",
|
|
model.payload.objects.size() == baseline_object_count)
|
|
|
|
# ── 8. Redo everything back ──
|
|
print("--- redo replays every step forward again ---")
|
|
var redo_count := 0
|
|
while core.redo():
|
|
redo_count += 1
|
|
_check("redo replayed all 7 recorded actions", redo_count == 7)
|
|
_check("tile (0,0) painted again after full redo", model.get_tile_raw(0, 0, 0) == 1)
|
|
_check("object gone again after full redo (last op was delete)", model.find_object(obj_id) == null)
|
|
|
|
# ── 9. Round-trip the final state through encode/decode ──
|
|
print("--- encode_binary()/decode_binary() round-trip after editing ---")
|
|
var re_encoded := model.encode_binary()
|
|
_check("re-encoded chunk is non-empty", re_encoded.size() > 0)
|
|
var re_decoded := ChunkDataDecoder.decode_binary(re_encoded)
|
|
_check("re-decoded without error", re_decoded != null)
|
|
if re_decoded != null:
|
|
_check("re-decoded tile layer matches edited model",
|
|
re_decoded.tile_layers[0].tiles == model.get_layer(0).tiles)
|
|
_check("re-decoded world_id preserved", re_decoded.world_id == model.payload.world_id)
|
|
_check("re-decoded coord preserved", re_decoded.coord == model.payload.coord)
|
|
_check("re-decoded bounds preserved",
|
|
re_decoded.bounds.position.is_equal_approx(model.payload.bounds.position)
|
|
and re_decoded.bounds.size.is_equal_approx(model.payload.bounds.size))
|
|
_check("re-decoded object count matches", re_decoded.objects.size() == model.payload.objects.size())
|
|
|
|
# ── 10. Also prove a fresh in-memory chunk (no fixture) works end to end,
|
|
# including that the InGameEditor UI wires up against a real EditorCore
|
|
# without crashing headlessly. ──
|
|
await _test_ui_smoke()
|
|
|
|
|
|
func _test_ui_smoke() -> void:
|
|
print("--- InGameEditor UI wires up against a real EditorCore (headless, no crash) ---")
|
|
var model := ChunkEditorModel.create_empty(8, 8, "1", "main", Vector3i.ZERO)
|
|
var catalog := EditorObjectCatalog.new()
|
|
catalog.load_catalog()
|
|
var core := EditorCore.new(model, catalog)
|
|
|
|
var editor := InGameEditor.new(core)
|
|
root.add_child(editor)
|
|
await process_frame # let _ready() run so the UI is actually built
|
|
_check("InGameEditor instantiated and added to the tree without error", is_instance_valid(editor))
|
|
|
|
# Drive a real placement through the viewport's own click handler (not a
|
|
# bare model mutation) to prove the UI layer -> EditorCore wiring works.
|
|
# EditorViewport is nested a few levels down (ScrollContainer, etc.).
|
|
var viewport: EditorViewport = _find_viewport(editor)
|
|
_check("EditorViewport exists in the built UI", viewport != null)
|
|
if viewport != null:
|
|
viewport.pending_catalog_id = "rock_01"
|
|
core.tools.set_active_tool("object_place")
|
|
viewport._handle_click(Vector2(20, 20)) # inside tile (0,0) at cell_size 24
|
|
_check("clicking with object_place active through the viewport placed an object",
|
|
model.payload.objects.size() == 1)
|
|
|
|
editor.queue_free()
|
|
|
|
|
|
func _find_viewport(node: Node) -> EditorViewport:
|
|
if node is EditorViewport:
|
|
return node
|
|
for child in node.get_children():
|
|
var found := _find_viewport(child)
|
|
if found != null:
|
|
return found
|
|
return null
|
|
|
|
|
|
func _test_catalog_rejects_unknown_id() -> void:
|
|
print("--- object_place tool rejects an unknown catalog id ---")
|
|
var model := ChunkEditorModel.create_empty()
|
|
var catalog := EditorObjectCatalog.new()
|
|
catalog.load_catalog()
|
|
var core := EditorCore.new(model, catalog)
|
|
|
|
var result := core.place_object("does_not_exist", Vector3.ZERO)
|
|
_check("unknown catalog_id is rejected (empty id returned)", result == "")
|
|
_check("nothing was added to the model", model.payload.objects.is_empty())
|
|
_check("nothing was pushed onto the undo stack", not core.undo_redo.can_undo())
|
|
|
|
|
|
func _test_selection_is_not_undoable() -> void:
|
|
print("--- selection is UI state, not chunk data (not on the undo stack) ---")
|
|
var model := ChunkEditorModel.create_empty()
|
|
var catalog := EditorObjectCatalog.new()
|
|
catalog.load_catalog()
|
|
var core := EditorCore.new(model, catalog)
|
|
|
|
var obj_id := core.place_object("chest_01", Vector3(2, 2, 0))
|
|
_check("setup: object placed", obj_id != "")
|
|
core.select_object(obj_id)
|
|
_check("object selected", core.selection.get_selected() == obj_id)
|
|
|
|
# Only one undoable action should exist (the placement) -- selecting it
|
|
# must not have added a second one.
|
|
core.undo()
|
|
_check("a single undo removes the placed object (selection wasn't a 2nd undo step)",
|
|
model.find_object(obj_id) == null)
|
|
_check("no further undo history from selection", not core.undo_redo.can_undo())
|