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. 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 signal respawn_pressed var _canvas: Control var _status: Label var _hint: Label var _respawn_button: Button var _hit_flash: float = 0.0 var client: ClientRuntime = null 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) _status = _make_label(Vector2(MARGIN, MARGIN)) _hint = _make_label(Vector2(MARGIN, 0.0)) _hint.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_LEFT) # `.position` assigns an ABSOLUTE coordinate regardless of anchors (that's # what stranded this label off-screen despite the anchor being correct). # offset_left/offset_top are the anchor-relative ones -- negative offset_top # here means "56px above the bottom anchor", which is what "-56" was meant # to say in the first place. _hint.offset_left = MARGIN # Above the inventory row, not through it. Derived from the row's own # geometry rather than written down, because the slots grew from 46px to 64 # and a hardcoded -56 put the control hints straight across them. _hint.offset_top = -(SLOT_BOTTOM + SLOT + 34.0) # 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 _make_label(pos: Vector2) -> Label: var l := Label.new() l.position = pos l.add_theme_font_size_override("font_size", 14) l.add_theme_color_override("font_color", Color(0.8, 0.85, 0.95)) _canvas.add_child(l) return l func _process(delta: float) -> void: client = Net.client _hit_flash = maxf(_hit_flash - delta * 2.5, 0.0) _status.text = _status_text() _hint.text = _hint_text() _update_respawn_button() _canvas.queue_redraw() 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) func _status_text() -> String: if client == null: return "connecting..." # Named rather than just "DUNGEON": there is more than one kind now, and # knowing which one you are standing in is the whole point of having two. var where := "LOBBY" if client.instance_kind != Protocol.InstanceKind.LOBBY: where = Dungeons.get_or_default(client.dungeon_id).display_name.to_upper() var who := client.current_character() var name_part := "" if not who.is_empty(): name_part = "%s lv %d " % [ who["name"], Progression.level_for_xp(client.my_total_xp)] return "%s%s instance %d hp %d/%d %d fps" % [ name_part, where, client.instance_id, client.my_hp, client.my_max_hp, Engine.get_frames_per_second()] func _hint_text() -> String: if client == null: return "" if not client.my_alive: return "DOWN" if client.instance_kind == Protocol.InstanceKind.LOBBY: return "WASD move mouse aim LMB fire E on a ring to enter that dungeon Esc menu F1 hitboxes" return "WASD move mouse aim LMB fire hold F to return to the hub Esc menu F1 hitboxes" func _draw_hud() -> void: if client == null: return var origin := Vector2(MARGIN, MARGIN + 28.0) _bar(origin, float(client.my_hp) / float(maxi(client.my_max_hp, 1)), Color(0.35, 0.9, 0.6), Color(0.1, 0.15, 0.18)) _draw_xp_bar(origin + Vector2(0.0, BAR_H + 4.0)) if client.my_escaping: _bar(origin + Vector2(0.0, (BAR_H + 4.0) * 2.0), client.my_escape, Color(0.5, 0.85, 1.0), Color(0.1, 0.15, 0.2)) _canvas.draw_string(ThemeDB.fallback_font, origin + Vector2(BAR_W + 12.0, (BAR_H + 4.0) * 2.0 + BAR_H - 3.0), "ESCAPING", HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.5, 0.85, 1.0)) _draw_boss_bar() if client.my_spawn_grace: _canvas.draw_string(ThemeDB.fallback_font, origin + Vector2(BAR_W + 12.0, BAR_H), "ARRIVING -- invulnerable, weapons cold", HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.6, 0.9, 1.0)) _draw_cleared_countdown() _draw_roster() _draw_inventory() _draw_pickup_prompt() _draw_level_up_notice() if not client.my_alive: # Centred on the canvas, which is only correct because _canvas actually # has the viewport's size now -- see the anchor note in _ready(). var centre := _canvas.size * 0.5 _canvas.draw_string(ThemeDB.fallback_font, centre - Vector2(52.0, 8.0), "DOWN", HORIZONTAL_ALIGNMENT_LEFT, -1, 34, Color(1.0, 0.4, 0.4)) if _hit_flash > 0.0: _canvas.draw_rect(Rect2(Vector2.ZERO, _canvas.size), Color(1.0, 0.2, 0.25, 0.18 * _hit_flash)) ## 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() -> 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" \ if client.instance_kind != Protocol.InstanceKind.LOBBY else "" _canvas.draw_string(ThemeDB.fallback_font, Vector2(MARGIN, MARGIN + 74.0), "%d LEVEL-UP%s TO SPEND%s" % [client.upgrades_pending, "" if client.upgrades_pending == 1 else "S", where], HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(1.0, 0.85, 0.4, 0.6 + 0.4 * pulse)) ## 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") _canvas.draw_texture_rect(slot_art, Rect2(at, Vector2(SLOT, SLOT)), false) # The slot number, because the key that uses it is the only thing the # player actually needs to know about a slot. _canvas.draw_string(ThemeDB.fallback_font, at + Vector2(6.0, 17.0), str(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) _canvas.draw_string(ThemeDB.fallback_font, Vector2(origin.x, origin.y + SLOT + 15.0), "1-%d use shift+1-%d drop E pick up" % [count, count], HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Color(0.5, 0.55, 0.66)) ## 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. func _draw_pickup_prompt() -> void: var near := client.loot_in_reach() if near.is_empty() or not client.my_alive: return var item := Items.by_index(int(near["item"])) var def := Items.get_def(item) if def == null: return var full := client.inventory_full() var text := "inventory full -- %s stays where it is" % def.display_name \ if full else "E take %s" % def.display_name var tint := Color(0.85, 0.5, 0.45) if full else def.tint _canvas.draw_string(ThemeDB.fallback_font, Vector2(_canvas.size.x * 0.5 - 150.0, _canvas.size.y - SLOT_BOTTOM - SLOT - 22.0), text, HORIZONTAL_ALIGNMENT_CENTER, 300.0, 14, tint) ## A thin bar under health: progress toward the next level, and the level ## itself. Drawn from the server's numbers, never recomputed locally. func _draw_xp_bar(at: Vector2) -> void: if client.current_character().is_empty(): return # 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 := Color(1.0, 0.85, 0.4) if capped else Color(0.6, 0.55, 1.0) _bar(at, progress, tint, Color.BLACK) # 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] _canvas.draw_string(ThemeDB.fallback_font, at + Vector2(BAR_W + 10.0, 13.0), text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, tint) ## 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 text := "DUNGEON CLEARED -- returning to the hub in %ds" % client.cleared_countdown _canvas.draw_string(ThemeDB.fallback_font, Vector2(_canvas.size.x * 0.5 - 190.0, _canvas.size.y * 0.5 - 60.0), text, HORIZONTAL_ALIGNMENT_LEFT, -1, 18, Color(0.6, 1.0, 0.75)) ## Who else is online, and whether they are already in a dungeon. Only useful in ## the hub, which is the one place you are deciding whether to go in. func _draw_roster() -> void: if client.instance_kind != Protocol.InstanceKind.LOBBY or client.roster.is_empty(): return var x := _canvas.size.x - 240.0 var y := MARGIN + 4.0 _canvas.draw_string(ThemeDB.fallback_font, Vector2(x, y), "ONLINE", HORIZONTAL_ALIGNMENT_LEFT, -1, 13, Color(0.65, 0.7, 0.8)) y += 20.0 for entry in client.roster: var in_dungeon: bool = int(entry["kind"]) == Protocol.InstanceKind.DUNGEON var where := "dungeon %d" % int(entry["instance"]) if in_dungeon else "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 := " <- you" if int(entry["peer"]) == client.my_peer else "" _canvas.draw_string(ThemeDB.fallback_font, Vector2(x, y), "%s -- %s%s" % [entry["name"], where, me], HORIZONTAL_ALIGNMENT_LEFT, -1, 13, col) y += 18.0 func _draw_boss_bar() -> void: var b := client.boss_state() if b.is_empty() or client.boss_def == null: return var w := 560.0 var pos := Vector2((_canvas.size.x - w) * 0.5, MARGIN) var frac := clampf(float(b["hp"]) / float(client.boss_def.max_hp), 0.0, 1.0) var rect := Rect2(pos, Vector2(w, BAR_H)) _hslice(UiTheme.texture("bar"), rect, Color.WHITE) var inner := Rect2(rect.position + Vector2(3.0, 3.0), Vector2((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_name := "" if phase_index < client.boss_def.phases.size(): phase_name = client.boss_def.phases[phase_index].name _canvas.draw_string(ThemeDB.fallback_font, pos + Vector2(0.0, -6.0), "%s -- %s" % [client.boss_def.display_name, phase_name], HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(1.0, 0.75, 0.8)) ## A bar in the pack's art: the frame stretched horizontally, the fill tinted. ## ## [param back] is no longer a colour -- the frame sprite is the background -- ## but the signature keeps it so callers read the same and a future flat-bar ## fallback has somewhere to go. func _bar(pos: Vector2, frac: float, fill: Color, _back: Color) -> void: var rect := Rect2(pos, Vector2(BAR_W, BAR_H)) _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)) 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) func flash_hit() -> void: _hit_flash = 1.0