Files
Papay-Shooter/ui/main_menu/main_menu.gd
T
2026-08-02 02:20:02 -04:00

742 lines
24 KiB
GDScript

extends Control
## The front door, arranged so the shortest path through it is the one a player
## takes every time.
##
## ── What was wrong ──────────────────────────────────────────────────────────
##
## Reaching a game was: Singleplayer → pick a level. Reaching a multiplayer game
## was: Multiplayer → Host Game → wait for the lobby → pick a level → and the
## gamemode dropdown had exactly one entry, which set a string nothing read.
## Every level card was a two-stop gradient generated from two colours in a meta
## file, which tells a player which card they clicked last time and nothing at
## all about the map.
##
## ── What it is now ──────────────────────────────────────────────────────────
##
## PLAY is one press, and it starts the last map and mode played. Everything else
## on the home screen is a detour from that, which is the correct shape for a
## menu: the common case is a button, not a path.
##
## This is the lesson HoYoverse published about Zenless Zone Zero's first months
## more loudly than anything else they have written. Their own postmortem on the
## TV mode names the three complaints — it took too long, it sat between the
## player and the combat, and there was too much of it early — and version 1.2
## removed it from the story entirely rather than shortening it. The general
## form is that time spent BEFORE the thing the player came for is not neutral,
## it is a cost, and a menu is the purest example.
##
## The other half of that team's writing that applies here is their Sixth Street
## level-design postmortem: functions grouped by relatedness, travel between them
## deliberately short, and every destination given a landmark you can recognise
## on sight rather than a label you have to read. A level select made of
## photographs is that last point exactly.
##
## ── Screens ─────────────────────────────────────────────────────────────────
##
## HOME → LEVELS, HOME → MULTIPLAYER → LOBBY. Nothing is more than two steps
## from the front, and PLAY is zero.
const PREVIEW_DIR := "res://assets/ui/map_previews/"
const MAPS_DIR := "res://scenes/maps/"
## Where "what I played last" is remembered, so PLAY has something to mean the
## second time the game is opened.
const LAST_CFG := "user://last_match.cfg"
enum { HOME, LEVELS, MULTIPLAYER, LOBBY }
var _screens: Dictionary = {}
var _maps: Array = [] # [{id, name, scene_path, gradient, preview}]
var _mode: String = GameMode.DEATHMATCH
var _last_scene: String = ""
var _play_button: Button
var _mode_note: Label
var _lobby_players: ItemList
var _lobby_start: Button
var _lobby_map_note: Label
var _pending_scene: String = ""
var _ip_input: LineEdit
var _port_input: LineEdit
var _join_status: Label
func _ready() -> void:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
UITheme.apply_global(get_tree())
_load_last()
_collect_maps()
_play_music()
var nm := _net()
if nm:
nm.connection_succeeded.connect(_on_connection_succeeded)
nm.connection_failed.connect(_on_connection_failed)
_build_diorama()
_screens[HOME] = _build_home()
_screens[LEVELS] = _build_levels()
_screens[MULTIPLAYER] = _build_multiplayer()
_screens[LOBBY] = _build_lobby()
_show(HOME)
UITheme.wire_sounds(self)
if _play_button:
_play_button.grab_focus()
# ── Persistence ──────────────────────────────────────────────────────────────
func _load_last() -> void:
var cfg := ConfigFile.new()
if cfg.load(LAST_CFG) == OK:
_last_scene = cfg.get_value("match", "scene", "")
var m: String = cfg.get_value("match", "mode", GameMode.DEATHMATCH)
if GameMode.MODES.has(m):
_mode = m
func _remember(scene_path: String) -> void:
var cfg := ConfigFile.new()
cfg.set_value("match", "scene", scene_path)
cfg.set_value("match", "mode", _mode)
cfg.save(LAST_CFG)
# ── Maps ─────────────────────────────────────────────────────────────────────
func _collect_maps() -> void:
var dir := DirAccess.open(MAPS_DIR)
if dir == null:
return
dir.list_dir_begin()
var folder := dir.get_next()
while folder != "":
if dir.current_is_dir() and not folder.begins_with("."):
var cfg := ConfigFile.new()
if cfg.load(MAPS_DIR + folder + "/map_meta.cfg") == OK:
var scene_path: String = cfg.get_value("map", "scene_path", "")
if scene_path != "":
var grad := Gradient.new()
grad.set_color(0, cfg.get_value("map", "color1", Color(0.5, 0.5, 0.5)))
grad.set_color(1, cfg.get_value("map", "color2", Color(0.2, 0.2, 0.2)))
_maps.append({
"id": folder,
"name": cfg.get_value("map", "name", folder),
"scene_path": scene_path,
"gradient": grad,
"preview": PREVIEW_DIR + folder + ".png",
})
folder = dir.get_next()
_maps.sort_custom(func(a, b): return String(a["name"]) < String(b["name"]))
# PLAY needs a target even on a first run, and the first map alphabetically
# is a better default than refusing to start.
if _last_scene == "" and not _maps.is_empty():
_last_scene = _maps[0]["scene_path"]
func _map_named(scene_path: String) -> String:
for m in _maps:
if m["scene_path"] == scene_path:
return String(m["name"])
return "Unknown"
# ── Screens ──────────────────────────────────────────────────────────────────
func _show(which: int) -> void:
for key in _screens:
(_screens[key] as Control).visible = key == which
## A screen: a scrim over the 3D background, then a margined column on top.
##
## The scrim is not decoration. The character standing in the diorama is roughly
## screen-centre, which is exactly where the level cards go, so without it their
## heads poke through the row of maps. Darkening the background also lifts every
## label on these screens off a bright sky. The HOME screen passes `scrim = 0.0`,
## because there the character IS the content.
func _panel(scrim: float = 0.55, margin_left: int = 60) -> VBoxContainer:
var screen := Control.new()
screen.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
screen.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(screen)
if scrim > 0.0:
var wash := ColorRect.new()
wash.color = Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, scrim)
wash.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
wash.mouse_filter = Control.MOUSE_FILTER_IGNORE
screen.add_child(wash)
var holder := MarginContainer.new()
holder.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
holder.add_theme_constant_override("margin_left", margin_left)
holder.add_theme_constant_override("margin_top", 48)
holder.add_theme_constant_override("margin_right", 60)
holder.add_theme_constant_override("margin_bottom", 48)
screen.add_child(holder)
var col := VBoxContainer.new()
col.add_theme_constant_override("separation", 14)
holder.add_child(col)
_pending_panel = screen
return col
var _pending_panel: Control
func _build_home() -> Control:
# No scrim: on the front screen the character IS the content.
var col := _panel(0.0)
var screen := _pending_panel
var title := UITheme.title("PAPAYA SHOOTER", 84)
col.add_child(title)
# The one loud button. It says what it will do, so pressing it is never a
# leap — "PLAY • Akiba Crossing • Deathmatch" is a promise, not a menu.
_play_button = UITheme.primary_button("PLAY", 52)
_play_button.custom_minimum_size = Vector2(430, 92)
_play_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
_play_button.pressed.connect(_on_play)
col.add_child(_play_button)
_mode_note = UITheme.caption("", 21)
col.add_child(_mode_note)
_refresh_play_note()
col.add_child(UITheme.divider(0.3))
_add_button(col, "Choose Map", func(): _show(LEVELS))
_add_button(col, "Multiplayer", func(): _show(MULTIPLAYER))
_add_button(col, "Loadouts", _on_loadouts)
_add_button(col, "Settings", _on_settings)
_add_button(col, "Exit", func(): get_tree().quit())
var spacer := Control.new()
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
col.add_child(spacer)
_build_skin_row(col)
return screen
func _refresh_play_note() -> void:
if _mode_note:
_mode_note.text = "%s%s" % [_map_named(_last_scene),
GameMode.display_name(_mode)]
if _play_button:
_play_button.disabled = _last_scene == ""
func _build_levels() -> Control:
var col := _panel()
var screen := _pending_panel
screen.visible = false
col.add_child(UITheme.title("CHOOSE MAP", 58))
col.add_child(_mode_selector(func(): _refresh_level_cards()))
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 22)
row.name = "Cards"
col.add_child(row)
for m in _maps:
var card := LevelCard.new()
card.setup(m["name"], m["preview"], m["gradient"], Vector2(310, 250))
var path: String = m["scene_path"]
# One press starts the match. There is no "select, then confirm" — a
# confirmation step on a level select exists only to make the screen
# take longer.
card.pressed.connect(func(): _start_single(path))
row.add_child(card)
var spacer := Control.new()
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
col.add_child(spacer)
_add_button(col, "Back", func(): _show(HOME))
_refresh_level_cards()
return screen
func _refresh_level_cards() -> void:
if not _screens.has(LEVELS):
return
for card in (_screens[LEVELS] as Control).find_children("*", "LevelCard", true, false):
(card as LevelCard).set_mode_text(GameMode.display_name(_mode).to_upper())
_refresh_play_note()
## The mode picker: a row of chips, not a dropdown.
##
## Every mode is visible and one press away, and each carries its own one-line
## description — a dropdown hides four fifths of the options behind a click and
## has nowhere to put the sentence that says what they are.
func _mode_selector(on_change: Callable) -> Control:
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 4)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10)
box.add_child(row)
var blurb := UITheme.caption(GameMode.blurb(_mode), 19)
var buttons: Array = []
for id in GameMode.all_ids():
var b := Button.new()
b.text = GameMode.display_name(id)
b.toggle_mode = true
b.button_pressed = id == _mode
b.add_theme_font_size_override("font_size", 24)
b.add_to_group("mode_chip")
var mode_id: String = id
b.pressed.connect(func():
_mode = mode_id
for other in buttons:
(other as Button).button_pressed = other == b
blurb.text = GameMode.blurb(mode_id)
on_change.call())
buttons.append(b)
row.add_child(b)
box.add_child(blurb)
return box
func _build_multiplayer() -> Control:
var col := _panel()
var screen := _pending_panel
screen.visible = false
col.add_child(UITheme.title("MULTIPLAYER", 58))
# Hosting is one press and it lands in the lobby already hosting. It used to
# be Host Game, then a lobby, then pick a level, then wait.
var host := UITheme.primary_button("HOST A GAME", 40)
host.custom_minimum_size = Vector2(400, 78)
host.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
host.pressed.connect(_on_host)
col.add_child(host)
col.add_child(UITheme.caption(
"Others join with your IP on port %d." % _default_port(), 19))
col.add_child(UITheme.divider(0.35))
col.add_child(UITheme.heading("JOIN A GAME", 30))
var fields := HBoxContainer.new()
fields.add_theme_constant_override("separation", 12)
col.add_child(fields)
_ip_input = LineEdit.new()
_ip_input.text = "127.0.0.1"
_ip_input.placeholder_text = "Host IP"
_ip_input.custom_minimum_size = Vector2(320, 54)
_ip_input.add_theme_font_size_override("font_size", 24)
fields.add_child(_ip_input)
_port_input = LineEdit.new()
_port_input.text = str(_default_port())
_port_input.placeholder_text = "Port"
_port_input.custom_minimum_size = Vector2(150, 54)
_port_input.add_theme_font_size_override("font_size", 24)
fields.add_child(_port_input)
var join := Button.new()
join.text = "Connect"
join.add_theme_font_size_override("font_size", 26)
join.pressed.connect(_on_join)
fields.add_child(join)
# Enter connects, because typing an address and then reaching for the mouse
# is a step nobody wants.
_ip_input.text_submitted.connect(func(_t): _on_join())
_port_input.text_submitted.connect(func(_t): _on_join())
_join_status = UITheme.caption("", 20)
col.add_child(_join_status)
var spacer := Control.new()
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
col.add_child(spacer)
_add_button(col, "Back", func(): _show(HOME))
return screen
## The lobby: map, mode, who is here, and start — all on one screen.
func _build_lobby() -> Control:
var outer := _panel()
var screen := _pending_panel
screen.visible = false
var split := HBoxContainer.new()
split.add_theme_constant_override("separation", 40)
split.size_flags_vertical = Control.SIZE_EXPAND_FILL
outer.add_child(split)
var left := VBoxContainer.new()
left.size_flags_horizontal = Control.SIZE_EXPAND_FILL
left.add_theme_constant_override("separation", 14)
split.add_child(left)
left.add_child(UITheme.title("LOBBY", 54))
left.add_child(_mode_selector(func(): _sync_lobby_mode()))
var cards := HBoxContainer.new()
cards.add_theme_constant_override("separation", 16)
left.add_child(cards)
for m in _maps:
var card := LevelCard.new()
card.setup(m["name"], m["preview"], m["gradient"], Vector2(230, 190))
card.add_to_group("host_only_ui")
var path: String = m["scene_path"]
card.pressed.connect(func(): _pick_lobby_map(path))
cards.add_child(card)
_lobby_map_note = UITheme.caption("", 21)
left.add_child(_lobby_map_note)
var spacer := Control.new()
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
left.add_child(spacer)
var actions := HBoxContainer.new()
actions.add_theme_constant_override("separation", 16)
left.add_child(actions)
_lobby_start = UITheme.primary_button("START MATCH", 38)
_lobby_start.custom_minimum_size = Vector2(330, 76)
_lobby_start.add_to_group("host_only_ui")
_lobby_start.pressed.connect(_on_start_match)
actions.add_child(_lobby_start)
var leave := Button.new()
leave.text = "Leave"
leave.add_theme_font_size_override("font_size", 28)
leave.pressed.connect(_on_leave_lobby)
actions.add_child(leave)
# ── Who is here ─────────────────────────────────────────────────────────
var right := VBoxContainer.new()
right.custom_minimum_size = Vector2(380, 0)
right.add_theme_constant_override("separation", 8)
split.add_child(right)
right.add_child(UITheme.heading("PLAYERS", 34))
right.add_child(UITheme.caption("Port %d" % _default_port(), 19))
_lobby_players = ItemList.new()
_lobby_players.size_flags_vertical = Control.SIZE_EXPAND_FILL
_lobby_players.add_theme_font_size_override("font_size", 22)
right.add_child(_lobby_players)
return screen
func _pick_lobby_map(scene_path: String) -> void:
_pending_scene = scene_path
_refresh_lobby()
func _sync_lobby_mode() -> void:
var nm := _net()
if nm and nm.multiplayer.is_server():
nm.current_gamemode = _mode
_refresh_lobby()
func _refresh_lobby() -> void:
if _lobby_map_note == null:
return
var picked := _pending_scene if _pending_scene != "" else _last_scene
_lobby_map_note.text = "%s%s • first to %d" % [
_map_named(picked), GameMode.display_name(_mode),
GameMode.score_limit(_mode)]
if _lobby_start:
_lobby_start.disabled = picked == ""
# ── Actions ──────────────────────────────────────────────────────────────────
func _on_play() -> void:
if _last_scene != "":
_start_single(_last_scene)
func _start_single(scene_path: String) -> void:
_remember(scene_path)
var nm := _net()
if nm:
nm.start_singleplayer_match(_mode)
get_tree().change_scene_to_file(scene_path)
func _on_host() -> void:
var nm := _net()
if nm == null:
return
if nm.host_game() == OK:
nm.current_gamemode = _mode
_pending_scene = _last_scene
_on_connection_succeeded()
elif _join_status:
_join_status.text = "Could not open port %d — is a server already running?" \
% _default_port()
_join_status.add_theme_color_override("font_color", UITheme.MAGENTA)
func _on_join() -> void:
var nm := _net()
if nm == null:
return
var port := _port_input.text.to_int()
if port <= 0:
port = _default_port()
if nm.join_game(_ip_input.text, port) == OK:
_join_status.text = "Connecting to %s…" % _ip_input.text
_join_status.add_theme_color_override("font_color", UITheme.PAPER_DIM)
else:
_on_connection_failed()
func _on_connection_failed() -> void:
if _join_status:
_join_status.text = "Could not reach that host."
_join_status.add_theme_color_override("font_color", UITheme.MAGENTA)
func _on_start_match() -> void:
var nm := _net()
if nm == null:
return
var picked := _pending_scene if _pending_scene != "" else _last_scene
if picked == "":
return
nm.current_gamemode = _mode
_remember(picked)
nm.rpc_load_level.rpc(picked)
func _on_leave_lobby() -> void:
var nm := _net()
if nm:
nm.disconnect_game()
if nm.player_connected.is_connected(_on_peer_changed):
nm.player_connected.disconnect(_on_peer_changed)
nm.player_disconnected.disconnect(_on_peer_changed)
if nm.stats_updated.is_connected(_update_lobby_players):
nm.stats_updated.disconnect(_update_lobby_players)
_show(MULTIPLAYER)
func _on_connection_succeeded() -> void:
_show(LOBBY)
var nm := _net()
if nm == null:
return
var is_host: bool = nm.multiplayer.is_server()
for node in get_tree().get_nodes_in_group("host_only_ui"):
if node is BaseButton:
(node as BaseButton).disabled = not is_host
# A client cannot change the mode either, so the chips go with the cards.
for chip in get_tree().get_nodes_in_group("mode_chip"):
if chip is BaseButton and (chip as Control).is_inside_tree() \
and _screens.has(LOBBY) \
and (_screens[LOBBY] as Control).is_ancestor_of(chip):
(chip as BaseButton).disabled = not is_host
if not is_host and _lobby_start:
_lobby_start.text = "WAITING FOR HOST"
if not nm.player_connected.is_connected(_on_peer_changed):
nm.player_connected.connect(_on_peer_changed)
nm.player_disconnected.connect(_on_peer_changed)
if not nm.stats_updated.is_connected(_update_lobby_players):
nm.stats_updated.connect(_update_lobby_players)
_update_lobby_players()
_refresh_lobby()
func _on_peer_changed(_id: int) -> void:
_update_lobby_players()
func _update_lobby_players() -> void:
if _lobby_players == null:
return
_lobby_players.clear()
var nm := _net()
if nm == null:
return
for pid in nm.player_stats.keys():
var s: Dictionary = nm.player_stats[pid]
var line := "%s%s" % [s.get("username", "Player"),
" (host)" if pid == 1 else ""]
if int(s.get("ping", 0)) > 0:
line += " %dms" % int(s.get("ping", 0))
var idx := _lobby_players.add_item(line)
var team := int(s.get("team", 0))
if team > 0:
_lobby_players.set_item_custom_fg_color(idx, GameMode.team_color(team))
func _on_loadouts() -> void:
var pm := get_node_or_null("/root/PauseMenu")
if pm:
pm.visible = true
pm._show_loadouts()
func _on_settings() -> void:
var pm := get_node_or_null("/root/PauseMenu")
if pm:
pm.visible = true
pm._show_settings()
# ── Bits ─────────────────────────────────────────────────────────────────────
func _net() -> Node:
return get_node_or_null("/root/NetworkManager")
func _default_port() -> int:
var nm := _net()
return int(nm.DEFAULT_PORT) if nm else 31415
func _add_button(parent: Container, text: String, callback: Callable) -> void:
var btn := Button.new()
btn.text = text
btn.add_theme_font_size_override("font_size", 30)
btn.custom_minimum_size = Vector2(300, 58)
btn.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
btn.pressed.connect(callback)
parent.add_child(btn)
func _build_skin_row(parent: Container) -> void:
var skin_mgr := get_node_or_null("/root/SkinManager")
if skin_mgr == null:
return
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 12)
parent.add_child(row)
row.add_child(UITheme.caption("Character", 22))
var opt := OptionButton.new()
opt.custom_minimum_size = Vector2(250, 46)
var ids: Array = skin_mgr.get_skin_ids()
ids.sort()
for i in ids.size():
opt.add_item(skin_mgr.get_skin(ids[i]).skin_name, i)
opt.set_item_metadata(i, ids[i])
if ids[i] == skin_mgr.active_skin_id:
opt.select(i)
opt.item_selected.connect(func(idx: int):
skin_mgr.set_active_skin(opt.get_item_metadata(idx)))
row.add_child(opt)
func _play_music() -> void:
# Menu music is scene-owned, so it stops the moment a level loads.
var path := "res://assets/sounds/music_menu_premium.ogg"
if not ResourceLoader.exists(path):
return
var music := AudioStreamPlayer.new()
music.stream = load(path)
if music.stream is AudioStreamOggVorbis:
music.stream.loop = true
music.bus = "Music"
music.volume_db = -36.0
add_child(music)
music.play()
create_tween().tween_property(music, "volume_db", -6.0, 1.4) \
.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)
# ── Background ───────────────────────────────────────────────────────────────
## The 3D scene behind the menu: the same stylised sky and toon materials the
## levels use, so the front door looks like the game it opens onto.
func _build_diorama() -> void:
var holder := SubViewportContainer.new()
holder.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
holder.stretch = true
holder.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(holder)
var viewport := SubViewport.new()
viewport.own_world_3d = true
viewport.msaa_3d = Viewport.MSAA_4X
holder.add_child(viewport)
var world := Node3D.new()
viewport.add_child(world)
var env := WorldEnvironment.new()
env.environment = LevelEnvironment.make_environment("sunset")
world.add_child(env)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-45, 45, 0)
sun.light_color = Color(1.0, 0.9, 0.8)
sun.light_energy = 1.5
sun.shadow_enabled = true
world.add_child(sun)
var fill := DirectionalLight3D.new()
fill.rotation_degrees = Vector3(-30, -135, 0)
fill.light_color = Color(0.5, 0.6, 1.0)
fill.light_energy = 0.5
world.add_child(fill)
var ground := MeshInstance3D.new()
var plane := PlaneMesh.new()
plane.size = Vector2(24, 24)
ground.mesh = plane
ground.mesh.surface_set_material(0, LevelMaterials.tinted(Color(0.32, 0.3, 0.38)))
world.add_child(ground)
# The player's OWN character, in their own skin, holding a weapon — rather
# than the box-and-capsule mannequin that used to stand here. The menu is
# where a player looks at what they picked, so it should show it.
var skin_mgr := get_node_or_null("/root/SkinManager")
var shown := false
if skin_mgr:
var skin = skin_mgr.get_active_skin()
if skin and skin.model_path != "":
var model := SkinnedPlayerModel.new()
model.skin_id = skin_mgr.active_skin_id
model.position = Vector3(0.35, 0, 0)
model.rotation_degrees.y = 205.0
world.add_child(model)
model.load_model(skin.model_path)
model.set_weapon("res://weapons/ak47.gd")
model.update_state("idle", 0.0, false)
shown = true
if not shown:
# No GLB skin selected: a plain capsule beats an empty stage.
var stand_in := MeshInstance3D.new()
var caps := CapsuleMesh.new()
caps.radius = 0.36
caps.height = 1.7
stand_in.mesh = caps
stand_in.position = Vector3(0.35, 0.85, 0)
world.add_child(stand_in)
LevelMaterials.apply_toon_recursive(stand_in, 0.0)
var camera := Camera3D.new()
world.add_child(camera)
# Off to the right and low, so the character stands clear of the menu column
# on the left rather than behind it.
camera.position = Vector3(2.4, 1.35, 3.1)
camera.look_at(Vector3(0.35, 1.0, 0))
camera.fov = 48.0