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 signal exit_browser_requested var core: EditorCore var _toolbar: EditorToolbar var _palette: PalettePanel var _inspector: InspectorPanel var _viewport: EditorViewport var _close_button: Button var _exit_browser_button: Button var _save_button: Button var _save_as_button: Button var _save_status_label: Label var _instruction_label: Label var _save_as_modal: Control = null var AudioManager: Node: get: return get_node_or_null("/root/AudioManager") ## 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.tooltip_text = "Save changes to server (Ctrl+S)" _save_button.pressed.connect(_on_save_pressed) top_bar.add_child(_save_button) _save_as_button = Button.new() _save_as_button.text = "Save as New World" _save_as_button.tooltip_text = "Save this creation as a new world you own on the server" _save_as_button.pressed.connect(_on_save_as_pressed) top_bar.add_child(_save_as_button) _close_button = Button.new() _close_button.text = "▶ Playtest (F2)" _close_button.tooltip_text = "Exit build mode and test world in 3D play mode (F2 / Esc)" _close_button.add_theme_color_override("font_color", Color(0.4, 0.9, 0.5)) _close_button.pressed.connect(_on_close_pressed) top_bar.add_child(_close_button) _exit_browser_button = Button.new() _exit_browser_button.text = "🚪 Exit to Browser" _exit_browser_button.tooltip_text = "Exit world and return to world browser" _exit_browser_button.pressed.connect(_on_exit_browser_pressed) top_bar.add_child(_exit_browser_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() elif key.keycode == KEY_ESCAPE: if _save_as_modal != null and is_instance_valid(_save_as_modal): _save_as_modal.queue_free() _save_as_modal = null get_viewport().set_input_as_handled() return 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 _saving: return if _save_service == null: _set_save_status("No world selected to save to. Choose 'Save as New World'.", Color(1.0, 0.8, 0.25)) _show_save_as_modal() 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): AudioManager.play_chime() _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: var status_code := int(result.get("status", 0)) var detail := str(result.get("detail", "unknown error")) if status_code == 403 or "owner" in detail.to_lower(): _set_save_status("Save failed: You do not own world %s. Use 'Save as New World' to save your work." % _world_id, Color(1.0, 0.45, 0.45)) _show_save_as_modal() else: _set_save_status("Save failed: %s" % detail, Color(1.0, 0.45, 0.45)) func _on_save_as_pressed() -> void: _show_save_as_modal() func _show_save_as_modal() -> void: if _save_as_modal != null and is_instance_valid(_save_as_modal): _save_as_modal.queue_free() var overlay := ColorRect.new() overlay.name = "SaveAsOverlay" overlay.color = Color(0, 0, 0, 0.75) overlay.set_anchors_preset(Control.PRESET_FULL_RECT) overlay.mouse_filter = Control.MOUSE_FILTER_STOP var center := CenterContainer.new() center.set_anchors_preset(Control.PRESET_FULL_RECT) overlay.add_child(center) var panel := PanelContainer.new() panel.custom_minimum_size = Vector2(460, 0) var style := StyleBoxFlat.new() style.bg_color = Color(0.18, 0.18, 0.22, 1.0) style.border_color = Color(0.4, 0.6, 0.9, 1.0) style.set_border_width_all(2) style.set_corner_radius_all(8) style.content_margin_left = 24 style.content_margin_right = 24 style.content_margin_top = 20 style.content_margin_bottom = 20 panel.add_theme_stylebox_override("panel", style) center.add_child(panel) var form := VBoxContainer.new() form.add_theme_constant_override("separation", 12) panel.add_child(form) var title := Label.new() title.text = "Save as New World" title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER title.add_theme_font_size_override("font_size", 20) form.add_child(title) var hint := Label.new() hint.text = "Creates a new world owned by you and saves your current build into it." hint.autowrap_mode = TextServer.AUTOWRAP_WORD hint.add_theme_font_size_override("font_size", 13) hint.add_theme_color_override("font_color", Color(0.75, 0.75, 0.75)) form.add_child(hint) var name_lbl := Label.new() name_lbl.text = "World Name:" name_lbl.add_theme_font_size_override("font_size", 14) form.add_child(name_lbl) var name_input := LineEdit.new() name_input.placeholder_text = "Enter world name..." name_input.text = "My Creation" if (_world_id.is_empty() or _world_id == "0") else "Remix of World %s" % _world_id form.add_child(name_input) var desc_lbl := Label.new() desc_lbl.text = "Description:" desc_lbl.add_theme_font_size_override("font_size", 14) form.add_child(desc_lbl) var desc_input := LineEdit.new() desc_input.placeholder_text = "Describe your world..." desc_input.text = "Created in editor" form.add_child(desc_input) var err_label := Label.new() err_label.add_theme_font_size_override("font_size", 13) err_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) err_label.visible = false form.add_child(err_label) var btn_row := HBoxContainer.new() btn_row.alignment = BoxContainer.ALIGNMENT_END btn_row.add_theme_constant_override("separation", 10) form.add_child(btn_row) var cancel_btn := Button.new() cancel_btn.text = "Cancel" cancel_btn.pressed.connect(func(): overlay.queue_free()) btn_row.add_child(cancel_btn) var save_btn := Button.new() save_btn.text = "Save New World" btn_row.add_child(save_btn) save_btn.pressed.connect(func() -> void: var wname := name_input.text.strip_edges() if wname.is_empty(): err_label.text = "World name cannot be empty." err_label.visible = true return save_btn.disabled = true cancel_btn.disabled = true err_label.text = "Creating world..." err_label.add_theme_color_override("font_color", Color(0.8, 0.8, 0.8)) err_label.visible = true var api := _api_client() if api == null: save_btn.disabled = false cancel_btn.disabled = false err_label.text = "API client not available." err_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) return var res: Dictionary = await api.create_world(wname, desc_input.text.strip_edges()) if not res.get("ok", false): save_btn.disabled = false cancel_btn.disabled = false err_label.text = "Failed to create world: %s" % str(res.get("detail", "unknown error")) err_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) return var new_world: Dictionary = res.get("data", {}) var new_id: int = int(new_world.get("id", 0)) var new_id_str: String = str(new_id) err_label.text = "Saving chunk data..." if _save_service == null: _save_service = ChunkSaveService.new() add_child(_save_service) _save_service.world_id = new_id_str _save_service.room_slug = "main" _world_id = new_id_str _room_slug = "main" _coord = Vector3i.ZERO _base_revision = 0 var sess := _session() if sess != null: sess.world_id = new_id core.model.payload.world_id = _world_id core.model.payload.room_id = _room_slug core.model.payload.coord = _coord var chunk_res: Dictionary = await _save_service.save_chunk(core.model, _coord, 0) if not chunk_res.get("ok", false): save_btn.disabled = false cancel_btn.disabled = false err_label.text = "World created (ID: %d) but chunk save failed: %s" % [new_id, str(chunk_res.get("detail", "unknown error"))] err_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) return _base_revision = int(chunk_res.get("revision", 1)) _save_button.disabled = false _save_button.tooltip_text = "Save chunk changes to backend (Ctrl+S)" _set_save_status("Saved successfully as new world '%s' (ID: %d, revision %d)!" % [wname, new_id, _base_revision], Color(0.55, 1.0, 0.55)) overlay.queue_free() ) _save_as_modal = overlay add_child(overlay) 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 func _api_client() -> Node: return get_node_or_null("/root/ApiClient") func _session() -> Node: return get_node_or_null("/root/Session") func _on_exit_browser_pressed() -> void: exit_browser_requested.emit() var sess := _session() if sess != null: sess.clear() get_tree().change_scene_to_file("res://scenes/world_list.tscn")