7c37a0dc2f
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
170 lines
4.9 KiB
GDScript
170 lines
4.9 KiB
GDScript
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()
|