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
+190
View File
@@ -0,0 +1,190 @@
class_name ChunkEditorModel
extends RefCounted
## In-memory chunk being edited (docs/architecture/editor.md §7.1's
## `ChunkEditorModel`).
##
## Wraps a `ChunkDataDecoder.ChunkPayload` directly rather than inventing a
## parallel data model -- that payload is already the exact structure that
## round-trips through `ChunkDataDecoder.encode_binary()` /
## `decode_binary()` (world.md §3.6, and the interop bugs fixed in that file
## are proof this contract is exercised for real). Editing this model and
## re-encoding it must always produce a valid, byte-compatible chunk, since
## the sibling save-path task depends on that being true.
##
## Every method here is a "raw" mutator: it changes the payload and returns
## whatever an undo needs to reverse the change, but it does **not** know
## about undo/redo itself. `scripts/editor/editor_core.gd`'s tools
## (scripts/editor/tools/*.gd) are the only things that call these -- they
## wrap each call in an `EditorUndoRedoStack` action. Nothing here should be
## called directly by UI code; that would bypass undo/redo silently.
var payload: ChunkDataDecoder.ChunkPayload
## Monotonic counter seeded from the highest existing "obj_NNNN"-style
## numeric suffix already in the chunk, so freshly generated ids never
## collide with ids that arrived from a decoded chunk.
var _next_object_seq: int = 0
func _init(p_payload: ChunkDataDecoder.ChunkPayload = null) -> void:
payload = p_payload if p_payload != null else ChunkDataDecoder.ChunkPayload.new()
_seed_object_sequence()
func _seed_object_sequence() -> void:
for obj in payload.objects:
var n := _numeric_suffix(obj.id)
if n >= _next_object_seq:
_next_object_seq = n + 1
static func _numeric_suffix(id: String) -> int:
var digits := ""
for i in range(id.length() - 1, -1, -1):
var c: String = id[i]
if c.is_valid_int():
digits = c + digits
else:
break
return int(digits) if digits != "" else -1
## Build a fresh, empty single-layer chunk model -- convenient for tests and
## for a "new chunk" starting point in the in-game editor before a real
## chunk has been loaded from the backend (loading real chunks over HTTP is
## HttpChunkSource's job; this just gives the editor something valid to
## operate on).
static func create_empty(width: int = 32, height: int = 32, world_id: String = "",
room_id: String = "main", coord: Vector3i = Vector3i.ZERO) -> ChunkEditorModel:
var layer := ChunkDataDecoder.TileLayerData.new()
layer.name = "ground"
layer.layer_id = 0
layer.width = width
layer.height = height
layer.depth = 1
layer.palette = [
TileDef.new(0, "", false, 0, 1),
TileDef.new(1, "tiles/default", true, 0, 1),
]
var tiles := PackedInt32Array()
tiles.resize(width * height)
layer.tiles = tiles
var payload := ChunkDataDecoder.ChunkPayload.new()
payload.world_id = world_id
payload.room_id = room_id
payload.coord = coord
payload.bounds = AABB(Vector3(coord.x * width, coord.y * height, coord.z), Vector3(width, height, 1))
payload.tile_layers = [layer]
payload.revision = 0
payload.created_at = Time.get_unix_time_from_system()
payload.updated_at = payload.created_at
return ChunkEditorModel.new(payload)
static func from_binary(data: PackedByteArray) -> ChunkEditorModel:
var decoded := ChunkDataDecoder.decode_binary(data)
if decoded == null:
return null
return ChunkEditorModel.new(decoded)
func encode_binary() -> PackedByteArray:
return ChunkDataDecoder.encode_binary(payload)
# ───────────────────────── tiles ─────────────────────────
func get_layer(layer_idx: int) -> ChunkDataDecoder.TileLayerData:
if layer_idx < 0 or layer_idx >= payload.tile_layers.size():
return null
return payload.tile_layers[layer_idx]
func is_tile_in_bounds(layer_idx: int, x: int, y: int, z: int = 0) -> bool:
var layer := get_layer(layer_idx)
if layer == null:
return false
return x >= 0 and x < layer.width and y >= 0 and y < layer.height and z >= 0 and z < layer.depth
func get_tile_raw(layer_idx: int, x: int, y: int, z: int = 0) -> int:
var layer := get_layer(layer_idx)
if layer == null:
return 0
return layer.get_tile(x, y, z)
## Raw setter -- undo-unaware. Only ever called (in both directions) by
## BrushTool/EraseTool via EditorUndoRedoStack.
func set_tile_raw(layer_idx: int, x: int, y: int, z: int, tile_id: int) -> void:
var layer := get_layer(layer_idx)
if layer == null:
return
layer.set_tile(x, y, z, tile_id)
# ───────────────────────── objects ─────────────────────────
func find_object(object_id: String) -> ChunkDataDecoder.WorldObjectData:
for obj in payload.objects:
if obj.id == object_id:
return obj
return null
func get_object_index(object_id: String) -> int:
for i in range(payload.objects.size()):
if payload.objects[i].id == object_id:
return i
return -1
func generate_object_id() -> String:
var id := "obj_%04d" % _next_object_seq
_next_object_seq += 1
return id
## Raw mutators -- undo-unaware. Only ever called (in both directions) by
## ObjectPlaceTool/EraseTool/TransformTool via EditorUndoRedoStack.
func add_object_raw(obj: ChunkDataDecoder.WorldObjectData) -> void:
payload.objects.append(obj)
func insert_object_raw(obj: ChunkDataDecoder.WorldObjectData, index: int) -> void:
var clamped: int = clampi(index, 0, payload.objects.size())
payload.objects.insert(clamped, obj)
func remove_object_raw(object_id: String) -> ChunkDataDecoder.WorldObjectData:
var idx := get_object_index(object_id)
if idx == -1:
return null
var obj := payload.objects[idx]
payload.objects.remove_at(idx)
return obj
func set_object_position_raw(object_id: String, position: Vector3) -> void:
var obj := find_object(object_id)
if obj != null:
obj.position = position
func set_object_rotation_raw(object_id: String, rotation: Vector4) -> void:
var obj := find_object(object_id)
if obj != null:
obj.rotation = rotation
func set_object_scale_raw(object_id: String, scale: Vector3) -> void:
var obj := find_object(object_id)
if obj != null:
obj.scale = scale
func set_object_property_raw(object_id: String, key: String, value: Variant) -> void:
var obj := find_object(object_id)
if obj != null:
obj.properties[key] = value