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
+31
View File
@@ -0,0 +1,31 @@
class_name BrushTool
extends EditorTool
## Paints a single tile id into a tile layer at (x, y[, z]).
##
## `args`: `layer` (int, default 0), `x` (int), `y` (int), `z` (int,
## default 0), `tile_id` (int).
func _init() -> void:
tool_id = "brush"
func apply(core: EditorCore, args: Dictionary) -> Variant:
var layer_idx: int = args.get("layer", 0)
var x: int = args.get("x", 0)
var y: int = args.get("y", 0)
var z: int = args.get("z", 0)
var tile_id: int = args.get("tile_id", 0)
if not core.model.is_tile_in_bounds(layer_idx, x, y, z):
return false
var old_tile_id: int = core.model.get_tile_raw(layer_idx, x, y, z)
if old_tile_id == tile_id:
return false # no-op -- nothing to undo
core.undo_redo.begin("Paint tile (%d,%d,%d) -> %d" % [x, y, z, tile_id])
core.undo_redo.add_do(core.model.set_tile_raw.bind(layer_idx, x, y, z, tile_id))
core.undo_redo.add_undo(core.model.set_tile_raw.bind(layer_idx, x, y, z, old_tile_id))
core.undo_redo.commit()
return true
+20
View File
@@ -0,0 +1,20 @@
class_name EditorTool
extends RefCounted
## Base class for EditorCore tools (docs/architecture/editor.md §7.1
## `ToolRegistry`: brush / object-place / select / erase / transform).
##
## A tool never mutates `ChunkEditorModel` "bare" -- every chunk-data
## mutation goes through `EditorCore.undo_redo` so it is always revertible.
## (Selection is the one exception: it is UI state, not chunk data, and
## `SelectTool` intentionally does not touch undo/redo -- see its comment.)
var tool_id: String = ""
## Apply this tool's action against `core` with `args`. Returns a
## tool-specific result on success (often `true`/a new object id), or a
## falsy value (`false`/`""`/`null`) if nothing changed -- callers should
## treat falsy as "no-op, nothing to undo".
func apply(_core: EditorCore, _args: Dictionary) -> Variant:
push_error("EditorTool.apply() not implemented for tool '%s'" % tool_id)
return null
+41
View File
@@ -0,0 +1,41 @@
class_name EraseTool
extends EditorTool
## Deletes an object, or clears a tile back to id 0 (the reserved empty/air
## id -- world.md §3.2 / TileDef doc comment).
##
## `args`: either `{"object_id": ...}` to delete an object, or
## `{"layer", "x", "y", "z"}` to clear a tile (delegates to BrushTool with
## `tile_id = 0` so both paths share one undo/redo implementation).
func _init() -> void:
tool_id = "erase"
func apply(core: EditorCore, args: Dictionary) -> Variant:
if args.has("object_id"):
return _erase_object(core, args["object_id"])
return _erase_tile(core, args)
func _erase_object(core: EditorCore, object_id: String) -> bool:
var idx: int = core.model.get_object_index(object_id)
if idx == -1:
return false
var obj: ChunkDataDecoder.WorldObjectData = core.model.payload.objects[idx]
core.undo_redo.begin("Delete '%s'" % object_id)
core.undo_redo.add_do(core.model.remove_object_raw.bind(object_id))
core.undo_redo.add_undo(core.model.insert_object_raw.bind(obj, idx))
core.undo_redo.commit()
if core.selection.get_selected() == object_id:
core.selection.clear()
return true
func _erase_tile(core: EditorCore, args: Dictionary) -> bool:
var brush: EditorTool = core.tools.get_tool("brush")
var tile_args: Dictionary = args.duplicate()
tile_args["tile_id"] = 0
return bool(brush.apply(core, tile_args))
+50
View File
@@ -0,0 +1,50 @@
class_name ObjectPlaceTool
extends EditorTool
## Places a new catalog object into the chunk at a given transform.
##
## `args`: `catalog_id` (String, required), `position` (Vector3), `rotation`
## (Vector4 quaternion, optional, default identity), `scale` (Vector3,
## optional, default ONE), `properties` (Dictionary, optional, merged in).
##
## Returns the new object's id (String) on success, or "" on failure
## (unknown catalog_id).
func _init() -> void:
tool_id = "object_place"
func apply(core: EditorCore, args: Dictionary) -> Variant:
var catalog_id: String = args.get("catalog_id", "")
if catalog_id == "":
push_error("ObjectPlaceTool: missing catalog_id")
return ""
var entry: Dictionary = {}
if core.catalog != null:
entry = core.catalog.resolve(catalog_id)
if entry.is_empty():
push_error("ObjectPlaceTool: unknown catalog_id '%s'" % catalog_id)
return ""
var obj := ChunkDataDecoder.WorldObjectData.new()
obj.id = core.model.generate_object_id()
obj.type = entry.get("type", "prop")
obj.asset_id = entry.get("asset_id", catalog_id)
obj.position = args.get("position", Vector3.ZERO)
obj.rotation = args.get("rotation", Vector4(0, 0, 0, 1))
obj.scale = args.get("scale", Vector3.ONE)
var props: Dictionary = (args.get("properties", {}) as Dictionary).duplicate()
if entry.has("display_name"):
props["display_name"] = entry["display_name"]
props["catalog_id"] = catalog_id
obj.properties = props
core.undo_redo.begin("Place '%s' (%s)" % [obj.id, catalog_id])
core.undo_redo.add_do(core.model.add_object_raw.bind(obj))
core.undo_redo.add_undo(core.model.remove_object_raw.bind(obj.id))
core.undo_redo.commit()
core.selection.select(obj.id)
return obj.id
+24
View File
@@ -0,0 +1,24 @@
class_name SelectTool
extends EditorTool
## Selects (or clears) the active object in `EditorCore.selection`.
##
## `args`: `object_id` (String) -- omitted or "" clears the selection.
##
## Selection is UI/session state, not chunk data (docs/architecture/editor.md
## §7.1 lists `SelectionManager` as a sibling of `UndoRedoStack`, not a
## client of it), so this intentionally never touches `core.undo_redo`.
func _init() -> void:
tool_id = "select"
func apply(core: EditorCore, args: Dictionary) -> Variant:
var object_id: String = args.get("object_id", "")
if object_id == "":
core.selection.clear()
return true
if core.model.find_object(object_id) == null:
return false
core.selection.select(object_id)
return true
+62
View File
@@ -0,0 +1,62 @@
class_name TransformTool
extends EditorTool
## Moves, rotates, or scales an existing object.
##
## Not one of the four tools literally named in docs/architecture/editor.md
## §7.1's `ToolRegistry` list ("brush / object-place / select / erase,
## etc.") but required by this task's brief ("place / move / rotate / scale
## / delete") -- the doc's own "etc." leaves room, so this is a fifth
## registered tool rather than overloading `ObjectPlaceTool` (placing a new
## object and re-transforming an existing one are different undo units)
## or `SelectTool` (selection is deliberately not chunk-mutating).
##
## `args`: `object_id` (String), `op` ("move" | "rotate" | "scale"), `value`
## (Vector3 for move/scale, Vector4 quaternion for rotate).
func _init() -> void:
tool_id = "transform"
func apply(core: EditorCore, args: Dictionary) -> Variant:
var object_id: String = args.get("object_id", "")
var op: String = args.get("op", "")
var obj := core.model.find_object(object_id)
if obj == null:
return false
match op:
"move":
return _apply_vector3(core, object_id, args.get("value", obj.position),
obj.position, "Move", core.model.set_object_position_raw)
"rotate":
return _apply_vector4(core, object_id, args.get("value", obj.rotation),
obj.rotation, "Rotate", core.model.set_object_rotation_raw)
"scale":
return _apply_vector3(core, object_id, args.get("value", obj.scale),
obj.scale, "Scale", core.model.set_object_scale_raw)
_:
push_error("TransformTool: unknown op '%s'" % op)
return false
func _apply_vector3(core: EditorCore, object_id: String, new_value: Vector3,
old_value: Vector3, label: String, setter: Callable) -> bool:
if new_value.is_equal_approx(old_value):
return false
core.undo_redo.begin("%s '%s'" % [label, object_id])
core.undo_redo.add_do(setter.bind(object_id, new_value))
core.undo_redo.add_undo(setter.bind(object_id, old_value))
core.undo_redo.commit()
return true
func _apply_vector4(core: EditorCore, object_id: String, new_value: Vector4,
old_value: Vector4, label: String, setter: Callable) -> bool:
if new_value.is_equal_approx(old_value):
return false
core.undo_redo.begin("%s '%s'" % [label, object_id])
core.undo_redo.add_do(setter.bind(object_id, new_value))
core.undo_redo.add_undo(setter.bind(object_id, old_value))
core.undo_redo.commit()
return true