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:
@@ -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
|
||||
Reference in New Issue
Block a user