Files
modbison/scripts/world_list.gd
T
2026-09-18 21:55:35 +00:00

463 lines
16 KiB
GDScript

extends Control
const WorldBriefingModal = preload("res://scripts/world_briefing_modal.gd")
## 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 create_button: Button = $VBoxContainer/HeaderHBox/CreateButton
@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
var _create_modal: Control = null
func _ready() -> void:
refresh_button.disabled = false
back_button.disabled = false
create_button.disabled = false
loading_label.visible = false
error_label.visible = false
empty_label.visible = false
world_list.visible = false
_show_build_stamp()
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 _show_build_stamp() -> void:
var lbl := Label.new()
var f := FileAccess.open("res://assets/build_info.json", FileAccess.READ)
var stamp := "dev"
if f != null:
var d = JSON.parse_string(f.get_as_text())
if d is Dictionary:
if d.has("build_label"):
var c: String = str(d.get("commit", ""))
stamp = ("%s (%s)" % [d.get("build_label"), c]) if not c.is_empty() else str(d.get("build_label"))
elif not str(d.get("version", "")).is_empty():
stamp = "MVP Build %s (%s)" % [d.get("version"), d.get("commit", "?")]
else:
stamp = "%s %s" % [d.get("checkpoint", "?"), d.get("commit", "?")]
lbl.text = stamp if stamp.begins_with("MVP Build") or stamp == "dev" else ("build %s" % stamp)
lbl.add_theme_color_override("font_color", Color(1, 1, 0.35, 1.0))
lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 1.0))
lbl.add_theme_constant_override("outline_size", 6)
lbl.add_theme_font_size_override("font_size", 18)
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(lbl)
var reposition := func() -> void:
if is_instance_valid(lbl) and is_inside_tree():
var vp := get_viewport().get_visible_rect().size
lbl.position = Vector2(20, vp.y - 40)
lbl.size = Vector2(vp.x - 40, 32)
reposition.call()
get_viewport().size_changed.connect(reposition)
func _on_create_button_pressed() -> void:
if _create_modal != null and is_instance_valid(_create_modal):
_create_modal.queue_free()
_create_modal = _build_create_world_modal()
add_child(_create_modal)
func _build_create_world_modal() -> Control:
var overlay := ColorRect.new()
overlay.name = "CreateWorldOverlay"
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(480, 0)
var style := StyleBoxFlat.new()
style.bg_color = Color(0.18, 0.18, 0.22, 1.0)
style.border_color = Color(0.4, 0.55, 0.8, 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 = "Create New World"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.add_theme_font_size_override("font_size", 22)
title.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0))
form.add_child(title)
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 New World"
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 = "A creative build world"
form.add_child(desc_input)
var tags_lbl := Label.new()
tags_lbl.text = "Content Tags (comma separated):"
tags_lbl.add_theme_font_size_override("font_size", 14)
form.add_child(tags_lbl)
var tags_input := LineEdit.new()
tags_input.placeholder_text = "e.g. Building, Roleplay, Chill, Parkour"
tags_input.text = "Building, Creative"
form.add_child(tags_input)
var guide_lbl := Label.new()
guide_lbl.text = "World Guidelines & Rules / Disclaimer:"
guide_lbl.add_theme_font_size_override("font_size", 14)
form.add_child(guide_lbl)
var guide_input := TextEdit.new()
guide_input.custom_minimum_size = Vector2(0, 60)
guide_input.placeholder_text = "Specify rules, content notices, or disclaimers for players..."
guide_input.text = "Welcome! Be respectful to fellow builders and enjoy creating."
form.add_child(guide_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 create_btn := Button.new()
create_btn.text = "Create World"
btn_row.add_child(create_btn)
var create_edit_btn := Button.new()
create_edit_btn.text = "Create & Edit Now"
btn_row.add_child(create_edit_btn)
var handle_create := func(open_editor: bool) -> 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
create_btn.disabled = true
create_edit_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 raw_tags := tags_input.text.split(",")
var clean_tags: Array = []
for t in raw_tags:
var st := t.strip_edges()
if not st.is_empty():
clean_tags.append(st)
var res: Dictionary = await ApiClient.create_world(
wname,
desc_input.text.strip_edges(),
"public",
"Building",
clean_tags,
guide_input.text.strip_edges()
)
if not res.get("ok", false):
create_btn.disabled = false
create_edit_btn.disabled = false
cancel_btn.disabled = false
err_label.text = "Failed: %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_world_id: int = int(new_world.get("id", 0))
overlay.queue_free()
if open_editor and new_world_id > 0:
Session.begin_edit(new_world_id)
get_tree().change_scene_to_file("res://scenes/game.tscn")
else:
_refresh_worlds()
create_btn.pressed.connect(handle_create.bind(false))
create_edit_btn.pressed.connect(handle_create.bind(true))
return overlay
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")
func _on_discover_button_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/discovery_ui.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 owner_id := int(world_dict.get("owner_id", -1))
var is_owner := (_current_user_id != -1 and owner_id == _current_user_id)
var name_label := Label.new()
var raw_name: String = world_dict.get("name", "Unnamed World")
if is_owner:
name_label.text = "%s ★ [Your World]" % raw_name
name_label.add_theme_color_override("font_color", Color(1.0, 0.9, 0.4, 1.0))
style.border_color = Color(0.7, 0.6, 0.2, 0.8)
style.set_border_width_all(2)
else:
name_label.text = raw_name
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(func(): _show_world_briefing(world_dict, 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.
if is_owner:
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 _show_world_briefing(world_dict: Dictionary, button: Button) -> void:
var modal := WorldBriefingModal.new()
var wid: int = int(world_dict.get("id", 0))
modal.setup_world_data(
wid,
str(world_dict.get("name", "World")),
str(world_dict.get("owner_username", "")),
str(world_dict.get("content_guidelines", "")),
world_dict.get("tags", []),
int(world_dict.get("play_count", 0))
)
modal.entered_confirmed.connect(func(target_wid: int): _on_enter_world_pressed(target_wid, button))
add_child(modal)
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")
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
if _create_modal != null and is_instance_valid(_create_modal):
_create_modal.queue_free()
_create_modal = null
get_viewport().set_input_as_handled()
return
for child in get_children():
if child is WorldBriefingModal:
child.queue_free()
get_viewport().set_input_as_handled()
return
get_viewport().set_input_as_handled()
_on_back_button_pressed()