diff --git a/assets/ui/map_previews/dust2.png b/assets/ui/map_previews/dust2.png new file mode 100644 index 0000000..333dd63 Binary files /dev/null and b/assets/ui/map_previews/dust2.png differ diff --git a/assets/ui/map_previews/fps_blockout.png b/assets/ui/map_previews/fps_blockout.png new file mode 100644 index 0000000..04fed9e Binary files /dev/null and b/assets/ui/map_previews/fps_blockout.png differ diff --git a/assets/ui/map_previews/neon_alley.png b/assets/ui/map_previews/neon_alley.png new file mode 100644 index 0000000..58a9567 Binary files /dev/null and b/assets/ui/map_previews/neon_alley.png differ diff --git a/assets/ui/map_previews/procedural_arena.png b/assets/ui/map_previews/procedural_arena.png new file mode 100644 index 0000000..43cdff0 Binary files /dev/null and b/assets/ui/map_previews/procedural_arena.png differ diff --git a/assets/ui/map_previews/test_level.png b/assets/ui/map_previews/test_level.png new file mode 100644 index 0000000..d5b2156 Binary files /dev/null and b/assets/ui/map_previews/test_level.png differ diff --git a/debug/anim_capture.gd b/debug/anim_capture.gd index a86b1be..9e6a1da 100644 --- a/debug/anim_capture.gd +++ b/debug/anim_capture.gd @@ -51,7 +51,7 @@ func _process(_delta: float) -> bool: sm.set_active_skin(_skin) var nm = root.get_node_or_null("NetworkManager") if nm and nm.has_method("start_singleplayer_match"): - nm.start_singleplayer_match("Deathmatch") + nm.start_singleplayer_match(GameMode.DEATHMATCH) change_scene_to_file("res://scenes/maps/test_level/test_level.tscn") return false if _frames < 160: diff --git a/debug/fp_weapon_capture.gd b/debug/fp_weapon_capture.gd index 905e832..a3162ce 100644 --- a/debug/fp_weapon_capture.gd +++ b/debug/fp_weapon_capture.gd @@ -41,7 +41,7 @@ func _process(_delta: float) -> bool: if _frames == 40: var nm = root.get_node_or_null("NetworkManager") if nm and nm.has_method("start_singleplayer_match"): - nm.start_singleplayer_match("Deathmatch") + nm.start_singleplayer_match(GameMode.DEATHMATCH) change_scene_to_file("res://scenes/maps/test_level/test_level.tscn") elif _frames >= 160: for p in root.find_children("*", "CharacterBody3D", true, false): diff --git a/debug/game_mode_check.gd.uid b/debug/game_mode_check.gd.uid new file mode 100644 index 0000000..b80d3da --- /dev/null +++ b/debug/game_mode_check.gd.uid @@ -0,0 +1 @@ +uid://cjpesfuv0vmcs diff --git a/debug/map_preview_capture.gd b/debug/map_preview_capture.gd new file mode 100644 index 0000000..4a9b73f --- /dev/null +++ b/debug/map_preview_capture.gd @@ -0,0 +1,201 @@ +extends SceneTree + +## Photograph every map, for the level select cards. +## +## godot --path . --windowed --resolution 640x400 \ +## -s res://debug/map_preview_capture.gd +## +## Writes assets/ui/map_previews/.png, which the menu picks up by +## convention — no map_meta key to add, so a new map that ships a preview gets +## one and a map that does not falls back to its gradient. +## +## The cards used to be a two-stop gradient built from `color1` and `color2` in +## the map's meta file. That tells a player which card they clicked last time and +## nothing whatsoever about the map, which is the entire job of a level select. +## +## ── Framing ───────────────────────────────────────────────────────────────── +## +## The camera stands INSIDE the map, at a spawn point, at eye height, looking +## across it. +## +## The first version framed the whole level from outside by merging every +## VisualInstance3D's AABB and backing off until it fit. Every map came out as a +## tiny diorama floating on a table in the middle of an empty sky — and one came +## out entirely black, because from orbit there was nothing lit in frame. That is +## a minimap, and a minimap is a different thing from a photograph. +## +## What a player wants off a card is the FEEL of a place: its light, its colour, +## how enclosed it is, what the skyline looks like. All of that only exists from +## where a player will actually stand, so that is where the camera goes — and +## spawn points are already authored in every map, so it costs nothing. + +const OUT_DIR := "res://assets/ui/map_previews" +const MAPS_DIR := "res://scenes/maps/" +## How long to let a map build itself. The procedural ones generate geometry in +## `_ready` and the neon map streams in props, so a shot taken too early is a +## photograph of an empty skybox. +const SETTLE_FRAMES := 90 + + +func _initialize() -> void: + _run() + + +func _run() -> void: + await process_frame + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUT_DIR)) + + for folder in _map_folders(): + var cfg := ConfigFile.new() + if cfg.load(MAPS_DIR + folder + "/map_meta.cfg") != OK: + continue + var scene_path: String = cfg.get_value("map", "scene_path", "") + if scene_path == "" or not ResourceLoader.exists(scene_path): + continue + + var packed: PackedScene = load(scene_path) + if packed == null: + print("map_preview: could not load ", scene_path) + continue + var level: Node = packed.instantiate() + root.add_child(level) + + for _i in SETTLE_FRAMES: + await process_frame + + var shot: Image = await _best_shot(level) + var path := "%s/%s.png" % [OUT_DIR, folder] + if shot == null: + print("map_preview: no usable vantage in ", folder) + else: + shot.save_png(ProjectSettings.globalize_path(path)) + print("map_preview: saved ", path) + + level.queue_free() + for _i in 6: + await process_frame + + quit(0) + + +func _map_folders() -> Array: + var out: Array = [] + var dir := DirAccess.open(MAPS_DIR) + if dir == null: + return out + dir.list_dir_begin() + var name := dir.get_next() + while name != "": + if dir.current_is_dir() and not name.begins_with("."): + if FileAccess.file_exists(MAPS_DIR + name + "/map_meta.cfg"): + out.append(name) + name = dir.get_next() + out.sort() + return out + + +## Try several vantages and keep the one that produces the best PICTURE. +## +## Not the one that satisfies a rule about where cameras should go. Two maps +## defeated every positional rule tried: fps_blockout is a genuinely dark map +## where most spawns face an unlit wall, and procedural_arena builds its geometry +## at runtime so no fixed offset is inside it. Both came out as black rectangles, +## and a black rectangle passes any check that asks "did the camera end up +## somewhere sensible". +## +## So the tool renders each candidate and SCORES the result. The score wants an +## image that is both bright and varied: mean luminance alone picks a shot of the +## empty sky, and variance alone picks a high-contrast corner of a dark room. +## Their product picks a photograph. +func _best_shot(level: Node): + var bounds := _bounds(level) + var centre := bounds.get_center() if bounds.size.length() > 0.01 else Vector3.ZERO + var extent: float = maxf(maxf(bounds.size.x, bounds.size.z), 8.0) + + var cam := Camera3D.new() + level.add_child(cam) + cam.far = maxf(bounds.size.length() * 2.0, 800.0) + # A little wider than the game's own view, so a card shows more of the space + # than a screenshot of play would. + cam.fov = 68.0 + cam.current = true + + var best_img: Image = null + var best_score := -1.0 + for vantage in _vantages(centre, extent): + cam.global_position = vantage[0] + var look: Vector3 = vantage[1] + if cam.global_position.distance_to(look) < 1.0: + continue + cam.look_at(look) + # Two frames: one for the transform, one for the frame drawn with it. + for _i in 3: + await process_frame + var img := root.get_texture().get_image() + var score := _score(img) + if score > best_score: + best_score = score + best_img = img + cam.queue_free() + return best_img + + +## Candidate camera placements, as `[eye, look_at]` pairs. +## +## Every authored spawn point, plus a raised view from each side of the map for +## the levels that generate themselves and have no spawns by the time this runs. +func _vantages(centre: Vector3, extent: float) -> Array: + var out: Array = [] + for s in get_nodes_in_group("spawn_points"): + if s is Node3D: + var p: Vector3 = (s as Node3D).global_position + out.append([p + Vector3.UP * 2.2, centre + Vector3.UP * 1.6]) + # Raised corners, looking in. Higher than eye level, because a generated + # arena's floor is not necessarily at the origin and standing "on" it is a + # guess where looking down at it is not. + for dir in [Vector3(1, 0, 1), Vector3(-1, 0, 1), Vector3(1, 0, -1), + Vector3(-1, 0, -1)]: + var d: Vector3 = dir.normalized() + out.append([centre + d * extent * 0.42 + Vector3.UP * extent * 0.22, + centre + Vector3.UP * extent * 0.04]) + return out + + +## How good a picture is: bright AND varied. See `_best_shot`. +func _score(img: Image) -> float: + var small := img.duplicate() as Image + small.resize(40, 24, Image.INTERPOLATE_BILINEAR) + var n := 40 * 24 + var mean := 0.0 + for y in 24: + for x in 40: + var c := small.get_pixel(x, y) + mean += 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b + mean /= float(n) + var variance := 0.0 + for y in 24: + for x in 40: + var c := small.get_pixel(x, y) + var l := 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b + variance += (l - mean) * (l - mean) + variance /= float(n) + return mean * sqrt(variance) + + +func _bounds(node: Node) -> AABB: + var out := AABB() + var any := false + for child in node.find_children("*", "VisualInstance3D", true, false): + var vi := child as VisualInstance3D + # Skip anything enormous: a WorldEnvironment's sky or a directional + # light's own AABB would swallow the map and push the camera to orbit. + var box := vi.get_aabb() + if box.size.length() > 100000.0 or box.size.length() < 0.001: + continue + box = vi.global_transform * box + if not any: + out = box + any = true + else: + out = out.merge(box) + return out if any else AABB() diff --git a/debug/map_preview_capture.gd.uid b/debug/map_preview_capture.gd.uid new file mode 100644 index 0000000..152f24b --- /dev/null +++ b/debug/map_preview_capture.gd.uid @@ -0,0 +1 @@ +uid://esvuwcyncpeb diff --git a/debug/menu_capture.gd b/debug/menu_capture.gd new file mode 100644 index 0000000..d7dfc0b --- /dev/null +++ b/debug/menu_capture.gd @@ -0,0 +1,65 @@ +extends SceneTree + +## One photograph of every main-menu screen, including the lobby. +## +## godot --path . --windowed --resolution 1280x720 \ +## -s res://debug/menu_capture.gd -- +## +## The lobby is the reason this exists. It only appears after hosting, so it is +## the screen least likely to be looked at and the one where an unstyled list or +## a collapsed card would sit unnoticed — which is exactly the class of defect +## that this project keeps finding by LOOKING rather than by asserting. + +const MENU := "res://ui/main_menu/main_menu.tscn" + +var _out := "." + + +func _initialize() -> void: + var args := OS.get_cmdline_user_args() + if args.size() > 0: + _out = String(args[0]) + _run() + + +func _run() -> void: + await process_frame + change_scene_to_file(MENU) + for _i in 60: + await process_frame + + var menu := current_scene + if menu == null: + print("menu_capture: no menu") + quit(1) + return + + await _shot("home") + + menu._show(menu.LEVELS) + await _shot("levels") + + menu._show(menu.MULTIPLAYER) + await _shot("multiplayer") + + # Host for real, so the lobby is photographed in the state a player reaches + # it in — host controls enabled, own name in the list — rather than in an + # empty one that would hide a broken player list. + var nm = root.get_node_or_null("NetworkManager") + if nm and nm.host_game() == OK: + menu._on_connection_succeeded() + for _i in 20: + await process_frame + await _shot("lobby") + nm.disconnect_game() + else: + print("menu_capture: could not host, lobby not shot") + + quit(0) + + +func _shot(tag: String) -> void: + for _i in 12: + await process_frame + root.get_texture().get_image().save_png("%s/menu_%s.png" % [_out, tag]) + print("menu_capture: saved ", tag) diff --git a/debug/orbit_capture.gd b/debug/orbit_capture.gd index b3f0652..4e90b9b 100644 --- a/debug/orbit_capture.gd +++ b/debug/orbit_capture.gd @@ -25,7 +25,7 @@ func _process(_delta: float) -> bool: sm.set_active_skin("taila") var nm = root.get_node_or_null("NetworkManager") if nm and nm.has_method("start_singleplayer_match"): - nm.start_singleplayer_match("Deathmatch") + nm.start_singleplayer_match(GameMode.DEATHMATCH) change_scene_to_file("res://scenes/maps/test_level/test_level.tscn") elif _frames == 160: for p in root.find_children("*", "CharacterBody3D", true, false): diff --git a/debug/roster_capture.gd b/debug/roster_capture.gd index 615f717..59bfe27 100644 --- a/debug/roster_capture.gd +++ b/debug/roster_capture.gd @@ -30,7 +30,7 @@ func _initialize() -> void: _out_dir = args[0] var nm = root.get_node_or_null("NetworkManager") if nm and nm.has_method("start_singleplayer_match"): - nm.start_singleplayer_match("Deathmatch") + nm.start_singleplayer_match(GameMode.DEATHMATCH) change_scene_to_file("res://scenes/maps/test_level/test_level.tscn") diff --git a/debug/spawn_smoke_test.gd b/debug/spawn_smoke_test.gd index 0567c58..bc5e10f 100644 --- a/debug/spawn_smoke_test.gd +++ b/debug/spawn_smoke_test.gd @@ -56,7 +56,7 @@ func _test_spawn_with_skin(skin_id: String, expect_skinned: bool) -> void: var nm = root.get_node_or_null("/root/NetworkManager") if not _check(nm != null, "NetworkManager autoload exists"): return - nm.start_singleplayer_match("Deathmatch") + nm.start_singleplayer_match(GameMode.DEATHMATCH) change_scene_to_file("res://scenes/maps/test_level/test_level.tscn") # Let the level build and the player spawn + settle. diff --git a/debug/summary_capture.gd.uid b/debug/summary_capture.gd.uid new file mode 100644 index 0000000..66310cb --- /dev/null +++ b/debug/summary_capture.gd.uid @@ -0,0 +1 @@ +uid://bnw4gima0hkli diff --git a/debug/ui_capture.gd b/debug/ui_capture.gd index 8d2f2f1..6555bb1 100644 --- a/debug/ui_capture.gd +++ b/debug/ui_capture.gd @@ -35,7 +35,7 @@ func _process(_delta: float) -> bool: # rather than a flat colour — that its legibility has to survive. var nm = root.get_node_or_null("NetworkManager") if nm and nm.has_method("start_singleplayer_match"): - nm.start_singleplayer_match("Deathmatch") + nm.start_singleplayer_match(GameMode.DEATHMATCH) change_scene_to_file("res://scenes/maps/test_level/test_level.tscn") 200: _open("_show_main_menu") diff --git a/debug/visual_capture.gd b/debug/visual_capture.gd index 13575be..567524c 100644 --- a/debug/visual_capture.gd +++ b/debug/visual_capture.gd @@ -26,7 +26,7 @@ func _process(_delta: float) -> bool: _shot("menu") var nm = root.get_node_or_null("NetworkManager") if nm and nm.has_method("start_singleplayer_match"): - nm.start_singleplayer_match("Deathmatch") + nm.start_singleplayer_match(GameMode.DEATHMATCH) change_scene_to_file(_scene) elif _frames == 240: _shot("level") diff --git a/globals/network_manager.gd b/globals/network_manager.gd index c82020b..d844440 100644 --- a/globals/network_manager.gd +++ b/globals/network_manager.gd @@ -85,7 +85,13 @@ func start_singleplayer_match(gamemode: String = GameMode.DEATHMATCH) -> void: connected_players.clear() connected_players.append(1) - current_gamemode = gamemode + # Normalised, because `current_gamemode` used to be a DISPLAY string + # ("Deathmatch") and half a dozen callers still pass one. GameMode's lookups + # all fall back safely, but an id that is not in the table would compare + # unequal to GameMode.GUN_GAME and silently disable Gun Game's weapon + # issuing — a mode that half works is worse than one that does not load. + current_gamemode = gamemode if GameMode.MODES.has(gamemode) \ + else GameMode.DEATHMATCH player_stats.clear() _init_player_stats(1, SettingsManager.username) diff --git a/ui/main_menu/level_card.gd b/ui/main_menu/level_card.gd new file mode 100644 index 0000000..6bbe078 --- /dev/null +++ b/ui/main_menu/level_card.gd @@ -0,0 +1,144 @@ +extends Button +class_name LevelCard + +## One map on the level select: its photograph, its name, and its mode. +## +## The cards used to be a two-stop gradient generated from `color1` and `color2` +## in the map's meta file. That tells a player which card they clicked last time +## and nothing at all about the map, which is the entire job of a level select — +## and the hover state was `use_hdr = true` on the gradient texture, which is not +## a visible change on any of them. +## +## The preview comes from assets/ui/map_previews/.png, shot by +## debug/map_preview_capture.gd from inside the map. A map with no preview falls +## back to its gradient, so nothing breaks for a map that has not been +## photographed yet. +## +## Built on Button rather than TextureButton so it gets focus, keyboard +## navigation and the theme's state machinery for free. The image sits inside the +## button's border rather than under it, so the border reads as a frame around a +## photograph instead of a rectangle behind one. + +const INSET := 5 + +var _plate: ColorRect +var _label: Label +var _mode_label: Label + + +func setup(title: String, preview_path: String, gradient: Gradient, + card_size: Vector2) -> void: + custom_minimum_size = card_size + clip_contents = true + + # The frame. Papaya at rest, volt on hover, and the fill stays transparent + # so the photograph is what the player sees. + var clear := Color(0, 0, 0, 0) + add_theme_stylebox_override("normal", _frame(clear, UITheme.PAPAYA, 3)) + add_theme_stylebox_override("hover", _frame(clear, UITheme.VOLT, 5)) + add_theme_stylebox_override("pressed", _frame( + Color(UITheme.VOLT.r, UITheme.VOLT.g, UITheme.VOLT.b, 0.25), + UITheme.VOLT, 5)) + add_theme_stylebox_override("focus", _frame(clear, UITheme.CYAN, 5)) + add_theme_stylebox_override("disabled", _frame( + Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.7), + UITheme.DEAD_EDGE, 3)) + + if ResourceLoader.exists(preview_path): + var shot := TextureRect.new() + shot.texture = load(preview_path) + shot.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + # COVERED, not scaled: a photograph letterboxed inside a card reads as a + # thumbnail in a file browser. Filling the card and cropping reads as a + # poster, which is what this is. + shot.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED + shot.mouse_filter = Control.MOUSE_FILTER_IGNORE + _fill(shot) + add_child(shot) + else: + var grad := TextureRect.new() + var tex := GradientTexture2D.new() + tex.gradient = gradient + tex.width = int(card_size.x) + tex.height = int(card_size.y) + tex.fill_to = Vector2(1, 1) + grad.texture = tex + grad.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + grad.stretch_mode = TextureRect.STRETCH_SCALE + grad.mouse_filter = Control.MOUSE_FILTER_IGNORE + _fill(grad) + add_child(grad) + + # The name plate: a hard ink band across the bottom rather than a gradient + # fade, because the theme is drawn and a soft fade is not. + _plate = ColorRect.new() + _plate.color = Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.88) + _plate.mouse_filter = Control.MOUSE_FILTER_IGNORE + _plate.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE) + _plate.offset_top = -62 + _plate.offset_left = INSET + _plate.offset_right = -INSET + _plate.offset_bottom = -INSET + add_child(_plate) + + var stack := VBoxContainer.new() + stack.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + stack.add_theme_constant_override("separation", -2) + stack.mouse_filter = Control.MOUSE_FILTER_IGNORE + _plate.add_child(stack) + + _label = Label.new() + _label.text = title + _label.add_theme_font_size_override("font_size", 28) + _label.add_theme_color_override("font_color", UITheme.PAPER) + _label.add_theme_color_override("font_outline_color", UITheme.INK) + _label.add_theme_constant_override("outline_size", 6) + _label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + stack.add_child(_label) + + _mode_label = Label.new() + _mode_label.add_theme_font_size_override("font_size", 16) + _mode_label.add_theme_color_override("font_color", UITheme.PAPER_DIM) + _mode_label.add_theme_color_override("font_outline_color", UITheme.INK) + _mode_label.add_theme_constant_override("outline_size", 4) + _mode_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + stack.add_child(_mode_label) + + # The label follows the frame, same rule as every other control: hover turns + # the frame volt, so the name turns volt with it. + mouse_entered.connect(func(): _tint(UITheme.VOLT)) + mouse_exited.connect(func(): _tint(UITheme.PAPER)) + focus_entered.connect(func(): _tint(UITheme.CYAN)) + focus_exited.connect(func(): _tint(UITheme.PAPER)) + + +## What pressing this card will start. Shown under the name so the player is +## never guessing which mode the button they are about to press launches. +func set_mode_text(text: String) -> void: + if _mode_label: + _mode_label.text = text + + +func _tint(c: Color) -> void: + if _label: + _label.add_theme_color_override("font_color", c) + + +func _fill(c: Control) -> void: + c.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + c.offset_left = INSET + c.offset_top = INSET + c.offset_right = -INSET + c.offset_bottom = -INSET + + +func _frame(fill: Color, border: Color, width: int) -> StyleBoxFlat: + var sb := StyleBoxFlat.new() + sb.bg_color = fill + sb.border_color = border + sb.set_border_width_all(width) + sb.set_corner_radius_all(5) + sb.shadow_color = Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.6) + sb.shadow_size = 4 + sb.shadow_offset = Vector2(5, 5) + return sb diff --git a/ui/main_menu/level_card.gd.uid b/ui/main_menu/level_card.gd.uid new file mode 100644 index 0000000..2b8b557 --- /dev/null +++ b/ui/main_menu/level_card.gd.uid @@ -0,0 +1 @@ +uid://2bfw74vxcitf diff --git a/ui/main_menu/main_menu.gd b/ui/main_menu/main_menu.gd index 9193d3c..4e481e2 100644 --- a/ui/main_menu/main_menu.gd +++ b/ui/main_menu/main_menu.gd @@ -1,602 +1,739 @@ extends Control -var _ui_vbox: VBoxContainer -var _level_selector_panel: VBoxContainer -var _multiplayer_panel: VBoxContainer -var _direct_connect_panel: VBoxContainer -var _host_lobby_panel: Control -var _host_lobby_players_list: ItemList +## 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) - - # Comic/cel theme for every Control in the game (root window inherits down) UITheme.apply_global(get_tree()) + _load_last() + _collect_maps() + _play_music() - # Menu music (scene-owned: stops automatically when a level loads). - # OGG loops natively over the full track — no manual loop points. - var music_path := "res://assets/sounds/music_menu.ogg" - if ResourceLoader.exists(music_path): - var music := AudioStreamPlayer.new() - music.stream = load(music_path) - if music.stream is AudioStreamOggVorbis: - music.stream.loop = true - music.bus = "Music" - music.volume_db = -8.0 - music.autoplay = true - add_child(music) - - var nm = get_node_or_null("/root/NetworkManager") + var nm := _net() if nm: nm.connection_succeeded.connect(_on_connection_succeeded) - - # ========================================== - # BACKGROUND: 3D DIORAMA (Sliding Player) - # ========================================== - var right_panel = SubViewportContainer.new() - right_panel.set_anchors_preset(Control.PRESET_FULL_RECT) - right_panel.stretch = true - right_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(right_panel) - - var viewport = SubViewport.new() - viewport.own_world_3d = true - viewport.msaa_3d = Viewport.MSAA_4X - right_panel.add_child(viewport) - - _build_3d_diorama(viewport) - - # ========================================== - # FOREGROUND: UI (Title and Buttons) - # ========================================== - _ui_vbox = VBoxContainer.new() - _ui_vbox.add_theme_constant_override("separation", 30) - _ui_vbox.set_anchor_and_offset(SIDE_LEFT, 0.0, 50) - _ui_vbox.set_anchor_and_offset(SIDE_TOP, 0.0, 50) - _ui_vbox.set_anchor_and_offset(SIDE_RIGHT, 0.0, 600) - _ui_vbox.set_anchor_and_offset(SIDE_BOTTOM, 1.0, -50) - add_child(_ui_vbox) + nm.connection_failed.connect(_on_connection_failed) - # Title — big comic wordmark with a slight kinetic tilt - var title = Label.new() - title.text = "PAPAYA SHOOTER" - title.add_theme_font_size_override("font_size", 92) - title.add_theme_color_override("font_color", UITheme.PAPAYA) - title.add_theme_color_override("font_outline_color", UITheme.INK) - title.add_theme_constant_override("outline_size", 16) - title.rotation_degrees = -2.5 - _ui_vbox.add_child(title) - - # Spacer - var spacer = Control.new() - spacer.custom_minimum_size = Vector2(0, 40) - _ui_vbox.add_child(spacer) - - # Buttons - _add_button(_ui_vbox, "Singleplayer", _on_singleplayer_pressed) - _add_button(_ui_vbox, "Multiplayer", _on_multiplayer_pressed) - _add_button(_ui_vbox, "Loadouts", _on_loadouts_pressed) - _add_button(_ui_vbox, "Settings", _on_settings_pressed) - _add_button(_ui_vbox, "Exit Game", _on_exit_pressed) + _build_diorama() + _screens[HOME] = _build_home() + _screens[LEVELS] = _build_levels() + _screens[MULTIPLAYER] = _build_multiplayer() + _screens[LOBBY] = _build_lobby() + _show(HOME) - # Skin selector (skins come from SkinManager: built-ins + skins.json) - _build_skin_selector(_ui_vbox) - - # ========================================== - # LEVEL SELECTOR PANEL - # ========================================== - _level_selector_panel = VBoxContainer.new() - _level_selector_panel.add_theme_constant_override("separation", 20) - _level_selector_panel.set_anchor_and_offset(SIDE_LEFT, 0.0, 50) - _level_selector_panel.set_anchor_and_offset(SIDE_TOP, 0.0, 50) - _level_selector_panel.set_anchor_and_offset(SIDE_RIGHT, 0.0, 800) - _level_selector_panel.set_anchor_and_offset(SIDE_BOTTOM, 1.0, -50) - _level_selector_panel.hide() - add_child(_level_selector_panel) - - var ls_title = Label.new() - ls_title.text = "Select Level" - ls_title.add_theme_font_size_override("font_size", 64) - ls_title.add_theme_color_override("font_color", UITheme.PAPAYA) - ls_title.add_theme_color_override("font_outline_color", UITheme.INK) - ls_title.add_theme_constant_override("outline_size", 8) - _level_selector_panel.add_child(ls_title) - - # Cards Container - var cards_hbox = HBoxContainer.new() - cards_hbox.add_theme_constant_override("separation", 30) - cards_hbox.size_flags_vertical = Control.SIZE_EXPAND_FILL - _level_selector_panel.add_child(cards_hbox) - - _load_maps_modularly(cards_hbox, false, Vector2(300, 400)) - - var ls_spacer = Control.new() - ls_spacer.custom_minimum_size = Vector2(0, 20) - _level_selector_panel.add_child(ls_spacer) - - _add_button(_level_selector_panel, "Back", _on_back_pressed) - - # ========================================== - # MULTIPLAYER PANEL - # ========================================== - _multiplayer_panel = VBoxContainer.new() - _multiplayer_panel.add_theme_constant_override("separation", 20) - _multiplayer_panel.set_anchor_and_offset(SIDE_LEFT, 0.0, 50) - _multiplayer_panel.set_anchor_and_offset(SIDE_TOP, 0.0, 50) - _multiplayer_panel.set_anchor_and_offset(SIDE_RIGHT, 0.0, 600) - _multiplayer_panel.set_anchor_and_offset(SIDE_BOTTOM, 1.0, -50) - _multiplayer_panel.hide() - add_child(_multiplayer_panel) - - var mp_title = Label.new() - mp_title.text = "Multiplayer" - mp_title.add_theme_font_size_override("font_size", 64) - mp_title.add_theme_color_override("font_color", UITheme.PAPAYA) - mp_title.add_theme_color_override("font_outline_color", UITheme.INK) - mp_title.add_theme_constant_override("outline_size", 8) - _multiplayer_panel.add_child(mp_title) - - var mp_spacer = Control.new() - mp_spacer.custom_minimum_size = Vector2(0, 20) - _multiplayer_panel.add_child(mp_spacer) - - _add_button(_multiplayer_panel, "Host Game", _on_host_pressed) - _add_button(_multiplayer_panel, "Direct Connect", _on_direct_connect_pressed) - _add_button(_multiplayer_panel, "Back", _on_multiplayer_back_pressed) - - # ========================================== - # DIRECT CONNECT PANEL - # ========================================== - _direct_connect_panel = VBoxContainer.new() - _direct_connect_panel.add_theme_constant_override("separation", 20) - _direct_connect_panel.set_anchor_and_offset(SIDE_LEFT, 0.0, 50) - _direct_connect_panel.set_anchor_and_offset(SIDE_TOP, 0.0, 50) - _direct_connect_panel.set_anchor_and_offset(SIDE_RIGHT, 0.0, 600) - _direct_connect_panel.set_anchor_and_offset(SIDE_BOTTOM, 1.0, -50) - _direct_connect_panel.hide() - add_child(_direct_connect_panel) - - var dc_title = Label.new() - dc_title.text = "Direct Connect" - dc_title.add_theme_font_size_override("font_size", 64) - dc_title.add_theme_color_override("font_color", UITheme.PAPAYA) - dc_title.add_theme_color_override("font_outline_color", UITheme.INK) - dc_title.add_theme_constant_override("outline_size", 8) - _direct_connect_panel.add_child(dc_title) - - var dc_ip_label = Label.new() - dc_ip_label.text = "IP Address:" - dc_ip_label.add_theme_font_size_override("font_size", 24) - _direct_connect_panel.add_child(dc_ip_label) - - var dc_ip_input = LineEdit.new() - dc_ip_input.text = "127.0.0.1" - dc_ip_input.name = "IPInput" - dc_ip_input.add_theme_font_size_override("font_size", 24) - dc_ip_input.custom_minimum_size = Vector2(300, 50) - _direct_connect_panel.add_child(dc_ip_input) - - var dc_port_label = Label.new() - dc_port_label.text = "Port:" - dc_port_label.add_theme_font_size_override("font_size", 24) - _direct_connect_panel.add_child(dc_port_label) - - var dc_port_input = LineEdit.new() - dc_port_input.text = "31415" # Default Port - dc_port_input.name = "PortInput" - dc_port_input.add_theme_font_size_override("font_size", 24) - dc_port_input.custom_minimum_size = Vector2(300, 50) - _direct_connect_panel.add_child(dc_port_input) - - var dc_spacer = Control.new() - dc_spacer.custom_minimum_size = Vector2(0, 20) - _direct_connect_panel.add_child(dc_spacer) - - _add_button(_direct_connect_panel, "Connect", _on_connect_confirm_pressed) - _add_button(_direct_connect_panel, "Back", _on_dc_back_pressed) - - # ========================================== - # HOST LOBBY PANEL - # ========================================== - _host_lobby_panel = Control.new() - _host_lobby_panel.set_anchors_preset(Control.PRESET_FULL_RECT) - _host_lobby_panel.hide() - add_child(_host_lobby_panel) - - var hl_hbox = HBoxContainer.new() - hl_hbox.set_anchors_preset(Control.PRESET_FULL_RECT) - hl_hbox.add_theme_constant_override("separation", 50) - _host_lobby_panel.add_child(hl_hbox) - - # Left Side (Level Selector) - var hl_left = VBoxContainer.new() - hl_left.size_flags_horizontal = Control.SIZE_EXPAND_FILL - hl_left.set_anchor_and_offset(SIDE_LEFT, 0.0, 50) - hl_left.set_anchor_and_offset(SIDE_TOP, 0.0, 50) - hl_left.add_theme_constant_override("separation", 20) - - var hl_margin = MarginContainer.new() - hl_margin.add_theme_constant_override("margin_left", 50) - hl_margin.add_theme_constant_override("margin_top", 50) - hl_margin.add_theme_constant_override("margin_bottom", 50) - hl_margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL - hl_hbox.add_child(hl_margin) - hl_margin.add_child(hl_left) - - var hl_title = Label.new() - hl_title.text = "Host Lobby - Select Level" - hl_title.add_theme_font_size_override("font_size", 64) - hl_title.add_theme_color_override("font_color", UITheme.PAPAYA) - hl_title.add_theme_color_override("font_outline_color", UITheme.INK) - hl_title.add_theme_constant_override("outline_size", 8) - hl_left.add_child(hl_title) - - var hl_cards = HBoxContainer.new() - hl_cards.add_theme_constant_override("separation", 20) - hl_left.add_child(hl_cards) - - _load_maps_modularly(hl_cards, true, Vector2(200, 250)) - - var mode_label = Label.new() - mode_label.text = "Gamemode" - mode_label.add_theme_font_size_override("font_size", 32) - hl_left.add_child(mode_label) - - var _gamemode_opt = OptionButton.new() - _gamemode_opt.add_theme_font_size_override("font_size", 24) - _gamemode_opt.add_item("Deathmatch", 0) - _gamemode_opt.add_to_group("host_only_ui") - _gamemode_opt.item_selected.connect(func(index: int): - if get_node_or_null("/root/NetworkManager"): - get_node("/root/NetworkManager").current_gamemode = _gamemode_opt.get_item_text(index) - ) - hl_left.add_child(_gamemode_opt) - - var hl_spacer = Control.new() - hl_spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL - hl_left.add_child(hl_spacer) - - _add_button(hl_left, "Disband & Back", _on_lobby_back_pressed) - - # Right Side (Player List) - var hl_right = VBoxContainer.new() - hl_right.custom_minimum_size = Vector2(400, 0) - var hl_right_margin = MarginContainer.new() - hl_right_margin.add_theme_constant_override("margin_top", 50) - hl_right_margin.add_theme_constant_override("margin_right", 50) - hl_right_margin.add_theme_constant_override("margin_bottom", 50) - hl_hbox.add_child(hl_right_margin) - hl_right_margin.add_child(hl_right) - - var pl_title = Label.new() - pl_title.text = "Players Connected" - pl_title.add_theme_font_size_override("font_size", 48) - pl_title.add_theme_color_override("font_outline_color", UITheme.INK) - pl_title.add_theme_constant_override("outline_size", 8) - hl_right.add_child(pl_title) - - var port_label = Label.new() - if get_node_or_null("/root/NetworkManager"): - port_label.text = "Port: " + str(get_node("/root/NetworkManager").DEFAULT_PORT) - port_label.add_theme_font_size_override("font_size", 24) - port_label.add_theme_color_override("font_color", UITheme.PAPER_DIM) - hl_right.add_child(port_label) - - _host_lobby_players_list = ItemList.new() - _host_lobby_players_list.size_flags_vertical = Control.SIZE_EXPAND_FILL - _host_lobby_players_list.add_theme_font_size_override("font_size", 24) - hl_right.add_child(_host_lobby_players_list) - - # Hover/click sounds on every menu button UITheme.wire_sounds(self) + if _play_button: + _play_button.grab_focus() -func _load_maps_modularly(target_hbox: Container, is_multiplayer: bool, card_size: Vector2) -> void: - var maps_dir = "res://scenes/maps/" - var dir = DirAccess.open(maps_dir) - if not dir: + +# ── 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_name = dir.get_next() - while folder_name != "": - if dir.current_is_dir() and not folder_name.begins_with("."): - var cfg_path = maps_dir + folder_name + "/map_meta.cfg" - if FileAccess.file_exists(cfg_path): - var cfg = ConfigFile.new() - if cfg.load(cfg_path) == OK: - var map_name = cfg.get_value("map", "name", "Unknown Map") - var scene_path = cfg.get_value("map", "scene_path", "") - var c1 = cfg.get_value("map", "color1", Color(0.5, 0.5, 0.5)) - var c2 = cfg.get_value("map", "color2", Color(0.2, 0.2, 0.2)) - - if scene_path != "": - var grad = Gradient.new() - # A new Gradient has 2 points at 0.0 and 1.0 by default. Set their colors directly. - grad.set_color(0, c1) - grad.set_color(1, c2) - _build_level_card(target_hbox, map_name, scene_path, grad, is_multiplayer, card_size) - folder_name = dir.get_next() + 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 _build_level_card(parent: Container, title: String, scene_path: String, gradient: Gradient, is_multiplayer: bool = false, card_size: Vector2 = Vector2(300, 400)) -> void: - var btn = TextureButton.new() - btn.custom_minimum_size = card_size - - var tex = GradientTexture2D.new() - tex.gradient = gradient - tex.width = int(card_size.x) - tex.height = int(card_size.y) - tex.fill_to = Vector2(1, 1) - btn.texture_normal = tex - - var tex_hover = GradientTexture2D.new() - tex_hover.gradient = gradient - tex_hover.width = int(card_size.x) - tex_hover.height = int(card_size.y) - tex_hover.fill_to = Vector2(1, 1) - tex_hover.use_hdr = true # Slight bright effect on hover - btn.texture_hover = tex_hover - btn.modulate = Color(1, 1, 1) - - # VBox to hold the label - var vbox = VBoxContainer.new() - vbox.set_anchors_preset(Control.PRESET_FULL_RECT) - vbox.alignment = BoxContainer.ALIGNMENT_END - btn.add_child(vbox) - - # Dimming overlay at the bottom for text readability - var overlay = ColorRect.new() - overlay.color = Color(0, 0, 0, 0.6) - overlay.custom_minimum_size = Vector2(0, 60) - vbox.add_child(overlay) - - var lbl = Label.new() - lbl.text = title - lbl.add_theme_font_size_override("font_size", 32) - lbl.add_theme_color_override("font_color", UITheme.PAPER) - lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - lbl.set_anchors_preset(Control.PRESET_FULL_RECT) - overlay.add_child(lbl) - - if is_multiplayer: - btn.add_to_group("host_only_ui") - btn.pressed.connect(func(): get_node("/root/NetworkManager").rpc_load_level.rpc(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: - btn.pressed.connect(func(): - var nm = get_node_or_null("/root/NetworkManager") - if nm: - nm.start_singleplayer_match("Deathmatch") - get_tree().change_scene_to_file(scene_path) - ) - - parent.add_child(btn) + _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() + var btn := Button.new() btn.text = text - btn.add_theme_font_size_override("font_size", 32) - btn.custom_minimum_size = Vector2(300, 60) + 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_selector(parent: Container) -> void: - var skin_mgr = get_node_or_null("/root/SkinManager") - if not skin_mgr: + +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() + var row := HBoxContainer.new() row.add_theme_constant_override("separation", 12) parent.add_child(row) + row.add_child(UITheme.caption("Character", 22)) - var lbl = Label.new() - lbl.text = "Skin" - lbl.add_theme_font_size_override("font_size", 24) - row.add_child(lbl) - - var opt = OptionButton.new() - opt.custom_minimum_size = Vector2(240, 44) + var opt := OptionButton.new() + opt.custom_minimum_size = Vector2(250, 46) var ids: Array = skin_mgr.get_skin_ids() ids.sort() - for i in range(ids.size()): - var skin = skin_mgr.get_skin(ids[i]) - opt.add_item(skin.skin_name, i) + 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)) - ) + skin_mgr.set_active_skin(opt.get_item_metadata(idx))) row.add_child(opt) -func _on_back_pressed() -> void: - _level_selector_panel.hide() - _ui_vbox.show() -func _on_singleplayer_pressed() -> void: - _ui_vbox.hide() - _level_selector_panel.show() +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) -func _on_multiplayer_pressed() -> void: - _ui_vbox.hide() - _multiplayer_panel.show() -func _on_multiplayer_back_pressed() -> void: - _multiplayer_panel.hide() - _ui_vbox.show() +# ── Background ─────────────────────────────────────────────────────────────── -func _on_host_pressed() -> void: - var nm = get_node("/root/NetworkManager") - if nm.host_game() == OK: - _on_connection_succeeded() -func _on_direct_connect_pressed() -> void: - _multiplayer_panel.hide() - _direct_connect_panel.show() +## 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) -func _on_dc_back_pressed() -> void: - _direct_connect_panel.hide() - _multiplayer_panel.show() + var viewport := SubViewport.new() + viewport.own_world_3d = true + viewport.msaa_3d = Viewport.MSAA_4X + holder.add_child(viewport) -func _on_connect_confirm_pressed() -> void: - var ip = _direct_connect_panel.get_node("IPInput").text - var port = _direct_connect_panel.get_node("PortInput").text.to_int() - get_node("/root/NetworkManager").join_game(ip, port) - -func _on_connection_succeeded() -> void: - _direct_connect_panel.hide() - _multiplayer_panel.hide() - _host_lobby_panel.show() - - var nm = get_node("/root/NetworkManager") - var is_host = nm.multiplayer.is_server() - for node in get_tree().get_nodes_in_group("host_only_ui"): - if node is BaseButton: - node.disabled = not is_host - - if not nm.player_connected.is_connected(_on_network_player_connected): - nm.player_connected.connect(_on_network_player_connected) - nm.player_disconnected.connect(_on_network_player_disconnected) - if not nm.stats_updated.is_connected(_update_lobby_players): - nm.stats_updated.connect(_update_lobby_players) - - _update_lobby_players() - -func _on_lobby_back_pressed() -> void: - _host_lobby_panel.hide() - _multiplayer_panel.show() - var nm = get_node("/root/NetworkManager") - nm.disconnect_game() - if nm.player_connected.is_connected(_on_network_player_connected): - nm.player_connected.disconnect(_on_network_player_connected) - nm.player_disconnected.disconnect(_on_network_player_disconnected) - if nm.stats_updated.is_connected(_update_lobby_players): - nm.stats_updated.disconnect(_update_lobby_players) - -func _on_network_player_connected(_id: int) -> void: - _update_lobby_players() - -func _on_network_player_disconnected(_id: int) -> void: - _update_lobby_players() - -func _update_lobby_players() -> void: - if not _host_lobby_players_list: return - _host_lobby_players_list.clear() - var nm = get_node("/root/NetworkManager") - - for pid in nm.player_stats.keys(): - var p_name = nm.player_stats[pid].get("username", "Unknown") - var p_ping = nm.player_stats[pid].get("ping", 0) - - if pid == 1: - _host_lobby_players_list.add_item("%s (Host) - %sms" % [p_name, p_ping]) - else: - _host_lobby_players_list.add_item("%s - %sms" % [p_name, p_ping]) - -func _on_loadouts_pressed() -> void: - if has_node("/root/PauseMenu"): - var pm = get_node("/root/PauseMenu") - pm.visible = true - pm._show_loadouts() - -func _on_settings_pressed() -> void: - if has_node("/root/PauseMenu"): - var pm = get_node("/root/PauseMenu") - pm.visible = true - pm._show_settings() - -func _on_exit_pressed() -> void: - get_tree().quit() - -# ========================================== -# 3D DIORAMA BUILDER -# ========================================== -func _build_3d_diorama(viewport: SubViewport) -> void: - var world = Node3D.new() + var world := Node3D.new() viewport.add_child(world) - - # Lighting and Env — same stylized sunset sky as in-game for continuity - var env = WorldEnvironment.new() + + var env := WorldEnvironment.new() env.environment = LevelEnvironment.make_environment("sunset") world.add_child(env) - - var sun = DirectionalLight3D.new() + + 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() + + 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) - - # Floor — toon world-grid like the levels - var floor_mesh = MeshInstance3D.new() - floor_mesh.mesh = PlaneMesh.new() - floor_mesh.mesh.size = Vector2(20, 20) - floor_mesh.mesh.surface_set_material(0, LevelMaterials.tinted(Color(0.32, 0.3, 0.38))) - world.add_child(floor_mesh) - # Player Model (Sliding Pose) - var player_model = Node3D.new() - player_model.position = Vector3(0, 0.5, 0) # Lowered for slide - world.add_child(player_model) + 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) - var body_mat = StandardMaterial3D.new() - body_mat.albedo_color = Color(0.3, 0.6, 0.9) - # Toonified below via apply_toon_recursive once the model is assembled - - # Torso (Capsule tilted back) - var torso = MeshInstance3D.new() - var t_mesh = CapsuleMesh.new() - t_mesh.radius = 0.4 - t_mesh.height = 1.2 - t_mesh.material = body_mat - torso.mesh = t_mesh - torso.rotation_degrees.x = -60 # Tilted back - torso.position = Vector3(0, 0.4, 0) - player_model.add_child(torso) - - # Legs (Sticking forward) - var legs = MeshInstance3D.new() - var l_mesh = BoxMesh.new() - l_mesh.size = Vector3(0.6, 0.4, 1.2) - l_mesh.material = body_mat - legs.mesh = l_mesh - legs.position = Vector3(0, -0.2, 0.6) - player_model.add_child(legs) - - # Arm holding shotgun - var arm = Node3D.new() - arm.position = Vector3(0.5, 0.6, 0.2) - player_model.add_child(arm) - - var arm_mesh = MeshInstance3D.new() - var a_mesh = BoxMesh.new() - a_mesh.size = Vector3(0.2, 0.2, 0.8) - a_mesh.material = body_mat - arm_mesh.mesh = a_mesh - arm_mesh.position = Vector3(0, 0, 0.3) - arm.add_child(arm_mesh) - - # Shotgun - var shotgun_script = load("res://weapons/double_barrel_shotgun.gd") - if shotgun_script: - var shotgun = shotgun_script.new() - arm_mesh.add_child(shotgun) - shotgun.position = Vector3(0, -0.1, 0.4) - shotgun.rotation_degrees = Vector3(0, 0, 0) - shotgun.set_process(false) - shotgun.set_physics_process(false) - if shotgun.has_method("set_process_input"): - shotgun.set_process_input(false) - - # Cel-shade the diorama (no hull outlines — the FBX shotgun tears them) - LevelMaterials.apply_toon_recursive(player_model, 0.0) + # 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) - # Camera - var camera = Camera3D.new() + var camera := Camera3D.new() world.add_child(camera) - camera.position = Vector3(3, 1.5, 4) - camera.look_at(Vector3(0, 0.5, 0)) + # 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 diff --git a/ui/ui_theme.gd b/ui/ui_theme.gd index 16db940..792da27 100644 --- a/ui/ui_theme.gd +++ b/ui/ui_theme.gd @@ -369,8 +369,20 @@ static func _button_look(t: Theme, cls: String) -> void: t.set_color("font_hover_pressed_color", cls, ink_for(VOLT)) t.set_color("font_focus_color", cls, PAPER) t.set_color("font_disabled_color", cls, DEAD_TEXT) + # NO OUTLINE on a button label. + # + # An outline exists to separate a glyph from a backdrop the glyph cannot beat + # on its own — over the 3D scene, or across a two-tone progress bar. A button + # label has neither problem: it sits on a solid chip, and `ink_for` has + # already given it a colour that beats that chip. + # + # Worse, the theme can only carry ONE outline colour per class, and half the + # states here use an ink glyph. Ink glyphs inside a 5 px ink outline are not + # outlined, they are five pixels fatter — the selected mode chip rendered as + # an unreadable dark blob on volt. The chips keep their heavy ink BORDER, so + # the drawn look is unaffected. t.set_color("font_outline_color", cls, INK) - t.set_constant("outline_size", cls, 5) + t.set_constant("outline_size", cls, 0) # The icon has to invert with the label. A paper glyph on a volt chip is the # same 1.1:1 the text would have been, and a CheckBox is ALL icon. t.set_color("icon_normal_color", cls, PAPER)