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