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) ## ## Persisting edits (world_persistence_impl.md's single/batch-PUT save path, ## §8.1-8.2) is wired through `ChunkSaveService`, set via ## `setup_persistence()`. Without it (e.g. the F2 scratch-chunk path, or ## `smoke_test_editor_core.gd`'s bare `InGameEditor.new(core)`), the Save ## button is present but disabled -- closing the editor just discards the ## UI and the in-memory `ChunkEditorModel` 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 var _save_button: Button var _save_status_label: Label var _instruction_label: Label ## Save-path state (world_persistence_impl.md §8.1/§8.2), set via ## `setup_persistence()`. `_save_service == null` means there is nothing to ## save to (see the class doc above) -- the Save button stays disabled. var _save_service: ChunkSaveService = null var _world_id: String = "" var _room_slug: String = "main" var _coord: Vector3i = Vector3i.ZERO var _base_revision: int = 0 var _saving: bool = false func _init(p_core: EditorCore) -> void: core = p_core name = "InGameEditor" set_anchors_preset(Control.PRESET_FULL_RECT) mouse_filter = Control.MOUSE_FILTER_STOP ## Wire this editor's Save button/Ctrl+S to a real backend round-trip. ## `save_service` must already be in the scene tree (game.gd adds it as a ## sibling before constructing this editor). `base_revision` is whatever the ## caller last fetched for `coord` (0 if the chunk didn't exist yet). func setup_persistence(save_service: ChunkSaveService, world_id: String, room_slug: String, coord: Vector3i, base_revision: int) -> void: _save_service = save_service _world_id = world_id _room_slug = room_slug _coord = coord _base_revision = base_revision if _save_button != null: _save_button.disabled = false _save_button.tooltip_text = "Save chunk changes to backend (Ctrl+S)" func _ready() -> void: _build_ui() _refresh_history_buttons() if core.tools.get_active_tool_id() == "select" and core.model.payload.objects.is_empty(): core.tools.set_active_tool("brush") _toolbar.set_active_tool("brush") _update_instruction() 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) _save_button = Button.new() _save_button.text = "Save (Ctrl+S)" _save_button.disabled = (_save_service == null) _save_button.tooltip_text = "Save changes to server (Ctrl+S)" if _save_service != null else "Saving not available in scratch mode" _save_button.pressed.connect(_on_save_pressed) top_bar.add_child(_save_button) _close_button = Button.new() _close_button.text = "Close (F2 / Esc)" _close_button.tooltip_text = "Exit build mode and return to game" _close_button.pressed.connect(_on_close_pressed) top_bar.add_child(_close_button) _instruction_label = Label.new() _instruction_label.add_theme_font_size_override("font_size", 13) _instruction_label.add_theme_color_override("font_color", Color(0.75, 0.92, 1.0)) root_vbox.add_child(_instruction_label) _save_status_label = Label.new() _save_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD _save_status_label.visible = false root_vbox.add_child(_save_status_label) 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(260, 0) body.add_child(sidebar) _palette = PalettePanel.new() _palette.size_flags_vertical = Control.SIZE_EXPAND_FILL sidebar.add_child(_palette) _inspector = InspectorPanel.new() _inspector.size_flags_vertical = Control.SIZE_EXPAND_FILL 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.ctrl_pressed and key.keycode == KEY_S: _on_save_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() elif key.keycode == KEY_B and not key.ctrl_pressed: _on_tool_selected("brush") _toolbar.set_active_tool("brush") get_viewport().set_input_as_handled() elif key.keycode == KEY_V and not key.ctrl_pressed: _on_tool_selected("select") _toolbar.set_active_tool("select") get_viewport().set_input_as_handled() elif key.keycode == KEY_P and not key.ctrl_pressed: _on_tool_selected("object_place") _toolbar.set_active_tool("object_place") get_viewport().set_input_as_handled() elif key.keycode == KEY_X and not key.ctrl_pressed: _on_tool_selected("erase") _toolbar.set_active_tool("erase") get_viewport().set_input_as_handled() func _on_tool_selected(tool_id: String) -> void: core.tools.set_active_tool(tool_id) _update_instruction() 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") _update_instruction() 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() _update_instruction() func _on_selection_changed(object_id: String) -> void: _inspector.show_object(object_id) _update_instruction() func _update_instruction() -> void: if _instruction_label == null: return var active_id := core.tools.get_active_tool_id() match active_id: "brush": _instruction_label.text = "Tool: [ Brush ] — Left-click and drag on grid squares to paint ground tiles. (Keys 0-9 pick tile type)" "object_place": var name := _viewport.pending_catalog_id if core.catalog != null: var e := core.catalog.resolve(name) if e.has("display_name"): name = str(e["display_name"]) if name == "": _instruction_label.text = "Tool: [ Place ] — Select an item in the Palette on the right, then click on the grid squares to place." else: _instruction_label.text = "Tool: [ Place: %s ] — Click any grid square to place '%s'." % [name, name] "select": if core.selection.has_selection(): var sel_id := core.selection.get_selected() var obj := core.model.find_object(sel_id) var dname := sel_id if obj != null and obj.properties is Dictionary and obj.properties.has("display_name"): dname = "%s (%s)" % [obj.properties["display_name"], sel_id] _instruction_label.text = "Tool: [ Select ] — Selected '%s'. Edit in the Inspector on the right, or press Delete to remove." % dname else: _instruction_label.text = "Tool: [ Select ] — Click on a placed object on the grid to inspect it, or switch to Brush/Place to build." "erase": _instruction_label.text = "Tool: [ Erase ] — Left-click and drag on grid squares to erase painted tiles or delete objects." _: _instruction_label.text = "Tool: [ %s ]" % active_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() ## Single-chunk save (world_persistence_impl.md §8.1). `ChunkSaveService` ## owns the actual HTTP round trip, retrying on `409` internally ## (§8.2/§5.4) -- this just reflects the outcome and, per §6 D4, surfaces ## the `live_session` flag as a warning instead of ignoring it. func _on_save_pressed() -> void: if _save_service == null or _saving: return _saving = true _save_button.disabled = true _set_save_status("Saving…", Color(0.85, 0.85, 0.85)) var result: Dictionary = await _save_service.save_chunk(core.model, _coord, _base_revision) _saving = false _save_button.disabled = false if result.get("ok", false): _base_revision = int(result.get("revision", _base_revision)) var msg := "Saved (revision %d)." % _base_revision if bool(result.get("retried", false)): msg += " (resolved a save conflict automatically)" if bool(result.get("live_session", false)): msg += " This world has players in it right now -- they'll see your changes when the session restarts." _set_save_status(msg, Color(1.0, 0.8, 0.25)) else: _set_save_status(msg, Color(0.55, 1.0, 0.55)) else: _set_save_status("Save failed: %s" % str(result.get("detail", "unknown error")), Color(1.0, 0.45, 0.45)) func _set_save_status(text: String, color: Color) -> void: if _save_status_label == null: return _save_status_label.text = text _save_status_label.add_theme_color_override("font_color", color) _save_status_label.visible = true