HUD and connect screen, second pass
ci / verify (push) Successful in 53s

At 331 of 1500 health the bar read as nearly full. The pack's bar sprite
is light all the way through, so the EMPTY part was the brightest thing
on it. Every bar now has a dark track inside its frame, scaled with the
bar's height -- a fixed inset left the thin experience bar four pixels of
track and six of white -- so empty reads as empty.

Health carries its number inside the bar, and a damage trail: the part
the last hit took stays in a darker red for a moment and then drains, so
a hit reads as an amount rather than as a bar that is suddenly shorter.
Two quick hits read as one, measured from where the first began. The
trail was pale at first and the number vanished against it; darker fixed
that. Experience flashes and shows "+N xp" on a kill, summed across a
burst so it does not flicker. Every block of HUD text sits on a dark
panel, since the floor art is busy enough to beat a text shadow.

Centre banners for the moments that change what you should do: a level
gained, a boss changing phase (not the phase it was found in), and the
escape channel -- which was a second bar in the corner and is now one
bar where you are looking, with the key to keep holding. Party members
you cannot see get a marker at the edge of sight pointing the way to
them. Empty inventory slots step back, so the blank parchment square is
no longer the most prominent thing in the row.

The connect screen opens on a real boss pattern behind its panel -- the
bosses' own emitters, fired into a pool and drawn by the game's own bullet
renderer, dimmed. It is primed with five seconds of ticks: every pattern
starts at the centre of the screen, which is where the panel is, so
unprimed it showed nothing for its first second or two. The title is
spaced and underlined, Credits is on the screen itself, Join says Enter
works and has focus, the port is validated before it can fail, and
"connecting" animates so a slow attempt does not look frozen.

The boss-bar logic is pulled out into track_boss() so it can be tested:
it waits for the fog to light the boss, stays for the fight, has to be
seen again in the next dungeon -- every dungeon's boss is actor 1, so the
id alone would have carried "seen" across -- and announces only real
phase changes. screenshot.tscn captures the escape banner by progress
rather than by frame count, because the channel is server ticks and a
completed escape teleports the player out mid-sequence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-10-05 13:13:11 +02:00
1 parent 3d1b5da72b
commit 6e872e2cf5
8 files changed
+703 -96

No files matched your search

