extends CanvasLayer ## Heads-up display. Built in code rather than as a scene because every element ## is data-driven -- there is no layout here a designer would want to drag. ## ## Layout, because several things here used to land on top of each other: ## top-left is you (name, where, health, experience, escape, level-ups), stacked ## by a running cursor so nothing has a fixed row to collide on; top-centre is ## the boss; top-right is everyone else; the bottom centre is the inventory ## with the pickup prompt and the message feed stacked above it; the controls ## hint has the bottom-left corner to itself. const MARGIN := 24.0 const BAR_W := 260.0 const BAR_H := 16.0 ## Inventory slot box, and the gap between boxes. ## ## Exactly twice the 32px source sprite. A non-integer scale on pixel art with ## nearest filtering gives uneven pixel widths, which on a 1px border reads as ## a wobble along the edge of every slot. const SLOT := 64.0 const SLOT_GAP := 8.0 ## Source-pixel width of the caps on the bar sprite. The bar is stretched only ## horizontally, so the ends are drawn at their own size and the middle takes ## whatever is left -- a plain stretched draw would smear a 1px border out to ## eight. const BAR_CAP := 6.0 ## How far above the bottom of the screen the inventory row sits. const SLOT_BOTTOM := 26.0 const BOSS_BAR_W := 480.0 ## The message feed: how long a message stays, and how many at once. const TOAST_LIFE := 2.6 const TOAST_FADE := 0.8 const TOAST_MAX := 4 ## Health below this share of maximum pulses the screen edge. const LOW_HEALTH := 0.35 ## How long the controls hint stays up after arriving in a dungeon. In the hub ## it stays: that is where you are learning the keys, and nothing is shooting. const HINT_HOLD := 6.0 const HINT_FADE := 1.5 ## 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) const COL_GOLD := Color(1.0, 0.85, 0.4) const COL_DANGER := Color(1.0, 0.25, 0.3) signal respawn_pressed ## What the world view could see this frame, handed over by the game scene so ## the boss bar can wait until the boss has actually been seen. See ## _draw_boss_bar. var sight: Visibility = null ## Mirrors the F1 overlay. The instance number and frame rate are for whoever ## is debugging, not for whoever is playing, so they live behind it. var debug_info: bool = false var _canvas: Control var _respawn_button: Button 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 ## moment it happens and a character switch is not mistaken for one. var _seen_level: int = -1 var _seen_character := "" ## When the current instance was entered, for fading the controls hint. var _instance_seen: int = -1 var _instance_since: float = 0.0 func _ready() -> void: layer = 10 _canvas = Control.new() # set_anchors_preset() alone leaves the offsets at whatever preserves the # control's rect at the moment of the call -- (0,0)-sized for a brand new # node, regardless of the anchors -- so _canvas.size silently stayed # (0, 0) forever. That took the hit-flash overlay, the boss bar centering # and the death-message centering down with it, since all three read # _canvas.size. set_anchors_and_offsets_preset() sets both correctly. _canvas.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) UiTheme.apply_to(_canvas) _canvas.mouse_filter = Control.MOUSE_FILTER_IGNORE _canvas.draw.connect(_draw_hud) add_child(_canvas) # A real Button rather than something drawn in _draw_hud: this is the one # HUD element the player has to actually click, and it starts disabled so # death is not exited by whatever key happened to be under a finger. _respawn_button = Button.new() _respawn_button.custom_minimum_size = Vector2(240.0, 40.0) _respawn_button.set_anchors_and_offsets_preset(Control.PRESET_CENTER) _respawn_button.offset_left = -120.0 _respawn_button.offset_right = 120.0 _respawn_button.offset_top = 40.0 _respawn_button.offset_bottom = 80.0 _respawn_button.pressed.connect(func() -> void: respawn_pressed.emit()) _respawn_button.visible = false _canvas.add_child(_respawn_button) func _process(delta: float) -> void: client = Net.client _hit_flash = maxf(_hit_flash - delta * 2.5, 0.0) var now := _now() _toasts = _toasts.filter(func(t: Dictionary) -> bool: return now - float(t["born"]) < TOAST_LIFE) if client != null: if client.instance_id != _instance_seen: _instance_seen = client.instance_id _instance_since = now _watch_level() _watch_health(delta) _watch_experience(delta) _update_respawn_button() _canvas.queue_redraw() func _now() -> float: return float(Time.get_ticks_msec()) / 1000.0 ## A short line in the message feed above the inventory. Called for things the ## server just confirmed -- an item taken, used or dropped, a level gained -- so ## the feed is a record of what happened, never of what the client hoped. func notify(text: String, colour: Color = COL_TEXT) -> void: _toasts.append({"text": text, "colour": colour, "born": _now()}) while _toasts.size() > TOAST_MAX: _toasts.pop_front() func flash_hit() -> void: _hit_flash = 1.0 ## 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. func _watch_level() -> void: var who := client.current_character() if who.is_empty(): _seen_character = "" return var level := Progression.level_for_xp(client.my_total_xp) if String(who["id"]) != _seen_character: _seen_character = String(who["id"]) _seen_level = level # 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: 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 func _update_respawn_button() -> void: var downed := client != null and not client.my_alive _respawn_button.visible = downed if not downed: return # Mirrors SimConfig.RESPAWN_LOCKOUT_TICKS, which the server enforces # independently -- this is presentation, not the rule. var ready := client.my_respawn_wait <= 0.0 _respawn_button.disabled = not ready _respawn_button.text = "Return to hub" if ready \ else "Return to hub (%.0fs)" % ceil(client.my_respawn_wait) # --- Text ------------------------------------------------------------------- ## Text with a one-pixel shadow. The floor art is busy and mid-toned, and plain ## light text over it was readable on the dark parts of a tile and not on the ## light ones. func _text(at: Vector2, s: String, size: int, colour: Color, align: HorizontalAlignment = HORIZONTAL_ALIGNMENT_LEFT, width: float = -1.0) -> void: var font := ThemeDB.fallback_font _canvas.draw_string(font, at + Vector2(1.0, 1.0), s, align, width, size, Color(0.0, 0.0, 0.0, 0.7 * colour.a)) _canvas.draw_string(font, at, s, align, width, size, colour) func _text_width(s: String, size: int) -> float: return ThemeDB.fallback_font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x func _centred(y: float, s: String, size: int, colour: Color) -> void: _text(Vector2(0.0, y), s, size, colour, HORIZONTAL_ALIGNMENT_CENTER, _canvas.size.x) # --- Key names -------------------------------------------------------------- ## What the player has [param action] bound to. Read from Settings every time ## rather than written into the strings: every one of these is rebindable, and ## the hint used to say "LMB fire" whatever fire was actually on. static func key(action: String) -> String: return Settings.short_label(action) ## The four movement keys as one word when they are single characters -- WASD, ## ZQSD, ESDF -- and spelled out when they are not, like the arrow keys. static func move_keys() -> String: var parts: Array[String] = [] for action in ["move_up", "move_left", "move_down", "move_right"]: parts.append(key(action)) for p in parts: if p.length() != 1: return "/".join(parts) return "".join(parts) ## The slot keys, as a range when they run 1-2-3-4 and listed when they do not. static func slot_keys(count: int) -> String: var parts: Array[String] = [] for i in count: parts.append(key("use_slot_%d" % (i + 1))) var plain := true for i in count: if parts[i] != str(i + 1): plain = false return "1-%d" % count if plain else " ".join(parts) static func controls_hint(in_hub: bool) -> String: if in_hub: return "%s move mouse aim %s fire %s on a ring to enter it %s menu" % [ move_keys(), key("fire"), key("interact"), key("system_menu")] return "%s move %s fire hold %s to return to the hub %s menu" % [ move_keys(), key("fire"), key("emergency_escape"), key("system_menu")] static func inventory_hint(count: int) -> String: return "%s use shift+%s drop %s pick up" % [ slot_keys(count), slot_keys(count), key("interact")] # --- Drawing ---------------------------------------------------------------- func _draw_hud() -> void: if client == null: _text(Vector2(MARGIN, MARGIN + 14.0), "connecting...", 14, COL_DIM) return # Under everything, so the edge of the screen colours without dimming any of # the text on top of it. _draw_vignette() _draw_party_markers() _draw_player_panel() _draw_boss_bar() _draw_banner() _draw_cleared_countdown() _draw_others() _draw_inventory() var above := _draw_pickup_prompt() _draw_toasts(above) _draw_controls_hint() _draw_debug_info() if not client.my_alive: _centred(_canvas.size.y * 0.5, "DOWN", 34, Color(1.0, 0.4, 0.4)) ## 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 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), "level %d" % Progression.level_for_xp(client.my_total_xp), 13, COL_DIM) y += 22.0 # Named rather than just "DUNGEON": there is more than one kind, and knowing # which one you are standing in is the whole point of having more than one. var where := "THE HUB" var tint := COL_DIM if client.instance_kind != Protocol.InstanceKind.LOBBY: var def := Dungeons.get_or_default(client.dungeon_id) where = def.display_name.to_upper() tint = def.tint _text(Vector2(x, y + 12.0), where, 12, tint) y += 20.0 var frac := float(client.my_hp) / float(maxi(client.my_max_hp, 1)) # Red when low, not a blend toward it: green-to-red through the middle is # brown, which reads as a colour rather than as a warning. var hp_tint := COL_HEALTH if frac >= LOW_HEALTH else Color(1.0, 0.38, 0.36) _bar(Vector2(x, y), frac, hp_tint, _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 has_character: _draw_xp_bar(Vector2(x, y)) y += XP_H + 18.0 # 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. 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) # 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(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 ## dungeon and spent in the hub -- if it only appeared next to the NPC, a player ## would have to already know to go and look. func _draw_level_up_notice(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 := " -- quartermaster, in the hub" \ if client.instance_kind != Protocol.InstanceKind.LOBBY else "" _text(Vector2(MARGIN, y + 12.0), "%d LEVEL-UP%s TO SPEND%s" % [client.upgrades_pending, "" if client.upgrades_pending == 1 else "S", where], 12, Color(COL_GOLD, 0.6 + 0.4 * pulse)) ## The boss's health, once you have seen the boss. ## ## The server sends the boss to anyone within the actor interest radius, which ## is deliberately wider than sight and passes through walls -- so the bar used ## to announce "Warden of the Fold" to a player still standing in the entrance ## room, which says exactly where the boss is to someone who has not found it. ## It now waits until the fog has actually lit the boss once, and then stays for ## the rest of the fight: losing the bar every time the boss stepped behind a ## pillar would be worse than the leak. func _draw_boss_bar() -> void: var b := client.boss_state() if b.is_empty() or client.boss_def == null: return 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) _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(): phase_name = client.boss_def.phases[phase_index].name _text(Vector2(pos.x, pos.y - 6.0), client.boss_def.display_name, 15, Color(1.0, 0.75, 0.8)) _text(Vector2(pos.x, pos.y - 6.0), phase_name, 12, Color(1.0, 0.6, 0.65, 0.85), HORIZONTAL_ALIGNMENT_RIGHT, BOSS_BAR_W) ## 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) 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. func _draw_cleared_countdown() -> void: if client.cleared_countdown >= Protocol.COUNTDOWN_NONE: return var y := _canvas.size.y * 0.5 - 60.0 _centred(y, "DUNGEON CLEARED", 24, Color(0.6, 1.0, 0.75)) _centred(y + 22.0, "returning to the hub in %ds" % client.cleared_countdown, 14, Color(0.6, 1.0, 0.75, 0.85)) ## Top right: in the hub, who is online and where; in a dungeon, the party you ## are in and how each of them is doing. Different questions -- in the hub you ## are deciding whether to go in, in a dungeon you are deciding who needs help. func _draw_others() -> void: if client.roster.is_empty(): return if client.instance_kind == Protocol.InstanceKind.LOBBY: _draw_online() else: _draw_party() func _draw_online() -> void: var x := _canvas.size.x - 240.0 var y := MARGIN + 12.0 _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: var in_dungeon: bool = int(entry["kind"]) == Protocol.InstanceKind.DUNGEON var where := "in a dungeon" if in_dungeon else "in the hub" var col := Color(1.0, 0.7, 0.45) if in_dungeon else Color(0.7, 0.8, 0.9) if not entry["alive"]: col = Color(0.85, 0.4, 0.4) where += ", down" var me := int(entry["peer"]) == client.my_peer _text(Vector2(x, y), "%s%s" % [entry["name"], " (you)" if me else ""], 13, col) _text(Vector2(x, y), where, 11, Color(col, 0.7), HORIZONTAL_ALIGNMENT_RIGHT, 216.0) y += 18.0 ## Everyone else in this dungeon, with their health. The snapshot only carries ## players inside the interest radius, so a party member further away than that ## is listed without a bar -- the client genuinely does not know. func _draw_party() -> void: var near := {} for p in client.remote_players(): near[int(p["peer"])] = p var members: Array[Dictionary] = [] for entry in client.roster: if int(entry["instance"]) == client.instance_id and int(entry["peer"]) != client.my_peer: members.append(entry) if members.is_empty(): return var x := _canvas.size.x - 240.0 var y := MARGIN + 12.0 _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 for entry in members: var peer := int(entry["peer"]) _text(Vector2(x, y + 13.0), String(entry["name"]), 13, COL_TEXT) var rec: Dictionary = near.get(peer, {}) var bar_at := Vector2(x + 76.0, y + 3.0) if rec.is_empty(): _text(Vector2(bar_at.x, y + 13.0), "out of range", 11, COL_DIM) else: var flags := int(rec["flags"]) var frac := float(rec["hp"]) / float(maxi(int(rec["max_hp"]), 1)) var col: Color = rec["colour"] if (flags & Protocol.F_ALIVE) == 0: _text(Vector2(bar_at.x, y + 13.0), "down", 12, COL_DANGER) else: var rect := Rect2(bar_at, Vector2(w, 12.0)) _canvas.draw_rect(rect, Color(0.0, 0.0, 0.0, 0.55)) _canvas.draw_rect(Rect2(rect.position + Vector2(2.0, 2.0), Vector2((w - 4.0) * clampf(frac, 0.0, 1.0), 8.0)), col if frac >= LOW_HEALTH else COL_DANGER) if (flags & Protocol.F_LINKDEAD) != 0: _text(Vector2(bar_at.x + w + 6.0, y + 13.0), "dropped", 11, COL_DIM) elif (flags & Protocol.F_ESCAPING) != 0: _text(Vector2(bar_at.x + w + 6.0, y + 13.0), "leaving", 11, Color(0.5, 0.85, 1.0)) y += 20.0 ## 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 ## snapshot, so it is what the server says you have, never a local guess. func _draw_inventory() -> void: if client.my_inventory.is_empty(): return var count := client.my_inventory.size() var total := float(count) * SLOT + float(count - 1) * SLOT_GAP var origin := Vector2((_canvas.size.x - total) * 0.5, _canvas.size.y - SLOT_BOTTOM - SLOT) var held := client.held_slot() for i in count: var at := origin + Vector2(float(i) * (SLOT + SLOT_GAP), 0.0) var item := Items.by_index(int(client.my_inventory[i])) var def := Items.get_def(item) # The held slot uses the pack's pressed-in slot art rather than a # 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") # 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), key("use_slot_%d" % (i + 1)), HORIZONTAL_ALIGNMENT_LEFT, -1, 12, UiTheme.INK_DIM) if def == null: continue var icon := Art.item_icon(item) var size := icon.size * Art.SCALE _canvas.draw_texture_rect_region(Art.TILESET, Rect2(at + (Vector2(SLOT, SLOT) - size) * 0.5, size), icon) _text(Vector2(origin.x, origin.y + SLOT + 15.0), inventory_hint(count), 11, Color(COL_DIM, 0.85), HORIZONTAL_ALIGNMENT_CENTER, total) ## What pressing interact right now would do. The server runs the same search ## for itself and does not care what this concluded -- this is a label, not a ## decision. Returns the y the next thing up should stack from. ## ## Loot first, then an entrance, which is the order the server resolves the ## same key in: an item in reach is taken, and only a press that took nothing ## goes through the portal underneath it. func _draw_pickup_prompt() -> float: var above := _canvas.size.y - SLOT_BOTTOM - SLOT - 14.0 if not client.my_alive: return above var near := client.loot_in_reach() if near.is_empty(): var portal := client.portal_here() if portal.is_empty() or client.instance_kind != Protocol.InstanceKind.LOBBY: return above var def := Dungeons.get_or_default(portal["dungeon"]) _centred(above, "%s enter %s" % [key("interact"), def.display_name], 15, def.tint) return above - 22.0 var item := Items.by_index(int(near["item"])) var def := Items.get_def(item) if def == null: return above var full := client.inventory_full() var text := "inventory full -- %s stays where it is" % def.display_name \ if full else "%s take %s" % [key("interact"), def.display_name] var tint := Color(0.85, 0.5, 0.45) if full else def.tint _centred(above, text, 14, tint) return above - 22.0 ## The message feed, newest nearest the inventory, each line fading out. func _draw_toasts(bottom: float) -> void: var now := _now() var y := bottom for i in range(_toasts.size() - 1, -1, -1): var t: Dictionary = _toasts[i] var age := now - float(t["born"]) var alpha := clampf((TOAST_LIFE - age) / TOAST_FADE, 0.0, 1.0) var col: Color = t["colour"] # A short rise on arrival, so a new line catches the eye without a sound. var rise := maxf(0.0, 0.25 - age) * 24.0 _centred(y + rise, String(t["text"]), 14, Color(col, col.a * alpha)) y -= 20.0 func _draw_controls_hint() -> void: if not client.my_alive: return var in_hub := client.instance_kind == Protocol.InstanceKind.LOBBY var alpha := 1.0 if not in_hub: var age := _now() - _instance_since alpha = clampf(1.0 - (age - HINT_HOLD) / HINT_FADE, 0.0, 1.0) if alpha <= 0.0: return _text(Vector2(MARGIN, _canvas.size.y - 14.0), controls_hint(in_hub), 12, Color(COL_DIM, 0.9 * alpha)) func _draw_debug_info() -> void: if not debug_info: return _text(Vector2(0.0, _canvas.size.y - 14.0), "instance %d %d fps" % [client.instance_id, Engine.get_frames_per_second()], 12, COL_DIM, HORIZONTAL_ALIGNMENT_RIGHT, _canvas.size.x - MARGIN) ## Red at the edges of the screen: briefly on every hit, and pulsing while ## health is low. ## ## At the edges, not across the middle. This used to tint the whole screen red ## on a hit, which in a bullet hell dims the bullets at exactly the moment you ## are among them -- the feedback made the next hit more likely. func _draw_vignette() -> void: var strength := _hit_flash * 0.55 if client.my_alive: var frac := float(client.my_hp) / float(maxi(client.my_max_hp, 1)) if frac < LOW_HEALTH: var low := (LOW_HEALTH - frac) / LOW_HEALTH var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.006) strength = maxf(strength, (0.2 + 0.35 * low) * (0.65 + 0.35 * pulse)) if strength <= 0.01: return var s := _canvas.size var depth := minf(s.x, s.y) * 0.16 var edge := Color(COL_DANGER, strength) var clear := Color(COL_DANGER, 0.0) var sides := [ [Vector2(0, 0), Vector2(s.x, 0), Vector2(s.x, depth), Vector2(0, depth)], [Vector2(0, s.y), Vector2(0, s.y - depth), Vector2(s.x, s.y - depth), Vector2(s.x, s.y)], [Vector2(0, 0), Vector2(depth, 0), Vector2(depth, s.y), Vector2(0, s.y)], [Vector2(s.x, 0), Vector2(s.x, s.y), Vector2(s.x - depth, s.y), Vector2(s.x - depth, 0)], ] var cols := [ [edge, edge, clear, clear], [edge, clear, clear, edge], [edge, clear, clear, edge], [edge, edge, clear, clear], ] for i in 4: _canvas.draw_polygon(PackedVector2Array(sides[i]), PackedColorArray(cols[i])) ## A bar in the pack's art: the frame stretched horizontally, 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) # 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 # boss. _canvas.draw_texture_rect(UiTheme.texture("bar_fill"), inner, false, fill) ## Draw a horizontally stretchable sprite as three slices: left cap, stretched ## middle, right cap. Godot's nine-patch lives on nodes rather than on the ## immediate-mode API, and the HUD is drawn rather than built out of controls. func _hslice(tex: Texture2D, rect: Rect2, tint: Color) -> void: if tex == null: return var src := Vector2(tex.get_size()) var cap := minf(BAR_CAP, src.x * 0.5) var draw_cap := cap * (rect.size.y / src.y) _canvas.draw_texture_rect_region(tex, Rect2(rect.position, Vector2(draw_cap, rect.size.y)), Rect2(0.0, 0.0, cap, src.y), tint) _canvas.draw_texture_rect_region(tex, Rect2(rect.position + Vector2(draw_cap, 0.0), Vector2(maxf(rect.size.x - draw_cap * 2.0, 0.0), rect.size.y)), Rect2(cap, 0.0, src.x - cap * 2.0, src.y), tint) _canvas.draw_texture_rect_region(tex, Rect2(rect.position + Vector2(rect.size.x - draw_cap, 0.0), Vector2(draw_cap, rect.size.y)), Rect2(src.x - cap, 0.0, cap, src.y), tint)