diff --git a/assets/editor/object_catalog.json b/assets/editor/object_catalog.json new file mode 100644 index 0000000..e27dd11 --- /dev/null +++ b/assets/editor/object_catalog.json @@ -0,0 +1,40 @@ +{ + "objects": { + "crate_small": { + "asset_id": "props/crate_small.glb", + "type": "prop", + "display_name": "Small Crate", + "category": "props" + }, + "rock_01": { + "asset_id": "props/rock_01.glb", + "type": "prop", + "display_name": "Rock", + "category": "nature" + }, + "tree_pine_01": { + "asset_id": "props/tree_pine_01.glb", + "type": "prop", + "display_name": "Pine Tree", + "category": "nature" + }, + "lamp_post_01": { + "asset_id": "props/lamp_post_01.glb", + "type": "prop", + "display_name": "Lamp Post", + "category": "props" + }, + "chest_01": { + "asset_id": "props/chest.glb", + "type": "interactable", + "display_name": "Treasure Chest", + "category": "interactables" + }, + "spawn_point": { + "asset_id": "", + "type": "spawn_point", + "display_name": "Spawn Point", + "category": "gameplay" + } + } +} diff --git a/project.godot b/project.godot index 8a1c16a..a1fabec 100644 --- a/project.godot +++ b/project.godot @@ -50,6 +50,11 @@ interact={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null) ] } +toggle_build_mode={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194333,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} [physics] diff --git a/scripts/chunk_data_decoder.gd b/scripts/chunk_data_decoder.gd index e79280b..e22c238 100644 --- a/scripts/chunk_data_decoder.gd +++ b/scripts/chunk_data_decoder.gd @@ -100,6 +100,17 @@ class TileLayerData: return tiles[idx] return 0 + ## Editor mutator: writes `tile_id` at (x, y, z). Silently no-ops out of + ## bounds, mirroring get_tile's "out of bounds is empty" contract rather + ## than erroring -- callers that need bounds feedback should check first + ## (see ChunkEditorModel.is_tile_in_bounds in scripts/editor/). + func set_tile(x: int, y: int, z: int, tile_id: int) -> void: + if x < 0 or x >= width or y < 0 or y >= height or z < 0 or z >= depth: + return + var idx := (z * height + y) * width + x + if idx < tiles.size(): + tiles[idx] = tile_id + func get_tile_def(tile_id: int) -> TileDef: for def in palette: if def.id == tile_id: diff --git a/scripts/editor/chunk_editor_model.gd b/scripts/editor/chunk_editor_model.gd new file mode 100644 index 0000000..a1395a9 --- /dev/null +++ b/scripts/editor/chunk_editor_model.gd @@ -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 diff --git a/scripts/editor/editor_core.gd b/scripts/editor/editor_core.gd new file mode 100644 index 0000000..e02312f --- /dev/null +++ b/scripts/editor/editor_core.gd @@ -0,0 +1,114 @@ +class_name EditorCore +extends RefCounted +## Shared, data-native editor core (docs/architecture/editor.md §7.1). +## +## Both the in-game editor UI (`scripts/editor/ui/in_game_editor.gd`) and, +## per the doc's §6.2/§7.3, any future thin dev-plugin surface are meant to +## sit on top of this single object. It never touches `Control` nodes, +## HTTP, or the live scene tree -- it only knows about the chunk model, +## undo/redo, selection, and tools. It owns: +## +## - `model` (ChunkEditorModel) the chunk being edited +## - `undo_redo` (EditorUndoRedoStack) command-pattern history +## - `selection` (SelectionManager) selected object id +## - `tools` (ToolRegistry) brush / object_place / select / +## erase / transform +## - `catalog` (EditorObjectCatalog, optional) palette source consumed +## by `object_place` +## +## Every mutating public method here (`paint_tile`, `erase_tile`, +## `place_object`, `delete_object`, `move_object`, `rotate_object`, +## `scale_object`, `select_object`) is a thin wrapper around `use_tool()` -- +## it goes through `ToolRegistry`, never straight at `ChunkEditorModel`. +## This is the seam this task's verification bar cares about: UI code and +## tests mutate the chunk exactly the same way, so a test that calls +## `core.move_object(...)` is exercising the same code path a player's +## drag-in-the-viewport would. +## +## Persisting the edited chunk to the backend is explicitly **out of +## scope** here (a sibling task owns the batch-PUT/conflict-handling save +## path per world_persistence_impl.md) -- `model.encode_binary()` is as far +## as this class goes. + +var model: ChunkEditorModel +var undo_redo: EditorUndoRedoStack +var selection: SelectionManager +var tools: ToolRegistry +var catalog: EditorObjectCatalog = null + + +func _init(p_model: ChunkEditorModel, p_catalog: EditorObjectCatalog = null) -> void: + model = p_model + catalog = p_catalog + undo_redo = EditorUndoRedoStack.new() + selection = SelectionManager.new() + tools = ToolRegistry.new() + tools.register_tool(BrushTool.new()) + tools.register_tool(ObjectPlaceTool.new()) + tools.register_tool(SelectTool.new()) + tools.register_tool(EraseTool.new()) + tools.register_tool(TransformTool.new()) + tools.set_active_tool("select") + + +## Route an action through the named tool. This is the single choke point +## every mutation (and every convenience wrapper below) goes through. +func use_tool(tool_id: String, args: Dictionary) -> Variant: + var tool: EditorTool = tools.get_tool(tool_id) + if tool == null: + push_error("EditorCore: unknown tool '%s'" % tool_id) + return null + return tool.apply(self, args) + + +# ───────── convenience wrappers (all route through use_tool/ToolRegistry) ───────── + +func paint_tile(layer_idx: int, x: int, y: int, tile_id: int, z: int = 0) -> bool: + return bool(use_tool("brush", {"layer": layer_idx, "x": x, "y": y, "z": z, "tile_id": tile_id})) + + +func erase_tile(layer_idx: int, x: int, y: int, z: int = 0) -> bool: + return bool(use_tool("erase", {"layer": layer_idx, "x": x, "y": y, "z": z})) + + +func place_object(catalog_id: String, position: Vector3, + rotation: Vector4 = Vector4(0, 0, 0, 1), scale: Vector3 = Vector3.ONE) -> String: + var result: Variant = use_tool("object_place", { + "catalog_id": catalog_id, + "position": position, + "rotation": rotation, + "scale": scale, + }) + return result if result is String else "" + + +func delete_object(object_id: String) -> bool: + return bool(use_tool("erase", {"object_id": object_id})) + + +func move_object(object_id: String, position: Vector3) -> bool: + return bool(use_tool("transform", {"object_id": object_id, "op": "move", "value": position})) + + +func rotate_object(object_id: String, rotation: Vector4) -> bool: + return bool(use_tool("transform", {"object_id": object_id, "op": "rotate", "value": rotation})) + + +func scale_object(object_id: String, scale: Vector3) -> bool: + return bool(use_tool("transform", {"object_id": object_id, "op": "scale", "value": scale})) + + +func select_object(object_id: String) -> bool: + return bool(use_tool("select", {"object_id": object_id})) + + +func clear_selection() -> void: + use_tool("select", {}) + + +func undo() -> bool: + return undo_redo.undo() + + +func redo() -> bool: + return undo_redo.redo() diff --git a/scripts/editor/editor_undo_redo_stack.gd b/scripts/editor/editor_undo_redo_stack.gd new file mode 100644 index 0000000..b24ca86 --- /dev/null +++ b/scripts/editor/editor_undo_redo_stack.gd @@ -0,0 +1,73 @@ +class_name EditorUndoRedoStack +extends RefCounted +## Command pattern over ChunkEditorModel mutations +## (docs/architecture/editor.md §7.1 `UndoRedoStack`). +## +## Thin wrapper around Godot's built-in `UndoRedo` -- the general-purpose +## class meant for use in game code (unlike the editor-only +## `EditorUndoRedoManager`, which only exists inside `EditorPlugin`s and +## isn't available at runtime). Every EditorCore tool (scripts/editor/tools/ +## *.gd) opens exactly one action per user operation and adds a do/undo +## Callable pair that calls the *same* ChunkEditorModel raw setter in both +## directions (e.g. `set_tile_raw` with the new id, then again with the old +## one) -- so undo genuinely reverts the mutation instead of re-deriving it. +## +## `UndoRedo` extends `Object`, not `RefCounted`, so it is not automatically +## freed; `_notification(NOTIFICATION_PREDELETE)` frees it when this wrapper +## (a RefCounted) goes out of scope. + +var _ur := UndoRedo.new() + + +func _notification(what: int) -> void: + if what == NOTIFICATION_PREDELETE and _ur != null: + _ur.free() + _ur = null + + +## Begin a new undoable action. Must be paired with `commit()`. +func begin(action_name: String) -> void: + _ur.create_action(action_name) + + +## Register the forward step of the current action. Called immediately by +## `commit()`, and again on every future `redo()`. +func add_do(callable: Callable) -> void: + _ur.add_do_method(callable) + + +## Register the reverse step of the current action. Called on every +## `undo()`. +func add_undo(callable: Callable) -> void: + _ur.add_undo_method(callable) + + +## Close the current action and execute its do-step once. +func commit() -> void: + _ur.commit_action() + + +## Close the current action without executing anything (used when a tool +## decides mid-way that there is nothing worth recording). +func cancel() -> void: + _ur.commit_action(false) + + +func undo() -> bool: + return _ur.undo() + + +func redo() -> bool: + return _ur.redo() + + +func can_undo() -> bool: + return _ur.has_undo() + + +func can_redo() -> bool: + return _ur.has_redo() + + +func clear_history() -> void: + _ur.clear_history() diff --git a/scripts/editor/object_catalog.gd b/scripts/editor/object_catalog.gd new file mode 100644 index 0000000..469f7b7 --- /dev/null +++ b/scripts/editor/object_catalog.gd @@ -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 diff --git a/scripts/editor/selection_manager.gd b/scripts/editor/selection_manager.gd new file mode 100644 index 0000000..19ed66c --- /dev/null +++ b/scripts/editor/selection_manager.gd @@ -0,0 +1,32 @@ +class_name SelectionManager +extends RefCounted +## Tracks the currently-selected object in the in-game editor +## (docs/architecture/editor.md §7.1 `SelectionManager`). +## +## Selection is UI/session state, not chunk data: it is never pushed onto +## `EditorUndoRedoStack` and never encoded into the `ChunkPayload`. Scoped +## to a single selected object id, matching the 2D-tile-editor scope +## (docs/architecture/editor.md §8) -- multi-select is future work. + +signal selection_changed(object_id: String) + +var _selected_id: String = "" + + +func select(object_id: String) -> void: + if _selected_id == object_id: + return + _selected_id = object_id + selection_changed.emit(_selected_id) + + +func clear() -> void: + select("") + + +func get_selected() -> String: + return _selected_id + + +func has_selection() -> bool: + return _selected_id != "" diff --git a/scripts/editor/tool_registry.gd b/scripts/editor/tool_registry.gd new file mode 100644 index 0000000..a661dd0 --- /dev/null +++ b/scripts/editor/tool_registry.gd @@ -0,0 +1,41 @@ +class_name ToolRegistry +extends RefCounted +## Registry of `EditorTool` instances, keyed by id +## (docs/architecture/editor.md §7.1: "brush / object-place / select / +## erase, etc."). `EditorCore.use_tool()` is the single choke point every +## chunk mutation goes through -- UI code and tests both call tools by id +## through here rather than reaching into `ChunkEditorModel` directly. + +var _tools: Dictionary = {} +var _active_id: String = "" + + +func register_tool(tool: EditorTool) -> void: + _tools[tool.tool_id] = tool + + +func get_tool(tool_id: String) -> EditorTool: + return _tools.get(tool_id, null) + + +func has_tool(tool_id: String) -> bool: + return _tools.has(tool_id) + + +func get_tool_ids() -> Array: + return _tools.keys() + + +func set_active_tool(tool_id: String) -> bool: + if not _tools.has(tool_id): + return false + _active_id = tool_id + return true + + +func get_active_tool_id() -> String: + return _active_id + + +func get_active_tool() -> EditorTool: + return _tools.get(_active_id, null) diff --git a/scripts/editor/tools/brush_tool.gd b/scripts/editor/tools/brush_tool.gd new file mode 100644 index 0000000..c278a6b --- /dev/null +++ b/scripts/editor/tools/brush_tool.gd @@ -0,0 +1,31 @@ +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 diff --git a/scripts/editor/tools/editor_tool.gd b/scripts/editor/tools/editor_tool.gd new file mode 100644 index 0000000..72fea92 --- /dev/null +++ b/scripts/editor/tools/editor_tool.gd @@ -0,0 +1,20 @@ +class_name EditorTool +extends RefCounted +## Base class for EditorCore tools (docs/architecture/editor.md §7.1 +## `ToolRegistry`: brush / object-place / select / erase / transform). +## +## A tool never mutates `ChunkEditorModel` "bare" -- every chunk-data +## mutation goes through `EditorCore.undo_redo` so it is always revertible. +## (Selection is the one exception: it is UI state, not chunk data, and +## `SelectTool` intentionally does not touch undo/redo -- see its comment.) + +var tool_id: String = "" + + +## Apply this tool's action against `core` with `args`. Returns a +## tool-specific result on success (often `true`/a new object id), or a +## falsy value (`false`/`""`/`null`) if nothing changed -- callers should +## treat falsy as "no-op, nothing to undo". +func apply(_core: EditorCore, _args: Dictionary) -> Variant: + push_error("EditorTool.apply() not implemented for tool '%s'" % tool_id) + return null diff --git a/scripts/editor/tools/erase_tool.gd b/scripts/editor/tools/erase_tool.gd new file mode 100644 index 0000000..7ce893e --- /dev/null +++ b/scripts/editor/tools/erase_tool.gd @@ -0,0 +1,41 @@ +class_name EraseTool +extends EditorTool +## Deletes an object, or clears a tile back to id 0 (the reserved empty/air +## id -- world.md §3.2 / TileDef doc comment). +## +## `args`: either `{"object_id": ...}` to delete an object, or +## `{"layer", "x", "y", "z"}` to clear a tile (delegates to BrushTool with +## `tile_id = 0` so both paths share one undo/redo implementation). + + +func _init() -> void: + tool_id = "erase" + + +func apply(core: EditorCore, args: Dictionary) -> Variant: + if args.has("object_id"): + return _erase_object(core, args["object_id"]) + return _erase_tile(core, args) + + +func _erase_object(core: EditorCore, object_id: String) -> bool: + var idx: int = core.model.get_object_index(object_id) + if idx == -1: + return false + var obj: ChunkDataDecoder.WorldObjectData = core.model.payload.objects[idx] + + core.undo_redo.begin("Delete '%s'" % object_id) + core.undo_redo.add_do(core.model.remove_object_raw.bind(object_id)) + core.undo_redo.add_undo(core.model.insert_object_raw.bind(obj, idx)) + core.undo_redo.commit() + + if core.selection.get_selected() == object_id: + core.selection.clear() + return true + + +func _erase_tile(core: EditorCore, args: Dictionary) -> bool: + var brush: EditorTool = core.tools.get_tool("brush") + var tile_args: Dictionary = args.duplicate() + tile_args["tile_id"] = 0 + return bool(brush.apply(core, tile_args)) diff --git a/scripts/editor/tools/object_place_tool.gd b/scripts/editor/tools/object_place_tool.gd new file mode 100644 index 0000000..0485066 --- /dev/null +++ b/scripts/editor/tools/object_place_tool.gd @@ -0,0 +1,50 @@ +class_name ObjectPlaceTool +extends EditorTool +## Places a new catalog object into the chunk at a given transform. +## +## `args`: `catalog_id` (String, required), `position` (Vector3), `rotation` +## (Vector4 quaternion, optional, default identity), `scale` (Vector3, +## optional, default ONE), `properties` (Dictionary, optional, merged in). +## +## Returns the new object's id (String) on success, or "" on failure +## (unknown catalog_id). + + +func _init() -> void: + tool_id = "object_place" + + +func apply(core: EditorCore, args: Dictionary) -> Variant: + var catalog_id: String = args.get("catalog_id", "") + if catalog_id == "": + push_error("ObjectPlaceTool: missing catalog_id") + return "" + + var entry: Dictionary = {} + if core.catalog != null: + entry = core.catalog.resolve(catalog_id) + if entry.is_empty(): + push_error("ObjectPlaceTool: unknown catalog_id '%s'" % catalog_id) + return "" + + var obj := ChunkDataDecoder.WorldObjectData.new() + obj.id = core.model.generate_object_id() + obj.type = entry.get("type", "prop") + obj.asset_id = entry.get("asset_id", catalog_id) + obj.position = args.get("position", Vector3.ZERO) + obj.rotation = args.get("rotation", Vector4(0, 0, 0, 1)) + obj.scale = args.get("scale", Vector3.ONE) + + var props: Dictionary = (args.get("properties", {}) as Dictionary).duplicate() + if entry.has("display_name"): + props["display_name"] = entry["display_name"] + props["catalog_id"] = catalog_id + obj.properties = props + + core.undo_redo.begin("Place '%s' (%s)" % [obj.id, catalog_id]) + core.undo_redo.add_do(core.model.add_object_raw.bind(obj)) + core.undo_redo.add_undo(core.model.remove_object_raw.bind(obj.id)) + core.undo_redo.commit() + + core.selection.select(obj.id) + return obj.id diff --git a/scripts/editor/tools/select_tool.gd b/scripts/editor/tools/select_tool.gd new file mode 100644 index 0000000..f230588 --- /dev/null +++ b/scripts/editor/tools/select_tool.gd @@ -0,0 +1,24 @@ +class_name SelectTool +extends EditorTool +## Selects (or clears) the active object in `EditorCore.selection`. +## +## `args`: `object_id` (String) -- omitted or "" clears the selection. +## +## Selection is UI/session state, not chunk data (docs/architecture/editor.md +## §7.1 lists `SelectionManager` as a sibling of `UndoRedoStack`, not a +## client of it), so this intentionally never touches `core.undo_redo`. + + +func _init() -> void: + tool_id = "select" + + +func apply(core: EditorCore, args: Dictionary) -> Variant: + var object_id: String = args.get("object_id", "") + if object_id == "": + core.selection.clear() + return true + if core.model.find_object(object_id) == null: + return false + core.selection.select(object_id) + return true diff --git a/scripts/editor/tools/transform_tool.gd b/scripts/editor/tools/transform_tool.gd new file mode 100644 index 0000000..cbbd132 --- /dev/null +++ b/scripts/editor/tools/transform_tool.gd @@ -0,0 +1,62 @@ +class_name TransformTool +extends EditorTool +## Moves, rotates, or scales an existing object. +## +## Not one of the four tools literally named in docs/architecture/editor.md +## §7.1's `ToolRegistry` list ("brush / object-place / select / erase, +## etc.") but required by this task's brief ("place / move / rotate / scale +## / delete") -- the doc's own "etc." leaves room, so this is a fifth +## registered tool rather than overloading `ObjectPlaceTool` (placing a new +## object and re-transforming an existing one are different undo units) +## or `SelectTool` (selection is deliberately not chunk-mutating). +## +## `args`: `object_id` (String), `op` ("move" | "rotate" | "scale"), `value` +## (Vector3 for move/scale, Vector4 quaternion for rotate). + + +func _init() -> void: + tool_id = "transform" + + +func apply(core: EditorCore, args: Dictionary) -> Variant: + var object_id: String = args.get("object_id", "") + var op: String = args.get("op", "") + var obj := core.model.find_object(object_id) + if obj == null: + return false + + match op: + "move": + return _apply_vector3(core, object_id, args.get("value", obj.position), + obj.position, "Move", core.model.set_object_position_raw) + "rotate": + return _apply_vector4(core, object_id, args.get("value", obj.rotation), + obj.rotation, "Rotate", core.model.set_object_rotation_raw) + "scale": + return _apply_vector3(core, object_id, args.get("value", obj.scale), + obj.scale, "Scale", core.model.set_object_scale_raw) + _: + push_error("TransformTool: unknown op '%s'" % op) + return false + + +func _apply_vector3(core: EditorCore, object_id: String, new_value: Vector3, + old_value: Vector3, label: String, setter: Callable) -> bool: + if new_value.is_equal_approx(old_value): + return false + core.undo_redo.begin("%s '%s'" % [label, object_id]) + core.undo_redo.add_do(setter.bind(object_id, new_value)) + core.undo_redo.add_undo(setter.bind(object_id, old_value)) + core.undo_redo.commit() + return true + + +func _apply_vector4(core: EditorCore, object_id: String, new_value: Vector4, + old_value: Vector4, label: String, setter: Callable) -> bool: + if new_value.is_equal_approx(old_value): + return false + core.undo_redo.begin("%s '%s'" % [label, object_id]) + core.undo_redo.add_do(setter.bind(object_id, new_value)) + core.undo_redo.add_undo(setter.bind(object_id, old_value)) + core.undo_redo.commit() + return true diff --git a/scripts/editor/ui/editor_toolbar.gd b/scripts/editor/ui/editor_toolbar.gd new file mode 100644 index 0000000..5325bb0 --- /dev/null +++ b/scripts/editor/ui/editor_toolbar.gd @@ -0,0 +1,65 @@ +class_name EditorToolbar +extends HBoxContainer +## Tool-mode switcher for the in-game editor (docs/architecture/editor.md +## §7.2 `Toolbar`). Pure UI: it only emits signals: `InGameEditor` decides +## what to do with them (calling into `EditorCore`/`ToolRegistry`). + +signal tool_selected(tool_id: String) +signal undo_pressed +signal redo_pressed + +const TOOL_LABELS := { + "select": "Select", + "brush": "Brush", + "object_place": "Place", + "erase": "Erase", +} + +var _tool_buttons: Dictionary = {} +var _undo_button: Button +var _redo_button: Button + + +func _init() -> void: + name = "EditorToolbar" + + +func _ready() -> void: + for tool_id in TOOL_LABELS: + var btn := Button.new() + btn.text = TOOL_LABELS[tool_id] + btn.toggle_mode = true + btn.pressed.connect(_on_tool_button_pressed.bind(tool_id)) + add_child(btn) + _tool_buttons[tool_id] = btn + + add_child(VSeparator.new()) + + _undo_button = Button.new() + _undo_button.text = "Undo" + _undo_button.pressed.connect(func(): undo_pressed.emit()) + add_child(_undo_button) + + _redo_button = Button.new() + _redo_button.text = "Redo" + _redo_button.pressed.connect(func(): redo_pressed.emit()) + add_child(_redo_button) + + +func _on_tool_button_pressed(tool_id: String) -> void: + set_active_tool(tool_id) + tool_selected.emit(tool_id) + + +## Reflect the active tool in the button states without re-emitting the signal. +func set_active_tool(tool_id: String) -> void: + for id in _tool_buttons: + _tool_buttons[id].button_pressed = (id == tool_id) + + +## Reflect whether undo/redo currently have anything to do. +func set_history_state(can_undo: bool, can_redo: bool) -> void: + if _undo_button: + _undo_button.disabled = not can_undo + if _redo_button: + _redo_button.disabled = not can_redo diff --git a/scripts/editor/ui/editor_viewport.gd b/scripts/editor/ui/editor_viewport.gd new file mode 100644 index 0000000..becfbb9 --- /dev/null +++ b/scripts/editor/ui/editor_viewport.gd @@ -0,0 +1,135 @@ +class_name EditorViewport +extends Control +## Custom 2D viewport for the in-game editor (docs/architecture/editor.md +## §7.2 `EditorViewport`). +## +## Deliberately 2D top-down, not a 3D gizmo viewport -- per §8's own risk +## mitigation ("start with a 2D tile editor... matches CHUNK_SIZE_Z=1 +## default; add 3D later"). Renders one tile layer as a grid plus the +## chunk's objects as dots, and turns clicks into calls on `EditorCore` +## routed through whichever tool is active in `ToolRegistry` -- this is UI +## glue, it never mutates `ChunkEditorModel` itself. +## +## Coordinate convention matches the rest of this codebase's tile system +## (scripts/tile_layer_node.gd `_tile_to_world`): chunk-local X/Y are the +## flat ground plane, Z is height (CHUNK_SIZE_Z=1 by default -- see +## scripts/chunk.gd). One grid cell == one world unit == `cell_size` pixels. + +signal edit_applied + +var core: EditorCore = null +var layer_idx: int = 0 +var cell_size: float = 24.0 +## Catalog id the next "object_place" click will place. Set by +## `InGameEditor` from `PalettePanel.object_selected`. +var pending_catalog_id: String = "" +## Tile id the next "brush" click paints. Cycled with number keys 0-9. +var current_tile_id: int = 1 + +const TILE_EMPTY_COLOR := Color(0.15, 0.15, 0.17) +const TILE_SOLID_COLOR := Color(0.35, 0.55, 0.35) +const GRID_COLOR := Color(0, 0, 0, 0.25) +const OBJECT_COLOR := Color(0.9, 0.6, 0.2) +const OBJECT_SELECTED_COLOR := Color(1.0, 0.95, 0.2) + + +func _init() -> void: + name = "EditorViewport" + mouse_filter = Control.MOUSE_FILTER_STOP + focus_mode = Control.FOCUS_ALL + + +func setup(p_core: EditorCore, p_layer_idx: int = 0) -> void: + core = p_core + layer_idx = p_layer_idx + core.selection.selection_changed.connect(func(_id): queue_redraw()) + _resize_to_layer() + queue_redraw() + + +func _resize_to_layer() -> void: + var layer := core.model.get_layer(layer_idx) + if layer == null: + custom_minimum_size = Vector2(cell_size * 8, cell_size * 8) + return + custom_minimum_size = Vector2(layer.width * cell_size, layer.height * cell_size) + + +func _draw() -> void: + if core == null: + return + var layer := core.model.get_layer(layer_idx) + if layer == null: + return + + for y in range(layer.height): + for x in range(layer.width): + var tile_id := layer.get_tile(x, y, 0) + var color := TILE_EMPTY_COLOR + var tdef := layer.get_tile_def(tile_id) + if tdef != null and tdef.solid: + color = TILE_SOLID_COLOR + draw_rect(Rect2(x * cell_size, y * cell_size, cell_size, cell_size), color, true) + + for x in range(layer.width + 1): + draw_line(Vector2(x * cell_size, 0), Vector2(x * cell_size, layer.height * cell_size), GRID_COLOR) + for y in range(layer.height + 1): + draw_line(Vector2(0, y * cell_size), Vector2(layer.width * cell_size, y * cell_size), GRID_COLOR) + + for obj in core.model.payload.objects: + var center := Vector2(obj.position.x * cell_size, obj.position.y * cell_size) + var is_selected: bool = core.selection.get_selected() == obj.id + var color := OBJECT_SELECTED_COLOR if is_selected else OBJECT_COLOR + draw_circle(center, cell_size * 0.3, color) + + +func _gui_input(event: InputEvent) -> void: + if core == null: + return + if event is InputEventMouseButton: + var mb := event as InputEventMouseButton + if mb.pressed and mb.button_index == MOUSE_BUTTON_LEFT: + _handle_click(mb.position) + accept_event() + elif event is InputEventKey: + var key := event as InputEventKey + if key.pressed and not key.echo: + _handle_key(key) + + +func _handle_key(key: InputEventKey) -> void: + if key.keycode >= KEY_0 and key.keycode <= KEY_9: + current_tile_id = key.keycode - KEY_0 + + +func _handle_click(local_pos: Vector2) -> void: + var tx := int(floor(local_pos.x / cell_size)) + var ty := int(floor(local_pos.y / cell_size)) + + match core.tools.get_active_tool_id(): + "brush": + if core.paint_tile(layer_idx, tx, ty, current_tile_id): + edit_applied.emit() + queue_redraw() + "erase": + var hit := _object_at(tx, ty) + var changed: bool = core.delete_object(hit) if hit != "" else core.erase_tile(layer_idx, tx, ty) + if changed: + edit_applied.emit() + queue_redraw() + "object_place": + if pending_catalog_id != "": + var new_id := core.place_object(pending_catalog_id, Vector3(tx + 0.5, ty + 0.5, 0.0)) + if new_id != "": + edit_applied.emit() + queue_redraw() + "select": + core.select_object(_object_at(tx, ty)) + queue_redraw() + + +func _object_at(tx: int, ty: int) -> String: + for obj in core.model.payload.objects: + if int(floor(obj.position.x)) == tx and int(floor(obj.position.y)) == ty: + return obj.id + return "" diff --git a/scripts/editor/ui/in_game_editor.gd b/scripts/editor/ui/in_game_editor.gd new file mode 100644 index 0000000..d990c2e --- /dev/null +++ b/scripts/editor/ui/in_game_editor.gd @@ -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() diff --git a/scripts/editor/ui/inspector_panel.gd b/scripts/editor/ui/inspector_panel.gd new file mode 100644 index 0000000..44c0753 --- /dev/null +++ b/scripts/editor/ui/inspector_panel.gd @@ -0,0 +1,166 @@ +class_name InspectorPanel +extends VBoxContainer +## Property editor for the selected object (docs/architecture/editor.md +## §7.2 `InspectorPanel`). +## +## 2D-tile-editor scope (docs/architecture/editor.md §8: the default world +## is flat, `CHUNK_SIZE_Z=1`): position exposes X/Y (world-plane) plus a +## single "height" Z for props that sit above the floor, and rotation +## exposes one yaw field (rotation about the vertical axis) rather than a +## full 3-axis gizmo -- deliberately not the volumetric 3D editing the doc +## says to defer. +## +## Pure UI: every edit calls back into `EditorCore` (`move_object` / +## `rotate_object` / `scale_object` / `delete_object`), so inspector edits +## go through the exact same undo/redo path as viewport edits. + +signal delete_requested(object_id: String) + +var _core: EditorCore = null +var _object_id: String = "" +var _updating := false # guards against feedback loops while refreshing fields + +var _id_label: Label +var _type_label: Label +var _asset_label: Label +var _pos_x: SpinBox +var _pos_y: SpinBox +var _pos_z: SpinBox +var _yaw: SpinBox +var _scale_x: SpinBox +var _scale_y: SpinBox +var _scale_z: SpinBox +var _delete_button: Button +var _empty_label: Label +var _fields_box: VBoxContainer + + +func _init() -> void: + name = "InspectorPanel" + + +func _ready() -> void: + var header := Label.new() + header.text = "Inspector" + add_child(header) + + _empty_label = Label.new() + _empty_label.text = "(nothing selected)" + add_child(_empty_label) + + _fields_box = VBoxContainer.new() + _fields_box.visible = false + add_child(_fields_box) + + _id_label = _add_readonly_row("id") + _type_label = _add_readonly_row("type") + _asset_label = _add_readonly_row("asset") + + _pos_x = _add_spinbox_row("pos x") + _pos_y = _add_spinbox_row("pos y") + _pos_z = _add_spinbox_row("pos z (height)") + _pos_x.value_changed.connect(_on_position_changed) + _pos_y.value_changed.connect(_on_position_changed) + _pos_z.value_changed.connect(_on_position_changed) + + _yaw = _add_spinbox_row("rotation (deg)") + _yaw.min_value = -360.0 + _yaw.max_value = 360.0 + _yaw.step = 1.0 + _yaw.value_changed.connect(_on_yaw_changed) + + _scale_x = _add_spinbox_row("scale x") + _scale_y = _add_spinbox_row("scale y") + _scale_z = _add_spinbox_row("scale z") + _scale_x.value_changed.connect(_on_scale_changed) + _scale_y.value_changed.connect(_on_scale_changed) + _scale_z.value_changed.connect(_on_scale_changed) + + _delete_button = Button.new() + _delete_button.text = "Delete" + _delete_button.pressed.connect(_on_delete_pressed) + _fields_box.add_child(_delete_button) + + +func setup(core: EditorCore) -> void: + _core = core + + +func show_object(object_id: String) -> void: + _object_id = object_id + if object_id == "" or _core == null: + _empty_label.visible = true + _fields_box.visible = false + return + + var obj := _core.model.find_object(object_id) + if obj == null: + _empty_label.visible = true + _fields_box.visible = false + return + + _empty_label.visible = false + _fields_box.visible = true + + _updating = true + _id_label.text = "id: %s" % obj.id + _type_label.text = "type: %s" % obj.type + _asset_label.text = "asset: %s" % obj.asset_id + _pos_x.value = obj.position.x + _pos_y.value = obj.position.y + _pos_z.value = obj.position.z + _yaw.value = rad_to_deg(Quaternion(obj.rotation.x, obj.rotation.y, obj.rotation.z, obj.rotation.w).get_euler().y) + _scale_x.value = obj.scale.x + _scale_y.value = obj.scale.y + _scale_z.value = obj.scale.z + _updating = false + + +func _on_position_changed(_v: float) -> void: + if _updating or _core == null or _object_id == "": + return + _core.move_object(_object_id, Vector3(_pos_x.value, _pos_y.value, _pos_z.value)) + + +func _on_yaw_changed(v: float) -> void: + if _updating or _core == null or _object_id == "": + return + var quat := Quaternion(Vector3.UP, deg_to_rad(v)) + _core.rotate_object(_object_id, Vector4(quat.x, quat.y, quat.z, quat.w)) + + +func _on_scale_changed(_v: float) -> void: + if _updating or _core == null or _object_id == "": + return + _core.scale_object(_object_id, Vector3(_scale_x.value, _scale_y.value, _scale_z.value)) + + +func _on_delete_pressed() -> void: + if _object_id == "": + return + var id := _object_id + delete_requested.emit(id) + + +func _add_readonly_row(label: String) -> Label: + var l := Label.new() + l.text = "%s: " % label + _fields_box.add_child(l) + return l + + +func _add_spinbox_row(label_text: String) -> SpinBox: + var row := HBoxContainer.new() + var l := Label.new() + l.text = label_text + l.custom_minimum_size = Vector2(90, 0) + row.add_child(l) + var sb := SpinBox.new() + sb.min_value = -100000.0 + sb.max_value = 100000.0 + sb.step = 0.1 + sb.allow_greater = true + sb.allow_lesser = true + row.add_child(sb) + _fields_box.add_child(row) + return sb diff --git a/scripts/editor/ui/palette_panel.gd b/scripts/editor/ui/palette_panel.gd new file mode 100644 index 0000000..034f54e --- /dev/null +++ b/scripts/editor/ui/palette_panel.gd @@ -0,0 +1,57 @@ +class_name PalettePanel +extends VBoxContainer +## Catalog-sourced object palette (docs/architecture/editor.md §7.2 +## `PalettePanel`). Lists every id in an `EditorObjectCatalog` and emits +## which one the player picked; `InGameEditor` remembers it as the pending +## `catalog_id` for the next `ObjectPlaceTool` placement. + +signal object_selected(catalog_id: String) + +var _list: ItemList +var _catalog: EditorObjectCatalog = null +var _ids: Array[String] = [] + + +func _init() -> void: + name = "PalettePanel" + + +func _ready() -> void: + var header := Label.new() + header.text = "Palette" + add_child(header) + + _list = ItemList.new() + _list.size_flags_vertical = Control.SIZE_EXPAND_FILL + _list.item_selected.connect(_on_item_selected) + add_child(_list) + + +func set_catalog(catalog: EditorObjectCatalog) -> void: + _catalog = catalog + _refresh() + + +func _refresh() -> void: + _list.clear() + _ids.clear() + if _catalog == null or not _catalog.is_loaded(): + return + for id in _catalog.get_all_ids(): + var entry := _catalog.resolve(id) + var label: String = entry.get("display_name", id) + _list.add_item(label) + _ids.append(id) + + +func _on_item_selected(index: int) -> void: + if index < 0 or index >= _ids.size(): + return + object_selected.emit(_ids[index]) + + +func get_selected_catalog_id() -> String: + var selected: PackedInt32Array = _list.get_selected_items() + if selected.is_empty(): + return "" + return _ids[selected[0]] diff --git a/scripts/game.gd b/scripts/game.gd index 7b6bd9c..0bfb144 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -28,6 +28,14 @@ var _remotes: Dictionary = {} # peer_id -> Node3D var _remote_prev: Dictionary = {} # peer_id -> Vector3 (last snapshot position) var _status_label: Label = null +## Build-mode UI, gated behind toggle_build_mode (F2) -- only ever +## instantiated once the player actually enters build mode +## (docs/architecture/editor.md §7.2: "Instantiated only when the player +## enters 'build mode'... so the runtime footprint is negligible during +## normal play"). Editing is in-memory only here; the save path to the +## backend is a sibling task (world_persistence_impl.md). +var _build_editor: InGameEditor = null + func _ready() -> void: _show_build_stamp() @@ -36,13 +44,64 @@ func _ready() -> void: func _unhandled_input(event: InputEvent) -> void: - # ESC leaves the world back to the browser. Handled here (the scene root + if event.is_action_pressed("toggle_build_mode"): + get_viewport().set_input_as_handled() + _toggle_build_mode() + return + + # ESC leaves the world back to the browser (or, while build mode is + # open, just closes build mode instead). Handled here (the scene root # that owns the connection) rather than in player.gd so the teardown is # a single ordered path -- doing it from two places raced _on_server_lost # against the scene change and looked like a crash. if event.is_action_pressed("ui_cancel"): get_viewport().set_input_as_handled() - _leave_world() + if _build_editor != null and is_instance_valid(_build_editor): + _close_build_mode() + else: + _leave_world() + + +## Entry point into the in-game editor (docs/architecture/editor.md §7.2). +## Freezes the player and opens a fresh, empty in-memory chunk to edit -- +## loading a *real* chunk from the backend for editing is the sibling +## save-path task's territory (it owns the HTTP round-trip); this task's +## contract stops at "a correctly-editable in-memory ChunkEditorModel". +func _toggle_build_mode() -> void: + if _build_editor != null and is_instance_valid(_build_editor): + _close_build_mode() + else: + _open_build_mode() + + +func _open_build_mode() -> void: + var model := ChunkEditorModel.create_empty(32, 32, str(Session.world_id), "main") + var catalog := EditorObjectCatalog.new() + var err := catalog.load_catalog() + if err != OK: + push_error("game.gd: failed to load editor object catalog (%d)" % err) + var core := EditorCore.new(model, catalog) + + _build_editor = InGameEditor.new(core) + _build_editor.closed.connect(_close_build_mode) + add_child(_build_editor) + + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + _player.set_physics_process(false) + _player.set_process_unhandled_input(false) + + +## Shared teardown for both close paths (F2 again, or the editor's own +## Close button/`closed` signal) -- safe to call twice: `queue_free()` on an +## already-freeing node and restoring already-restored player state are +## both no-ops. +func _close_build_mode() -> void: + if _build_editor != null and is_instance_valid(_build_editor): + _build_editor.queue_free() + _build_editor = null + _player.set_physics_process(true) + _player.set_process_unhandled_input(true) + _player._refresh_mouse_mode() func _leave_world() -> void: diff --git a/scripts/smoke_test_editor_core.gd b/scripts/smoke_test_editor_core.gd new file mode 100644 index 0000000..d69a699 --- /dev/null +++ b/scripts/smoke_test_editor_core.gd @@ -0,0 +1,260 @@ +extends SceneTree +## Real exercise of the in-game editor's data-native core +## (docs/architecture/editor.md §7.1/§7.2, scoped to 2D per §8). +## +## Loads a real chunk (the backend's golden fixture +## backend/tests/fixtures/chunks/simple_tile_chunk.bin -- the same file +## scripts/smoke_test_chunk_codec_interop.gd proves decodes correctly), +## wraps it in a ChunkEditorModel, and drives every operation +## (place/move/rotate/scale/delete an object, paint/erase a tile) through +## the actual EditorCore.use_tool()/ToolRegistry path -- never by calling +## ChunkEditorModel's raw setters directly, which would bypass the tool +## layer this task was scoped to build. Then it undoes every single one of +## those operations and checks the chunk is back to exactly its starting +## state, redoes them all back, and finally round-trips the result through +## encode_binary()/decode_binary() to prove the model stays a valid, +## byte-compatible ChunkPayload throughout -- the contract the sibling +## save-path task depends on. +## +## Run with: +## ~/godot/godot --headless --path . --script res://scripts/smoke_test_editor_core.gd + +const FIXTURE_PATH := "res://backend/tests/fixtures/chunks/simple_tile_chunk.bin" + +var passed := 0 +var failed := 0 + + +func _check(label: String, cond: bool) -> void: + if cond: + passed += 1 + print(" PASS: ", label) + else: + failed += 1 + printerr(" FAIL: ", label) + + +func _initialize() -> void: + _run() + + +func _run() -> void: + await _test_full_editing_loop() + _test_catalog_rejects_unknown_id() + _test_selection_is_not_undoable() + + print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed]) + quit(0 if failed == 0 else 1) + + +func _read_fixture() -> PackedByteArray: + var f := FileAccess.open(FIXTURE_PATH, FileAccess.READ) + if f == null: + push_error("Could not open fixture: " + FIXTURE_PATH) + return PackedByteArray() + return f.get_buffer(f.get_length()) + + +func _test_full_editing_loop() -> void: + print("--- EditorCore/ToolRegistry over a real decoded chunk (simple_tile_chunk.bin) ---") + + var original_bytes := _read_fixture() + _check("fixture file read (non-empty)", original_bytes.size() > 0) + if original_bytes.is_empty(): + return + + var model := ChunkEditorModel.from_binary(original_bytes) + _check("ChunkEditorModel.from_binary decoded the fixture", model != null) + if model == null: + return + + # Baseline sanity on the loaded real chunk before touching anything. + _check("loaded chunk has 1 tile layer", model.payload.tile_layers.size() == 1) + _check("loaded chunk is 4x4", model.get_layer(0).width == 4 and model.get_layer(0).height == 4) + var baseline_tiles := (model.get_layer(0).tiles as PackedInt32Array).duplicate() + var baseline_object_count := model.payload.objects.size() + + var catalog := EditorObjectCatalog.new() + var cat_err := catalog.load_catalog() + _check("object catalog loaded", cat_err == OK) + + var core := EditorCore.new(model, catalog) + _check("EditorCore registers all 5 tools", core.tools.get_tool_ids().size() == 5) + for expected_tool in ["brush", "object_place", "select", "erase", "transform"]: + _check("ToolRegistry has tool '%s'" % expected_tool, core.tools.has_tool(expected_tool)) + + # ── 1. Paint a tile via the brush tool (through EditorCore/ToolRegistry) ── + _check("tile (0,0) starts as 0 in the fixture", model.get_tile_raw(0, 0, 0) == 0) + var painted := core.paint_tile(0, 0, 0, 1) + _check("core.paint_tile (brush tool) applied", painted) + _check("tile (0,0) is now 1 after paint", model.get_tile_raw(0, 0, 0) == 1) + + # ── 2. Place a catalog object (through EditorCore/ToolRegistry) ── + var obj_id := core.place_object("crate_small", Vector3(1.5, 1.5, 0.0)) + _check("core.place_object (object_place tool) returned a real id", obj_id != "") + _check("placed object exists in the model", model.find_object(obj_id) != null) + _check("placed object got the catalog's asset_id", model.find_object(obj_id).asset_id == "props/crate_small.glb") + _check("placing auto-selected the new object", core.selection.get_selected() == obj_id) + + # ── 3. Select it explicitly via the select tool ── + _check("core.select_object (select tool)", core.select_object(obj_id)) + _check("selection reflects it", core.selection.get_selected() == obj_id) + + # ── 4. Move / rotate / scale via the transform tool ── + var moved := core.move_object(obj_id, Vector3(5.0, 6.0, 0.0)) + _check("core.move_object (transform tool) applied", moved) + _check("object position updated", model.find_object(obj_id).position.is_equal_approx(Vector3(5.0, 6.0, 0.0))) + + var new_rot := Vector4(0.0, 0.7071068, 0.0, 0.7071068) # 90 deg yaw + var rotated := core.rotate_object(obj_id, new_rot) + _check("core.rotate_object (transform tool) applied", rotated) + _check("object rotation updated", model.find_object(obj_id).rotation.is_equal_approx(new_rot)) + + var scaled := core.scale_object(obj_id, Vector3(2.0, 2.0, 2.0)) + _check("core.scale_object (transform tool) applied", scaled) + _check("object scale updated", model.find_object(obj_id).scale.is_equal_approx(Vector3(2.0, 2.0, 2.0))) + + # ── 5. Erase a different tile (the fixture already has a nonzero tile at + # (1,0) -- world_id 12's palette makes it a real, non-empty tile id, so + # erase has something genuine to clear without needing a manual seed). ── + var original_tile_1_0 := model.get_tile_raw(0, 1, 0) + _check("tile (1,0) is nonzero in the fixture (something real to erase)", original_tile_1_0 != 0) + var erased_tile := core.erase_tile(0, 1, 0) + _check("core.erase_tile (erase tool) applied", erased_tile) + _check("tile (1,0) is 0 after erase", model.get_tile_raw(0, 1, 0) == 0) + + # ── 6. Delete the object via the erase tool ── + var deleted := core.delete_object(obj_id) + _check("core.delete_object (erase tool) applied", deleted) + _check("object is gone from the model", model.find_object(obj_id) == null) + _check("deleting the selected object cleared the selection", not core.selection.has_selection()) + + # ── 7. Undo every operation (6 ops) and verify each actually reverts ── + print("--- undo/redo actually revert each step ---") + _check("undo #1 (re-adds deleted object)", core.undo()) + _check("object exists again after undo", model.find_object(obj_id) != null) + + _check("undo #2 (un-erases tile)", core.undo()) + _check("tile (1,0) is back to its original value after undo", + model.get_tile_raw(0, 1, 0) == original_tile_1_0) + + _check("undo #3 (un-scales)", core.undo()) + _check("scale reverted", model.find_object(obj_id).scale.is_equal_approx(Vector3.ONE)) + + _check("undo #4 (un-rotates)", core.undo()) + _check("rotation reverted", model.find_object(obj_id).rotation.is_equal_approx(Vector4(0, 0, 0, 1))) + + _check("undo #5 (un-moves)", core.undo()) + _check("position reverted", model.find_object(obj_id).position.is_equal_approx(Vector3(1.5, 1.5, 0.0))) + + _check("undo #6 (un-places, object removed)", core.undo()) + _check("placed object is gone after undo", model.find_object(obj_id) == null) + + _check("undo #7 (un-paints tile)", core.undo()) + _check("tile (0,0) is back to 0 after undo", model.get_tile_raw(0, 0, 0) == 0) + + _check("no more undo history", not core.undo_redo.can_undo()) + _check("tiles exactly match the original fixture after full undo", + model.get_layer(0).tiles == baseline_tiles) + _check("object count matches the original fixture after full undo", + model.payload.objects.size() == baseline_object_count) + + # ── 8. Redo everything back ── + print("--- redo replays every step forward again ---") + var redo_count := 0 + while core.redo(): + redo_count += 1 + _check("redo replayed all 7 recorded actions", redo_count == 7) + _check("tile (0,0) painted again after full redo", model.get_tile_raw(0, 0, 0) == 1) + _check("object gone again after full redo (last op was delete)", model.find_object(obj_id) == null) + + # ── 9. Round-trip the final state through encode/decode ── + print("--- encode_binary()/decode_binary() round-trip after editing ---") + var re_encoded := model.encode_binary() + _check("re-encoded chunk is non-empty", re_encoded.size() > 0) + var re_decoded := ChunkDataDecoder.decode_binary(re_encoded) + _check("re-decoded without error", re_decoded != null) + if re_decoded != null: + _check("re-decoded tile layer matches edited model", + re_decoded.tile_layers[0].tiles == model.get_layer(0).tiles) + _check("re-decoded world_id preserved", re_decoded.world_id == model.payload.world_id) + _check("re-decoded coord preserved", re_decoded.coord == model.payload.coord) + _check("re-decoded bounds preserved", + re_decoded.bounds.position.is_equal_approx(model.payload.bounds.position) + and re_decoded.bounds.size.is_equal_approx(model.payload.bounds.size)) + _check("re-decoded object count matches", re_decoded.objects.size() == model.payload.objects.size()) + + # ── 10. Also prove a fresh in-memory chunk (no fixture) works end to end, + # including that the InGameEditor UI wires up against a real EditorCore + # without crashing headlessly. ── + await _test_ui_smoke() + + +func _test_ui_smoke() -> void: + print("--- InGameEditor UI wires up against a real EditorCore (headless, no crash) ---") + var model := ChunkEditorModel.create_empty(8, 8, "1", "main", Vector3i.ZERO) + var catalog := EditorObjectCatalog.new() + catalog.load_catalog() + var core := EditorCore.new(model, catalog) + + var editor := InGameEditor.new(core) + root.add_child(editor) + await process_frame # let _ready() run so the UI is actually built + _check("InGameEditor instantiated and added to the tree without error", is_instance_valid(editor)) + + # Drive a real placement through the viewport's own click handler (not a + # bare model mutation) to prove the UI layer -> EditorCore wiring works. + # EditorViewport is nested a few levels down (ScrollContainer, etc.). + var viewport: EditorViewport = _find_viewport(editor) + _check("EditorViewport exists in the built UI", viewport != null) + if viewport != null: + viewport.pending_catalog_id = "rock_01" + core.tools.set_active_tool("object_place") + viewport._handle_click(Vector2(20, 20)) # inside tile (0,0) at cell_size 24 + _check("clicking with object_place active through the viewport placed an object", + model.payload.objects.size() == 1) + + editor.queue_free() + + +func _find_viewport(node: Node) -> EditorViewport: + if node is EditorViewport: + return node + for child in node.get_children(): + var found := _find_viewport(child) + if found != null: + return found + return null + + +func _test_catalog_rejects_unknown_id() -> void: + print("--- object_place tool rejects an unknown catalog id ---") + var model := ChunkEditorModel.create_empty() + var catalog := EditorObjectCatalog.new() + catalog.load_catalog() + var core := EditorCore.new(model, catalog) + + var result := core.place_object("does_not_exist", Vector3.ZERO) + _check("unknown catalog_id is rejected (empty id returned)", result == "") + _check("nothing was added to the model", model.payload.objects.is_empty()) + _check("nothing was pushed onto the undo stack", not core.undo_redo.can_undo()) + + +func _test_selection_is_not_undoable() -> void: + print("--- selection is UI state, not chunk data (not on the undo stack) ---") + var model := ChunkEditorModel.create_empty() + var catalog := EditorObjectCatalog.new() + catalog.load_catalog() + var core := EditorCore.new(model, catalog) + + var obj_id := core.place_object("chest_01", Vector3(2, 2, 0)) + _check("setup: object placed", obj_id != "") + core.select_object(obj_id) + _check("object selected", core.selection.get_selected() == obj_id) + + # Only one undoable action should exist (the placement) -- selecting it + # must not have added a second one. + core.undo() + _check("a single undo removes the placed object (selection wasn't a 2nd undo step)", + model.find_object(obj_id) == null) + _check("no further undo history from selection", not core.undo_redo.can_undo())