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
215 lines
7.7 KiB
GDScript
215 lines
7.7 KiB
GDScript
extends Control
|
|
## World list screen — browses public worlds via GET /api/worlds.
|
|
##
|
|
## Displays a scrollable list of world cards (name + description).
|
|
## Handles loading, empty list, errors, and refresh.
|
|
|
|
|
|
# UI references
|
|
@onready var scroll_container: ScrollContainer = $VBoxContainer/ScrollContainer
|
|
@onready var world_list: VBoxContainer = $VBoxContainer/ScrollContainer/WorldList
|
|
@onready var loading_label: Label = $VBoxContainer/LoadingLabel
|
|
@onready var error_label: Label = $VBoxContainer/ErrorLabel
|
|
@onready var refresh_button: Button = $VBoxContainer/HeaderHBox/RefreshButton
|
|
@onready var back_button: Button = $VBoxContainer/HeaderHBox/BackButton
|
|
@onready var empty_label: Label = $VBoxContainer/EmptyLabel
|
|
|
|
## Cached so each world card can decide whether to show "Edit World"
|
|
## (docs/architecture/world_persistence_impl.md §5.3: chunk writes require
|
|
## `world.owner_id == caller`, same as `update_world`) without an extra
|
|
## round trip per card. -1 until resolved / on failure, in which case no
|
|
## card shows the edit entry point.
|
|
var _current_user_id: int = -1
|
|
|
|
|
|
func _ready() -> void:
|
|
refresh_button.disabled = false
|
|
back_button.disabled = false
|
|
loading_label.visible = false
|
|
error_label.visible = false
|
|
empty_label.visible = false
|
|
world_list.visible = false
|
|
|
|
var me_result: Dictionary = await ApiClient.get_me()
|
|
if me_result.ok and me_result.data is Dictionary:
|
|
_current_user_id = int((me_result.data as Dictionary).get("id", -1))
|
|
|
|
_refresh_worlds()
|
|
|
|
|
|
func _on_refresh_button_pressed() -> void:
|
|
_refresh_worlds()
|
|
|
|
|
|
func _on_back_button_pressed() -> void:
|
|
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
|
|
|
|
|
|
## Fetch the world list from the backend and update the UI.
|
|
func _refresh_worlds() -> void:
|
|
# Reset UI state
|
|
loading_label.visible = true
|
|
error_label.visible = false
|
|
empty_label.visible = false
|
|
world_list.visible = false
|
|
refresh_button.disabled = true
|
|
|
|
# Clear previous world cards
|
|
for child in world_list.get_children():
|
|
child.queue_free()
|
|
|
|
var result := await ApiClient.get_worlds()
|
|
|
|
loading_label.visible = false
|
|
refresh_button.disabled = false
|
|
|
|
if not result.ok:
|
|
# Show error message (401, network failure, etc.)
|
|
error_label.text = "Error: %s" % result.detail
|
|
error_label.visible = true
|
|
empty_label.visible = false
|
|
world_list.visible = false
|
|
return
|
|
|
|
var data = result.data
|
|
if not data is Array:
|
|
error_label.text = "Unexpected response format."
|
|
error_label.visible = true
|
|
empty_label.visible = false
|
|
world_list.visible = false
|
|
return
|
|
|
|
if data.is_empty():
|
|
empty_label.text = "No worlds yet. Check back later!"
|
|
empty_label.visible = true
|
|
error_label.visible = false
|
|
world_list.visible = false
|
|
return
|
|
|
|
# Render world cards
|
|
world_list.visible = true
|
|
empty_label.visible = false
|
|
error_label.visible = false
|
|
|
|
for world_dict in data:
|
|
if not world_dict is Dictionary:
|
|
continue
|
|
var card := _create_world_card(world_dict)
|
|
world_list.add_child(card)
|
|
|
|
|
|
## Create a visual card for a single world entry.
|
|
func _create_world_card(world_dict: Dictionary) -> Control:
|
|
# PanelContainer for the background, not a ColorRect moved to child index
|
|
# 0 of a VBoxContainer -- a VBoxContainer lays out ALL its children in a
|
|
# vertical sequence, it has no concept of "float this one child behind
|
|
# the others as an overlay". That version rendered as an empty dark bar
|
|
# sitting above the text, not a background behind it.
|
|
var panel := PanelContainer.new()
|
|
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
panel.custom_minimum_size = Vector2(0, 80)
|
|
var style := StyleBoxFlat.new()
|
|
style.bg_color = Color(0.15, 0.15, 0.15, 1.0)
|
|
style.content_margin_left = 12
|
|
style.content_margin_right = 12
|
|
style.content_margin_top = 8
|
|
style.content_margin_bottom = 8
|
|
panel.add_theme_stylebox_override("panel", style)
|
|
|
|
var card := VBoxContainer.new()
|
|
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
card.add_theme_constant_override("separation", 4)
|
|
panel.add_child(card)
|
|
|
|
var name_label := Label.new()
|
|
name_label.text = world_dict.get("name", "Unnamed World")
|
|
name_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
|
name_label.size_flags_horizontal = Control.SIZE_FILL
|
|
name_label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
|
name_label.add_theme_font_size_override("font_size", 18)
|
|
|
|
var desc_label := Label.new()
|
|
var description: String = str(world_dict.get("description", ""))
|
|
desc_label.text = description if description != "" else "No description."
|
|
desc_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
|
desc_label.size_flags_horizontal = Control.SIZE_FILL
|
|
desc_label.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
desc_label.add_theme_font_size_override("font_size", 14)
|
|
desc_label.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7, 1.0))
|
|
|
|
var meta_label := Label.new()
|
|
var visibility: String = str(world_dict.get("visibility", "public"))
|
|
var created: String = str(world_dict.get("created_at", ""))
|
|
meta_label.text = "Visibility: %s%s" % [visibility, " | Created: " + created if created != "" else ""]
|
|
meta_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
|
meta_label.size_flags_horizontal = Control.SIZE_FILL
|
|
meta_label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
|
meta_label.add_theme_font_size_override("font_size", 12)
|
|
meta_label.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5, 1.0))
|
|
|
|
# "Enter World" asks the backend to host this world (POST /join) and then
|
|
# loads scenes/game.tscn networked. If the backend has no game-server
|
|
# orchestration (a plain dev instance), it falls back to local
|
|
# single-player. All worlds still load the same test level -- per-world
|
|
# content is a later milestone.
|
|
var world_id := int(world_dict.get("id", 0))
|
|
var enter_button := Button.new()
|
|
enter_button.text = "Enter World"
|
|
enter_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
|
enter_button.pressed.connect(_on_enter_world_pressed.bind(world_id, enter_button))
|
|
|
|
var buttons_row := HBoxContainer.new()
|
|
buttons_row.add_child(enter_button)
|
|
|
|
# "Edit World" (world_persistence_impl.md §6 D4): a non-joined entry
|
|
# point into the in-game editor -- it does NOT call POST /join. Only
|
|
# shown for the world's owner, matching the write-side authorization the
|
|
# save path itself enforces (§5.3), so a non-owner never sees a button
|
|
# that would just 403 on the first save.
|
|
var owner_id := int(world_dict.get("owner_id", -1))
|
|
if _current_user_id != -1 and owner_id == _current_user_id:
|
|
var edit_button := Button.new()
|
|
edit_button.text = "Edit World"
|
|
edit_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
|
edit_button.pressed.connect(_on_edit_world_pressed.bind(world_id))
|
|
buttons_row.add_child(edit_button)
|
|
|
|
card.add_child(name_label)
|
|
card.add_child(desc_label)
|
|
card.add_child(meta_label)
|
|
card.add_child(buttons_row)
|
|
|
|
return panel
|
|
|
|
|
|
func _on_enter_world_pressed(world_id: int, button: Button) -> void:
|
|
button.disabled = true
|
|
button.text = "Joining…"
|
|
|
|
var result := await ApiClient.join_world(world_id)
|
|
|
|
if result.ok and result.data is Dictionary:
|
|
var d: Dictionary = result.data
|
|
Session.begin(world_id, str(d.get("host", "")), int(d.get("port", 0)))
|
|
get_tree().change_scene_to_file("res://scenes/game.tscn")
|
|
return
|
|
|
|
if result.status == 503:
|
|
# This backend can't host game servers -- go in local single-player.
|
|
Session.clear()
|
|
get_tree().change_scene_to_file("res://scenes/game.tscn")
|
|
return
|
|
|
|
button.disabled = false
|
|
button.text = "Enter World"
|
|
error_label.text = "Couldn't join: %s" % result.detail
|
|
error_label.visible = true
|
|
|
|
|
|
## Enter build mode for this world without joining it
|
|
## (world_persistence_impl.md §6 D4). No /join call, no ENet connection, no
|
|
## dedicated server -- game.gd loads/saves the world's chunks over HTTP.
|
|
func _on_edit_world_pressed(world_id: int) -> void:
|
|
Session.begin_edit(world_id)
|
|
get_tree().change_scene_to_file("res://scenes/game.tscn")
|