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,68 @@
|
||||
class_name EditorObjectCatalog
|
||||
extends RefCounted
|
||||
## Catalog of placeable world objects for the in-game editor's
|
||||
## `PalettePanel` (docs/architecture/editor.md §7.2).
|
||||
##
|
||||
## Mirrors the loading convention already used twice in this codebase --
|
||||
## `scripts/avatar/avatar_catalog.gd` (AvatarCatalog) and
|
||||
## `scripts/scripting/spawn_catalog.gd` (SpawnCatalog): a data-driven JSON
|
||||
## manifest, loaded once, looked up by id. Checked both before adding this:
|
||||
## `AvatarCatalog` is avatar-attachment-specific (base_body/slot/mount_bone)
|
||||
## and `SpawnCatalog` is scoped to `ScriptBehavior`'s `spawn_from_catalog`
|
||||
## verb specifically (its own doc comment says as much) -- neither is a
|
||||
## general "placeable world object" catalog, so this is a new, small one
|
||||
## rather than a reuse of either.
|
||||
|
||||
## Catalog manifest structure:
|
||||
## { "objects": { "id": { "type", "asset_id", "display_name", ... } } }
|
||||
var _catalog: Dictionary = {}
|
||||
var _loaded: bool = false
|
||||
var _catalog_path: String = "res://assets/editor/object_catalog.json"
|
||||
|
||||
|
||||
func load_catalog(path: String = _catalog_path) -> Error:
|
||||
_catalog_path = path
|
||||
_catalog = {}
|
||||
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
return FileAccess.get_open_error()
|
||||
|
||||
var json_text: String = file.get_as_text()
|
||||
file.close()
|
||||
|
||||
var result: Variant = JSON.parse_string(json_text)
|
||||
if result == null or not (result is Dictionary) or not (result as Dictionary).has("objects"):
|
||||
return ERR_INVALID_DATA
|
||||
|
||||
_catalog = (result as Dictionary)["objects"]
|
||||
_loaded = true
|
||||
return OK
|
||||
|
||||
|
||||
func is_loaded() -> bool:
|
||||
return _loaded
|
||||
|
||||
|
||||
## Resolve a catalog id to its template Dictionary ({} if unknown/unloaded).
|
||||
func resolve(id: String) -> Dictionary:
|
||||
if not _loaded:
|
||||
return {}
|
||||
return _catalog.get(id, {})
|
||||
|
||||
|
||||
func has_object(id: String) -> bool:
|
||||
return _loaded and _catalog.has(id)
|
||||
|
||||
|
||||
func get_all_ids() -> Array[String]:
|
||||
if not _loaded:
|
||||
return []
|
||||
var ids: Array[String] = []
|
||||
for id in _catalog.keys():
|
||||
ids.append(id)
|
||||
return ids
|
||||
|
||||
|
||||
func get_catalog_path() -> String:
|
||||
return _catalog_path
|
||||
Reference in New Issue
Block a user