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
+169
View File
@@ -0,0 +1,169 @@
class_name InGameEditor
extends Control
## In-game "build mode" UI (docs/architecture/editor.md §7.2 `InGameEditor`).
##
## Built entirely from `Control` nodes in code (matching this codebase's
## existing convention for dynamic UI -- see `game.gd`'s `_show_build_stamp`
## / `_show_status`) rather than a hand-authored `.tscn`, so there is one
## fewer place for a scene-file/script mismatch to hide. It is only ever
## instantiated when the player enters build mode (see `game.gd`'s
## `_toggle_build_mode`), so it costs nothing during normal play.
##
## Composition matches the doc exactly:
## EditorToolbar -- tool mode switcher
## PalettePanel -- catalog-sourced object palette
## EditorViewport -- the 2D tile/object canvas
## InspectorPanel -- property editor for the selected object
## EditorCore -- shared core (passed in, not owned/created here)
##
## This UI (and `EditorCore`) never talk to the network -- persisting edits
## is a sibling task's job (world_persistence_impl.md's batch-PUT save
## path). Closing this editor just discards the UI; the in-memory
## `ChunkEditorModel` it was pointed at is unaffected either way.
signal closed
var core: EditorCore
var _toolbar: EditorToolbar
var _palette: PalettePanel
var _inspector: InspectorPanel
var _viewport: EditorViewport
var _close_button: Button
func _init(p_core: EditorCore) -> void:
core = p_core
name = "InGameEditor"
set_anchors_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_STOP
func _ready() -> void:
_build_ui()
_refresh_history_buttons()
func _build_ui() -> void:
var bg := ColorRect.new()
bg.color = Color(0.08, 0.08, 0.09, 0.96)
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(bg)
var root_vbox := VBoxContainer.new()
root_vbox.set_anchors_preset(Control.PRESET_FULL_RECT)
root_vbox.offset_left = 12
root_vbox.offset_top = 12
root_vbox.offset_right = -12
root_vbox.offset_bottom = -12
add_child(root_vbox)
var top_bar := HBoxContainer.new()
root_vbox.add_child(top_bar)
_toolbar = EditorToolbar.new()
_toolbar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
top_bar.add_child(_toolbar)
_close_button = Button.new()
_close_button.text = "Close (F2)"
_close_button.pressed.connect(_on_close_pressed)
top_bar.add_child(_close_button)
var body := HBoxContainer.new()
body.size_flags_vertical = Control.SIZE_EXPAND_FILL
root_vbox.add_child(body)
var viewport_scroll := ScrollContainer.new()
viewport_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
viewport_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
body.add_child(viewport_scroll)
_viewport = EditorViewport.new()
viewport_scroll.add_child(_viewport)
var sidebar := VBoxContainer.new()
sidebar.custom_minimum_size = Vector2(240, 0)
body.add_child(sidebar)
_palette = PalettePanel.new()
sidebar.add_child(_palette)
_inspector = InspectorPanel.new()
sidebar.add_child(_inspector)
_viewport.setup(core, 0)
_palette.set_catalog(core.catalog)
_inspector.setup(core)
_toolbar.set_active_tool(core.tools.get_active_tool_id())
_toolbar.tool_selected.connect(_on_tool_selected)
_toolbar.undo_pressed.connect(_on_undo_pressed)
_toolbar.redo_pressed.connect(_on_redo_pressed)
_palette.object_selected.connect(_on_palette_object_selected)
_inspector.delete_requested.connect(_on_inspector_delete_requested)
_viewport.edit_applied.connect(_on_edit_applied)
core.selection.selection_changed.connect(_on_selection_changed)
func _unhandled_key_input(event: InputEvent) -> void:
if not (event is InputEventKey):
return
var key := event as InputEventKey
if not key.pressed or key.echo:
return
if key.ctrl_pressed and key.keycode == KEY_Z:
_on_undo_pressed()
get_viewport().set_input_as_handled()
elif key.ctrl_pressed and key.keycode == KEY_Y:
_on_redo_pressed()
get_viewport().set_input_as_handled()
elif key.keycode == KEY_DELETE and core.selection.has_selection():
core.delete_object(core.selection.get_selected())
_on_edit_applied()
get_viewport().set_input_as_handled()
func _on_tool_selected(tool_id: String) -> void:
core.tools.set_active_tool(tool_id)
func _on_palette_object_selected(catalog_id: String) -> void:
_viewport.pending_catalog_id = catalog_id
core.tools.set_active_tool("object_place")
_toolbar.set_active_tool("object_place")
func _on_inspector_delete_requested(object_id: String) -> void:
core.delete_object(object_id)
_on_edit_applied()
func _on_undo_pressed() -> void:
core.undo()
_on_edit_applied()
func _on_redo_pressed() -> void:
core.redo()
_on_edit_applied()
func _on_edit_applied() -> void:
_viewport.queue_redraw()
_inspector.show_object(core.selection.get_selected())
_refresh_history_buttons()
func _on_selection_changed(object_id: String) -> void:
_inspector.show_object(object_id)
func _refresh_history_buttons() -> void:
_toolbar.set_history_state(core.undo_redo.can_undo(), core.undo_redo.can_redo())
func _on_close_pressed() -> void:
closed.emit()
queue_free()