Files
modbison/scripts/editor/ui/in_game_editor.gd
T

536 lines
18 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)
##
## 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")