From 19d2295bec21f22a26bffa066b757f2ae3616ab7 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 5 Oct 2026 10:41:04 +0200 Subject: [PATCH] A UI pass: prompts that tell the truth, and a HUD that does not collide Prompts named the default keys whatever was bound. Every action is rebindable, but the HUD said "WASD move", "LMB fire", "hold F" and "1-4 use", the quartermaster said "E talk" and the upgrade screen "Close (E)". All of them now go through Settings.short_label(), and a test rebinds fire, escape, interact and a slot key and checks each prompt follows. The HUD's top-left was a developer's status line -- name, instance id, health as text, frame rate -- with the boss bar drawn over its right end and the level-up notice sharing a row with the escape bar. It is now name and level, where you are, health with its numbers, experience, escape and level-ups, stacked by a running cursor so nothing has a fixed row to collide on. Instance and fps moved behind F1, which is who they were for. Arrival protection is a centred banner rather than a note beside the health bar, because it is the one moment the rules change. A hit used to tint the whole screen red, which in a bullet hell dims the bullets at exactly the moment you are among them. It is an edge now, and the same edge pulses below a third of your health. New: a message feed above the inventory for what the server just confirmed -- items taken, used and dropped, levels gained -- and party health top-right in a dungeon, where the hub shows who is online. The pickup prompt now also names the dungeon under your feet when you stand on its ring, in the same order the server resolves that key: loot first, then the entrance. The boss bar used to announce the boss to anyone within the actor interest radius -- which passes through walls -- so a player in the entrance room was told the Warden was near. It waits until the fog has actually lit the boss once, then stays for the fight. Permadeath was announced only by the roster screen appearing. The client records its own death from the server's event and the roster says who fell, at what level, and where. The connect screen lost a name field that did nothing for a person -- it only named the characters bots create -- gained a proper panel, remembers the last server, and shows the protocol number a mismatch refusal will quote. And screenshot.tscn ran as the player's real account against their real character store, walking their character into dungeons for pictures -- with health now carried between runs, a stray hit would have stuck. It has its own store and account now, like every diagnostic, and a seeded character. The real store was checked: nothing it carries changed. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 12 +- docs/ROADMAP.md | 4 + src/core/settings.gd | 34 ++ src/net/client_runtime.gd | 28 ++ src/ui/character_select.gd | 17 + src/ui/hud.gd | 636 ++++++++++++++++++++++++---------- src/ui/main_menu.gd | 107 ++++-- src/ui/upgrade_screen.gd | 13 +- src/view/game_scene.gd | 39 ++- src/view/world_view.gd | 13 +- tests/unit/test_fallen.gd | 45 +++ tests/unit/test_fallen.gd.uid | 1 + tests/unit/test_settings.gd | 86 +++++ tools/screenshot.gd | 97 +++++- 14 files changed, 903 insertions(+), 229 deletions(-) create mode 100644 tests/unit/test_fallen.gd create mode 100644 tests/unit/test_fallen.gd.uid diff --git a/CLAUDE.md b/CLAUDE.md index 998065d..294cc5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ the obvious default. ```bash tools/check.sh # parse-check every script (~5s) -- run after every edit -tools/test.sh # 534 GUT tests, headless (~4s) +tools/test.sh # 551 GUT tests, headless (~4s) tools/smoke.sh # real server + 4 bot clients over ENet (~40s) tools/server.sh # dedicated server tools/client.sh --listen # host and play, no menu @@ -29,7 +29,7 @@ godot --headless --path . res://tools/diag_loot.tscn # drop -> pick up godot --headless --path . res://tools/diag_upgrades.tscn # level -> choice -> taken at the NPC -> new stats godot --headless --path . --script tools/bench.gd # sim cost per tick python3 tools/build_local_assets.py # rebuild the local-only bullet atlas -SHOT_DIR=/tmp/shots godot --path . res://tools/screenshot.tscn # menus, HUD, roster, and the fog in a dungeon and a tunnel (needs a display) +SHOT_DIR=/tmp/shots godot --path . res://tools/screenshot.tscn # every screen, the fog, a staged boss fight (needs a display; own store and account) ``` `diag_progression`, `diag_loot` and `diag_upgrades` exist because the bot smoke @@ -37,6 +37,11 @@ test cannot cover any of them: bots are poor shots, so they rarely kill anything, which means they neither earn levels, produce drops, nor ever reach the quartermaster. +**Every key the game names to the player goes through `Settings.short_label()`.** +All of them are rebindable, and a prompt that names the default is wrong for +anyone who changed it — the HUD said "LMB fire" and the upgrade screen said +"Close (E)" whatever those were bound to. + **Nothing automated can see the screen.** `check.sh`, the suite and `smoke.sh` have all passed with the entire interface rendering unstyled, an invisible slider and an invisible scrollbar. After touching anything under `src/ui/` or @@ -51,7 +56,8 @@ Everything after `--` goes to `GameOpts.parse()`: | `--join` | Skip the menu and connect. | | `--listen` | Skip the menu and host a listen server with a local player. | | `--bot` | Scripted input instead of the keyboard. Implies `--join`. | -| `--host`, `--port`, `--name` | Connection details. | +| `--host`, `--port` | Connection details. | +| `--name` | Bots only: what a bot calls the character it creates. People name characters on the roster screen; the connect screen no longer asks. | | `--autoquit N` | Quit after N physics ticks. | | `--boss-rush` | Server-side: dungeons spawn the boss and no trash. | | `--depth N` | Server-side: depth of new dungeons, which drives map size. | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 901864d..338665f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -92,6 +92,10 @@ against itself. | Hand-authored boss arenas and hub | done | [src/content/rooms.gd](../src/content/rooms.gd) | | Walls, pillars, pits, barricades | done | `MapGrid.Kind` + the three `BLOCKS_*` tables | | Enemies placed per room at creation | done | `Instance._populate()` | +| HUD: player panel, party health, message feed, low-health edge | done | [src/ui/hud.gd](../src/ui/hud.gd) — stacked by a running cursor, so nothing has a fixed row to collide on | +| Prompts name the keys actually bound | done | `Settings.short_label()` | +| Boss bar waits until the boss has been seen | done | `Hud._draw_boss_bar`, reading the fog's `Visibility` | +| A death is announced on the roster screen | done | `ClientRuntime.last_fallen`, `GameScene.fallen_line` | | Occasional enemy groups in tunnels | done | `Instance._populate_tunnels()` — about one depth-1 run in three, never within aggro range of the entrance | | Scrolling camera | done | [src/view/game_scene.gd](../src/view/game_scene.gd) | | Hard fog of war, lit by rays rather than per tile | done | [src/view/visibility.gd](../src/view/visibility.gd), `WorldView._draw_darkness` | diff --git a/src/core/settings.gd b/src/core/settings.gd index 378b249..da21f7d 100644 --- a/src/core/settings.gd +++ b/src/core/settings.gd @@ -45,6 +45,11 @@ const SFX_BUS := "SFX" static var master_volume: float = 0.8 static var sfx_volume: float = 0.8 +## The server last joined, so the connect screen does not ask for the same +## address every launch. Empty until the first join. Client-local like +## everything else here -- it is where you play, not who you are. +static var last_address: String = "" +static var last_port: int = 0 ## action -> a serialisable description of its event. See _describe(). static var bindings: Dictionary[String, Dictionary] = {} @@ -64,6 +69,8 @@ static func load_and_apply() -> void: if cfg.load(path) == OK: master_volume = clampf(float(cfg.get_value("audio", "master", master_volume)), 0.0, 1.0) sfx_volume = clampf(float(cfg.get_value("audio", "sfx", sfx_volume)), 0.0, 1.0) + last_address = String(cfg.get_value("connection", "address", "")) + last_port = clampi(int(cfg.get_value("connection", "port", 0)), 0, 65535) bindings.clear() for action in REBINDABLE: var key := String(action[0]) @@ -86,6 +93,9 @@ static func save() -> void: var cfg := ConfigFile.new() cfg.set_value("audio", "master", master_volume) cfg.set_value("audio", "sfx", sfx_volume) + if not last_address.is_empty(): + cfg.set_value("connection", "address", last_address) + cfg.set_value("connection", "port", last_port) for action in bindings: cfg.set_value("input", action, bindings[action]) var err := cfg.save(path) @@ -93,6 +103,16 @@ static func save() -> void: GameLog.warn("settings", "could not write %s (error %d)" % [path, err]) +## Remember where the player just tried to connect. +static func remember_server(address: String, port: int) -> void: + var clean := address.strip_edges() + if clean.is_empty() or port <= 0 or port > 65535: + return + last_address = clean + last_port = port + save() + + # --- Audio ------------------------------------------------------------------- ## Ensure the SFX bus exists and push both volumes onto the mixer. @@ -191,6 +211,20 @@ static func binding_label(action: String) -> String: return describe_label(current_binding(action)) +## The binding as short as it can be said, for prompts on screen: "LMB" rather +## than "Mouse Left", "Esc" rather than "Escape". Every key the game names to the +## player goes through this, because every one of them is rebindable and a +## prompt that names the default is wrong for anyone who changed it. +static func short_label(action: String) -> String: + var label := binding_label(action) + match label: + "Mouse Left": return "LMB" + "Mouse Right": return "RMB" + "Mouse Middle": return "MMB" + "Escape": return "Esc" + return label + + static func describe_label(described: Dictionary) -> String: match String(described.get("type", "")): "key": diff --git a/src/net/client_runtime.gd b/src/net/client_runtime.gd index 963b527..926cb67 100644 --- a/src/net/client_runtime.gd +++ b/src/net/client_runtime.gd @@ -80,6 +80,12 @@ var request_escape: bool = false ## Who is online and where, for the hub's player list. Server-pushed. var roster: Array[Dictionary] = [] +## The character this player just lost, as { "name", "level", "where" }, or +## empty. Death is permanent and the server unbinds you on the same tick, so +## without this the only sign anything happened was that the roster screen +## appeared. Recorded from the server's own PLAYER_DIED event, never guessed. +var last_fallen: Dictionary = {} + ## This account's characters, and which one is being played. Server-pushed; ## the client never invents an entry. var characters: Array[Dictionary] = [] @@ -546,6 +552,14 @@ func on_events(data: PackedByteArray) -> void: SimEvent.Type.PLAYER_DIED: if int(ev["peer"]) == my_peer: my_alive = false + var who := current_character() + if not who.is_empty(): + last_fallen = { + "name": String(who["name"]), + "level": Progression.level_for_xp(my_total_xp), + "where": Dungeons.get_or_default(dungeon_id).display_name + if instance_kind == Protocol.InstanceKind.DUNGEON else "", + } hud_dirty.emit() SimEvent.Type.PLAYER_RESPAWNED: if int(ev["peer"]) == my_peer: @@ -620,6 +634,20 @@ func loot_in_reach() -> Dictionary: return best +## The entrance this player is standing on, as { "pos", "dungeon" }, or empty. +## Purely for the prompt, like loot_in_reach: nearest wins, the same tie-break +## SimWorld.portal_at() uses, and the server decides for itself regardless. +func portal_here() -> Dictionary: + var best := {} + var best_d := SimConfig.PORTAL_RADIUS * SimConfig.PORTAL_RADIUS + for p: Dictionary in portals: + var d: float = predicted_pos.distance_squared_to(p["pos"]) + if d <= best_d: + best_d = d + best = p + return best + + ## True when every slot is taken, so the HUD can explain why a pickup did ## nothing rather than looking broken. func inventory_full() -> bool: diff --git a/src/ui/character_select.gd b/src/ui/character_select.gd index 6649474..a718e88 100644 --- a/src/ui/character_select.gd +++ b/src/ui/character_select.gd @@ -14,6 +14,7 @@ var _list: VBoxContainer var _name_field: LineEdit var _create_button: Button var _status: Label +var _fallen: Label var _title: Label var _close_button: Button var _known: Array[Dictionary] = [] @@ -51,6 +52,14 @@ func _ready() -> void: _title.add_theme_font_size_override("font_size", 26) panel.add_child(_title) + _fallen = Label.new() + _fallen.add_theme_font_size_override("font_size", 14) + _fallen.add_theme_color_override("font_color", Color(0.95, 0.55, 0.55)) + _fallen.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _fallen.custom_minimum_size = Vector2(460.0, 0.0) + _fallen.visible = false + panel.add_child(_fallen) + _list = VBoxContainer.new() _list.add_theme_constant_override("separation", 4) panel.add_child(_list) @@ -166,5 +175,13 @@ func _row_for(c: Dictionary) -> Control: ## Whether the player may dismiss this screen. False when they have nothing to ## play, in which case there is nowhere to dismiss it to. +## A line for the character that was just lost, above the list of the ones +## that are left. Separate from the status line, which is for refusals: a death +## is not an error, and the status line clears the moment anything else is said. +func set_fallen(text: String) -> void: + _fallen.text = text + _fallen.visible = not text.is_empty() + + func set_dismissible(can_close: bool) -> void: _close_button.visible = can_close diff --git a/src/ui/hud.gd b/src/ui/hud.gd index da9d45b..919cd10 100644 --- a/src/ui/hud.gd +++ b/src/ui/hud.gd @@ -1,6 +1,13 @@ extends CanvasLayer ## Heads-up display. Built in code rather than as a scene because every element ## is data-driven -- there is no layout here a designer would want to drag. +## +## Layout, because several things here used to land on top of each other: +## top-left is you (name, where, health, experience, escape, level-ups), stacked +## by a running cursor so nothing has a fixed row to collide on; top-centre is +## the boss; top-right is everyone else; the bottom centre is the inventory +## with the pickup prompt and the message feed stacked above it; the controls +## hint has the bottom-left corner to itself. const MARGIN := 24.0 const BAR_W := 260.0 @@ -19,16 +26,51 @@ const SLOT_GAP := 8.0 const BAR_CAP := 6.0 ## How far above the bottom of the screen the inventory row sits. const SLOT_BOTTOM := 26.0 +const BOSS_BAR_W := 480.0 + +## The message feed: how long a message stays, and how many at once. +const TOAST_LIFE := 2.6 +const TOAST_FADE := 0.8 +const TOAST_MAX := 4 +## Health below this share of maximum pulses the screen edge. +const LOW_HEALTH := 0.35 +## How long the controls hint stays up after arriving in a dungeon. In the hub +## it stays: that is where you are learning the keys, and nothing is shooting. +const HINT_HOLD := 6.0 +const HINT_FADE := 1.5 + +const COL_TEXT := Color(0.86, 0.9, 0.97) +const COL_DIM := Color(0.58, 0.63, 0.74) +const COL_HEALTH := Color(0.35, 0.9, 0.6) +const COL_GOLD := Color(1.0, 0.85, 0.4) +const COL_DANGER := Color(1.0, 0.25, 0.3) signal respawn_pressed +## What the world view could see this frame, handed over by the game scene so +## the boss bar can wait until the boss has actually been seen. See +## _draw_boss_bar. +var sight: Visibility = null +## Mirrors the F1 overlay. The instance number and frame rate are for whoever +## is debugging, not for whoever is playing, so they live behind it. +var debug_info: bool = false + var _canvas: Control -var _status: Label -var _hint: Label var _respawn_button: Button var _hit_flash: float = 0.0 var client: ClientRuntime = null +var _toasts: Array[Dictionary] = [] +## Which boss, in which instance, has been seen -- see _draw_boss_bar. +var _boss_seen := "" +## The level last drawn and whose it was, so a level-up can be announced the +## moment it happens and a character switch is not mistaken for one. +var _seen_level: int = -1 +var _seen_character := "" +## When the current instance was entered, for fading the controls hint. +var _instance_seen: int = -1 +var _instance_since: float = 0.0 + func _ready() -> void: layer = 10 @@ -45,20 +87,6 @@ func _ready() -> void: _canvas.draw.connect(_draw_hud) add_child(_canvas) - _status = _make_label(Vector2(MARGIN, MARGIN)) - _hint = _make_label(Vector2(MARGIN, 0.0)) - _hint.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_LEFT) - # `.position` assigns an ABSOLUTE coordinate regardless of anchors (that's - # what stranded this label off-screen despite the anchor being correct). - # offset_left/offset_top are the anchor-relative ones -- negative offset_top - # here means "56px above the bottom anchor", which is what "-56" was meant - # to say in the first place. - _hint.offset_left = MARGIN - # Above the inventory row, not through it. Derived from the row's own - # geometry rather than written down, because the slots grew from 46px to 64 - # and a hardcoded -56 put the control hints straight across them. - _hint.offset_top = -(SLOT_BOTTOM + SLOT + 34.0) - # A real Button rather than something drawn in _draw_hud: this is the one # HUD element the player has to actually click, and it starts disabled so # death is not exited by whatever key happened to be under a finger. @@ -74,24 +102,58 @@ func _ready() -> void: _canvas.add_child(_respawn_button) -func _make_label(pos: Vector2) -> Label: - var l := Label.new() - l.position = pos - l.add_theme_font_size_override("font_size", 14) - l.add_theme_color_override("font_color", Color(0.8, 0.85, 0.95)) - _canvas.add_child(l) - return l - - func _process(delta: float) -> void: client = Net.client _hit_flash = maxf(_hit_flash - delta * 2.5, 0.0) - _status.text = _status_text() - _hint.text = _hint_text() + var now := _now() + _toasts = _toasts.filter(func(t: Dictionary) -> bool: + return now - float(t["born"]) < TOAST_LIFE) + if client != null: + if client.instance_id != _instance_seen: + _instance_seen = client.instance_id + _instance_since = now + _watch_level() _update_respawn_button() _canvas.queue_redraw() +func _now() -> float: + return float(Time.get_ticks_msec()) / 1000.0 + + +## A short line in the message feed above the inventory. Called for things the +## server just confirmed -- an item taken, used or dropped, a level gained -- so +## the feed is a record of what happened, never of what the client hoped. +func notify(text: String, colour: Color = COL_TEXT) -> void: + _toasts.append({"text": text, "colour": colour, "born": _now()}) + while _toasts.size() > TOAST_MAX: + _toasts.pop_front() + + +func flash_hit() -> void: + _hit_flash = 1.0 + + +## Announce a level the moment the snapshot's experience crosses one. Watched +## rather than signalled because experience rides the snapshot, and the first +## sight of a character -- logging in, swapping -- must not read as a level-up. +func _watch_level() -> void: + var who := client.current_character() + if who.is_empty(): + _seen_character = "" + return + var level := Progression.level_for_xp(client.my_total_xp) + if String(who["id"]) != _seen_character: + _seen_character = String(who["id"]) + _seen_level = level + return + if level > _seen_level: + var where := "" if client.instance_kind == Protocol.InstanceKind.LOBBY \ + else " -- a choice waits at the quartermaster" + notify("LEVEL %d%s" % [level, where], COL_GOLD) + _seen_level = level + + func _update_respawn_button() -> void: var downed := client != null and not client.my_alive _respawn_button.visible = downed @@ -105,91 +167,311 @@ func _update_respawn_button() -> void: else "Return to hub (%.0fs)" % ceil(client.my_respawn_wait) -func _status_text() -> String: - if client == null: - return "connecting..." - # Named rather than just "DUNGEON": there is more than one kind now, and - # knowing which one you are standing in is the whole point of having two. - var where := "LOBBY" - if client.instance_kind != Protocol.InstanceKind.LOBBY: - where = Dungeons.get_or_default(client.dungeon_id).display_name.to_upper() - var who := client.current_character() - var name_part := "" - if not who.is_empty(): - name_part = "%s lv %d " % [ - who["name"], Progression.level_for_xp(client.my_total_xp)] - return "%s%s instance %d hp %d/%d %d fps" % [ - name_part, where, client.instance_id, client.my_hp, client.my_max_hp, - Engine.get_frames_per_second()] +# --- Text ------------------------------------------------------------------- + +## Text with a one-pixel shadow. The floor art is busy and mid-toned, and plain +## light text over it was readable on the dark parts of a tile and not on the +## light ones. +func _text(at: Vector2, s: String, size: int, colour: Color, + align: HorizontalAlignment = HORIZONTAL_ALIGNMENT_LEFT, width: float = -1.0) -> void: + var font := ThemeDB.fallback_font + _canvas.draw_string(font, at + Vector2(1.0, 1.0), s, align, width, size, + Color(0.0, 0.0, 0.0, 0.7 * colour.a)) + _canvas.draw_string(font, at, s, align, width, size, colour) -func _hint_text() -> String: - if client == null: - return "" - if not client.my_alive: - return "DOWN" - if client.instance_kind == Protocol.InstanceKind.LOBBY: - return "WASD move mouse aim LMB fire E on a ring to enter that dungeon Esc menu F1 hitboxes" - return "WASD move mouse aim LMB fire hold F to return to the hub Esc menu F1 hitboxes" +func _text_width(s: String, size: int) -> float: + return ThemeDB.fallback_font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x +func _centred(y: float, s: String, size: int, colour: Color) -> void: + _text(Vector2(0.0, y), s, size, colour, HORIZONTAL_ALIGNMENT_CENTER, _canvas.size.x) + + +# --- Key names -------------------------------------------------------------- + +## What the player has [param action] bound to. Read from Settings every time +## rather than written into the strings: every one of these is rebindable, and +## the hint used to say "LMB fire" whatever fire was actually on. +static func key(action: String) -> String: + return Settings.short_label(action) + + +## The four movement keys as one word when they are single characters -- WASD, +## ZQSD, ESDF -- and spelled out when they are not, like the arrow keys. +static func move_keys() -> String: + var parts: Array[String] = [] + for action in ["move_up", "move_left", "move_down", "move_right"]: + parts.append(key(action)) + for p in parts: + if p.length() != 1: + return "/".join(parts) + return "".join(parts) + + +## The slot keys, as a range when they run 1-2-3-4 and listed when they do not. +static func slot_keys(count: int) -> String: + var parts: Array[String] = [] + for i in count: + parts.append(key("use_slot_%d" % (i + 1))) + var plain := true + for i in count: + if parts[i] != str(i + 1): + plain = false + return "1-%d" % count if plain else " ".join(parts) + + +static func controls_hint(in_hub: bool) -> String: + if in_hub: + return "%s move mouse aim %s fire %s on a ring to enter it %s menu" % [ + move_keys(), key("fire"), key("interact"), key("system_menu")] + return "%s move %s fire hold %s to return to the hub %s menu" % [ + move_keys(), key("fire"), key("emergency_escape"), key("system_menu")] + + +static func inventory_hint(count: int) -> String: + return "%s use shift+%s drop %s pick up" % [ + slot_keys(count), slot_keys(count), key("interact")] + + +# --- Drawing ---------------------------------------------------------------- + func _draw_hud() -> void: if client == null: + _text(Vector2(MARGIN, MARGIN + 14.0), "connecting...", 14, COL_DIM) return - var origin := Vector2(MARGIN, MARGIN + 28.0) - _bar(origin, float(client.my_hp) / float(maxi(client.my_max_hp, 1)), - Color(0.35, 0.9, 0.6), Color(0.1, 0.15, 0.18)) - _draw_xp_bar(origin + Vector2(0.0, BAR_H + 4.0)) - - if client.my_escaping: - _bar(origin + Vector2(0.0, (BAR_H + 4.0) * 2.0), client.my_escape, - Color(0.5, 0.85, 1.0), Color(0.1, 0.15, 0.2)) - _canvas.draw_string(ThemeDB.fallback_font, - origin + Vector2(BAR_W + 12.0, (BAR_H + 4.0) * 2.0 + BAR_H - 3.0), - "ESCAPING", HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.5, 0.85, 1.0)) + # Under everything, so the edge of the screen colours without dimming any of + # the text on top of it. + _draw_vignette() + var y := _draw_player_panel() + _draw_level_up_notice(y) _draw_boss_bar() - - if client.my_spawn_grace: - _canvas.draw_string(ThemeDB.fallback_font, - origin + Vector2(BAR_W + 12.0, BAR_H), - "ARRIVING -- invulnerable, weapons cold", - HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.6, 0.9, 1.0)) - + _draw_arriving() _draw_cleared_countdown() - _draw_roster() + _draw_others() _draw_inventory() - _draw_pickup_prompt() - _draw_level_up_notice() + var above := _draw_pickup_prompt() + _draw_toasts(above) + _draw_controls_hint() + _draw_debug_info() if not client.my_alive: - # Centred on the canvas, which is only correct because _canvas actually - # has the viewport's size now -- see the anchor note in _ready(). - var centre := _canvas.size * 0.5 - _canvas.draw_string(ThemeDB.fallback_font, centre - Vector2(52.0, 8.0), - "DOWN", HORIZONTAL_ALIGNMENT_LEFT, -1, 34, Color(1.0, 0.4, 0.4)) + _centred(_canvas.size.y * 0.5, "DOWN", 34, Color(1.0, 0.4, 0.4)) - if _hit_flash > 0.0: - _canvas.draw_rect(Rect2(Vector2.ZERO, _canvas.size), - Color(1.0, 0.2, 0.25, 0.18 * _hit_flash)) +## Who you are, where you are, and how you are doing. Returns the y below the +## last thing drawn, so whatever comes next stacks rather than overlaps. +func _draw_player_panel() -> float: + var x := MARGIN + var y := MARGIN + var who := client.current_character() + if not who.is_empty(): + var name_text := String(who["name"]) + _text(Vector2(x, y + 15.0), name_text, 17, COL_TEXT) + _text(Vector2(x + _text_width(name_text, 17) + 10.0, y + 15.0), + "level %d" % Progression.level_for_xp(client.my_total_xp), 13, COL_DIM) + y += 22.0 + + # Named rather than just "DUNGEON": there is more than one kind, and knowing + # which one you are standing in is the whole point of having more than one. + var where := "THE HUB" + var tint := COL_DIM + if client.instance_kind != Protocol.InstanceKind.LOBBY: + var def := Dungeons.get_or_default(client.dungeon_id) + where = def.display_name.to_upper() + tint = def.tint + _text(Vector2(x, y + 12.0), where, 12, tint) + y += 20.0 + + var frac := float(client.my_hp) / float(maxi(client.my_max_hp, 1)) + # Red when low, not a blend toward it: green-to-red through the middle is + # brown, which reads as a colour rather than as a warning. + var hp_tint := COL_HEALTH if frac >= LOW_HEALTH else Color(1.0, 0.38, 0.36) + _bar(Vector2(x, y), frac, hp_tint) + _text(Vector2(x + BAR_W + 10.0, y + 13.0), + "%d / %d" % [client.my_hp, client.my_max_hp], 12, hp_tint) + y += BAR_H + 4.0 + + if _draw_xp_bar(Vector2(x, y)): + y += BAR_H + 4.0 + + if client.my_escaping: + _bar(Vector2(x, y), client.my_escape, Color(0.5, 0.85, 1.0)) + _text(Vector2(x + BAR_W + 10.0, y + 13.0), "ESCAPING", 12, + Color(0.5, 0.85, 1.0)) + y += BAR_H + 4.0 + return y + + +## A thin bar under health: progress toward the next level, and the level +## itself. Drawn from the server's numbers, never recomputed locally. Returns +## whether it drew, since a player at the roster screen has no character. +func _draw_xp_bar(at: Vector2) -> bool: + if client.current_character().is_empty(): + return false + # Derived from the snapshot's live experience total rather than the roster's + # copy, which only arrives when the set of characters changes. + var level := Progression.level_for_xp(client.my_total_xp) + var capped := level >= Progression.MAX_LEVEL + var progress := Progression.level_progress(client.my_total_xp) + var tint := COL_GOLD if capped else Color(0.6, 0.55, 1.0) + _bar(at, progress, tint) + # The number as well as the bar: "how far to the next level" is a question + # a bar answers vaguely and a percentage answers exactly. + var text := "MAX" if capped else "%d%% to level %d" % [ + int(floor(progress * 100.0)), level + 1] + _text(at + Vector2(BAR_W + 10.0, 13.0), text, 12, tint) + return true ## Unspent level-ups. Shown wherever you are, because the choice is banked in a ## dungeon and spent in the hub -- if it only appeared next to the NPC, a player ## would have to already know to go and look. -func _draw_level_up_notice() -> void: +func _draw_level_up_notice(y: float) -> void: if client.upgrades_pending <= 0: return var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.004) - var where := " — see the quartermaster in the hub" \ + var where := " -- see the quartermaster in the hub" \ if client.instance_kind != Protocol.InstanceKind.LOBBY else "" - _canvas.draw_string(ThemeDB.fallback_font, - Vector2(MARGIN, MARGIN + 74.0), + _text(Vector2(MARGIN, y + 16.0), "%d LEVEL-UP%s TO SPEND%s" % [client.upgrades_pending, "" if client.upgrades_pending == 1 else "S", where], - HORIZONTAL_ALIGNMENT_LEFT, -1, 14, - Color(1.0, 0.85, 0.4, 0.6 + 0.4 * pulse)) + 13, Color(COL_GOLD, 0.6 + 0.4 * pulse)) + + +## The boss's health, once you have seen the boss. +## +## The server sends the boss to anyone within the actor interest radius, which +## is deliberately wider than sight and passes through walls -- so the bar used +## to announce "Warden of the Fold" to a player still standing in the entrance +## room, which says exactly where the boss is to someone who has not found it. +## It now waits until the fog has actually lit the boss once, and then stays for +## the rest of the fight: losing the bar every time the boss stepped behind a +## pillar would be worse than the leak. +func _draw_boss_bar() -> void: + var b := client.boss_state() + if b.is_empty() or client.boss_def == null: + return + var seen_key := "%d:%d" % [client.instance_id, int(b["id"])] + if _boss_seen != seen_key: + if sight != null and sight.light_at(b["pos"]) <= 0.05: + return + _boss_seen = seen_key + var pos := Vector2((_canvas.size.x - BOSS_BAR_W) * 0.5, MARGIN + 20.0) + var frac := clampf(float(b["hp"]) / float(client.boss_def.max_hp), 0.0, 1.0) + var rect := Rect2(pos, Vector2(BOSS_BAR_W, BAR_H)) + _hslice(UiTheme.texture("bar"), rect, Color.WHITE) + var inner := Rect2(rect.position + Vector2(3.0, 3.0), + Vector2((BOSS_BAR_W - 6.0) * frac, BAR_H - 6.0)) + if inner.size.x > 0.5: + _canvas.draw_texture_rect(UiTheme.texture("bar_fill"), inner, false, + Color(0.95, 0.35, 0.45)) + var phase_index := int(b["phase"]) + var phase_name := "" + if phase_index < client.boss_def.phases.size(): + phase_name = client.boss_def.phases[phase_index].name + _text(Vector2(pos.x, pos.y - 6.0), client.boss_def.display_name, 15, + Color(1.0, 0.75, 0.8)) + _text(Vector2(pos.x, pos.y - 6.0), phase_name, 12, Color(1.0, 0.6, 0.65, 0.85), + HORIZONTAL_ALIGNMENT_RIGHT, BOSS_BAR_W) + + +## Arrival protection, as a banner rather than a line tucked beside the health +## bar: it is the one moment the rules are different, and both halves of it -- +## you cannot be hurt, and you cannot shoot -- change what you should do. +func _draw_arriving() -> void: + if not client.my_spawn_grace: + return + var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.008) + var y := _canvas.size.y * 0.28 + _centred(y, "ARRIVING", 24, Color(0.6, 0.9, 1.0, 0.75 + 0.25 * pulse)) + _centred(y + 22.0, "invulnerable · weapons cold", 13, Color(0.6, 0.9, 1.0, 0.8)) + + +## Shown after the boss dies, so the victory lap has a visible clock on it. +func _draw_cleared_countdown() -> void: + if client.cleared_countdown >= Protocol.COUNTDOWN_NONE: + return + var y := _canvas.size.y * 0.5 - 60.0 + _centred(y, "DUNGEON CLEARED", 24, Color(0.6, 1.0, 0.75)) + _centred(y + 22.0, "returning to the hub in %ds" % client.cleared_countdown, 14, + Color(0.6, 1.0, 0.75, 0.85)) + + +## Top right: in the hub, who is online and where; in a dungeon, the party you +## are in and how each of them is doing. Different questions -- in the hub you +## are deciding whether to go in, in a dungeon you are deciding who needs help. +func _draw_others() -> void: + if client.roster.is_empty(): + return + if client.instance_kind == Protocol.InstanceKind.LOBBY: + _draw_online() + else: + _draw_party() + + +func _draw_online() -> void: + var x := _canvas.size.x - 240.0 + var y := MARGIN + 12.0 + _text(Vector2(x, y), "ONLINE", 13, COL_DIM) + y += 20.0 + for entry in client.roster: + var in_dungeon: bool = int(entry["kind"]) == Protocol.InstanceKind.DUNGEON + var where := "in a dungeon" if in_dungeon else "in the hub" + var col := Color(1.0, 0.7, 0.45) if in_dungeon else Color(0.7, 0.8, 0.9) + if not entry["alive"]: + col = Color(0.85, 0.4, 0.4) + where += ", down" + var me := int(entry["peer"]) == client.my_peer + _text(Vector2(x, y), "%s%s" % [entry["name"], " (you)" if me else ""], 13, col) + _text(Vector2(x, y), where, 11, Color(col, 0.7), HORIZONTAL_ALIGNMENT_RIGHT, 216.0) + y += 18.0 + + +## Everyone else in this dungeon, with their health. The snapshot only carries +## players inside the interest radius, so a party member further away than that +## is listed without a bar -- the client genuinely does not know. +func _draw_party() -> void: + var near := {} + for p in client.remote_players(): + near[int(p["peer"])] = p + var members: Array[Dictionary] = [] + for entry in client.roster: + if int(entry["instance"]) == client.instance_id and int(entry["peer"]) != client.my_peer: + members.append(entry) + if members.is_empty(): + return + var x := _canvas.size.x - 240.0 + var y := MARGIN + 12.0 + _text(Vector2(x, y), "PARTY", 13, COL_DIM) + y += 10.0 + var w := 140.0 + for entry in members: + var peer := int(entry["peer"]) + _text(Vector2(x, y + 13.0), String(entry["name"]), 13, COL_TEXT) + var rec: Dictionary = near.get(peer, {}) + var bar_at := Vector2(x + 76.0, y + 3.0) + if rec.is_empty(): + _text(Vector2(bar_at.x, y + 13.0), "out of range", 11, COL_DIM) + else: + var flags := int(rec["flags"]) + var frac := float(rec["hp"]) / float(maxi(int(rec["max_hp"]), 1)) + var col: Color = rec["colour"] + if (flags & Protocol.F_ALIVE) == 0: + _text(Vector2(bar_at.x, y + 13.0), "down", 12, COL_DANGER) + else: + var rect := Rect2(bar_at, Vector2(w, 12.0)) + _canvas.draw_rect(rect, Color(0.0, 0.0, 0.0, 0.55)) + _canvas.draw_rect(Rect2(rect.position + Vector2(2.0, 2.0), + Vector2((w - 4.0) * clampf(frac, 0.0, 1.0), 8.0)), + col if frac >= LOW_HEALTH else COL_DANGER) + if (flags & Protocol.F_LINKDEAD) != 0: + _text(Vector2(bar_at.x + w + 6.0, y + 13.0), "dropped", 11, COL_DIM) + elif (flags & Protocol.F_ESCAPING) != 0: + _text(Vector2(bar_at.x + w + 6.0, y + 13.0), "leaving", 11, + Color(0.5, 0.85, 1.0)) + y += 20.0 ## Four slots, always on screen. Deliberately not a panel you open: an @@ -213,126 +495,126 @@ func _draw_inventory() -> void: # way every other pressed thing in the interface does. var slot_art := UiTheme.texture("slot_active" if i == held else "slot") _canvas.draw_texture_rect(slot_art, Rect2(at, Vector2(SLOT, SLOT)), false) - # The slot number, because the key that uses it is the only thing the - # player actually needs to know about a slot. + # The key that uses the slot, because that is the only thing the player + # actually needs to know about it -- and it is whatever they bound. _canvas.draw_string(ThemeDB.fallback_font, at + Vector2(6.0, 17.0), - str(i + 1), HORIZONTAL_ALIGNMENT_LEFT, -1, 12, UiTheme.INK_DIM) + key("use_slot_%d" % (i + 1)), HORIZONTAL_ALIGNMENT_LEFT, -1, 12, UiTheme.INK_DIM) if def == null: continue var icon := Art.item_icon(item) var size := icon.size * Art.SCALE _canvas.draw_texture_rect_region(Art.TILESET, Rect2(at + (Vector2(SLOT, SLOT) - size) * 0.5, size), icon) - _canvas.draw_string(ThemeDB.fallback_font, - Vector2(origin.x, origin.y + SLOT + 15.0), - "1-%d use shift+1-%d drop E pick up" % [count, count], - HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Color(0.5, 0.55, 0.66)) + _text(Vector2(origin.x, origin.y + SLOT + 15.0), inventory_hint(count), 11, + Color(COL_DIM, 0.85), HORIZONTAL_ALIGNMENT_CENTER, total) ## What pressing interact right now would do. The server runs the same search ## for itself and does not care what this concluded -- this is a label, not a -## decision. -func _draw_pickup_prompt() -> void: +## decision. Returns the y the next thing up should stack from. +## +## Loot first, then an entrance, which is the order the server resolves the +## same key in: an item in reach is taken, and only a press that took nothing +## goes through the portal underneath it. +func _draw_pickup_prompt() -> float: + var above := _canvas.size.y - SLOT_BOTTOM - SLOT - 14.0 + if not client.my_alive: + return above var near := client.loot_in_reach() - if near.is_empty() or not client.my_alive: - return + if near.is_empty(): + var portal := client.portal_here() + if portal.is_empty() or client.instance_kind != Protocol.InstanceKind.LOBBY: + return above + var def := Dungeons.get_or_default(portal["dungeon"]) + _centred(above, "%s enter %s" % [key("interact"), def.display_name], 15, def.tint) + return above - 22.0 var item := Items.by_index(int(near["item"])) var def := Items.get_def(item) if def == null: - return + return above var full := client.inventory_full() var text := "inventory full -- %s stays where it is" % def.display_name \ - if full else "E take %s" % def.display_name + if full else "%s take %s" % [key("interact"), def.display_name] var tint := Color(0.85, 0.5, 0.45) if full else def.tint - _canvas.draw_string(ThemeDB.fallback_font, - Vector2(_canvas.size.x * 0.5 - 150.0, - _canvas.size.y - SLOT_BOTTOM - SLOT - 22.0), - text, HORIZONTAL_ALIGNMENT_CENTER, 300.0, 14, tint) + _centred(above, text, 14, tint) + return above - 22.0 -## A thin bar under health: progress toward the next level, and the level -## itself. Drawn from the server's numbers, never recomputed locally. -func _draw_xp_bar(at: Vector2) -> void: - if client.current_character().is_empty(): +## The message feed, newest nearest the inventory, each line fading out. +func _draw_toasts(bottom: float) -> void: + var now := _now() + var y := bottom + for i in range(_toasts.size() - 1, -1, -1): + var t: Dictionary = _toasts[i] + var age := now - float(t["born"]) + var alpha := clampf((TOAST_LIFE - age) / TOAST_FADE, 0.0, 1.0) + var col: Color = t["colour"] + # A short rise on arrival, so a new line catches the eye without a sound. + var rise := maxf(0.0, 0.25 - age) * 24.0 + _centred(y + rise, String(t["text"]), 14, Color(col, col.a * alpha)) + y -= 20.0 + + +func _draw_controls_hint() -> void: + if not client.my_alive: return - # Derived from the snapshot's live experience total rather than the roster's - # copy, which only arrives when the set of characters changes. - var level := Progression.level_for_xp(client.my_total_xp) - var capped := level >= Progression.MAX_LEVEL - var progress := Progression.level_progress(client.my_total_xp) - var tint := Color(1.0, 0.85, 0.4) if capped else Color(0.6, 0.55, 1.0) - _bar(at, progress, tint, Color.BLACK) - # The number as well as the bar: "how far to the next level" is a question - # a bar answers vaguely and a percentage answers exactly. - var text := "MAX" if capped else "%d%% to level %d" % [ - int(floor(progress * 100.0)), level + 1] - _canvas.draw_string(ThemeDB.fallback_font, at + Vector2(BAR_W + 10.0, 13.0), - text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, tint) - - -## Shown after the boss dies, so the victory lap has a visible clock on it. -func _draw_cleared_countdown() -> void: - if client.cleared_countdown >= Protocol.COUNTDOWN_NONE: + var in_hub := client.instance_kind == Protocol.InstanceKind.LOBBY + var alpha := 1.0 + if not in_hub: + var age := _now() - _instance_since + alpha = clampf(1.0 - (age - HINT_HOLD) / HINT_FADE, 0.0, 1.0) + if alpha <= 0.0: return - var text := "DUNGEON CLEARED -- returning to the hub in %ds" % client.cleared_countdown - _canvas.draw_string(ThemeDB.fallback_font, - Vector2(_canvas.size.x * 0.5 - 190.0, _canvas.size.y * 0.5 - 60.0), - text, HORIZONTAL_ALIGNMENT_LEFT, -1, 18, Color(0.6, 1.0, 0.75)) + _text(Vector2(MARGIN, _canvas.size.y - 14.0), controls_hint(in_hub), 12, + Color(COL_DIM, 0.9 * alpha)) -## Who else is online, and whether they are already in a dungeon. Only useful in -## the hub, which is the one place you are deciding whether to go in. -func _draw_roster() -> void: - if client.instance_kind != Protocol.InstanceKind.LOBBY or client.roster.is_empty(): +func _draw_debug_info() -> void: + if not debug_info: return - var x := _canvas.size.x - 240.0 - var y := MARGIN + 4.0 - _canvas.draw_string(ThemeDB.fallback_font, Vector2(x, y), "ONLINE", - HORIZONTAL_ALIGNMENT_LEFT, -1, 13, Color(0.65, 0.7, 0.8)) - y += 20.0 - for entry in client.roster: - var in_dungeon: bool = int(entry["kind"]) == Protocol.InstanceKind.DUNGEON - var where := "dungeon %d" % int(entry["instance"]) if in_dungeon else "hub" - var col := Color(1.0, 0.7, 0.45) if in_dungeon else Color(0.7, 0.8, 0.9) - if not entry["alive"]: - col = Color(0.85, 0.4, 0.4) - where += " (down)" - var me := " <- you" if int(entry["peer"]) == client.my_peer else "" - _canvas.draw_string(ThemeDB.fallback_font, Vector2(x, y), - "%s -- %s%s" % [entry["name"], where, me], - HORIZONTAL_ALIGNMENT_LEFT, -1, 13, col) - y += 18.0 + _text(Vector2(0.0, _canvas.size.y - 14.0), + "instance %d %d fps" % [client.instance_id, Engine.get_frames_per_second()], + 12, COL_DIM, HORIZONTAL_ALIGNMENT_RIGHT, _canvas.size.x - MARGIN) -func _draw_boss_bar() -> void: - var b := client.boss_state() - if b.is_empty() or client.boss_def == null: +## Red at the edges of the screen: briefly on every hit, and pulsing while +## health is low. +## +## At the edges, not across the middle. This used to tint the whole screen red +## on a hit, which in a bullet hell dims the bullets at exactly the moment you +## are among them -- the feedback made the next hit more likely. +func _draw_vignette() -> void: + var strength := _hit_flash * 0.55 + if client.my_alive: + var frac := float(client.my_hp) / float(maxi(client.my_max_hp, 1)) + if frac < LOW_HEALTH: + var low := (LOW_HEALTH - frac) / LOW_HEALTH + var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.006) + strength = maxf(strength, (0.2 + 0.35 * low) * (0.65 + 0.35 * pulse)) + if strength <= 0.01: return - var w := 560.0 - var pos := Vector2((_canvas.size.x - w) * 0.5, MARGIN) - var frac := clampf(float(b["hp"]) / float(client.boss_def.max_hp), 0.0, 1.0) - var rect := Rect2(pos, Vector2(w, BAR_H)) - _hslice(UiTheme.texture("bar"), rect, Color.WHITE) - var inner := Rect2(rect.position + Vector2(3.0, 3.0), - Vector2((w - 6.0) * frac, BAR_H - 6.0)) - if inner.size.x > 0.5: - _canvas.draw_texture_rect(UiTheme.texture("bar_fill"), inner, false, - Color(0.95, 0.35, 0.45)) - var phase_index := int(b["phase"]) - var phase_name := "" - if phase_index < client.boss_def.phases.size(): - phase_name = client.boss_def.phases[phase_index].name - _canvas.draw_string(ThemeDB.fallback_font, pos + Vector2(0.0, -6.0), - "%s -- %s" % [client.boss_def.display_name, phase_name], - HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(1.0, 0.75, 0.8)) + var s := _canvas.size + var depth := minf(s.x, s.y) * 0.16 + var edge := Color(COL_DANGER, strength) + var clear := Color(COL_DANGER, 0.0) + var sides := [ + [Vector2(0, 0), Vector2(s.x, 0), Vector2(s.x, depth), Vector2(0, depth)], + [Vector2(0, s.y), Vector2(0, s.y - depth), Vector2(s.x, s.y - depth), Vector2(s.x, s.y)], + [Vector2(0, 0), Vector2(depth, 0), Vector2(depth, s.y), Vector2(0, s.y)], + [Vector2(s.x, 0), Vector2(s.x, s.y), Vector2(s.x - depth, s.y), Vector2(s.x - depth, 0)], + ] + var cols := [ + [edge, edge, clear, clear], + [edge, clear, clear, edge], + [edge, clear, clear, edge], + [edge, edge, clear, clear], + ] + for i in 4: + _canvas.draw_polygon(PackedVector2Array(sides[i]), PackedColorArray(cols[i])) ## A bar in the pack's art: the frame stretched horizontally, the fill tinted. -## -## [param back] is no longer a colour -- the frame sprite is the background -- -## but the signature keeps it so callers read the same and a future flat-bar -## fallback has somewhere to go. -func _bar(pos: Vector2, frac: float, fill: Color, _back: Color) -> void: +func _bar(pos: Vector2, frac: float, fill: Color) -> void: var rect := Rect2(pos, Vector2(BAR_W, BAR_H)) _hslice(UiTheme.texture("bar"), rect, Color.WHITE) var inset := 3.0 @@ -366,7 +648,3 @@ func _hslice(tex: Texture2D, rect: Rect2, tint: Color) -> void: Rect2(rect.position + Vector2(rect.size.x - draw_cap, 0.0), Vector2(draw_cap, rect.size.y)), Rect2(src.x - cap, 0.0, cap, src.y), tint) - - -func flash_hit() -> void: - _hit_flash = 1.0 diff --git a/src/ui/main_menu.gd b/src/ui/main_menu.gd index 3707fd9..d1caee9 100644 --- a/src/ui/main_menu.gd +++ b/src/ui/main_menu.gd @@ -8,7 +8,6 @@ signal settings_requested var _address: LineEdit var _port: LineEdit -var _name: LineEdit var _status: Label var _join_button: Button var _host_button: Button @@ -33,19 +32,51 @@ func _ready() -> void: center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) add_child(center) + # The same dark panel every other screen sits in, rather than controls + # floating on the clear colour: this is the first screen anyone sees. + var frame := UiTheme.dialog_panel() + center.add_child(frame) var panel := VBoxContainer.new() - panel.custom_minimum_size = Vector2(360.0, 0.0) + panel.custom_minimum_size = Vector2(400.0, 0.0) panel.add_theme_constant_override("separation", 8) - center.add_child(panel) + frame.add_child(panel) var title := Label.new() title.text = "TRANSCIENCE" - title.add_theme_font_size_override("font_size", 32) + title.add_theme_font_size_override("font_size", 34) panel.add_child(title) + var subtitle := Label.new() + subtitle.text = "a co-operative bullet hell" + subtitle.add_theme_font_size_override("font_size", 13) + subtitle.add_theme_color_override("font_color", Color(0.6, 0.65, 0.78)) + panel.add_child(subtitle) + panel.add_child(_gap(10.0)) - _name = _field(panel, "name", GameOpts.player_name) - _address = _field(panel, "server address", GameOpts.host_address) - _port = _field(panel, "port", str(GameOpts.port)) + # No name field. There used to be one, and for a person it did nothing: + # characters are named on the roster screen, and this only ever fed + # GameOpts.player_name, which names the characters BOTS create. Asking for + # a name the game then ignores is worse than not asking. + var heading := Label.new() + heading.text = "server" + heading.add_theme_font_size_override("font_size", 12) + panel.add_child(heading) + var where := HBoxContainer.new() + where.add_theme_constant_override("separation", 8) + panel.add_child(where) + # Remembered from the last join, so a returning player does not retype it. + _address = LineEdit.new() + _address.text = Settings.last_address if not Settings.last_address.is_empty() \ + else GameOpts.host_address + _address.placeholder_text = "address" + _address.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _address.text_submitted.connect(func(_t: String) -> void: _on_join()) + where.add_child(_address) + _port = LineEdit.new() + _port.text = str(Settings.last_port if Settings.last_port > 0 else GameOpts.port) + _port.placeholder_text = "port" + _port.custom_minimum_size = Vector2(90.0, 0.0) + _port.text_submitted.connect(func(_t: String) -> void: _on_join()) + where.add_child(_port) _join_button = Button.new() _join_button.text = "Join" @@ -53,38 +84,55 @@ func _ready() -> void: panel.add_child(_join_button) _host_button = Button.new() - _host_button.text = "Host and play (listen server)" + _host_button.text = "Host and play" + _host_button.tooltip_text = "Run a server on this machine and join it -- others can join you" _host_button.pressed.connect(_on_host) panel.add_child(_host_button) + panel.add_child(_gap(6.0)) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 8) + panel.add_child(row) var settings := Button.new() settings.text = "Settings" + settings.size_flags_horizontal = Control.SIZE_EXPAND_FILL settings.pressed.connect(func() -> void: settings_requested.emit()) - panel.add_child(settings) - + row.add_child(settings) var quit := Button.new() quit.text = "Quit" + quit.size_flags_horizontal = Control.SIZE_EXPAND_FILL quit.pressed.connect(func() -> void: get_tree().quit()) - panel.add_child(quit) + row.add_child(quit) _status = Label.new() - _status.add_theme_color_override("font_color", Color(1.0, 0.55, 0.5)) + _status.add_theme_font_size_override("font_size", 13) + _status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _status.custom_minimum_size = Vector2(400.0, 0.0) panel.add_child(_status) - -func _field(parent: Control, label: String, value: String) -> LineEdit: - var l := Label.new() - l.text = label - l.add_theme_font_size_override("font_size", 12) - parent.add_child(l) - var e := LineEdit.new() - e.text = value - parent.add_child(e) - return e + # Which protocol this build speaks. A "protocol mismatch" refusal names two + # numbers, and this is where a player finds out which one is theirs. + var version := Label.new() + version.text = "protocol %d" % Protocol.VERSION + version.add_theme_font_size_override("font_size", 11) + version.add_theme_color_override("font_color", Color(0.45, 0.5, 0.6)) + version.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + panel.add_child(version) +func _gap(height: float) -> Control: + var c := Control.new() + c.custom_minimum_size = Vector2(0.0, height) + return c + + +## Shown under the buttons. Coloured by what it is: "connecting..." is not an +## error, and it used to be drawn in the same red as one. func set_status(text: String) -> void: _status.text = text + var working := text.begins_with("connecting") + _status.add_theme_color_override("font_color", + Color(0.65, 0.8, 1.0) if working else Color(1.0, 0.55, 0.5)) ## Locks the connect buttons while an attempt is in flight, so a second click @@ -94,17 +142,14 @@ func set_busy(busy: bool) -> void: _host_button.disabled = busy -func _commit_name() -> void: - var clean := _name.text.strip_edges() - if not clean.is_empty(): - GameOpts.player_name = clean - - func _on_join() -> void: - _commit_name() - join_requested.emit(_address.text.strip_edges(), int(_port.text)) + if _join_button.disabled: + return + var address := _address.text.strip_edges() + var port := int(_port.text) + Settings.remember_server(address, port) + join_requested.emit(address, port) func _on_host() -> void: - _commit_name() host_requested.emit(int(_port.text)) diff --git a/src/ui/upgrade_screen.gd b/src/ui/upgrade_screen.gd index ac85405..8c22a9a 100644 --- a/src/ui/upgrade_screen.gd +++ b/src/ui/upgrade_screen.gd @@ -26,6 +26,7 @@ var _cards: HBoxContainer var _taken: Label var _summary: Label var _status: Label +var _close: Button func _ready() -> void: @@ -84,11 +85,10 @@ func _ready() -> void: _summary.custom_minimum_size = Vector2(740.0, 0.0) panel.add_child(_summary) - var close := Button.new() - close.text = "Close (E)" - close.custom_minimum_size = Vector2(160.0, 34.0) - close.pressed.connect(func() -> void: closed.emit()) - panel.add_child(close) + _close = Button.new() + _close.custom_minimum_size = Vector2(160.0, 34.0) + _close.pressed.connect(func() -> void: closed.emit()) + panel.add_child(_close) func _rule() -> Control: @@ -106,6 +106,9 @@ func set_status(text: String) -> void: ## screen can never show an offer that has already been spent. func refresh(pending: int, offer: Array[StringName], taken: Array[StringName]) -> void: _status.text = "" + # Named on every refresh rather than once: the same key closes the screen + # that opened it, and it is whatever interact is bound to now. + _close.text = "Close (%s)" % Settings.short_label("interact") if pending > 0: _title.text = "CHOOSE AN UPGRADE (%d waiting)" % pending else: diff --git a/src/view/game_scene.gd b/src/view/game_scene.gd index a63f992..9541c54 100644 --- a/src/view/game_scene.gd +++ b/src/view/game_scene.gd @@ -75,10 +75,14 @@ func _process(_delta: float) -> void: # Item feedback rides the same rule as every other sound here: it # plays because a server event arrived, never because the client # guessed a pickup succeeded. - _bound.item_picked_up.connect(func(_i: StringName) -> void: - sfx.play(Art.SFX_ENEMY_DEATH, -16.0)) - _bound.item_used.connect(func(_i: StringName) -> void: - sfx.play(Art.SFX_SHOOT, -10.0)) + _bound.item_picked_up.connect(func(i: StringName) -> void: + sfx.play(Art.SFX_ENEMY_DEATH, -16.0) + _announce_item("+ %s", i)) + _bound.item_used.connect(func(i: StringName) -> void: + sfx.play(Art.SFX_SHOOT, -10.0) + _announce_item("%s used", i)) + _bound.item_dropped.connect(func(i: StringName) -> void: + _announce_item("dropped %s", i)) _bound.characters_changed.connect(_refresh_characters) _bound.upgrades_changed.connect(_refresh_upgrades) _refresh_upgrades() @@ -97,10 +101,19 @@ func _process(_delta: float) -> void: _roster_open = false characters.visible = forced or _roster_open characters.set_dismissible(not forced) + # Said once, on the screen death sends you to, and gone as soon as the + # player has picked who comes next. + if forced and not _bound.last_fallen.is_empty(): + characters.set_fallen(fallen_line(_bound.last_fallen)) + elif not forced and not _bound.last_fallen.is_empty(): + _bound.last_fallen = {} + characters.set_fallen("") if _bound != null and _upgrades_open and not _bound.at_upgrade_npc(): _upgrades_open = false upgrades.visible = _upgrades_open _follow_camera() + hud.sight = world_view.sight + hud.debug_info = world_view.debug_hitboxes menu.set_in_dungeon(_bound != null and _bound.instance_kind == Protocol.InstanceKind.DUNGEON) @@ -147,6 +160,24 @@ func _on_respawn_pressed() -> void: _bound.request_respawn = true +## Permadeath, said plainly: who, how far they got, and where. A death used to +## be announced only by the roster screen appearing. +static func fallen_line(fallen: Dictionary) -> String: + var where := String(fallen.get("where", "")) + return "%s fell at level %d%s. They are gone -- choose who goes next." % [ + fallen["name"], int(fallen["level"]), + " in the %s" % where.trim_prefix("The ") if not where.is_empty() else ""] + + +## A line in the HUD's message feed for an item the server just moved. The +## events are this player's own -- ClientRuntime filters them by peer -- so a +## party member's pickup does not read as yours. +func _announce_item(pattern: String, item: StringName) -> void: + var def := Items.get_def(item) + if def != null: + hud.notify(pattern % def.display_name, def.tint) + + func _on_local_hit(_damage: int) -> void: hud.flash_hit() sfx.play(Art.SFX_PLAYER_HIT, -4.0) diff --git a/src/view/world_view.gd b/src/view/world_view.gd index 3c887e9..53a4ec0 100644 --- a/src/view/world_view.gd +++ b/src/view/world_view.gd @@ -33,8 +33,9 @@ var client: ClientRuntime = null var _anim_time: float = 0.0 ## F1 overlay: draws what the simulation collides against, over what is drawn. var debug_hitboxes: bool = false -## What the local player can see this frame. See [Visibility]. -var _sight := Visibility.new() +## What the local player can see this frame. See [Visibility]. Read by the HUD +## too, which waits for the boss to have been seen before showing its bar. +var sight := Visibility.new() ## The darkness is the window's clear colour, exactly, so where it is complete it ## is indistinguishable from the undrawn void beyond it and there is no seam. var _dark: Color = ProjectSettings.get_setting( @@ -76,7 +77,7 @@ func _draw() -> void: return var map: MapGrid = client.world.map if map != null: - _sight.compute(map, client.predicted_pos, SimConfig.FOG_VIEW_RADIUS) + sight.compute(map, client.predicted_pos, SimConfig.FOG_VIEW_RADIUS) _draw_terrain() _draw_portals() _draw_upgrade_npc() @@ -105,7 +106,7 @@ func _draw() -> void: ## and the shadow is several hundred. Issued from inside _draw on this item's own ## canvas, so it lands in order between what was drawn before and after it. func _draw_darkness() -> void: - var mesh := _sight.shadow_mesh(SimConfig.FOG_VIEW_RADIUS + DARK_REACH, _dark) + var mesh := sight.shadow_mesh(SimConfig.FOG_VIEW_RADIUS + DARK_REACH, _dark) RenderingServer.canvas_item_add_triangle_array(get_canvas_item(), mesh["indices"], mesh["points"], mesh["colors"]) @@ -129,7 +130,7 @@ func _draw_debug() -> void: DebugDraw.draw_player(self, p["pos"], p["aim"]) DebugDraw.draw_player(self, client.predicted_pos, client.aim) DebugDraw.draw_ranges(self, client.predicted_pos) - DebugDraw.draw_sight(self, _sight) + DebugDraw.draw_sight(self, sight) ## Aggro radius by visual index. Read from the content definitions rather than @@ -247,7 +248,7 @@ func _draw_upgrade_npc() -> void: HORIZONTAL_ALIGNMENT_CENTER, 300.0, 14, tint) if client.at_upgrade_npc(): draw_string(ThemeDB.fallback_font, at - Vector2(150.0, 18.0), - "E talk", HORIZONTAL_ALIGNMENT_CENTER, 300.0, 13, + "%s talk" % Settings.short_label("interact"), HORIZONTAL_ALIGNMENT_CENTER, 300.0, 13, Color(0.8, 0.85, 0.95)) # A pending choice pulses, so it is visible from across the hub. if waiting > 0: diff --git a/tests/unit/test_fallen.gd b/tests/unit/test_fallen.gd new file mode 100644 index 0000000..aa9afc2 --- /dev/null +++ b/tests/unit/test_fallen.gd @@ -0,0 +1,45 @@ +extends GutTest +## Permadeath is announced. The server unbinds a character on the tick it dies, +## so before this the only sign anything had happened was the roster appearing. + +const GAME_SCENE := preload("res://src/view/game_scene.gd") + + +func test_the_line_names_who_fell_how_far_they_got_and_where() -> void: + var line: String = GAME_SCENE.fallen_line( + {"name": "Bramcreek", "level": 7, "where": "Warden's Descent"}) + assert_string_contains(line, "Bramcreek") + assert_string_contains(line, "level 7") + assert_string_contains(line, "in the Warden's Descent") + + +func test_a_dungeon_already_called_the_something_is_not_the_the_something() -> void: + var line: String = GAME_SCENE.fallen_line( + {"name": "Wren", "level": 3, "where": "The Choir Vault"}) + assert_string_contains(line, "in the Choir Vault") + assert_false(line.contains("the The")) + + +func test_the_client_records_its_own_death_from_the_server_event() -> void: + var c: ClientRuntime = autofree(ClientRuntime.new()) + c.my_peer = 5 + c.characters = [{"id": "a", "name": "Ash", "active": true}] as Array[Dictionary] + c.selected_character = "a" + c.instance_kind = Protocol.InstanceKind.DUNGEON + c.dungeon_id = Dungeons.VAULT + c.my_total_xp = Progression.total_xp_for_level(4) + c.on_events(NetCodec.encode_events(10, + [{"t": SimEvent.Type.PLAYER_DIED, "peer": 5}] as Array[Dictionary])) + assert_eq(c.last_fallen.get("name", ""), "Ash") + assert_eq(int(c.last_fallen.get("level", 0)), 4) + assert_eq(String(c.last_fallen.get("where", "")), "The Choir Vault") + + +func test_someone_elses_death_is_not_yours() -> void: + var c: ClientRuntime = autofree(ClientRuntime.new()) + c.my_peer = 5 + c.characters = [{"id": "a", "name": "Ash", "active": true}] as Array[Dictionary] + c.selected_character = "a" + c.on_events(NetCodec.encode_events(10, + [{"t": SimEvent.Type.PLAYER_DIED, "peer": 6}] as Array[Dictionary])) + assert_true(c.last_fallen.is_empty()) diff --git a/tests/unit/test_fallen.gd.uid b/tests/unit/test_fallen.gd.uid new file mode 100644 index 0000000..1b9467b --- /dev/null +++ b/tests/unit/test_fallen.gd.uid @@ -0,0 +1 @@ +uid://chmutr64rrmsh diff --git a/tests/unit/test_settings.gd b/tests/unit/test_settings.gd index 5cf9fa9..afb366c 100644 --- a/tests/unit/test_settings.gd +++ b/tests/unit/test_settings.gd @@ -254,3 +254,89 @@ func test_saving_writes_to_the_configured_path() -> void: var cfg := ConfigFile.new() assert_eq(cfg.load(_scratch), OK) assert_almost_eq(float(cfg.get_value("audio", "master")), 0.33, 0.001) + + +# --- The connect screen remembers --------------------------------------------- + +func test_the_last_server_survives_a_save_and_reload() -> void: + var saved_address := Settings.last_address + var saved_port := Settings.last_port + Settings.remember_server(" play.example.net ", 27020) + Settings.last_address = "" + Settings.last_port = 0 + Settings.load_and_apply() + assert_eq(Settings.last_address, "play.example.net", "trimmed, and back again") + assert_eq(Settings.last_port, 27020) + Settings.last_address = saved_address + Settings.last_port = saved_port + + +func test_nonsense_is_not_remembered() -> void: + var saved_address := Settings.last_address + Settings.last_address = "kept.example.net" + Settings.remember_server(" ", 27020) + Settings.remember_server("x.example.net", 0) + Settings.remember_server("x.example.net", 70000) + assert_eq(Settings.last_address, "kept.example.net", + "a blank address or an impossible port must not replace a good one") + Settings.last_address = saved_address + + +# --- The HUD says what the keys actually are ---------------------------------- +# Every hint on screen used to be a literal -- "WASD move", "LMB fire", "hold F" +# -- while every one of those actions is rebindable. Rebind fire and the HUD +# kept telling you to click. + +const HUD := preload("res://src/ui/hud.gd") + + +func test_the_hints_name_the_default_keys() -> void: + assert_eq(HUD.move_keys(), "WASD") + assert_eq(HUD.key("fire"), "LMB") + assert_eq(HUD.slot_keys(4), "1-4") + assert_string_contains(HUD.controls_hint(false), "hold F") + assert_string_contains(HUD.controls_hint(false), "Esc menu", + "spelled the way a keycap spells it, not the way the OS names it") + + +func test_the_hints_follow_a_rebind() -> void: + var click := InputEventMouseButton.new() + click.button_index = MOUSE_BUTTON_RIGHT + click.device = -1 + assert_eq(Settings.rebind("fire", click), "") + assert_eq(Settings.rebind("emergency_escape", _key_event(KEY_G)), "") + var hint := HUD.controls_hint(false) + assert_string_contains(hint, "RMB fire") + assert_string_contains(hint, "hold G") + assert_false(hint.contains("LMB"), "the old key is not mentioned anywhere") + + +func test_movement_spelled_out_when_it_is_not_four_letters() -> void: + assert_eq(Settings.rebind("move_up", _key_event(KEY_UP)), "") + var keys := HUD.move_keys() + assert_true(keys.begins_with("Up/"), "arrow keys read as words, got %s" % keys) + + +func test_slot_keys_are_listed_when_they_are_not_one_to_four() -> void: + assert_eq(Settings.rebind("use_slot_3", _key_event(KEY_Z)), "") + assert_eq(HUD.slot_keys(4), "1 2 Z 4") + + +func test_the_upgrade_screen_names_the_key_that_closes_it() -> void: + assert_eq(Settings.rebind("interact", _key_event(KEY_G)), "") + var screen: CanvasLayer = preload("res://src/ui/upgrade_screen.gd").new() + add_child_autofree(screen) + var none: Array[StringName] = [] + screen.refresh(0, none, none) + assert_eq(screen._close.text, "Close (G)", + "it used to say (E) whatever interact was bound to") + + +func test_short_labels_are_what_a_keycap_says() -> void: + var click := InputEventMouseButton.new() + click.button_index = MOUSE_BUTTON_LEFT + click.device = -1 + assert_eq(Settings.rebind("interact", click), "fire", + "left click is fire's, and the clash is named") + assert_eq(Settings.short_label("fire"), "LMB") + assert_eq(Settings.short_label("system_menu"), "Esc") diff --git a/tools/screenshot.gd b/tools/screenshot.gd index 2497862..c2506c2 100644 --- a/tools/screenshot.gd +++ b/tools/screenshot.gd @@ -14,6 +14,14 @@ extends Node ## touching anything under src/ui/ and open the files. const GAME := preload("res://src/game.tscn") +const GAME_SCENE := preload("res://src/view/game_scene.gd") +## Its own store and its own account, like every diagnostic. Without these the +## tool hosted a listen server on the player's REAL character store as the +## player's REAL account, and walked their character into dungeons to take +## pictures -- with health carried between runs, a stray hit would have stuck, +## and a death would have retired someone's character for a screenshot. +const STORE := "user://screenshot_characters.json" +const ACCOUNT := 777001 ## Frame numbers, not seconds: the point is a deterministic sequence, and a ## windowed client at 60fps gets there in about four seconds. const AT_MENU := 30 @@ -34,7 +42,9 @@ const AT_DUNGEON := 480 const TO_TUNNEL := 490 const AT_TUNNEL := 550 const AT_TUNNEL_DEBUG := 560 -const DONE := 570 +const TO_BOSS := 570 +const AT_BOSS := 640 +const DONE := 650 var _frame: int = 0 var _game: Node = null @@ -44,6 +54,9 @@ var _dir: String = "user://" func _ready() -> void: GameOpts.parse() + GameOpts.store_path = STORE + GameOpts.account_override = ACCOUNT + DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE)) Engine.physics_ticks_per_second = SimConfig.TICK_RATE Settings.load_and_apply() var from_env := OS.get_environment("SHOT_DIR") @@ -78,16 +91,24 @@ func _process(_delta: float) -> void: # Held until the shot is taken; one frame of it is not enough. if _frame > OPEN_UPGRADES and _frame <= AT_UPGRADES: _stand_at_the_npc() + # On an entrance for the hub shot, so its prompt is in the picture. + if _frame > AT_GAME - 40 and _frame <= AT_GAME: + _stand_on_a_portal() # Same reason: the game scene owns this screen's visibility too and decides # it every frame, so one assignment lasts exactly one frame. if _frame > OPEN_ROSTER and _frame <= AT_ROSTER: _show_the_roster() + # Held low, so the low-health edge is in the picture -- and held at all, + # because the boss is firing and one landed ring would end the shot. + if _frame > TO_BOSS and _frame <= AT_BOSS: + _hold_health(0.22) match _frame: AT_MENU: _grab("menu") ENTER_GAME: _menu.queue_free() Net.host(27455) + _seed_character() Net.start_local_client() _game = GAME.instantiate() add_child(_game) @@ -131,6 +152,7 @@ func _process(_delta: float) -> void: # The fog is what these two are for, and the hub is the worst place to # judge it: one big room with little in it to cast a shadow. _game.set("_roster_open", false) + _game.get_node("CharacterSelect").set_fallen("") Net.server._send_to_dungeon(Net.LOCAL_PEER) AT_DUNGEON: _grab("dungeon") @@ -144,6 +166,11 @@ func _process(_delta: float) -> void: _game.get_node("WorldView").debug_hitboxes = true AT_TUNNEL_DEBUG: _grab("tunnel_debug") + TO_BOSS: + _game.get_node("WorldView").debug_hitboxes = false + _stage_a_boss_fight() + AT_BOSS: + _grab("boss_fight") DONE: Net.shutdown() get_tree().quit(0) @@ -170,6 +197,9 @@ func _show_the_roster() -> void: p.pos = away _game.set("_upgrades_open", false) _game.set("_roster_open", true) + # A death, as it is announced on the screen a death sends you to. + _game.get_node("CharacterSelect").set_fallen(GAME_SCENE.fallen_line( + {"name": "Quillfell", "level": 9, "where": "The Choir Vault"})) var roster: Array[Dictionary] = [ _roster_entry("Whole", 4, 1300, 1300, Color(0.5, 0.9, 1.0)), _roster_entry("Bruised", 7, 1600, 940, Color(1.0, 0.8, 0.4)), @@ -178,6 +208,71 @@ func _show_the_roster() -> void: _game.get_node("CharacterSelect").refresh(roster, "c4") +## A character worth photographing: levelled, carrying things, so the HUD has +## something in every slot and a bar that is not empty. Made in the scratch +## store before the local client says hello, so it is who the client resumes. +func _seed_character() -> void: + var store := Net.server.store + var c := store.create_character(ACCOUNT, "Ash") + store.grant_xp(ACCOUNT, c.id, Progression.total_xp_for_level(6) + 260) + var bag: Array[StringName] = [Items.HEALTH_POTION, Items.WARDENS_RATION, + Items.HEALTH_POTION, Items.NONE] + store.set_inventory(ACCOUNT, c.id, bag) + store.flush() + + +func _stand_on_a_portal() -> void: + if Net.client == null or Net.client.portals.is_empty(): + return + var at: Vector2 = Net.client.portals[0]["pos"] + Net.client.predicted_pos = at + var p := _player() + if p != null: + p.pos = at + + +func _player() -> SimPlayer: + var inst := Net.server.instance_of(Net.LOCAL_PEER) + return inst.world.players.get(Net.LOCAL_PEER) if inst != null else null + + +func _hold_health(share: float) -> void: + var p := _player() + if p != null: + p.hp = maxi(1, int(float(p.max_hp) * share)) + + +## Into the boss arena, low on health, with a party member beside you and a +## few messages in the feed: everything on the HUD that only exists mid-fight, +## in one picture. +## +## The party member is a world player with no socket behind it -- added to the +## simulation and the roster, never to the instance's send list, so the server +## never tries to talk to it. +func _stage_a_boss_fight() -> void: + var inst := Net.server.instance_of(Net.LOCAL_PEER) + if inst == null: + return + var at := inst.boss_spawn + Vector2(-200.0, 60.0) + Net.client.predicted_pos = at + var me := _player() + if me != null: + me.pos = at + me.spawn_grace = 0 + var friend := inst.world.add_player(999, "Wren") + friend.pos = at + Vector2(-40.0, -70.0) + friend.colour = Color(1.0, 0.75, 0.45) + friend.hp = int(float(friend.max_hp) * 0.6) + friend.spawn_grace = 0 + Net.server.peer_names[999] = "Wren" + Net.server.peer_instance[999] = inst.id + Net.server._broadcast_roster() + var hud := _game.get_node("HUD") + hud.notify("+ Health Potion", Items.get_def(Items.HEALTH_POTION).tint) + hud.notify("Warden's Ration used", Items.get_def(Items.WARDENS_RATION).tint) + hud.notify("LEVEL 7 -- a choice waits at the quartermaster", Color(1.0, 0.85, 0.4)) + + ## Move the player, on both sides, into the tunnel closest to where it spawned: ## somewhere with walls close on two sides and a corner in view, which is where ## the old fog looked worst.