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) 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 _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.mecha_theme = str(skin.get("mecha_theme")) 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