509 lines
17 KiB
GDScript
509 lines
17 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
|
|
|
|
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_as_button: Button
|
|
var _save_status_label: Label
|
|
var _instruction_label: Label
|
|
var _save_as_modal: Control = null
|
|
|
|
|
|
## 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 = "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 _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):
|
|
_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")
|
|
|