Add a second dungeon: the Proving Grounds, a test harness you walk into
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>
This commit is contained in:
2026-09-04 21:34:41 +02:00
parent 050b8251a7
commit a943aa19f6
28 changed files with 733 additions and 74 deletions
+45
View File
@@ -0,0 +1,45 @@
class_name DungeonDef
extends Resource
## A kind of dungeon run. Same generator, same rooms, same enemies -- what
## differs is how tough its contents are and how freely they drop.
##
## Deliberately a set of multipliers over the shared content rather than a
## parallel copy of it: a second dungeon that duplicated `Content` would drift
## from the first the moment anything was tuned, and then "identical but
## easier" would quietly stop being true.
@export var id: StringName = &"dungeon"
@export var display_name: String = "Dungeon"
## One line for the portal label, so a player standing in the hub can tell the
## entrances apart without reading a wiki.
@export var subtitle: String = ""
@export var enemy_hp_mult: float = 1.0
@export var boss_hp_mult: float = 1.0
## Multiplies every loot chance, clamped at certain. Guaranteed drops stay
## guaranteed; there is nothing above 1.0 to reach for.
@export var loot_chance_mult: float = 1.0
## Portal colour, and the tint of the dungeon's name on the HUD.
@export var tint := Color(0.5, 0.9, 1.0)
## Scale a freshly built [EnemyDef] in place.
##
## Safe to mutate because every caller of `Content.enemy()` gets a new object --
## the content functions construct one per call. If that ever changes, this has
## to duplicate first, or one easy dungeon would nerf every hard one.
func apply_to_enemy(def: EnemyDef) -> EnemyDef:
def.max_hp = maxi(1, roundi(float(def.max_hp) * enemy_hp_mult))
_scale_loot(def.loot)
return def
func apply_to_boss(def: BossDef) -> BossDef:
def.max_hp = maxi(1, roundi(float(def.max_hp) * boss_hp_mult))
_scale_loot(def.loot)
return def
func _scale_loot(table: Array[LootDrop]) -> void:
for entry in table:
if entry != null:
entry.chance = clampf(entry.chance * loot_chance_mult, 0.0, 1.0)
+1
View File
@@ -0,0 +1 @@
uid://cqert4nr662vl
+8 -5
View File
@@ -169,12 +169,13 @@ func send_welcome(peer_id: int) -> void:
## hack with no work required; tiles are streamed instead (send_map_chunks).
func send_enter_instance(peer_id: int, id: int, kind: int, server_tick: int,
boss_id: String, spawn: Vector2, map_w: int, map_h: int,
portal: Vector2) -> void:
portals: PackedByteArray, dungeon: String) -> void:
if _is_local(peer_id):
client.on_enter_instance(id, kind, server_tick, boss_id, spawn, map_w, map_h, portal)
client.on_enter_instance(id, kind, server_tick, boss_id, spawn, map_w,
map_h, portals, dungeon)
else:
s_enter_instance.rpc_id(peer_id, id, kind, server_tick, boss_id, spawn,
map_w, map_h, portal)
map_w, map_h, portals, dungeon)
func send_map_chunks(peer_id: int, instance_id: int, data: PackedByteArray) -> void:
@@ -289,10 +290,12 @@ func s_welcome(peer_id: int, _version: int) -> void:
@rpc("authority", "call_remote", "reliable", 1)
func s_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
spawn: Vector2, map_w: int, map_h: int, portal: Vector2) -> void:
spawn: Vector2, map_w: int, map_h: int, portals: PackedByteArray,
dungeon: String) -> void:
if client == null:
return
client.on_enter_instance(id, kind, server_tick, boss_id, spawn, map_w, map_h, portal)
client.on_enter_instance(id, kind, server_tick, boss_id, spawn, map_w,
map_h, portals, dungeon)
@rpc("authority", "call_remote", "reliable", 1)
+80
View File
@@ -0,0 +1,80 @@
class_name Dungeons
extends RefCounted
## The kinds of dungeon run, defined in code like everything else in
## `src/content/`.
##
## [constant ORDER] does double duty: it is the wire order for the dungeon id,
## and it is the order the hub's portals are assigned. The Nth `P` marker in the
## lobby stamp (reading order) opens the Nth entry here, so adding a dungeon is
## one entry plus one marker.
const STANDARD := &"warden_descent"
const PROVING := &"proving_grounds"
const ORDER: Array[StringName] = [
STANDARD,
PROVING,
]
static func default_id() -> StringName:
return STANDARD
static func get_def(id: StringName) -> DungeonDef:
match id:
STANDARD: return standard()
PROVING: return proving_grounds()
return null
## Falls back to the standard run rather than to null. An id off the wire has to
## resolve to something playable, and "the normal dungeon" is the safe answer.
static func get_or_default(id: StringName) -> DungeonDef:
var d := get_def(id)
return d if d != null else standard()
static func index_of(id: StringName) -> int:
return maxi(ORDER.find(id), 0)
static func by_index(index: int) -> StringName:
if index < 0 or index >= ORDER.size():
return default_id()
return ORDER[index]
# --- The dungeons -----------------------------------------------------------
## The real thing. Every multiplier is 1.0, which is the point: this is the
## baseline the other definitions are described against.
static func standard() -> DungeonDef:
var d := DungeonDef.new()
d.id = STANDARD
d.display_name = "Warden's Descent"
d.subtitle = "the real run"
d.tint = Color(0.5, 0.9, 1.0)
return d
## A test harness you can walk into.
##
## Same generator, same rooms, same enemies and the same Warden -- everything
## dies far faster and drops far more often, so a manual pass over the loot,
## the inventory and all four boss phases takes a couple of minutes instead of a
## quarter of an hour. Being reachable from the hub rather than hidden behind a
## launch flag is most of the value: you can compare the two back to back in one
## session without restarting the server.
static func proving_grounds() -> DungeonDef:
var d := DungeonDef.new()
d.id = PROVING
d.display_name = "Proving Grounds"
d.subtitle = "for testing -- fragile, generous"
d.enemy_hp_mult = 0.2
d.boss_hp_mult = 0.08
# 0.08 -> 0.8 for trash. High enough that a handful of kills fills a bag,
# which is what makes the four-slot limit and dropping testable at all.
d.loot_chance_mult = 10.0
d.tint = Color(1.0, 0.75, 0.35)
return d
+1
View File
@@ -0,0 +1 @@
uid://dw7j78sn8obvw
+9 -4
View File
@@ -96,16 +96,21 @@ static func stamp(grid: MapGrid, s: PackedStringArray, origin: Vector2i) -> Dict
markers[ch].append(Vector2i(tx, ty))
return markers
## The hub. Hand-authored like the boss arenas, and reproduced identically on
## the client from this same function -- see MapGen.build().
## P portal to the dungeons S player spawn T practice target
## The hub. Hand-authored like the boss arenas.
##
## P a dungeon portal S player spawn T practice target
##
## Each `P`, in reading order, opens the matching entry in Dungeons.ORDER. Two
## of them now: the real run and the Proving Grounds, side by side so they can
## be compared without restarting anything. Adding a third dungeon means adding
## a third marker here.
static func lobby() -> PackedStringArray:
return PackedStringArray([
"#########################################",
"#.......................................#",
"#.......................................#",
"#.......................................#",
"#...................P...................#",
"#..............P.........P..............#",
"#.......................................#",
"#.....o...........................o.....#",
"#.......................................#",
+3 -1
View File
@@ -174,5 +174,7 @@ const MAP_STREAM_RADIUS := 900.0
const MAP_CHUNKS_PER_TICK := 6
# --- Portal -----------------------------------------------------------------
## Position is per-world now (SimWorld.portal_pos), taken from the hub's map.
## Positions are per-world (SimWorld.portals), taken from the hub's map. Two
## entrances must be placed further apart than twice this, or their catchment
## areas overlap and which one you get stops being obvious from where you stand.
const PORTAL_RADIUS := 60.0
+17 -6
View File
@@ -22,6 +22,8 @@ var age: int = 0
var seed_value: int = 0
## Drives dungeon size and difficulty. The hub is always depth 0.
var depth: int = 0
## Which kind of run this is -- see [Dungeons]. Empty for the hub.
var dungeon_id: StringName = &""
## Dungeon progression. -1 is the pre-fight breather.
var stage: int = -1
@@ -41,8 +43,7 @@ static func make_lobby(instance_id: int) -> Instance:
inst.world = SimWorld.new(inst.seed_value)
var built := MapGen.build(Protocol.InstanceKind.LOBBY, inst.seed_value, 0)
inst.world.set_map(built["grid"])
inst.world.portal_enabled = true
inst.world.portal_pos = built["portal"]
inst.world.portals = built["portals"]
inst.world.spawn_point = built["spawn"]
inst.state = State.ACTIVE
# A single inert practice target so players can feel out the gun before
@@ -51,12 +52,17 @@ static func make_lobby(instance_id: int) -> Instance:
return inst
static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int = 1) -> Instance:
## [param dungeon] picks the flavour of run. The map, the rooms, the enemies and
## the boss are the same whichever is chosen; only how much health they have and
## how freely they drop differs. See [Dungeons].
static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int = 1,
dungeon: StringName = &"") -> Instance:
var inst := Instance.new()
inst.id = instance_id
inst.kind = Protocol.InstanceKind.DUNGEON
inst.seed_value = dungeon_seed
inst.depth = maxi(dungeon_depth, 1)
inst.dungeon_id = dungeon if not dungeon.is_empty() else Dungeons.default_id()
inst.world = SimWorld.new(dungeon_seed)
var built := MapGen.build(Protocol.InstanceKind.DUNGEON, dungeon_seed, inst.depth)
inst.world.set_map(built["grid"])
@@ -80,7 +86,8 @@ static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int
func _populate() -> void:
var rng := RandomNumberGenerator.new()
rng.seed = seed_value ^ 0x5eed
var boss := world.spawn_boss(Content.boss(boss_id))
var flavour := Dungeons.get_or_default(dungeon_id)
var boss := world.spawn_boss(flavour.apply_to_boss(Content.boss(boss_id)))
boss.pos = boss_spawn
boss.room = _room_rect_world(boss_room)
GameLog.info("instance", "BOSS_SPAWNED %s in instance %d" % [boss_id, id])
@@ -94,7 +101,7 @@ func _populate() -> void:
var r: Rect2i = rooms[i]
var count := rng.randi_range(1, 3 + depth / 2)
for _n in count:
var def := _pick_enemy(rng)
var def := flavour.apply_to_enemy(_pick_enemy(rng))
var tx := rng.randi_range(r.position.x + 1, r.end.x - 2)
var ty := rng.randi_range(r.position.y + 1, r.end.y - 2)
var at := world.map.tile_centre(tx, ty)
@@ -166,8 +173,12 @@ func exit_countdown_seconds() -> int:
Protocol.COUNTDOWN_NONE - 1)
func accepts_new_party_member() -> bool:
## [param dungeon] has to match: walking into the Proving Grounds portal must
## never drop you into a standard run that happens to still be forming, however
## conveniently timed.
func accepts_new_party_member(dungeon: StringName) -> bool:
return kind == Protocol.InstanceKind.DUNGEON \
and dungeon_id == dungeon \
and state == State.FORMING \
and peers.size() < SimConfig.DUNGEON_PARTY_MAX
+30 -7
View File
@@ -88,8 +88,13 @@ var characters_known: bool = false
## Whole seconds until a cleared dungeon returns the party, or
## Protocol.COUNTDOWN_NONE outside that state.
var cleared_countdown: int = Protocol.COUNTDOWN_NONE
## Where this world's dungeon portal is. Per-map now, so it has to be told.
var portal_pos := Vector2.ZERO
## Dungeon entrances in this world, as [{ "pos": Vector2, "dungeon": StringName }].
## Empty outside the hub.
var portals: Array[Dictionary] = []
## Which kind of run this instance is, or empty in the hub. Drives the HUD label
## and the boss's health ceiling -- an easier dungeon's boss has less of it, and
## a bar computed from the unscaled definition would sit near empty all fight.
var dungeon_id: StringName = &""
## Offset the view applies when drawing the world: screen = world + this.
## Published by the game scene every frame, rather than assumed, so aiming
@@ -213,6 +218,15 @@ func held_slot() -> int:
## Scripted input so `tools/smoke.sh` can play the game with no display: orbit
## the arena, fire constantly, take the portal, then punch out with the escape.
## The portal this bot heads for. Spread across the available entrances by
## account so the smoke test opens one of each kind.
func bot_portal() -> Vector2:
if portals.is_empty():
return predicted_pos
var pick: int = absi(GameOpts.account_override) % portals.size()
return portals[pick]["pos"]
func _bot_input() -> InputFrame:
_bot_tick += 1
var t := float(_bot_tick) * SimConfig.TICK_DELTA
@@ -225,8 +239,10 @@ func _bot_input() -> InputFrame:
return InputFrame.make(input_tick, Vector2.ZERO, aim, InputFrame.BTN_INTERACT)
if instance_kind == Protocol.InstanceKind.LOBBY and _bot_tick % 120 < 30:
buttons |= InputFrame.BTN_INTERACT
# Walk onto the portal instead of orbiting, or interact never lands.
move = (portal_pos - predicted_pos).normalized()
# Walk onto a portal instead of orbiting, or interact never lands.
# Which one is picked from the account id, so a smoke run with several
# bots exercises every dungeon rather than only the first.
move = (bot_portal() - predicted_pos).normalized()
var slot := 0
if instance_kind == Protocol.InstanceKind.DUNGEON:
# Grab at whatever is underfoot and occasionally drink, so the item
@@ -329,11 +345,18 @@ func on_roster(data: PackedByteArray) -> void:
func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
spawn: Vector2, map_w: int, map_h: int, portal: Vector2) -> void:
spawn: Vector2, map_w: int, map_h: int, portals_data: PackedByteArray,
dungeon: String) -> void:
instance_id = id
instance_kind = kind as Protocol.InstanceKind
portal_pos = portal
boss_def = Content.boss(StringName(boss_id)) if not boss_id.is_empty() else null
portals = NetCodec.decode_portals(portals_data)
dungeon_id = StringName(dungeon)
# Scaled the same way the server scaled it, so the boss bar reads as a
# fraction of the health this particular run's boss actually has.
boss_def = null
if not boss_id.is_empty():
boss_def = Dungeons.get_or_default(dungeon_id).apply_to_boss(
Content.boss(StringName(boss_id)))
# We are told how big the map is and nothing else. Every tile starts UNKNOWN
# and is filled in by streaming as the player moves, so the client never
# holds terrain it has not been near -- there is no seed here to regenerate
+38
View File
@@ -424,6 +424,44 @@ static func decode_map_chunks_into(map: MapGrid, data: PackedByteArray) -> int:
return applied
# --- Portals ----------------------------------------------------------------
# Sent once, with enter_instance. There are two of them and they never move, so
# this is about as cold as a message gets -- it is a codec only because the
# count is variable and RPC arguments are not.
static func encode_portals(portals: Array[SimPortal]) -> PackedByteArray:
var b := StreamPeerBuffer.new()
b.big_endian = false
b.put_u8(mini(portals.size(), 255))
for portal in portals:
b.put_float(portal.pos.x)
b.put_float(portal.pos.y)
# By index, like item ids. See Dungeons.ORDER.
b.put_u8(Dungeons.index_of(portal.dungeon))
return b.data_array
## Returns [{ "pos": Vector2, "dungeon": StringName }].
static func decode_portals(data: PackedByteArray) -> Array[Dictionary]:
var out: Array[Dictionary] = []
if data.size() < 1:
return out
var b := StreamPeerBuffer.new()
b.big_endian = false
b.data_array = data
var count := b.get_u8()
for _i in count:
# 4 + 4 + 1. A truncated packet gives back fewer portals rather than
# reading past the end and inventing one at a garbage position.
if b.get_available_bytes() < 9:
break
out.append({
"pos": Vector2(b.get_float(), b.get_float()),
"dungeon": Dungeons.by_index(b.get_u8()),
})
return out
# --- Character roster -------------------------------------------------------
# Sent once at login and after any change. Low frequency and carries strings,
# like the online roster, so it is the same fixed-header-then-utf8 shape.
+3 -1
View File
@@ -17,7 +17,9 @@ extends RefCounted
## the ground-loot list, the input frame gained a slot byte, and three item
## events were appended. Every one of those changes the byte layout of a
## message both ends parse positionally.
const VERSION := 6
## 7: more than one dungeon. enter_instance carries a portal LIST and the id of
## the dungeon you are standing in, replacing the single portal position.
const VERSION := 7
const DEFAULT_PORT := 27015
const MAX_CLIENTS := 32
+20 -8
View File
@@ -89,7 +89,8 @@ func _dispatch_events(inst: Instance) -> void:
# Collected and applied after the send below, because a transfer mutates
# inst.peers and would otherwise change the list mid-broadcast.
var to_lobby: Array[int] = []
var to_dungeon: Array[int] = []
# peer -> which dungeon their portal opens.
var to_dungeon: Dictionary[int, StringName] = {}
var died: Array[int] = []
for ev in events:
match int(ev["t"]):
@@ -114,8 +115,11 @@ func _dispatch_events(inst: Instance) -> void:
to_lobby.append(peer)
SimEvent.Type.PORTAL_USED:
var peer := int(ev["peer"])
# Keyed by peer, so a player brushing both entrances in one tick
# still only enters one dungeon -- the first portal that
# answered.
if not to_dungeon.has(peer):
to_dungeon.append(peer)
to_dungeon[peer] = StringName(ev.get("dungeon", ""))
_:
pass
@@ -144,7 +148,7 @@ func _dispatch_events(inst: Instance) -> void:
for peer in to_lobby:
_send_to_lobby(peer)
for peer in to_dungeon:
_send_to_dungeon(peer)
_send_to_dungeon(peer, to_dungeon[peer])
# --- Peer lifecycle ---------------------------------------------------------
@@ -327,7 +331,8 @@ func _place(peer_id: int, inst: Instance) -> void:
peer_chunks[peer_id] = {}
Net.send_enter_instance(peer_id, inst.id, int(inst.kind), inst.world.tick,
String(inst.boss_id), inst.world.spawn_point,
inst.world.map.width, inst.world.map.height, inst.world.portal_pos)
inst.world.map.width, inst.world.map.height,
NetCodec.encode_portals(inst.world.portals), String(inst.dungeon_id))
# Seed the area around the spawn before anything else, so the player is not
# briefly standing in an unrendered void on arrival.
_stream_map(peer_id, inst)
@@ -379,16 +384,23 @@ func _send_to_lobby(peer_id: int) -> void:
_transfer(peer_id, lobby)
func _send_to_dungeon(peer_id: int) -> void:
## Put a peer into a forming run of the kind they asked for, opening one if
## there is none. The dungeon id comes from the portal they used, which the
## simulation reported -- never from anything the client said.
func _send_to_dungeon(peer_id: int, dungeon_id: StringName = &"") -> void:
var wanted := dungeon_id if Dungeons.get_def(dungeon_id) != null \
else Dungeons.default_id()
var target: Instance = null
for inst in instances.values():
if inst.accepts_new_party_member():
if inst.accepts_new_party_member(wanted):
target = inst
break
if target == null:
target = Instance.make_dungeon(_take_instance_id(), randi(), GameOpts.dungeon_depth)
target = Instance.make_dungeon(_take_instance_id(), randi(),
GameOpts.dungeon_depth, wanted)
instances[target.id] = target
GameLog.info("server", "opened dungeon instance %d" % target.id)
GameLog.info("server", "opened dungeon instance %d (%s)"
% [target.id, wanted])
_transfer(peer_id, target)
+29 -14
View File
@@ -3,16 +3,22 @@ extends RefCounted
## Builds a dungeon: generated rooms and corridors around a hand-authored boss
## arena (see [Rooms]).
##
## Deterministic from (seed, depth) alone, so the server can hand a client the
## same two numbers instead of a map, and a failing run can be reproduced from
## its log line.
## Deterministic from (seed, depth) alone, which is what makes a failing run
## reproducible from its log line. It does NOT mean the client can rebuild the
## map -- see the note on build().
## The single entry point both server and client use.
## The single entry point. SERVER-SIDE ONLY.
##
## Maps are never sent as tile data: they are a pure function of
## (kind, seed, depth), so the server ships three integers and the client
## rebuilds the identical grid. tests/unit/test_map_gen.gd pins the determinism
## that makes that safe, and it keeps a big dungeon free on the wire.
## This used to be described as "both sides call it with the same three
## integers" -- that is no longer true and has not been since map streaming
## landed. Handing a client the seed would let it regenerate the entire floor
## plan, which is a map hack with no work required, so the client is given the
## map's SIZE and nothing else and fills tiles in from streamed chunks as it
## walks. See ServerRuntime._stream_map.
##
## Generation is still deterministic from (kind, seed, depth), which is what
## makes a bad run reproducible from its log line; tests/unit/test_map_gen.gd
## pins that.
static func build(kind: Protocol.InstanceKind, seed_value: int, depth: int) -> Dictionary:
if kind == Protocol.InstanceKind.LOBBY:
return _build_lobby()
@@ -29,10 +35,19 @@ static func _build_lobby() -> Dictionary:
if not markers["S"].is_empty():
var m: Vector2i = markers["S"][0]
spawn = grid.tile_centre(m.x, m.y)
var portal := grid.tile_centre(size.x / 2, 3)
if not markers["P"].is_empty():
var m: Vector2i = markers["P"][0]
portal = grid.tile_centre(m.x, m.y)
# One portal per marker, in reading order, matched against Dungeons.ORDER.
# A stamp with fewer markers than dungeons simply makes the extra ones
# unreachable rather than crashing -- an unreachable dungeon is a content
# bug, not a runtime one.
var portals: Array[SimPortal] = []
for i in (markers["P"] as Array).size():
if i >= Dungeons.ORDER.size():
break
var m: Vector2i = markers["P"][i]
portals.append(SimPortal.make(grid.tile_centre(m.x, m.y), Dungeons.ORDER[i]))
if portals.is_empty():
portals.append(SimPortal.make(
grid.tile_centre(size.x / 2, 3), Dungeons.default_id()))
var target := grid.tile_centre(size.x / 4, size.y / 2)
if not markers["T"].is_empty():
var m: Vector2i = markers["T"][0]
@@ -41,7 +56,7 @@ static func _build_lobby() -> Dictionary:
"grid": grid,
"rooms": [] as Array[Rect2i],
"spawn": spawn,
"portal": portal,
"portals": portals,
"dummy": target,
"boss_pos": Vector2.ZERO,
"boss_room": Rect2i(),
@@ -131,7 +146,7 @@ static func generate(seed_value: int, depth: int) -> Dictionary:
"grid": grid,
"rooms": rooms,
"spawn": spawn,
"portal": Vector2.ZERO,
"portals": [] as Array[SimPortal],
"dummy": Vector2.ZERO,
"boss_pos": boss_pos,
"boss_room": boss_room,
+16
View File
@@ -0,0 +1,16 @@
class_name SimPortal
extends RefCounted
## A dungeon entrance standing in the hub.
##
## The hub holds a list of these rather than a single position, because "which
## dungeon does this one open" is the whole reason there is more than one.
var pos := Vector2.ZERO
var dungeon: StringName = &""
static func make(at: Vector2, dungeon_id: StringName) -> SimPortal:
var p := SimPortal.new()
p.pos = at
p.dungeon = dungeon_id
return p
+1
View File
@@ -0,0 +1 @@
uid://fnxk8yj4shad
+23 -7
View File
@@ -33,10 +33,10 @@ var events: Array[Dictionary] = []
## unit test build a world without thinking about terrain.
var map: MapGrid = null
## Set on a lobby world so the interact button can open a dungeon.
var portal_enabled: bool = false
## Where the hub's dungeon portal sits. Per-world now that maps vary in size.
var portal_pos := Vector2.ZERO
## Dungeon entrances standing in this world. Empty everywhere but the hub.
## A list rather than a single position because which dungeon an entrance opens
## is the whole reason there is more than one.
var portals: Array[SimPortal] = []
var spawn_point := Vector2(0.0, 240.0)
## Arrival protection granted to players entering this world. 0 in the hub,
## SimConfig.SPAWN_GRACE_TICKS in a dungeon.
@@ -117,6 +117,20 @@ func spawn_boss(def: BossDef) -> SimBoss:
return b
## The portal [param at] is standing on, or null. Nearest wins, so two
## entrances placed close enough to overlap still resolve to one answer instead
## of to whichever happens to be first in the list.
func portal_at(at: Vector2) -> SimPortal:
var best: SimPortal = null
var best_d := SimConfig.PORTAL_RADIUS * SimConfig.PORTAL_RADIUS
for portal in portals:
var d := at.distance_squared_to(portal.pos)
if d <= best_d:
best_d = d
best = portal
return best
func alive_player_count() -> int:
var n := 0
for p in players.values():
@@ -226,9 +240,11 @@ func _step_players() -> void:
if edge & InputFrame.BTN_INTERACT:
took_item = _try_pickup(p)
if portal_enabled and not took_item and frame.pressed(InputFrame.BTN_INTERACT):
if p.pos.distance_to(portal_pos) <= SimConfig.PORTAL_RADIUS:
events.append({"t": SimEvent.Type.PORTAL_USED, "peer": p.peer_id})
if not took_item and frame.pressed(InputFrame.BTN_INTERACT):
var portal := portal_at(p.pos)
if portal != null:
events.append({"t": SimEvent.Type.PORTAL_USED, "peer": p.peer_id,
"dungeon": String(portal.dungeon)})
## Pull the next input for this player, or coast on the last one. Coasting is
+6 -2
View File
@@ -95,7 +95,11 @@ func _update_respawn_button() -> void:
func _status_text() -> String:
if client == null:
return "connecting..."
var where := "LOBBY" if client.instance_kind == Protocol.InstanceKind.LOBBY else "DUNGEON"
# 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():
@@ -112,7 +116,7 @@ func _hint_text() -> String:
if not client.my_alive:
return "DOWN"
if client.instance_kind == Protocol.InstanceKind.LOBBY:
return "WASD move mouse aim LMB fire E on the ring to enter a dungeon Esc menu F1 hitboxes"
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"
+24 -7
View File
@@ -54,8 +54,7 @@ func _draw() -> void:
if client == null:
return
_draw_terrain()
if client.instance_kind == Protocol.InstanceKind.LOBBY:
_draw_portal()
_draw_portals()
for l in client.ground_loot():
if _visible(l["pos"]):
_draw_loot(l)
@@ -170,12 +169,30 @@ func _visible(at: Vector2) -> bool:
return map.has_line_of_sight(eye, at)
func _draw_portal() -> void:
## One ring per entrance, coloured and labelled by the dungeon it opens. The
## label is not decoration: with two entrances a few metres apart, a player has
## to be able to tell which is the real run and which is the test harness while
## standing between them.
func _draw_portals() -> void:
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.003)
draw_arc(client.portal_pos, SimConfig.PORTAL_RADIUS, 0.0, TAU, 48,
Color(COL_PORTAL, 0.4 + 0.4 * pulse), 3.0)
draw_circle(client.portal_pos, SimConfig.PORTAL_RADIUS * 0.25,
Color(COL_PORTAL, 0.25 + 0.25 * pulse))
for portal in client.portals:
var at: Vector2 = portal["pos"]
var def := Dungeons.get_or_default(portal["dungeon"])
draw_arc(at, SimConfig.PORTAL_RADIUS, 0.0, TAU, 48,
Color(def.tint, 0.4 + 0.4 * pulse), 3.0)
draw_circle(at, SimConfig.PORTAL_RADIUS * 0.25,
Color(def.tint, 0.25 + 0.25 * pulse))
_draw_portal_label(at, def)
func _draw_portal_label(at: Vector2, def: DungeonDef) -> void:
var font := ThemeDB.fallback_font
var top := at - Vector2(0.0, SimConfig.PORTAL_RADIUS + 26.0)
draw_string(font, top - Vector2(110.0, 0.0), def.display_name,
HORIZONTAL_ALIGNMENT_CENTER, 220.0, 16, def.tint)
if not def.subtitle.is_empty():
draw_string(font, top - Vector2(110.0, -16.0), def.subtitle,
HORIZONTAL_ALIGNMENT_CENTER, 220.0, 12, Color(def.tint, 0.65))
## An item on the floor. The server has already decided this player may see it