Reaching a game was Singleplayer → pick a level. Reaching a multiplayer game
was Multiplayer → Host Game → wait → pick a level → wait, and the gamemode
dropdown had exactly one entry which set a string nothing read.
PLAY now starts the last map and mode played, and says which — "Akiba Crossing
• Deathmatch" under the button, so pressing it is a promise rather than a leap.
Everything else on the home screen is a detour from that, which is the right
shape for a menu: the common case is a button, not a path. Hosting is one press
and lands in the lobby already hosting; the lobby puts map, mode, players and
start on ONE screen; Enter connects, so typing an address does not then require
reaching for the mouse.
This is the lesson HoYoverse published about Zenless Zone Zero's first months
more loudly than anything else they have written. Their postmortem on the TV
mode names three complaints — it took too long, it sat between the player and
the combat, and there was too much of it early — and 1.2 removed it from the
story entirely rather than shortening it. Time spent BEFORE the thing the
player came for is not neutral, it is a cost.
The cards were a two-stop gradient generated from two colours in a meta file,
whose hover state was `use_hdr = true` — not a visible change on any of them.
They are photographs now, shot by debug/map_preview_capture.gd, which took
three attempts to get right and each attempt is a comment in the file:
- framing each map from OUTSIDE by merging every VisualInstance3D's AABB
produced five tiny dioramas floating on a table, and one solid black
rectangle. That is a minimap, and a minimap is not a photograph.
- standing at a spawn point fixed three maps and left two black: fps_blockout
is genuinely dark and most of its spawns face an unlit wall, and
procedural_arena builds its geometry at runtime so no fixed offset is
reliably inside it.
- so the tool now RENDERS several vantages and scores each result — mean
luminance times its standard deviation, because brightness alone picks the
empty sky and variance alone picks a high-contrast corner of a dark room.
Their product picks a photograph. A black rectangle passes any check that
only asks whether the camera ended up somewhere sensible.
Also fixed, and caught by looking at the screenshot: the selected mode chip was
unreadable. Its glyph is ink on volt, and the theme gave every button a 5 px INK
outline — an ink glyph inside an ink outline is not outlined, it is five pixels
fatter, and on a small chip that is a solid blob. Button labels now carry no
outline at all, which is the same reasoning debug/ui_contrast_check.gd already
encodes: an outline separates a glyph from a backdrop it cannot beat alone, and
a label on a solid chip does not have that problem. The chips keep their heavy
ink border, so nothing about the drawn look changes.
`current_gamemode` is normalised on the way in, because it used to hold a
display string and six callers still pass one. An unrecognised id compares
unequal to GameMode.GUN_GAME and would silently disable that mode's weapon
issuing — half a mode is worse than none.
The menu's background character is the player's OWN skin holding their weapon,
rather than the box-and-capsule mannequin that stood there before.
spawn smoke 0, game modes 30/30, movement 11/11, contrast 108/108, HUD layout
PASS, weapon holds 0, dances 0.
Co-Authored-By: Claude Opus 5 <[email protected]>
740 lines
24 KiB
GDScript
740 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.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 = -8.0
|
|
music.autoplay = true
|
|
add_child(music)
|
|
|
|
|
|
# ── 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
|