Add in-game editor MVP: EditorCore + 2D tile/object editing

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
This commit is contained in:
2026-09-14 23:18:45 +00:00
parent 4c95ad79e6
commit 7c37a0dc2f
22 changed files with 1715 additions and 2 deletions
+61 -2
View File
@@ -28,6 +28,14 @@ var _remotes: Dictionary = {} # peer_id -> Node3D
var _remote_prev: Dictionary = {} # peer_id -> Vector3 (last snapshot position)
var _status_label: Label = null
## Build-mode UI, gated behind toggle_build_mode (F2) -- only ever
## instantiated once the player actually enters build mode
## (docs/architecture/editor.md §7.2: "Instantiated only when the player
## enters 'build mode'... so the runtime footprint is negligible during
## normal play"). Editing is in-memory only here; the save path to the
## backend is a sibling task (world_persistence_impl.md).
var _build_editor: InGameEditor = null
func _ready() -> void:
_show_build_stamp()
@@ -36,13 +44,64 @@ func _ready() -> void:
func _unhandled_input(event: InputEvent) -> void:
# ESC leaves the world back to the browser. Handled here (the scene root
if event.is_action_pressed("toggle_build_mode"):
get_viewport().set_input_as_handled()
_toggle_build_mode()
return
# ESC leaves the world back to the browser (or, while build mode is
# open, just closes build mode instead). Handled here (the scene root
# that owns the connection) rather than in player.gd so the teardown is
# a single ordered path -- doing it from two places raced _on_server_lost
# against the scene change and looked like a crash.
if event.is_action_pressed("ui_cancel"):
get_viewport().set_input_as_handled()
_leave_world()
if _build_editor != null and is_instance_valid(_build_editor):
_close_build_mode()
else:
_leave_world()
## Entry point into the in-game editor (docs/architecture/editor.md §7.2).
## Freezes the player and opens a fresh, empty in-memory chunk to edit --
## loading a *real* chunk from the backend for editing is the sibling
## save-path task's territory (it owns the HTTP round-trip); this task's
## contract stops at "a correctly-editable in-memory ChunkEditorModel".
func _toggle_build_mode() -> void:
if _build_editor != null and is_instance_valid(_build_editor):
_close_build_mode()
else:
_open_build_mode()
func _open_build_mode() -> void:
var model := ChunkEditorModel.create_empty(32, 32, str(Session.world_id), "main")
var catalog := EditorObjectCatalog.new()
var err := catalog.load_catalog()
if err != OK:
push_error("game.gd: failed to load editor object catalog (%d)" % err)
var core := EditorCore.new(model, catalog)
_build_editor = InGameEditor.new(core)
_build_editor.closed.connect(_close_build_mode)
add_child(_build_editor)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_player.set_physics_process(false)
_player.set_process_unhandled_input(false)
## Shared teardown for both close paths (F2 again, or the editor's own
## Close button/`closed` signal) -- safe to call twice: `queue_free()` on an
## already-freeing node and restoring already-restored player state are
## both no-ops.
func _close_build_mode() -> void:
if _build_editor != null and is_instance_valid(_build_editor):
_build_editor.queue_free()
_build_editor = null
_player.set_physics_process(true)
_player.set_process_unhandled_input(true)
_player._refresh_mouse_mode()
func _leave_world() -> void: