diff --git a/src/main.gd b/src/main.gd index 69f654f..38b4d1b 100644 --- a/src/main.gd +++ b/src/main.gd @@ -70,6 +70,7 @@ func _show_menu() -> void: _menu.join_requested.connect(_join) _menu.host_requested.connect(_host_and_play) _menu.settings_requested.connect(_open_settings) + _menu.credits_requested.connect(_open_credits) add_child(_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 diff --git a/src/ui/hud.gd b/src/ui/hud.gd index 919cd10..d618e1a 100644 --- a/src/ui/hud.gd +++ b/src/ui/hud.gd @@ -39,6 +39,23 @@ const LOW_HEALTH := 0.35 const HINT_HOLD := 6.0 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_DIM := Color(0.58, 0.63, 0.74) 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 _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. var _boss_seen := "" ## 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_since = now _watch_level() + _watch_health(delta) + _watch_experience(delta) _update_respawn_button() _canvas.queue_redraw() @@ -134,6 +172,43 @@ func flash_hit() -> void: _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 ## 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. @@ -146,11 +221,15 @@ func _watch_level() -> void: if String(who["id"]) != _seen_character: _seen_character = String(who["id"]) _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 if level > _seen_level: - var where := "" if client.instance_kind == Protocol.InstanceKind.LOBBY \ - else " -- a choice waits at the quartermaster" - notify("LEVEL %d%s" % [level, where], COL_GOLD) + banner("LEVEL %d" % level, + "a choice waits at the quartermaster" if level < Progression.MAX_LEVEL + else "the highest there is", COL_GOLD) _seen_level = level @@ -243,11 +322,11 @@ func _draw_hud() -> void: # Under everything, so the edge of the screen colours without dimming any of # the text on top of it. _draw_vignette() + _draw_party_markers() - var y := _draw_player_panel() - _draw_level_up_notice(y) + _draw_player_panel() _draw_boss_bar() - _draw_arriving() + _draw_banner() _draw_cleared_countdown() _draw_others() _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)) -## Who you are, where you are, and how you are doing. Returns the y below the -## last thing drawn, so whatever comes next stacks rather than overlaps. -func _draw_player_panel() -> float: +## A dark, slightly translucent box behind a block of HUD text. The floor art is +## busy and mid-toned; text straight over it was readable on some tiles and not +## 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 y := MARGIN - var who := client.current_character() - if not who.is_empty(): + if has_character: var name_text := String(who["name"]) _text(Vector2(x, y + 15.0), name_text, 17, COL_TEXT) _text(Vector2(x + _text_width(name_text, 17) + 10.0, y + 15.0), @@ -288,41 +391,44 @@ func _draw_player_panel() -> float: # Red when low, not a blend toward it: green-to-red through the middle is # brown, which reads as a colour rather than as a warning. var hp_tint := COL_HEALTH if frac >= LOW_HEALTH else Color(1.0, 0.38, 0.36) - _bar(Vector2(x, y), frac, hp_tint) - _text(Vector2(x + BAR_W + 10.0, y + 13.0), - "%d / %d" % [client.my_hp, client.my_max_hp], 12, hp_tint) - y += BAR_H + 4.0 + _bar(Vector2(x, y), frac, hp_tint, _hp_trail, Vector2(BAR_W, HP_H)) + # Inside the bar, where the eye already is. Light on the dark track and on + # every fill colour, which a number beside the bar did not need to be. + _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)): - y += BAR_H + 4.0 + if has_character: + _draw_xp_bar(Vector2(x, y)) + y += XP_H + 18.0 - if client.my_escaping: - _bar(Vector2(x, y), client.my_escape, Color(0.5, 0.85, 1.0)) - _text(Vector2(x + BAR_W + 10.0, y + 13.0), "ESCAPING", 12, - Color(0.5, 0.85, 1.0)) - y += BAR_H + 4.0 - return y + # No escape row here: the channel is a banner in the middle of the screen, + # where you are looking, and a second bar for the same second was noise. + _draw_level_up_notice(y) -## A thin bar under health: progress toward the next level, and the level -## itself. Drawn from the server's numbers, never recomputed locally. Returns -## whether it drew, since a player at the roster screen has no character. -func _draw_xp_bar(at: Vector2) -> bool: - if client.current_character().is_empty(): - return false +## A thin bar under health: progress toward the next level. Drawn from the +## server's numbers, never recomputed locally. +func _draw_xp_bar(at: Vector2) -> void: # Derived from the snapshot's live experience total rather than the roster's # copy, which only arrives when the set of characters changes. var level := Progression.level_for_xp(client.my_total_xp) var capped := level >= Progression.MAX_LEVEL var progress := Progression.level_progress(client.my_total_xp) var tint := COL_GOLD if capped else Color(0.6, 0.55, 1.0) - _bar(at, progress, tint) + # 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 # a bar answers vaguely and a percentage answers exactly. var text := "MAX" if capped else "%d%% to level %d" % [ int(floor(progress * 100.0)), level + 1] - _text(at + Vector2(BAR_W + 10.0, 13.0), text, 12, tint) - return true + _text(at + Vector2(0.0, XP_H + 13.0), text, 11, Color(tint, 0.9)) + 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 @@ -332,12 +438,12 @@ func _draw_level_up_notice(y: float) -> void: if client.upgrades_pending <= 0: return var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.004) - var where := " -- see the quartermaster in the hub" \ + var where := " -- quartermaster, in the hub" \ 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, "" 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. @@ -353,20 +459,12 @@ func _draw_boss_bar() -> void: var b := client.boss_state() if b.is_empty() or client.boss_def == null: return - var seen_key := "%d:%d" % [client.instance_id, int(b["id"])] - if _boss_seen != seen_key: - if sight != null and sight.light_at(b["pos"]) <= 0.05: - return - _boss_seen = seen_key + if not track_boss(b): + return var pos := Vector2((_canvas.size.x - BOSS_BAR_W) * 0.5, MARGIN + 20.0) var frac := clampf(float(b["hp"]) / float(client.boss_def.max_hp), 0.0, 1.0) - var rect := Rect2(pos, Vector2(BOSS_BAR_W, BAR_H)) - _hslice(UiTheme.texture("bar"), rect, Color.WHITE) - var inner := Rect2(rect.position + Vector2(3.0, 3.0), - Vector2((BOSS_BAR_W - 6.0) * frac, BAR_H - 6.0)) - if inner.size.x > 0.5: - _canvas.draw_texture_rect(UiTheme.texture("bar_fill"), inner, false, - Color(0.95, 0.35, 0.45)) + _panel(Rect2(pos.x - 10.0, MARGIN - 8.0, BOSS_BAR_W + 20.0, BAR_H + 36.0)) + _bar(pos, frac, Color(0.95, 0.35, 0.45), -1.0, Vector2(BOSS_BAR_W, BAR_H)) var phase_index := int(b["phase"]) var phase_name := "" if phase_index < client.boss_def.phases.size(): @@ -377,16 +475,67 @@ func _draw_boss_bar() -> void: HORIZONTAL_ALIGNMENT_RIGHT, BOSS_BAR_W) -## Arrival protection, as a banner rather than a line tucked beside the health -## bar: it is the one moment the rules are different, and both halves of it -- -## you cannot be hurt, and you cannot shoot -- change what you should do. -func _draw_arriving() -> void: - if not client.my_spawn_grace: - return +## Whether the boss bar should be up, and the place a phase change is noticed. +## Returns false until the fog has lit the boss once; true from then on, for the +## rest of that fight. +## +## A phase change is announced because it is the moment the patterns you just +## 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 y := _canvas.size.y * 0.28 - _centred(y, "ARRIVING", 24, Color(0.6, 0.9, 1.0, 0.75 + 0.25 * pulse)) - _centred(y + 22.0, "invulnerable · weapons cold", 13, Color(0.6, 0.9, 1.0, 0.8)) + if client.my_escaping: + var blue := Color(0.55, 0.85, 1.0) + _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. @@ -414,6 +563,7 @@ func _draw_others() -> void: func _draw_online() -> void: var x := _canvas.size.x - 240.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) y += 20.0 for entry in client.roster: @@ -444,6 +594,7 @@ func _draw_party() -> void: return var x := _canvas.size.x - 240.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) y += 10.0 var w := 140.0 @@ -474,6 +625,47 @@ func _draw_party() -> void: 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 ## 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 @@ -494,7 +686,11 @@ func _draw_inventory() -> void: # recoloured border, so "this is the one the key is on" reads the same # way every other pressed thing in the interface does. var slot_art := UiTheme.texture("slot_active" if i == held else "slot") - _canvas.draw_texture_rect(slot_art, Rect2(at, Vector2(SLOT, SLOT)), false) + # 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 # actually needs to know about it -- and it is whatever they bound. _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])) -## A bar in the pack's art: the frame stretched horizontally, the fill tinted. -func _bar(pos: Vector2, frac: float, fill: Color) -> void: - var rect := Rect2(pos, Vector2(BAR_W, BAR_H)) +## A bar in the pack's art: the frame stretched horizontally, a dark track +## inside it, and the fill tinted on top. +## +## [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) - var inset := 3.0 - var inner := Rect2(rect.position + Vector2(inset, inset), - Vector2((rect.size.x - inset * 2.0) * clampf(frac, 0.0, 1.0), - rect.size.y - inset * 2.0)) + # The frame scales with the bar's height, so the inset has to as well: a + # fixed three pixels left a ten-pixel bar four pixels of track and six of + # white frame, and it read as an empty white bar whatever it held. + 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: # The fill sprite is a flat strip, so tinting it is the whole palette -- # one texture covers health, experience, the escape channel and the diff --git a/src/ui/main_menu.gd b/src/ui/main_menu.gd index d1caee9..b3bfe49 100644 --- a/src/ui/main_menu.gd +++ b/src/ui/main_menu.gd @@ -5,12 +5,20 @@ extends Control signal join_requested(address: String, port: int) signal host_requested(port: int) 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 _port: LineEdit var _status: Label var _join_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: @@ -28,6 +36,10 @@ func _ready() -> void: # A CenterContainer recomputes its child's centered position itself, every # time the child's minimum size or the window size changes -- no manual # 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() center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) add_child(center) @@ -43,8 +55,19 @@ func _ready() -> void: var title := Label.new() 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) + 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() subtitle.text = "a co-operative bullet hell" subtitle.add_theme_font_size_override("font_size", 13) @@ -70,16 +93,20 @@ func _ready() -> void: _address.placeholder_text = "address" _address.size_flags_horizontal = Control.SIZE_EXPAND_FILL _address.text_submitted.connect(func(_t: String) -> void: _on_join()) + _address.text_changed.connect(func(_t: String) -> void: _validate()) where.add_child(_address) _port = LineEdit.new() _port.text = str(Settings.last_port if Settings.last_port > 0 else GameOpts.port) _port.placeholder_text = "port" _port.custom_minimum_size = Vector2(90.0, 0.0) _port.text_submitted.connect(func(_t: String) -> void: _on_join()) + _port.text_changed.connect(func(_t: String) -> void: _validate()) where.add_child(_port) _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) panel.add_child(_join_button) @@ -98,12 +125,25 @@ func _ready() -> void: settings.size_flags_horizontal = Control.SIZE_EXPAND_FILL settings.pressed.connect(func() -> void: settings_requested.emit()) 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() quit.text = "Quit" quit.size_flags_horizontal = Control.SIZE_EXPAND_FILL quit.pressed.connect(func() -> void: get_tree().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.add_theme_font_size_override("font_size", 13) _status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART @@ -119,6 +159,29 @@ func _ready() -> void: version.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT 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: 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 ## error, and it used to be drawn in the same red as one. func set_status(text: String) -> void: + _status_base = text _status.text = text - var working := text.begins_with("connecting") + _working = text.begins_with("connecting") _status.add_theme_color_override("font_color", - Color(0.65, 0.8, 1.0) if working else Color(1.0, 0.55, 0.5)) + 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 ## cannot start a competing connection over the top of the first. func set_busy(busy: bool) -> void: - _join_button.disabled = busy - _host_button.disabled = busy + _busy = busy + _validate() func _on_join() -> void: diff --git a/src/ui/menu_backdrop.gd b/src/ui/menu_backdrop.gd new file mode 100644 index 0000000..d01a7b9 --- /dev/null +++ b/src/ui/menu_backdrop.gd @@ -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 diff --git a/src/ui/menu_backdrop.gd.uid b/src/ui/menu_backdrop.gd.uid new file mode 100644 index 0000000..d579187 --- /dev/null +++ b/src/ui/menu_backdrop.gd.uid @@ -0,0 +1 @@ +uid://cpbu6rmxpqg0e diff --git a/tests/unit/test_hud.gd b/tests/unit/test_hud.gd new file mode 100644 index 0000000..4850d04 --- /dev/null +++ b/tests/unit/test_hud.gd @@ -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()) diff --git a/tests/unit/test_hud.gd.uid b/tests/unit/test_hud.gd.uid new file mode 100644 index 0000000..b905248 --- /dev/null +++ b/tests/unit/test_hud.gd.uid @@ -0,0 +1 @@ +uid://cn1mu2io17h4i diff --git a/tools/screenshot.gd b/tools/screenshot.gd index c2506c2..7b78c9b 100644 --- a/tools/screenshot.gd +++ b/tools/screenshot.gd @@ -24,29 +24,32 @@ const STORE := "user://screenshot_characters.json" const ACCOUNT := 777001 ## Frame numbers, not seconds: the point is a deterministic sequence, and a ## windowed client at 60fps gets there in about four seconds. -const AT_MENU := 30 -const ENTER_GAME := 34 -const AT_GAME := 200 -const OPEN_MENU := 210 -const AT_INGAME_MENU := 240 -const OPEN_SETTINGS := 250 -const AT_SETTINGS := 280 -const OPEN_CREDITS := 290 -const AT_CREDITS := 320 -const OPEN_UPGRADES := 330 -const AT_UPGRADES := 360 -const OPEN_ROSTER := 370 -const AT_ROSTER := 400 -const ENTER_DUNGEON := 410 -const AT_DUNGEON := 480 -const TO_TUNNEL := 490 -const AT_TUNNEL := 550 -const AT_TUNNEL_DEBUG := 560 -const TO_BOSS := 570 -const AT_BOSS := 640 -const DONE := 650 +const AT_MENU := 200 +const ENTER_GAME := 204 +const AT_GAME := 370 +const OPEN_MENU := 380 +const AT_INGAME_MENU := 410 +const OPEN_SETTINGS := 420 +const AT_SETTINGS := 450 +const OPEN_CREDITS := 460 +const AT_CREDITS := 490 +const OPEN_UPGRADES := 500 +const AT_UPGRADES := 530 +const OPEN_ROSTER := 540 +const AT_ROSTER := 570 +const ENTER_DUNGEON := 580 +const AT_DUNGEON := 650 +const TO_TUNNEL := 660 +const AT_TUNNEL := 720 +const AT_TUNNEL_DEBUG := 730 +const TO_BOSS := 740 +const AT_BOSS := 810 +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 _left_shot: bool = false var _game: Node = null var _menu: Control = null var _dir: String = "user://" @@ -98,10 +101,30 @@ func _process(_delta: float) -> void: # it every frame, so one assignment lasts exactly one frame. if _frame > OPEN_ROSTER and _frame <= AT_ROSTER: _show_the_roster() - # Held low, so the low-health edge is in the picture -- and held at all, - # because the boss is firing and one landed ring would end the shot. - if _frame > TO_BOSS and _frame <= AT_BOSS: - _hold_health(0.22) + # Held, 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 + # -- the part of the bar the drop took -- is still draining when it is taken. + 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: AT_MENU: _grab("menu") @@ -266,11 +289,17 @@ func _stage_a_boss_fight() -> void: friend.spawn_grace = 0 Net.server.peer_names[999] = "Wren" 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() var hud := _game.get_node("HUD") hud.notify("+ Health Potion", Items.get_def(Items.HEALTH_POTION).tint) hud.notify("Warden's Ration used", Items.get_def(Items.WARDENS_RATION).tint) - hud.notify("LEVEL 7 -- a choice waits at the quartermaster", Color(1.0, 0.85, 0.4)) ## Move the player, on both sides, into the tunnel closest to where it spawned: