4ff74ae823
Implements world_persistence_impl.md §9 client/editor task items 2-3: - ChunkSaveService (scripts/editor/chunk_save_service.gd): single-chunk PUT with base_revision, batch PUT for multi-chunk edits, and a real 409 -> re-fetch -> retry loop. Full-chunk overwrite (§8.2 D5) means "re-apply the edit" is just retrying with the locally-edited payload at the freshly-learned revision, not delta replay. - InGameEditor gets a Save button (+ Ctrl+S) that surfaces live_session as an explicit warning instead of ignoring it, per §6 D4. - "Edit World" entry point on the world list (owner-gated) that calls Session.begin_edit() instead of ApiClient.join_world() -- editing never joins, opens no ENet connection, spawns no dedicated server. - game.gd wires Session.editing to fetch the real (0,0,0) chunk over HTTP and open the editor against it, falling back to an empty chunk for an unwritten coordinate. Verified against a real backend (register/world/save/reload, a genuine 409 from two racing saves, atomic 2-chunk batch, live_session field) -- new scripts/smoke_test_editor_save.gd (19/19) plus an extended smoke_test_world_list.gd covering the non-joined entry point (11/11). Full existing Godot smoke suite (18 files) and backend pytest (132) still pass unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
251 lines
8.0 KiB
GDScript
251 lines
8.0 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_status_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
|
|
|
|
|
|
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)
|
|
|
|
_save_button = Button.new()
|
|
_save_button.text = "Save (Ctrl+S)"
|
|
_save_button.disabled = (_save_service == null)
|
|
_save_button.pressed.connect(_on_save_pressed)
|
|
top_bar.add_child(_save_button)
|
|
|
|
_close_button = Button.new()
|
|
_close_button.text = "Close (F2)"
|
|
_close_button.pressed.connect(_on_close_pressed)
|
|
top_bar.add_child(_close_button)
|
|
|
|
_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(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.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()
|
|
|
|
|
|
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()
|
|
|
|
|
|
## 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
|