b351bc2d55
ci / verify (push) Successful in 48s
Every level banks one choice. Choices queue, and are spent at an NPC in the
hub: walk to it, press E, take one of three weighted options. Seven upgrades,
all data — split shot, glass cannon, spread, sniper, doubleshot, poison,
eraser — and SimWorld gained no per-upgrade branch to run any of them.
The four ambiguities in the brief were settled with the user first, since
each changes what gets written:
damage base x (1 + sum additive) x product multiplicative. The flat
+5% every upgrade carries, spread's -10%, doubleshot's -50%
and glass cannon's +100% pool; sniper multiplies on top, so
two snipers is 4x and not +200%.
glass half the LEVELLED maximum, multiplying if taken twice, so the
price does not fade to a rounding error by level 15.
poison independent stacks, not a refresh.
split +/-45 degrees from the original heading.
Independent poison stacks sound expensive and are not: every dose lasts the
same number of ticks, so doses expire in the order they were added, the
pending expiries are a plain FIFO, and PoisonTrack only ever looks at its
front. O(1) per actor per tick however many are live.
Stats are derived from the upgrade list and never stored, the way level is
derived from experience -- a saved stat cannot disagree with the upgrades
that produced it. Upgrade riders (split charges, poison, erase chance) travel
on the bullet instead, because a shot in flight has to keep what it was fired
with rather than gaining Poison because the shooter just took it.
Two invariants this collided with, both now pinned:
- bullet speed gained a ceiling. Wall collision samples once per tick, so
anything over a tile per tick tunnels; two snipers asked for 2480 u/s
against a 1920 threshold, and a tunnelling bullet looks like a bullet.
- BULLET_INTEREST_RADIUS rose to 2900, because an upgraded player shot is
now the longest-travelling bullet in the game. test_interest measured
the worst case from static content, which upgrades quietly invalidated.
Choosing is intent checked three ways: a choice must be owed, the index must
name one of the three options the SERVER put on the table, and the player
must be standing at the NPC. The offer is rolled once and persisted, so
closing the screen is not a reroll and neither is a crash.
tools/diag_upgrades.tscn covers level -> banked choice -> refused in a
dungeon and refused across the room -> taken at the NPC -> new stats ->
on disk. Bots never walk to the quartermaster, so the smoke test cannot.
Known gap recorded in the roadmap: at PLAYER_BULLET_DAMAGE = 6, the +5% the
first upgrade carries rounds back to 6 and visibly does nothing. It comes out
right in aggregate, but the fix is a balance edit across content.gd and so is
the user's call.
check.sh clean, 357 tests, SMOKE PASS (18 assertions), all four diagnostics
green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
320 lines
12 KiB
GDScript
320 lines
12 KiB
GDScript
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.
|
|
const SLOT := 46.0
|
|
const SLOT_GAP := 8.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)
|
|
_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
|
|
_hint.offset_top = -56.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 + 3.0))
|
|
|
|
if client.my_escaping:
|
|
_bar(origin + Vector2(0.0, BAR_H + 8.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 + 8.0 + BAR_H),
|
|
"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)
|
|
var frame_col := Color(0.55, 0.6, 0.72, 0.75) if i == held \
|
|
else Color(0.3, 0.33, 0.42, 0.6)
|
|
_canvas.draw_rect(Rect2(at, Vector2(SLOT, SLOT)), Color(0.07, 0.08, 0.12, 0.72))
|
|
_canvas.draw_rect(Rect2(at, Vector2(SLOT, SLOT)), frame_col, false, 1.5)
|
|
# 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(4.0, 13.0),
|
|
str(i + 1), HORIZONTAL_ALIGNMENT_LEFT, -1, 11,
|
|
Color(0.5, 0.55, 0.68))
|
|
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)
|
|
_canvas.draw_rect(Rect2(at, Vector2(BAR_W, 5.0)), Color(0.1, 0.12, 0.18))
|
|
_canvas.draw_rect(Rect2(at, Vector2(BAR_W * progress, 5.0)), tint)
|
|
# 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, 6.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)
|
|
_canvas.draw_rect(Rect2(pos, Vector2(w, BAR_H)), Color(0.12, 0.08, 0.1))
|
|
_canvas.draw_rect(Rect2(pos, Vector2(w * frac, BAR_H)), 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))
|
|
|
|
|
|
func _bar(pos: Vector2, frac: float, fill: Color, back: Color) -> void:
|
|
_canvas.draw_rect(Rect2(pos, Vector2(BAR_W, BAR_H)), back)
|
|
_canvas.draw_rect(Rect2(pos, Vector2(BAR_W * clampf(frac, 0.0, 1.0), BAR_H)), fill)
|
|
|
|
|
|
func flash_hit() -> void:
|
|
_hit_flash = 1.0
|