+1
View File
@@ -70,6 +70,7 @@ func _show_menu() -> void:
_menu.join_requested.connect(_join) _menu.join_requested.connect(_join)
_menu.host_requested.connect(_host_and_play) _menu.host_requested.connect(_host_and_play)
_menu.settings_requested.connect(_open_settings) _menu.settings_requested.connect(_open_settings)
_menu.credits_requested.connect(_open_credits)
add_child(_menu) add_child(_menu)
# Unconditional, and the reason this is not an early-return when the menu # Unconditional, and the reason this is not an early-return when the menu
# already exists: a failed connection returns here with the buttons still # already exists: a failed connection returns here with the buttons still
+276 -64
View File
@@ -39,6 +39,23 @@ const LOW_HEALTH := 0.35
const HINT_HOLD := 6.0 const HINT_HOLD := 6.0
const HINT_FADE := 1.5 const HINT_FADE := 1.5
## Health bar, taller than the others because it carries its own numbers.
const HP_H := 20.0
## Experience is a thin strip under health: it matters between fights, not in
## them.
const XP_H := 10.0
## How long the damage trail holds before draining, and how fast it drains (as
## a share of the bar per second). Long enough to read what the hit took.
const TRAIL_HOLD := 0.45
const TRAIL_DRAIN := 0.9
## Centre-screen banners: how long one stays, and its fade at each end.
const BANNER_LIFE := 2.4
const BANNER_FADE := 0.5
## The inside of every bar. The pack's bar sprite is light all the way through,
## so on its own an EMPTY bar was the brightest thing on screen and a player on
## a fifth of their health saw a bar that read as nearly full.
const COL_TRACK := Color(0.07, 0.07, 0.11)
const COL_TEXT := Color(0.86, 0.9, 0.97) const COL_TEXT := Color(0.86, 0.9, 0.97)
const COL_DIM := Color(0.58, 0.63, 0.74) const COL_DIM := Color(0.58, 0.63, 0.74)
const COL_HEALTH := Color(0.35, 0.9, 0.6) const COL_HEALTH := Color(0.35, 0.9, 0.6)
@@ -61,6 +78,25 @@ var _hit_flash: float = 0.0
var client: ClientRuntime = null var client: ClientRuntime = null
var _toasts: Array[Dictionary] = [] var _toasts: Array[Dictionary] = []
## The damage trail: where the health bar was before the last hit, held a
## moment and then drained down to where it is now.
var _hp_trail: float = 1.0
var _hp_last: float = 1.0
var _hp_hold: float = 0.0
## Experience just gained, shown beside the bar for a moment.
var _xp_last: int = -1
var _xp_gain: int = 0
var _xp_flash: float = 0.0
## The banner currently shown, or an empty title.
var _banner_title := ""
var _banner_sub := ""
var _banner_col := Color.WHITE
var _banner_born: float = -100.0
## The boss phase last announced, keyed like _boss_seen.
var _boss_phase_key := ""
var _boss_phase: int = -1
## Dark backing for every panel of text over the world. Built once.
var _panel_style: StyleBoxFlat = null
## Which boss, in which instance, has been seen -- see _draw_boss_bar. ## Which boss, in which instance, has been seen -- see _draw_boss_bar.
var _boss_seen := "" var _boss_seen := ""
## The level last drawn and whose it was, so a level-up can be announced the ## The level last drawn and whose it was, so a level-up can be announced the
@@ -113,6 +149,8 @@ func _process(delta: float) -> void:
_instance_seen = client.instance_id _instance_seen = client.instance_id
_instance_since = now _instance_since = now
_watch_level() _watch_level()
_watch_health(delta)
_watch_experience(delta)
_update_respawn_button() _update_respawn_button()
_canvas.queue_redraw() _canvas.queue_redraw()
@@ -134,6 +172,43 @@ func flash_hit() -> void:
_hit_flash = 1.0 _hit_flash = 1.0
## A line across the middle of the screen for a moment: a level gained, a boss
## changing phase. Things that change what you should do next, which is why
## they are not in the message feed with the potions.
func banner(title: String, sub: String = "", colour: Color = COL_GOLD) -> void:
_banner_title = title
_banner_sub = sub
_banner_col = colour
_banner_born = _now()
## Keep the damage trail: when health drops, the trail stays where it was for
## TRAIL_HOLD and then drains to meet the bar. Healing moves both at once.
func _watch_health(delta: float) -> void:
var frac := clampf(float(client.my_hp) / float(maxi(client.my_max_hp, 1)), 0.0, 1.0)
if frac < _hp_last - 0.0001:
_hp_trail = maxf(_hp_trail, _hp_last)
_hp_hold = TRAIL_HOLD
if frac >= _hp_trail:
_hp_trail = frac
if _hp_hold > 0.0:
_hp_hold -= delta
else:
_hp_trail = move_toward(_hp_trail, frac, TRAIL_DRAIN * delta)
_hp_last = frac
func _watch_experience(delta: float) -> void:
_xp_flash = maxf(_xp_flash - delta, 0.0)
var xp := client.my_total_xp
if _xp_last >= 0 and xp > _xp_last and String(_seen_character) != "":
# Summed while the flash is up, so a burst of kills reads as one total
# rather than as a number that flickers.
_xp_gain = (_xp_gain if _xp_flash > 0.0 else 0) + (xp - _xp_last)
_xp_flash = 1.4
_xp_last = xp
## Announce a level the moment the snapshot's experience crosses one. Watched ## Announce a level the moment the snapshot's experience crosses one. Watched
## rather than signalled because experience rides the snapshot, and the first ## 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. ## sight of a character -- logging in, swapping -- must not read as a level-up.
@@ -146,11 +221,15 @@ func _watch_level() -> void:
if String(who["id"]) != _seen_character: if String(who["id"]) != _seen_character:
_seen_character = String(who["id"]) _seen_character = String(who["id"])
_seen_level = level _seen_level = level
# A new character is not a hit, and its experience is not a gain.
_hp_trail = clampf(float(client.my_hp) / float(maxi(client.my_max_hp, 1)), 0.0, 1.0)
_hp_last = _hp_trail
_xp_last = client.my_total_xp
return return
if level > _seen_level: if level > _seen_level:
var where := "" if client.instance_kind == Protocol.InstanceKind.LOBBY \ banner("LEVEL %d" % level,
else " -- a choice waits at the quartermaster" "a choice waits at the quartermaster" if level < Progression.MAX_LEVEL
notify("LEVEL %d%s" % [level, where], COL_GOLD) else "the highest there is", COL_GOLD)
_seen_level = level _seen_level = level
@@ -243,11 +322,11 @@ func _draw_hud() -> void:
# Under everything, so the edge of the screen colours without dimming any of # Under everything, so the edge of the screen colours without dimming any of
# the text on top of it. # the text on top of it.
_draw_vignette() _draw_vignette()
_draw_party_markers()
var y := _draw_player_panel() _draw_player_panel()
_draw_level_up_notice(y)
_draw_boss_bar() _draw_boss_bar()
_draw_arriving() _draw_banner()
_draw_cleared_countdown() _draw_cleared_countdown()
_draw_others() _draw_others()
_draw_inventory() _draw_inventory()
@@ -260,13 +339,37 @@ func _draw_hud() -> void:
_centred(_canvas.size.y * 0.5, "DOWN", 34, Color(1.0, 0.4, 0.4)) _centred(_canvas.size.y * 0.5, "DOWN", 34, Color(1.0, 0.4, 0.4))
## Who you are, where you are, and how you are doing. Returns the y below the ## A dark, slightly translucent box behind a block of HUD text. The floor art is
## last thing drawn, so whatever comes next stacks rather than overlaps. ## busy and mid-toned; text straight over it was readable on some tiles and not
func _draw_player_panel() -> float: ## on others, and the shadow under each letter only went so far.
func _panel(rect: Rect2) -> void:
if _panel_style == null:
_panel_style = StyleBoxFlat.new()
_panel_style.bg_color = Color(0.05, 0.05, 0.09, 0.62)
_panel_style.border_color = Color(1.0, 1.0, 1.0, 0.07)
_panel_style.set_border_width_all(1)
_panel_style.set_corner_radius_all(4)
_canvas.draw_style_box(_panel_style, rect)
## Who you are, where you are, and how you are doing.
##
## Laid out in two passes -- measure the rows, draw the panel behind them, then
## draw them -- because the panel has to be under the text and its height
## depends on which rows are showing.
func _draw_player_panel() -> void:
var who := client.current_character()
var has_character := not who.is_empty()
var rows := 20.0 + HP_H + 6.0
if has_character:
rows += 22.0 + XP_H + 18.0
if client.upgrades_pending > 0:
rows += 20.0
_panel(Rect2(MARGIN - 10.0, MARGIN - 8.0, BAR_W + 20.0, rows + 12.0))
var x := MARGIN var x := MARGIN
var y := MARGIN var y := MARGIN
var who := client.current_character() if has_character:
if not who.is_empty():
var name_text := String(who["name"]) var name_text := String(who["name"])
_text(Vector2(x, y + 15.0), name_text, 17, COL_TEXT) _text(Vector2(x, y + 15.0), name_text, 17, COL_TEXT)
_text(Vector2(x + _text_width(name_text, 17) + 10.0, y + 15.0), _text(Vector2(x + _text_width(name_text, 17) + 10.0, y + 15.0),
@@ -288,41 +391,44 @@ func _draw_player_panel() -> float:
# Red when low, not a blend toward it: green-to-red through the middle is # 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. # 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) var hp_tint := COL_HEALTH if frac >= LOW_HEALTH else Color(1.0, 0.38, 0.36)
_bar(Vector2(x, y), frac, hp_tint) _bar(Vector2(x, y), frac, hp_tint, _hp_trail, Vector2(BAR_W, HP_H))
_text(Vector2(x + BAR_W + 10.0, y + 13.0), # Inside the bar, where the eye already is. Light on the dark track and on
"%d / %d" % [client.my_hp, client.my_max_hp], 12, hp_tint) # every fill colour, which a number beside the bar did not need to be.
y += BAR_H + 4.0 _text(Vector2(x, y + HP_H - 5.0), "%d / %d" % [client.my_hp, client.my_max_hp],
12, COL_TEXT, HORIZONTAL_ALIGNMENT_CENTER, BAR_W)
y += HP_H + 6.0
if _draw_xp_bar(Vector2(x, y)): if has_character:
y += BAR_H + 4.0 _draw_xp_bar(Vector2(x, y))
y += XP_H + 18.0
if client.my_escaping: # No escape row here: the channel is a banner in the middle of the screen,
_bar(Vector2(x, y), client.my_escape, Color(0.5, 0.85, 1.0)) # where you are looking, and a second bar for the same second was noise.
_text(Vector2(x + BAR_W + 10.0, y + 13.0), "ESCAPING", 12, _draw_level_up_notice(y)
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 ## A thin bar under health: progress toward the next level. Drawn from the
## itself. Drawn from the server's numbers, never recomputed locally. Returns ## server's numbers, never recomputed locally.
## whether it drew, since a player at the roster screen has no character. func _draw_xp_bar(at: Vector2) -> void:
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 # Derived from the snapshot's live experience total rather than the roster's
# copy, which only arrives when the set of characters changes. # copy, which only arrives when the set of characters changes.
var level := Progression.level_for_xp(client.my_total_xp) var level := Progression.level_for_xp(client.my_total_xp)
var capped := level >= Progression.MAX_LEVEL var capped := level >= Progression.MAX_LEVEL
var progress := Progression.level_progress(client.my_total_xp) var progress := Progression.level_progress(client.my_total_xp)
var tint := COL_GOLD if capped else Color(0.6, 0.55, 1.0) var tint := COL_GOLD if capped else Color(0.6, 0.55, 1.0)
_bar(at, progress, tint) # Brighter for a moment after a kill, so experience arriving is something
# you notice rather than something you check.
if _xp_flash > 0.0:
tint = tint.lerp(Color.WHITE, 0.45 * clampf(_xp_flash, 0.0, 1.0))
_bar(at, progress, tint, -1.0, Vector2(BAR_W, XP_H))
# The number as well as the bar: "how far to the next level" is a question # 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. # a bar answers vaguely and a percentage answers exactly.
var text := "MAX" if capped else "%d%% to level %d" % [ var text := "MAX" if capped else "%d%% to level %d" % [
int(floor(progress * 100.0)), level + 1] int(floor(progress * 100.0)), level + 1]
_text(at + Vector2(BAR_W + 10.0, 13.0), text, 12, tint) _text(at + Vector2(0.0, XP_H + 13.0), text, 11, Color(tint, 0.9))
return true if _xp_flash > 0.0 and _xp_gain > 0:
_text(at + Vector2(0.0, XP_H + 13.0), "+%d xp" % _xp_gain, 11,
Color(COL_GOLD, clampf(_xp_flash, 0.0, 1.0)), HORIZONTAL_ALIGNMENT_RIGHT, BAR_W)
## Unspent level-ups. Shown wherever you are, because the choice is banked in a ## Unspent level-ups. Shown wherever you are, because the choice is banked in a
@@ -332,12 +438,12 @@ func _draw_level_up_notice(y: float) -> void:
if client.upgrades_pending <= 0: if client.upgrades_pending <= 0:
return return
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.004) var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.004)
var where := " -- see the quartermaster in the hub" \ var where := " -- quartermaster, in the hub" \
if client.instance_kind != Protocol.InstanceKind.LOBBY else "" if client.instance_kind != Protocol.InstanceKind.LOBBY else ""
_text(Vector2(MARGIN, y + 16.0), _text(Vector2(MARGIN, y + 12.0),
"%d LEVEL-UP%s TO SPEND%s" % [client.upgrades_pending, "%d LEVEL-UP%s TO SPEND%s" % [client.upgrades_pending,
"" if client.upgrades_pending == 1 else "S", where], "" if client.upgrades_pending == 1 else "S", where],
13, Color(COL_GOLD, 0.6 + 0.4 * pulse)) 12, Color(COL_GOLD, 0.6 + 0.4 * pulse))
## The boss's health, once you have seen the boss. ## The boss's health, once you have seen the boss.
@@ -353,20 +459,12 @@ func _draw_boss_bar() -> void:
var b := client.boss_state() var b := client.boss_state()
if b.is_empty() or client.boss_def == null: if b.is_empty() or client.boss_def == null:
return return
var seen_key := "%d:%d" % [client.instance_id, int(b["id"])] if not track_boss(b):
if _boss_seen != seen_key: return
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 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 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)) _panel(Rect2(pos.x - 10.0, MARGIN - 8.0, BOSS_BAR_W + 20.0, BAR_H + 36.0))
_hslice(UiTheme.texture("bar"), rect, Color.WHITE) _bar(pos, frac, Color(0.95, 0.35, 0.45), -1.0, Vector2(BOSS_BAR_W, BAR_H))
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_index := int(b["phase"])
var phase_name := "" var phase_name := ""
if phase_index < client.boss_def.phases.size(): if phase_index < client.boss_def.phases.size():
@@ -377,16 +475,67 @@ func _draw_boss_bar() -> void:
HORIZONTAL_ALIGNMENT_RIGHT, BOSS_BAR_W) HORIZONTAL_ALIGNMENT_RIGHT, BOSS_BAR_W)
## Arrival protection, as a banner rather than a line tucked beside the health ## Whether the boss bar should be up, and the place a phase change is noticed.
## bar: it is the one moment the rules are different, and both halves of it -- ## Returns false until the fog has lit the boss once; true from then on, for the
## you cannot be hurt, and you cannot shoot -- change what you should do. ## rest of that fight.
func _draw_arriving() -> void: ##
if not client.my_spawn_grace: ## A phase change is announced because it is the moment the patterns you just
return ## learned stop being the ones coming at you. Not the phase the boss is in when
## first seen -- that is not a change, only where it started.
func track_boss(b: Dictionary) -> bool:
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 false
_boss_seen = seen_key
var phase_index := int(b["phase"])
if _boss_phase_key != seen_key:
_boss_phase_key = seen_key
_boss_phase = phase_index
elif phase_index > _boss_phase:
_boss_phase = phase_index
var phase_name := ""
if client.boss_def != null and phase_index < client.boss_def.phases.size():
phase_name = client.boss_def.phases[phase_index].name
banner(phase_name.to_upper(),
client.boss_def.display_name if client.boss_def != null else "",
Color(1.0, 0.55, 0.6))
return true
## One message across the middle of the screen, chosen by what matters most
## right now: leaving, then arriving, then whatever was announced last.
##
## Arrival protection is a banner rather than a line beside the health bar
## because 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_banner() -> void:
var y := _canvas.size.y * 0.27
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.008) var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.008)
var y := _canvas.size.y * 0.28 if client.my_escaping:
_centred(y, "ARRIVING", 24, Color(0.6, 0.9, 1.0, 0.75 + 0.25 * pulse)) var blue := Color(0.55, 0.85, 1.0)
_centred(y + 22.0, "invulnerable · weapons cold", 13, Color(0.6, 0.9, 1.0, 0.8)) _centred(y, "LEAVING", 24, blue)
var w := 220.0
var at := Vector2((_canvas.size.x - w) * 0.5, y + 12.0)
_bar(at, client.my_escape, blue, -1.0, Vector2(w, 12.0))
_centred(y + 42.0, "keep holding %s" % key("emergency_escape"), 12, Color(blue, 0.8))
return
if client.my_spawn_grace:
_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))
return
if _banner_title.is_empty():
return
var age := _now() - _banner_born
if age > BANNER_LIFE:
return
var alpha := clampf(minf(age / 0.15, (BANNER_LIFE - age) / BANNER_FADE), 0.0, 1.0)
# A short settle on arrival rather than a pop, so it catches the eye without
# jolting it away from the bullets.
var settle := maxf(0.0, 0.2 - age) * 40.0
_centred(y - settle, _banner_title, 28, Color(_banner_col, alpha))
if not _banner_sub.is_empty():
_centred(y + 24.0 - settle, _banner_sub, 13, Color(_banner_col, 0.8 * alpha))
## Shown after the boss dies, so the victory lap has a visible clock on it. ## Shown after the boss dies, so the victory lap has a visible clock on it.
@@ -414,6 +563,7 @@ func _draw_others() -> void:
func _draw_online() -> void: func _draw_online() -> void:
var x := _canvas.size.x - 240.0 var x := _canvas.size.x - 240.0
var y := MARGIN + 12.0 var y := MARGIN + 12.0
_panel(Rect2(x - 10.0, MARGIN - 8.0, 236.0, 28.0 + 18.0 * client.roster.size()))
_text(Vector2(x, y), "ONLINE", 13, COL_DIM) _text(Vector2(x, y), "ONLINE", 13, COL_DIM)
y += 20.0 y += 20.0
for entry in client.roster: for entry in client.roster:
@@ -444,6 +594,7 @@ func _draw_party() -> void:
return return
var x := _canvas.size.x - 240.0 var x := _canvas.size.x - 240.0
var y := MARGIN + 12.0 var y := MARGIN + 12.0
_panel(Rect2(x - 10.0, MARGIN - 8.0, 236.0, 26.0 + 20.0 * members.size()))
_text(Vector2(x, y), "PARTY", 13, COL_DIM) _text(Vector2(x, y), "PARTY", 13, COL_DIM)
y += 10.0 y += 10.0
var w := 140.0 var w := 140.0
@@ -474,6 +625,47 @@ func _draw_party() -> void:
y += 20.0 y += 20.0
## A marker at the edge of sight for each party member you cannot see, pointing
## the way to them.
##
## The fog hides players behind walls and beyond sight like anything else, and
## in a dungeon the most common question about the rest of your party is where
## they went. Allies only, from data the client already holds -- a party member
## outside the interest radius is not in the snapshot, and gets no marker.
func _draw_party_markers() -> void:
if client.instance_kind == Protocol.InstanceKind.LOBBY or not client.my_alive:
return
var eye := client.predicted_pos
var centre := eye + client.camera_offset
var ring := SimConfig.FOG_VIEW_RADIUS - 40.0
var names := {}
for entry in client.roster:
names[int(entry["peer"])] = String(entry["name"])
var bounds := Rect2(Vector2(MARGIN, MARGIN), _canvas.size - Vector2(MARGIN, MARGIN) * 2.0)
for p in client.remote_players():
var pos: Vector2 = p["pos"]
var offset := pos - eye
var dist := offset.length()
if dist < 1.0:
continue
var lit := sight == null or sight.light_at(pos) > 0.3
if dist < ring and lit:
continue
var dir := offset / dist
var at := centre + dir * minf(ring, dist)
at = at.clamp(bounds.position, bounds.end)
var flags := int(p["flags"])
var col: Color = p["colour"]
if (flags & Protocol.F_ALIVE) == 0:
col = COL_DANGER
var tip := at + dir * 9.0
var side := dir.orthogonal() * 6.0
_canvas.draw_colored_polygon(PackedVector2Array([
tip, at - dir * 4.0 + side, at - dir * 4.0 - side]), Color(col, 0.9))
_text(at - dir * 12.0 + Vector2(-60.0, 4.0), String(names.get(int(p["peer"]), "")),
11, Color(col, 0.85), HORIZONTAL_ALIGNMENT_CENTER, 120.0)
## Four slots, always on screen. Deliberately not a panel you open: an ## Four slots, always on screen. Deliberately not a panel you open: an
## inventory you have to stop and read is a menu, and a menu is a death in a ## inventory you have to stop and read is a menu, and a menu is a death in a
## game where the floor is bullets. Everything drawn here comes from the ## game where the floor is bullets. Everything drawn here comes from the
@@ -494,7 +686,11 @@ func _draw_inventory() -> void:
# recoloured border, so "this is the one the key is on" reads the same # recoloured border, so "this is the one the key is on" reads the same
# way every other pressed thing in the interface does. # way every other pressed thing in the interface does.
var slot_art := UiTheme.texture("slot_active" if i == held else "slot") var slot_art := UiTheme.texture("slot_active" if i == held else "slot")
_canvas.draw_texture_rect(slot_art, Rect2(at, Vector2(SLOT, SLOT)), false) # An empty slot steps back. The pack's slot is a bright parchment
# square, and an empty one drew the eye as hard as a full one -- the
# blank box was the most prominent thing in the row.
var slot_tint := Color.WHITE if def != null else Color(1.0, 1.0, 1.0, 0.45)
_canvas.draw_texture_rect(slot_art, Rect2(at, Vector2(SLOT, SLOT)), false, slot_tint)
# The key that uses the slot, because that is the only thing the player # 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. # actually needs to know about it -- and it is whatever they bound.
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(6.0, 17.0), _canvas.draw_string(ThemeDB.fallback_font, at + Vector2(6.0, 17.0),
@@ -613,14 +809,30 @@ func _draw_vignette() -> void:
_canvas.draw_polygon(PackedVector2Array(sides[i]), PackedColorArray(cols[i])) _canvas.draw_polygon(PackedVector2Array(sides[i]), PackedColorArray(cols[i]))
## A bar in the pack's art: the frame stretched horizontally, the fill tinted. ## A bar in the pack's art: the frame stretched horizontally, a dark track
func _bar(pos: Vector2, frac: float, fill: Color) -> void: ## inside it, and the fill tinted on top.
var rect := Rect2(pos, Vector2(BAR_W, BAR_H)) ##
## [param trail], when it is above [param frac], is drawn first in a pale
## version of the fill -- the part of the bar the last hit took, before it
## drains away. That is what makes a hit readable as an amount rather than as a
## bar that is suddenly shorter.
func _bar(pos: Vector2, frac: float, fill: Color, trail: float = -1.0,
size: Vector2 = Vector2(BAR_W, BAR_H)) -> void:
var rect := Rect2(pos, size)
_hslice(UiTheme.texture("bar"), rect, Color.WHITE) _hslice(UiTheme.texture("bar"), rect, Color.WHITE)
var inset := 3.0 # The frame scales with the bar's height, so the inset has to as well: a
var inner := Rect2(rect.position + Vector2(inset, inset), # fixed three pixels left a ten-pixel bar four pixels of track and six of
Vector2((rect.size.x - inset * 2.0) * clampf(frac, 0.0, 1.0), # white frame, and it read as an empty white bar whatever it held.
rect.size.y - inset * 2.0)) var inset := maxf(1.0, roundf(size.y * 0.15))
var track := Rect2(rect.position + Vector2(inset, inset), rect.size - Vector2(inset, inset) * 2.0)
_canvas.draw_rect(track, COL_TRACK)
var full := track.size.x
if trail > frac:
# Darker than the fill, not paler. The health number sits on top of
# this, and a pale trail was the one background it vanished against.
_canvas.draw_rect(Rect2(track.position, Vector2(full * clampf(trail, 0.0, 1.0), track.size.y)),
Color(fill.darkened(0.5), 0.95))
var inner := Rect2(track.position, Vector2(full * clampf(frac, 0.0, 1.0), track.size.y))
if inner.size.x > 0.5: if inner.size.x > 0.5:
# The fill sprite is a flat strip, so tinting it is the whole palette -- # The fill sprite is a flat strip, so tinting it is the whole palette --
# one texture covers health, experience, the escape channel and the # one texture covers health, experience, the escape channel and the
+80 -6
View File
@@ -5,12 +5,20 @@ extends Control
signal join_requested(address: String, port: int) signal join_requested(address: String, port: int)
signal host_requested(port: int) signal host_requested(port: int)
signal settings_requested signal settings_requested
## The licences two of the asset packs require, one click from the first
## screen rather than two screens deep in settings.
signal credits_requested
var _address: LineEdit var _address: LineEdit
var _port: LineEdit var _port: LineEdit
var _status: Label var _status: Label
var _join_button: Button var _join_button: Button
var _host_button: Button var _host_button: Button
## True while a connection attempt is in flight; see set_busy().
var _busy: bool = false
## The status as set, and whether it is progress rather than a complaint.
var _status_base := ""
var _working: bool = false
func _ready() -> void: func _ready() -> void:
@@ -28,6 +36,10 @@ func _ready() -> void:
# A CenterContainer recomputes its child's centered position itself, every # A CenterContainer recomputes its child's centered position itself, every
# time the child's minimum size or the window size changes -- no manual # time the child's minimum size or the window size changes -- no manual
# offset math to get wrong. # offset math to get wrong.
# Behind everything, so it is first: a child draws over its earlier
# siblings, and the panel has to be in front of the bullets.
add_child(preload("res://src/ui/menu_backdrop.gd").new())
var center := CenterContainer.new() var center := CenterContainer.new()
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(center) add_child(center)
@@ -43,8 +55,19 @@ func _ready() -> void:
var title := Label.new() var title := Label.new()
title.text = "TRANSCIENCE" title.text = "TRANSCIENCE"
title.add_theme_font_size_override("font_size", 34) # Spaced out, which the default font will not do on its own: a word set
# tight at 38px reads as a heading, and this is a name.
var spaced := FontVariation.new()
spaced.base_font = ThemeDB.fallback_font
spaced.spacing_glyph = 5
title.add_theme_font_override("font", spaced)
title.add_theme_font_size_override("font_size", 38)
panel.add_child(title) panel.add_child(title)
var accent := ColorRect.new()
accent.color = Color(1.0, 0.72, 0.35, 0.85)
accent.custom_minimum_size = Vector2(64.0, 3.0)
accent.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
panel.add_child(accent)
var subtitle := Label.new() var subtitle := Label.new()
subtitle.text = "a co-operative bullet hell" subtitle.text = "a co-operative bullet hell"
subtitle.add_theme_font_size_override("font_size", 13) subtitle.add_theme_font_size_override("font_size", 13)
@@ -70,16 +93,20 @@ func _ready() -> void:
_address.placeholder_text = "address" _address.placeholder_text = "address"
_address.size_flags_horizontal = Control.SIZE_EXPAND_FILL _address.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_address.text_submitted.connect(func(_t: String) -> void: _on_join()) _address.text_submitted.connect(func(_t: String) -> void: _on_join())
_address.text_changed.connect(func(_t: String) -> void: _validate())
where.add_child(_address) where.add_child(_address)
_port = LineEdit.new() _port = LineEdit.new()
_port.text = str(Settings.last_port if Settings.last_port > 0 else GameOpts.port) _port.text = str(Settings.last_port if Settings.last_port > 0 else GameOpts.port)
_port.placeholder_text = "port" _port.placeholder_text = "port"
_port.custom_minimum_size = Vector2(90.0, 0.0) _port.custom_minimum_size = Vector2(90.0, 0.0)
_port.text_submitted.connect(func(_t: String) -> void: _on_join()) _port.text_submitted.connect(func(_t: String) -> void: _on_join())
_port.text_changed.connect(func(_t: String) -> void: _validate())
where.add_child(_port) where.add_child(_port)
_join_button = Button.new() _join_button = Button.new()
_join_button.text = "Join" # Enter works from either field, and from the button itself, which has focus
# when the screen opens. Saying so costs one word.
_join_button.text = "Join (Enter)"
_join_button.pressed.connect(_on_join) _join_button.pressed.connect(_on_join)
panel.add_child(_join_button) panel.add_child(_join_button)
@@ -98,12 +125,25 @@ func _ready() -> void:
settings.size_flags_horizontal = Control.SIZE_EXPAND_FILL settings.size_flags_horizontal = Control.SIZE_EXPAND_FILL
settings.pressed.connect(func() -> void: settings_requested.emit()) settings.pressed.connect(func() -> void: settings_requested.emit())
row.add_child(settings) row.add_child(settings)
var credits := Button.new()
credits.text = "Credits"
credits.size_flags_horizontal = Control.SIZE_EXPAND_FILL
credits.pressed.connect(func() -> void: credits_requested.emit())
row.add_child(credits)
var quit := Button.new() var quit := Button.new()
quit.text = "Quit" quit.text = "Quit"
quit.size_flags_horizontal = Control.SIZE_EXPAND_FILL quit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
quit.pressed.connect(func() -> void: get_tree().quit()) quit.pressed.connect(func() -> void: get_tree().quit())
row.add_child(quit) row.add_child(quit)
var how := Label.new()
how.text = "Host and play runs a server on this machine. Others join it by your address."
how.add_theme_font_size_override("font_size", 11)
how.add_theme_color_override("font_color", Color(0.5, 0.55, 0.66))
how.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
how.custom_minimum_size = Vector2(400.0, 0.0)
panel.add_child(how)
_status = Label.new() _status = Label.new()
_status.add_theme_font_size_override("font_size", 13) _status.add_theme_font_size_override("font_size", 13)
_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
@@ -119,6 +159,29 @@ func _ready() -> void:
version.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT version.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
panel.add_child(version) panel.add_child(version)
_validate()
# So Enter joins straight away for a returning player, whose address is
# already filled in from last time.
_join_button.call_deferred("grab_focus")
## Whether what is typed could possibly connect, said before the button is
## pressed rather than after. A non-number in the port used to go through as
## port 0 and come back as a connection error that did not say why.
func _validate() -> void:
var port_text := _port.text.strip_edges()
var port := int(port_text) if port_text.is_valid_int() else 0
var port_ok := port >= 1 and port <= 65535
var address_ok := not _address.text.strip_edges().is_empty()
_join_button.disabled = _busy or not (port_ok and address_ok)
_host_button.disabled = _busy or not port_ok
if not port_ok:
set_status("the port is a number from 1 to 65535")
elif not address_ok:
set_status("which server? type its address")
elif _status.text.begins_with("the port") or _status.text.begins_with("which server"):
set_status("")
func _gap(height: float) -> Control: func _gap(height: float) -> Control:
var c := Control.new() var c := Control.new()
@@ -129,17 +192,28 @@ func _gap(height: float) -> Control:
## Shown under the buttons. Coloured by what it is: "connecting..." is not an ## 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. ## error, and it used to be drawn in the same red as one.
func set_status(text: String) -> void: func set_status(text: String) -> void:
_status_base = text
_status.text = text _status.text = text
var working := text.begins_with("connecting") _working = text.begins_with("connecting")
_status.add_theme_color_override("font_color", _status.add_theme_color_override("font_color",
Color(0.65, 0.8, 1.0) if working else Color(1.0, 0.55, 0.5)) Color(0.65, 0.8, 1.0) if _working else Color(1.0, 0.55, 0.5))
## A connection attempt can take a few seconds, and a line of text that does not
## move while it does reads as frozen. The dots are the only thing animated.
func _process(_delta: float) -> void:
if not _working:
return
var base := _status_base.trim_suffix("...").trim_suffix(" ")
var dots := int(float(Time.get_ticks_msec()) / 400.0) % 4
_status.text = base + " " + ".".repeat(dots)
## Locks the connect buttons while an attempt is in flight, so a second click ## Locks the connect buttons while an attempt is in flight, so a second click
## cannot start a competing connection over the top of the first. ## cannot start a competing connection over the top of the first.
func set_busy(busy: bool) -> void: func set_busy(busy: bool) -> void:
_join_button.disabled = busy _busy = busy
_host_button.disabled = busy _validate()
func _on_join() -> void: func _on_join() -> void:
+76
View File
@@ -0,0 +1,76 @@
extends Node2D
## The connect screen's background: a real boss pattern, played out behind the
## panel, dimmed.
##
## Real in the sense that matters -- the boss's own emitters fired into a
## [BulletPool] and drawn by the game's own bullet renderer, so what plays here
## is what the fight actually looks like, and a pattern that changes in
## content.gd changes here with it. Nothing is simulated beyond the bullets:
## no world, no hits, no map. The first thing a player sees is the genre.
const BULLETS := preload("res://src/view/bullet_renderer.gd")
## How long each phase plays before the next one takes over, in ticks.
const PHASE_TICKS := 60 * 9
## How visible it is. Enough to read as a bullet hell, not enough to compete
## with the panel in front of it.
const DIM := 0.32
## Run this many ticks before the first frame is drawn. Every pattern starts at
## the centre of the screen, which is exactly where the panel is, so a backdrop
## that started from nothing showed nothing at all for its first second or two
## -- it was there, all of it, behind the buttons.
const PRIME_TICKS := 60 * 5
var _pool := BulletPool.new()
var _ctx := EmitContext.new()
var _renderer: Node2D
var _phases: Array = []
var _tick: int = 0
func _ready() -> void:
modulate = Color(1.0, 1.0, 1.0, DIM)
_renderer = BULLETS.new()
add_child(_renderer)
# Every phase of every boss, in order -- the Warden's folds, then the
# Cantor's verses -- so leaving the menu up shows the whole bestiary.
for id in Content.ALL_BOSSES:
for phase in Content.boss(id).phases:
if not phase.emitters.is_empty():
_phases.append(phase.emitters)
_ctx.pool = _pool
_ctx.rng = RandomNumberGenerator.new()
_ctx.rng.seed = 1
for _i in PRIME_TICKS:
_step()
_renderer.render_pool(_pool)
func _physics_process(_delta: float) -> void:
_step()
_renderer.render_pool(_pool)
func _step() -> void:
if _phases.is_empty():
return
var centre := get_viewport_rect().size * 0.5
var phase_index := (_tick / PHASE_TICKS) % _phases.size()
var local := _tick % PHASE_TICKS
_ctx.origin = centre
# Aimed patterns need someone to aim at. A point drifting slowly round the
# centre, so aimed volleys sweep rather than all going the same way.
var t := float(_tick) * 0.006
_ctx.target = centre + Vector2(cos(t), sin(t * 0.7)) * 260.0
_ctx.has_target = true
_ctx.bounds = get_viewport_rect()
_ctx.local_tick = local
# Telegraphed strikes announce through the context; there is nobody to warn.
_ctx.events = []
for e: BulletEmitter in _phases[phase_index]:
if e == null or not e.should_fire(local):
continue
_ctx.shot_index = e.shot_index_at(local)
e.fire(_ctx)
_pool.step()
_tick += 1
+1
View File
@@ -0,0 +1 @@
uid://cpbu6rmxpqg0e
+213
View File
@@ -0,0 +1,213 @@
extends GutTest
## HUD and connect-screen behaviour that does not need a screen to check.
## What things LOOK like is screenshot.tscn's job; this is what they DO.
const HUD := preload("res://src/ui/hud.gd")
const MENU := preload("res://src/ui/main_menu.gd")
const BACKDROP := preload("res://src/ui/menu_backdrop.gd")
func _hud_with(hp: int, max_hp: int) -> Array:
var hud: CanvasLayer = HUD.new()
add_child_autofree(hud)
var c: ClientRuntime = autofree(ClientRuntime.new())
c.my_hp = hp
c.my_max_hp = max_hp
hud.client = c
hud._hp_trail = float(hp) / float(max_hp)
hud._hp_last = hud._hp_trail
return [hud, c]
# --- The damage trail -------------------------------------------------------
func test_a_hit_leaves_a_trail_where_the_bar_was() -> void:
var pair := _hud_with(1000, 1000)
var hud: CanvasLayer = pair[0]
var c: ClientRuntime = pair[1]
c.my_hp = 400
hud._watch_health(0.016)
assert_almost_eq(hud._hp_trail, 1.0, 0.001, "the trail holds at the old value")
assert_almost_eq(hud._hp_last, 0.4, 0.001)
func test_the_trail_holds_and_then_drains_to_meet_the_bar() -> void:
var pair := _hud_with(1000, 1000)
var hud: CanvasLayer = pair[0]
var c: ClientRuntime = pair[1]
c.my_hp = 400
hud._watch_health(0.016)
# Within the hold, nothing moves -- that pause is what makes the amount
# readable.
for _i in int(HUD.TRAIL_HOLD / 0.016) - 2:
hud._watch_health(0.016)
assert_almost_eq(hud._hp_trail, 1.0, 0.001, "still holding")
for _i in 200:
hud._watch_health(0.016)
assert_almost_eq(hud._hp_trail, 0.4, 0.001, "and then drained all the way down")
func test_healing_leaves_no_trail() -> void:
var pair := _hud_with(400, 1000)
var hud: CanvasLayer = pair[0]
var c: ClientRuntime = pair[1]
c.my_hp = 900
hud._watch_health(0.016)
assert_almost_eq(hud._hp_trail, 0.9, 0.001,
"a trail is for what was lost; on a heal the bar and trail move together")
func test_a_second_hit_during_the_hold_keeps_the_original_top() -> void:
var pair := _hud_with(1000, 1000)
var hud: CanvasLayer = pair[0]
var c: ClientRuntime = pair[1]
c.my_hp = 700
hud._watch_health(0.016)
c.my_hp = 300
hud._watch_health(0.016)
assert_almost_eq(hud._hp_trail, 1.0, 0.001,
"two quick hits read as one big one, measured from where it started")
# --- Experience -------------------------------------------------------------
func test_experience_arriving_quickly_is_summed_into_one_number() -> void:
var pair := _hud_with(1000, 1000)
var hud: CanvasLayer = pair[0]
var c: ClientRuntime = pair[1]
hud._seen_character = "a"
c.my_total_xp = 100
hud._watch_experience(0.016)
c.my_total_xp = 112
hud._watch_experience(0.016)
c.my_total_xp = 120
hud._watch_experience(0.016)
assert_eq(hud._xp_gain, 20, "+12 then +8 shows as +20, not as a flicker")
func test_the_first_sight_of_a_character_is_not_a_gain() -> void:
var pair := _hud_with(1000, 1000)
var hud: CanvasLayer = pair[0]
var c: ClientRuntime = pair[1]
c.my_total_xp = 5000
hud._watch_experience(0.016)
assert_eq(hud._xp_flash, 0.0, "logging in with 5000 experience did not just earn it")
# --- The connect screen -----------------------------------------------------
func _menu() -> Control:
var m: Control = MENU.new()
add_child_autofree(m)
return m
func test_a_port_that_cannot_connect_disables_join_and_says_why() -> void:
var m := _menu()
m._port.text = "twenty"
m._validate()
assert_true(m._join_button.disabled)
assert_true(m._host_button.disabled)
assert_string_contains(m._status.text, "port")
m._port.text = "70000"
m._validate()
assert_true(m._join_button.disabled, "out of range is as useless as not a number")
m._port.text = "27015"
m._validate()
assert_false(m._join_button.disabled)
assert_eq(m._status.text, "", "and the complaint goes when the problem does")
func test_no_address_disables_join_but_not_host() -> void:
var m := _menu()
m._address.text = " "
m._validate()
assert_true(m._join_button.disabled, "nowhere to join")
assert_false(m._host_button.disabled, "hosting needs no address")
func test_busy_wins_over_valid() -> void:
var m := _menu()
m.set_busy(true)
assert_true(m._join_button.disabled, "a connection in flight is not a second invitation")
m.set_busy(false)
assert_false(m._join_button.disabled)
func test_the_connect_screen_asks_for_no_name() -> void:
# It used to, and the answer went nowhere a person could see -- it only named
# the characters bots create.
var m := _menu()
for node in m.find_children("*", "LineEdit", true, false):
assert_ne((node as LineEdit).placeholder_text, "name")
assert_eq(m.find_children("*", "LineEdit", true, false).size(), 2,
"an address and a port, nothing else")
func test_the_backdrop_opens_on_a_pattern_already_in_flow() -> void:
# Every pattern starts at the centre of the screen, behind the panel. Primed,
# the first frame already has bullets well clear of it.
var b: Node2D = BACKDROP.new()
add_child_autofree(b)
var centre := b.get_viewport_rect().size * 0.5
var clear_of_panel := 0
for i in b._pool.high_water:
if b._pool.alive[i] != 0 and b._pool.pos[i].distance_to(centre) > 320.0:
clear_of_panel += 1
assert_gt(clear_of_panel, 10, "bullets beyond the panel on the very first frame")
# --- The boss bar -----------------------------------------------------------
## A HUD whose fog sees from the left of a room, with a wall down the middle.
func _hud_with_sight() -> Array:
var pair := _hud_with(1000, 1000)
var hud: CanvasLayer = pair[0]
var c: ClientRuntime = pair[1]
var g := MapGrid.new(40, 20, MapGrid.Kind.FLOOR)
for ty in 20:
g.set_tile(20, ty, MapGrid.Kind.WALL)
var sight := Visibility.new()
sight.compute(g, Vector2(10.5, 10.5) * MapGrid.TILE, 460.0)
hud.sight = sight
c.instance_id = 3
c.boss_def = Content.boss(&"warden")
return [hud, c]
func _boss_at(x_tiles: float, phase: int = 0) -> Dictionary:
return {"id": 1, "pos": Vector2(x_tiles, 10.5) * MapGrid.TILE, "phase": phase, "hp": 100}
func test_the_bar_waits_until_the_boss_has_been_seen() -> void:
var hud: CanvasLayer = _hud_with_sight()[0]
# Behind the wall: in the snapshot -- the interest radius passes through
# walls -- but not seen.
assert_false(hud.track_boss(_boss_at(24.0)), "known about is not the same as seen")
assert_true(hud.track_boss(_boss_at(16.0)), "in the light, it shows")
func test_once_seen_it_stays_for_the_fight() -> void:
var hud: CanvasLayer = _hud_with_sight()[0]
hud.track_boss(_boss_at(16.0))
assert_true(hud.track_boss(_boss_at(24.0)),
"stepping behind a pillar must not take the bar away")
func test_a_new_dungeon_has_to_be_seen_again() -> void:
var pair := _hud_with_sight()
var hud: CanvasLayer = pair[0]
var c: ClientRuntime = pair[1]
hud.track_boss(_boss_at(16.0))
# Every dungeon's boss is actor 1, so the id alone would carry the last
# fight's "seen" straight into the next one.
c.instance_id = 4
assert_false(hud.track_boss(_boss_at(24.0)))
func test_a_phase_change_is_announced_and_the_first_phase_is_not() -> void:
var hud: CanvasLayer = _hud_with_sight()[0]
hud.track_boss(_boss_at(16.0, 1))
assert_eq(hud._banner_title, "", "the phase it was in when found is not a change")
hud.track_boss(_boss_at(16.0, 2))
assert_eq(hud._banner_title, Content.boss(&"warden").phases[2].name.to_upper())
+1
View File
@@ -0,0 +1 @@
uid://cn1mu2io17h4i
+55 -26
View File
@@ -24,29 +24,32 @@ const STORE := "user://screenshot_characters.json"
const ACCOUNT := 777001 const ACCOUNT := 777001
## Frame numbers, not seconds: the point is a deterministic sequence, and a ## Frame numbers, not seconds: the point is a deterministic sequence, and a
## windowed client at 60fps gets there in about four seconds. ## windowed client at 60fps gets there in about four seconds.
const AT_MENU := 30 const AT_MENU := 200
const ENTER_GAME := 34 const ENTER_GAME := 204
const AT_GAME := 200 const AT_GAME := 370
const OPEN_MENU := 210 const OPEN_MENU := 380
const AT_INGAME_MENU := 240 const AT_INGAME_MENU := 410
const OPEN_SETTINGS := 250 const OPEN_SETTINGS := 420
const AT_SETTINGS := 280 const AT_SETTINGS := 450
const OPEN_CREDITS := 290 const OPEN_CREDITS := 460
const AT_CREDITS := 320 const AT_CREDITS := 490
const OPEN_UPGRADES := 330 const OPEN_UPGRADES := 500
const AT_UPGRADES := 360 const AT_UPGRADES := 530
const OPEN_ROSTER := 370 const OPEN_ROSTER := 540
const AT_ROSTER := 400 const AT_ROSTER := 570
const ENTER_DUNGEON := 410 const ENTER_DUNGEON := 580
const AT_DUNGEON := 480 const AT_DUNGEON := 650
const TO_TUNNEL := 490 const TO_TUNNEL := 660
const AT_TUNNEL := 550 const AT_TUNNEL := 720
const AT_TUNNEL_DEBUG := 560 const AT_TUNNEL_DEBUG := 730
const TO_BOSS := 570 const TO_BOSS := 740
const AT_BOSS := 640 const AT_BOSS := 810
const DONE := 650 const ESCAPE_START := 830
## A safety net only: the run normally ends the moment the escape shot is taken.
const DONE := 1710
var _frame: int = 0 var _frame: int = 0
var _left_shot: bool = false
var _game: Node = null var _game: Node = null
var _menu: Control = null var _menu: Control = null
var _dir: String = "user://" var _dir: String = "user://"
@@ -98,10 +101,30 @@ func _process(_delta: float) -> void:
# it every frame, so one assignment lasts exactly one frame. # it every frame, so one assignment lasts exactly one frame.
if _frame > OPEN_ROSTER and _frame <= AT_ROSTER: if _frame > OPEN_ROSTER and _frame <= AT_ROSTER:
_show_the_roster() _show_the_roster()
# Held low, so the low-health edge is in the picture -- and held at all, # Held, because the boss is firing and one landed ring would end the shot.
# because the boss is firing and one landed ring would end the shot. # High until just before the picture and low after, so the damage trail
if _frame > TO_BOSS and _frame <= AT_BOSS: # -- the part of the bar the drop took -- is still draining when it is taken.
_hold_health(0.22) if _frame > TO_BOSS and not _left_shot:
_hold_health(0.62 if _frame < AT_BOSS - 10 else 0.22)
# Captured by progress rather than by frame count. The channel is one second
# of server ticks and frames run as fast as the display allows, so a fixed
# frame number lands anywhere from a quarter of the way in to after it has
# completed -- and a completed escape teleports the player out mid-sequence.
if _frame >= ESCAPE_START and not _left_shot:
Net.client.request_escape = true
if Net.client.my_escape >= 0.45:
_grab("leaving")
Net.client.request_escape = false
_left_shot = true
Net.shutdown()
get_tree().quit(0)
return
if _frame == AT_BOSS - 30:
Net.server._grant_xp(Net.LOCAL_PEER, 46)
# Frames, not seconds, and this runs as fast as the display allows -- so
# early enough that the banner's fade-in is over even at a high frame rate.
if _frame == AT_BOSS - 60:
_game.get_node("HUD").banner("LEVEL 7", "a choice waits at the quartermaster")
match _frame: match _frame:
AT_MENU: AT_MENU:
_grab("menu") _grab("menu")
@@ -266,11 +289,17 @@ func _stage_a_boss_fight() -> void:
friend.spawn_grace = 0 friend.spawn_grace = 0
Net.server.peer_names[999] = "Wren" Net.server.peer_names[999] = "Wren"
Net.server.peer_instance[999] = inst.id Net.server.peer_instance[999] = inst.id
# And one out of sight, so the marker pointing the way to them shows.
var away := inst.world.add_player(998, "Lark")
away.pos = at + Vector2(-560.0, 260.0)
away.colour = Color(0.55, 0.85, 1.0)
away.spawn_grace = 0
Net.server.peer_names[998] = "Lark"
Net.server.peer_instance[998] = inst.id
Net.server._broadcast_roster() Net.server._broadcast_roster()
var hud := _game.get_node("HUD") var hud := _game.get_node("HUD")
hud.notify("+ Health Potion", Items.get_def(Items.HEALTH_POTION).tint) 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("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: ## Move the player, on both sides, into the tunnel closest to where it spawned: