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
+135
View File
@@ -0,0 +1,135 @@
class_name EditorViewport
extends Control
## Custom 2D viewport for the in-game editor (docs/architecture/editor.md
## §7.2 `EditorViewport`).
##
## Deliberately 2D top-down, not a 3D gizmo viewport -- per §8's own risk
## mitigation ("start with a 2D tile editor... matches CHUNK_SIZE_Z=1
## default; add 3D later"). Renders one tile layer as a grid plus the
## chunk's objects as dots, and turns clicks into calls on `EditorCore`
## routed through whichever tool is active in `ToolRegistry` -- this is UI
## glue, it never mutates `ChunkEditorModel` itself.
##
## Coordinate convention matches the rest of this codebase's tile system
## (scripts/tile_layer_node.gd `_tile_to_world`): chunk-local X/Y are the
## flat ground plane, Z is height (CHUNK_SIZE_Z=1 by default -- see
## scripts/chunk.gd). One grid cell == one world unit == `cell_size` pixels.
signal edit_applied
var core: EditorCore = null
var layer_idx: int = 0
var cell_size: float = 24.0
## Catalog id the next "object_place" click will place. Set by
## `InGameEditor` from `PalettePanel.object_selected`.
var pending_catalog_id: String = ""
## Tile id the next "brush" click paints. Cycled with number keys 0-9.
var current_tile_id: int = 1
const TILE_EMPTY_COLOR := Color(0.15, 0.15, 0.17)
const TILE_SOLID_COLOR := Color(0.35, 0.55, 0.35)
const GRID_COLOR := Color(0, 0, 0, 0.25)
const OBJECT_COLOR := Color(0.9, 0.6, 0.2)
const OBJECT_SELECTED_COLOR := Color(1.0, 0.95, 0.2)
func _init() -> void:
name = "EditorViewport"
mouse_filter = Control.MOUSE_FILTER_STOP
focus_mode = Control.FOCUS_ALL
func setup(p_core: EditorCore, p_layer_idx: int = 0) -> void:
core = p_core
layer_idx = p_layer_idx
core.selection.selection_changed.connect(func(_id): queue_redraw())
_resize_to_layer()
queue_redraw()
func _resize_to_layer() -> void:
var layer := core.model.get_layer(layer_idx)
if layer == null:
custom_minimum_size = Vector2(cell_size * 8, cell_size * 8)
return
custom_minimum_size = Vector2(layer.width * cell_size, layer.height * cell_size)
func _draw() -> void:
if core == null:
return
var layer := core.model.get_layer(layer_idx)
if layer == null:
return
for y in range(layer.height):
for x in range(layer.width):
var tile_id := layer.get_tile(x, y, 0)
var color := TILE_EMPTY_COLOR
var tdef := layer.get_tile_def(tile_id)
if tdef != null and tdef.solid:
color = TILE_SOLID_COLOR
draw_rect(Rect2(x * cell_size, y * cell_size, cell_size, cell_size), color, true)
for x in range(layer.width + 1):
draw_line(Vector2(x * cell_size, 0), Vector2(x * cell_size, layer.height * cell_size), GRID_COLOR)
for y in range(layer.height + 1):
draw_line(Vector2(0, y * cell_size), Vector2(layer.width * cell_size, y * cell_size), GRID_COLOR)
for obj in core.model.payload.objects:
var center := Vector2(obj.position.x * cell_size, obj.position.y * cell_size)
var is_selected: bool = core.selection.get_selected() == obj.id
var color := OBJECT_SELECTED_COLOR if is_selected else OBJECT_COLOR
draw_circle(center, cell_size * 0.3, color)
func _gui_input(event: InputEvent) -> void:
if core == null:
return
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.pressed and mb.button_index == MOUSE_BUTTON_LEFT:
_handle_click(mb.position)
accept_event()
elif event is InputEventKey:
var key := event as InputEventKey
if key.pressed and not key.echo:
_handle_key(key)
func _handle_key(key: InputEventKey) -> void:
if key.keycode >= KEY_0 and key.keycode <= KEY_9:
current_tile_id = key.keycode - KEY_0
func _handle_click(local_pos: Vector2) -> void:
var tx := int(floor(local_pos.x / cell_size))
var ty := int(floor(local_pos.y / cell_size))
match core.tools.get_active_tool_id():
"brush":
if core.paint_tile(layer_idx, tx, ty, current_tile_id):
edit_applied.emit()
queue_redraw()
"erase":
var hit := _object_at(tx, ty)
var changed: bool = core.delete_object(hit) if hit != "" else core.erase_tile(layer_idx, tx, ty)
if changed:
edit_applied.emit()
queue_redraw()
"object_place":
if pending_catalog_id != "":
var new_id := core.place_object(pending_catalog_id, Vector3(tx + 0.5, ty + 0.5, 0.0))
if new_id != "":
edit_applied.emit()
queue_redraw()
"select":
core.select_object(_object_at(tx, ty))
queue_redraw()
func _object_at(tx: int, ty: int) -> String:
for obj in core.model.payload.objects:
if int(floor(obj.position.x)) == tx and int(floor(obj.position.y)) == ty:
return obj.id
return ""