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.