Files
daddy 7c37a0dc2f 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
2026-09-14 23:18:45 +00:00

32 lines
985 B
GDScript

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