A UI pass: prompts that tell the truth, and a HUD that does not collide
ci / verify (push) Successful in 53s

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 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-10-05 10:41:04 +02:00
1 parent 069abdb3fa
commit 19d2295bec
14 files changed
+903 -229

No files matched your search

+9 -3
View File
@@ -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. |
+4
View File
@@ -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` |
+34
View File
@@ -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":
+28
View File
@@ -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:
+17
View File
@@ -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
+457 -179
View File
@@ -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
+76 -31
View File
@@ -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))
+8 -5
View File
@@ -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:
+35 -4
View File
@@ -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)
+7 -6
View File
@@ -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:
+45
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
uid://chmutr64rrmsh
+86
View File
@@ -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")
+96 -1
View File
@@ -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.