a943aa19f6
ci / verify (push) Successful in 47s
Two labelled portals now stand side by side in the hub. The Proving Grounds runs the same generator, the same rooms, the same enemies and the same four-phase Warden -- enemies at a fifth health, the boss at 288 instead of 3600, and trash dropping potions 80% of the time instead of 8%. A manual pass over loot, the inventory, dropping and every boss phase takes a couple of minutes rather than a quarter of an hour. It is multipliers over the shared content rather than a parallel copy: a duplicated Content would drift the first time anything was tuned, and "identical but easier" would quietly stop being true. And it is a portal rather than a launch flag, so the two can be compared back to back without restarting the server -- which is most of the point. Which dungeon you enter is resolved from the player's server-side position, and PORTAL_USED carries the answer. There is deliberately no client message that names a dungeon: one would let any client ask for the generous loot table and bring the results back to the hub. Instance matching compares dungeon ids too, so walking into one entrance can never drop you into the other's run on timing alone. SimWorld.portals replaces portal_pos/portal_enabled, enter_instance carries the portal list and the dungeon id (the client needs the latter to scale the boss bar's ceiling the way the server scaled the boss), and Protocol.VERSION goes to 7. Also pins what happens when two players reach for one item on the same tick: exactly one gets it -- the loop is sequential and the pickup erases the entity before the next player looks. The tie-break is join order rather than distance, which is arbitrary rather than designed, so it is recorded as such. Stale doc fixed while here: MapGen.build() still claimed the client rebuilds the map from the seed, which has not been true since map streaming landed and is the opposite of the rule. check.sh clean, 288 tests, SMOKE PASS (18 assertions, both dungeon kinds opened over a real socket), all three diagnostics green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
302 lines
12 KiB
GDScript
302 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()
|
|
|
|
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))
|
|
|
|
|
|
## 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
|