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

+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: