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:
1 parent
3d1b5da72b
commit
6e872e2cf5
8 files changed
+703
-96
No files matched your search
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cpbu6rmxpqg0e
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cn1mu2io17h4i
|
||||||
+55
-26
@@ -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:
|
||||||
|
|||||||
Reference in new issue
Block a user