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
+114
View File
@@ -0,0 +1,114 @@
class_name EditorCore
extends RefCounted
## Shared, data-native editor core (docs/architecture/editor.md §7.1).
##
## Both the in-game editor UI (`scripts/editor/ui/in_game_editor.gd`) and,
## per the doc's §6.2/§7.3, any future thin dev-plugin surface are meant to
## sit on top of this single object. It never touches `Control` nodes,
## HTTP, or the live scene tree -- it only knows about the chunk model,
## undo/redo, selection, and tools. It owns:
##
## - `model` (ChunkEditorModel) the chunk being edited
## - `undo_redo` (EditorUndoRedoStack) command-pattern history
## - `selection` (SelectionManager) selected object id
## - `tools` (ToolRegistry) brush / object_place / select /
## erase / transform
## - `catalog` (EditorObjectCatalog, optional) palette source consumed
## by `object_place`
##
## Every mutating public method here (`paint_tile`, `erase_tile`,
## `place_object`, `delete_object`, `move_object`, `rotate_object`,
## `scale_object`, `select_object`) is a thin wrapper around `use_tool()` --
## it goes through `ToolRegistry`, never straight at `ChunkEditorModel`.
## This is the seam this task's verification bar cares about: UI code and
## tests mutate the chunk exactly the same way, so a test that calls
## `core.move_object(...)` is exercising the same code path a player's
## drag-in-the-viewport would.
##
## Persisting the edited chunk to the backend is explicitly **out of
## scope** here (a sibling task owns the batch-PUT/conflict-handling save
## path per world_persistence_impl.md) -- `model.encode_binary()` is as far
## as this class goes.
var model: ChunkEditorModel
var undo_redo: EditorUndoRedoStack
var selection: SelectionManager
var tools: ToolRegistry
var catalog: EditorObjectCatalog = null
func _init(p_model: ChunkEditorModel, p_catalog: EditorObjectCatalog = null) -> void:
model = p_model
catalog = p_catalog
undo_redo = EditorUndoRedoStack.new()
selection = SelectionManager.new()
tools = ToolRegistry.new()
tools.register_tool(BrushTool.new())
tools.register_tool(ObjectPlaceTool.new())
tools.register_tool(SelectTool.new())
tools.register_tool(EraseTool.new())
tools.register_tool(TransformTool.new())
tools.set_active_tool("select")
## Route an action through the named tool. This is the single choke point
## every mutation (and every convenience wrapper below) goes through.
func use_tool(tool_id: String, args: Dictionary) -> Variant:
var tool: EditorTool = tools.get_tool(tool_id)
if tool == null:
push_error("EditorCore: unknown tool '%s'" % tool_id)
return null
return tool.apply(self, args)
# ───────── convenience wrappers (all route through use_tool/ToolRegistry) ─────────
func paint_tile(layer_idx: int, x: int, y: int, tile_id: int, z: int = 0) -> bool:
return bool(use_tool("brush", {"layer": layer_idx, "x": x, "y": y, "z": z, "tile_id": tile_id}))
func erase_tile(layer_idx: int, x: int, y: int, z: int = 0) -> bool:
return bool(use_tool("erase", {"layer": layer_idx, "x": x, "y": y, "z": z}))
func place_object(catalog_id: String, position: Vector3,
rotation: Vector4 = Vector4(0, 0, 0, 1), scale: Vector3 = Vector3.ONE) -> String:
var result: Variant = use_tool("object_place", {
"catalog_id": catalog_id,
"position": position,
"rotation": rotation,
"scale": scale,
})
return result if result is String else ""
func delete_object(object_id: String) -> bool:
return bool(use_tool("erase", {"object_id": object_id}))
func move_object(object_id: String, position: Vector3) -> bool:
return bool(use_tool("transform", {"object_id": object_id, "op": "move", "value": position}))
func rotate_object(object_id: String, rotation: Vector4) -> bool:
return bool(use_tool("transform", {"object_id": object_id, "op": "rotate", "value": rotation}))
func scale_object(object_id: String, scale: Vector3) -> bool:
return bool(use_tool("transform", {"object_id": object_id, "op": "scale", "value": scale}))
func select_object(object_id: String) -> bool:
return bool(use_tool("select", {"object_id": object_id}))
func clear_selection() -> void:
use_tool("select", {})
func undo() -> bool:
return undo_redo.undo()
func redo() -> bool:
return undo_redo.redo()