Add a second dungeon: the Proving Grounds, a test harness you walk into
ci / verify (push) Successful in 47s
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:
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cqert4nr662vl
|
||||
+8
-5
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dw7j78sn8obvw
|
||||
@@ -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.....#",
|
||||
"#.......................................#",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://fnxk8yj4shad
|
||||
+23
-7
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user