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,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
|
||||
@@ -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 ""
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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]]
|
||||
Reference in New Issue
Block a user