742 lines
24 KiB
GDScript
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
|