Replaces the fixed centred arena with per-world tile geometry, which is the foundation the remaining features sit on. - MapGrid: tile grid with three independent flags -- blocks movement, bullets, sight -- so a pit stops feet but not bullets or eyes, and a barricade stops feet and bullets but not eyes. Circle collision with per-axis sliding, and Bresenham line of sight shared by fog and aggro. - MapGen: rooms and corridors generated from (seed, depth), with hand-authored boss arenas from Rooms stamped in first so a corridor can never carve through a designed fight. Reachability from spawn to boss is asserted over 40 seeds -- "usually connected" is the failure mode that ruins one run in twenty. - Dungeons are populated at creation, per room, instead of gating on waves. You explore and choose your fights; the run ends when the boss dies, not when the map is swept. Boss rooms have no lock, so walking out is always available. - Aggro: enemies need range AND line of sight, so a dungeon stays quiet until engaged and cover actually protects. - Hard fog, scrolling camera, and terrain rendering. Maps are streamed per peer in chunks around that peer's player, and the seed is deliberately NOT sent -- a client holding it could regenerate the whole dungeon, which is a map hack for free. Stream radius (900u) is wider than view radius (460u) because the client predicts movement against walls and simulates bullets that die on them; the accepted cost is a cheater seeing a little further than the fog, never the floor plan. Partial map knowledge means wall deaths must be announced rather than derived. A test caught the subtle half of that: out-of-bounds tiles read as WALL by design, so checking geometry before bounds reported every bullet leaving the map as a wall kill. 128 tests (was 103); check.sh, test.sh and smoke.sh pass. 0.26 ms/tick with 4 players and a boss, ~65x headroom.
This commit is contained in:
@@ -24,7 +24,8 @@ Everything after `--` goes to `GameOpts.parse()`:
|
||||
| `--bot` | Scripted input instead of the keyboard. Implies `--join`. |
|
||||
| `--host`, `--port`, `--name` | Connection details. |
|
||||
| `--autoquit N` | Quit after N physics ticks. |
|
||||
| `--boss-rush` | Server-side: new dungeons open straight onto the boss. |
|
||||
| `--boss-rush` | Server-side: dungeons spawn the boss and no trash. |
|
||||
| `--depth N` | Server-side: depth of new dungeons, which drives map size. |
|
||||
| `--verbose` / `--quiet` | Log level. |
|
||||
|
||||
A change is done when `check.sh`, `test.sh` and — if it touched networking,
|
||||
@@ -60,6 +61,9 @@ and `tests/integration/test_replica_parity.gd` pin this down.
|
||||
| --- | --- |
|
||||
| `src/sim/` | The whole game as plain RefCounted objects. No nodes, no physics server, no rendering. |
|
||||
| `src/sim/patterns/` | Bullet emitters — the authoring surface for every enemy and boss. |
|
||||
| `src/sim/map_grid.gd` | Tile grid: collision, line of sight, chunk streaming. |
|
||||
| `src/sim/map_gen.gd` | Dungeon generation; `build()` is the only entry point. |
|
||||
| `src/content/rooms.gd` | Hand-authored room stamps (hub, boss arenas) as text. |
|
||||
| `src/content/content.gd` | All enemies and bosses, defined in code. Source of truth. |
|
||||
| `src/net/` | Codec, `ServerRuntime`, `ClientRuntime`. |
|
||||
| `src/instances/` | Lobby hub and dungeon runs. |
|
||||
@@ -116,6 +120,13 @@ ticks in milliseconds with no SceneTree.
|
||||
dead zone where the server rejects everything and the client never notices —
|
||||
the ship and the authoritative position separate permanently. Pinned by
|
||||
`tests/unit/test_input_lead.gd`.
|
||||
- **Never send the map, or its seed.** Geometry is streamed per peer in chunks
|
||||
around that peer's player (`ServerRuntime._stream_map`). The seed would let
|
||||
any client regenerate the whole dungeon. `MAP_STREAM_RADIUS` must stay wider
|
||||
than `FOG_VIEW_RADIUS`, or prediction runs on terrain the client lacks.
|
||||
- **Bullet speed must stay under one tile per tick.** Wall collision samples
|
||||
position once per tick, so anything faster tunnels. Pinned by
|
||||
`test_bullet_speeds_stay_below_the_tunnelling_threshold`.
|
||||
- **No contact damage.** Every enemy threatens through bullets only; touching
|
||||
one is harmless. `tests/unit/test_content.gd` enforces that every hostile has
|
||||
an emitter.
|
||||
|
||||
@@ -124,6 +124,40 @@ would break that test, which is the point of having it.
|
||||
The menu says so out loud when you are in a dungeon -- the mechanic only reads
|
||||
as fair if the player knows the cost before clicking.
|
||||
|
||||
## Maps are streamed, never sent
|
||||
|
||||
A dungeon's geometry reaches a client as **chunks around that client's own
|
||||
player**, and nothing else. In particular the server does *not* send the
|
||||
generation seed, even though maps are a pure function of (kind, seed, depth) and
|
||||
sending two integers would be far cheaper: a client holding the seed can
|
||||
regenerate the entire dungeon locally, which is a complete map hack requiring no
|
||||
skill. Streaming bounds what a modified client can know to roughly where its
|
||||
player has physically been.
|
||||
|
||||
`MAP_STREAM_RADIUS` (900u) is deliberately wider than `FOG_VIEW_RADIUS` (460u).
|
||||
The client needs real geometry it cannot see, because it predicts its own
|
||||
movement against walls and simulates bullets that die on them. The accepted
|
||||
trade is that a cheater sees somewhat further than the fog shows -- what they
|
||||
cannot get is the floor plan.
|
||||
|
||||
Two consequences fall out of partial knowledge:
|
||||
|
||||
- **Wall deaths are announced.** A bullet dying to its lifetime or by leaving
|
||||
the map is derivable from the spawn event and the map's dimensions, so it
|
||||
costs nothing. A bullet dying against a *wall* is not derivable by a client
|
||||
that has not been streamed that wall, so the server sends an explicit
|
||||
`BULLET_DESPAWN`. Getting the order of those two checks wrong is easy and was
|
||||
caught by a test: out-of-bounds tiles read as `WALL` by design, so testing
|
||||
geometry before bounds reports every bullet that merely left the map as a
|
||||
wall kill.
|
||||
- **Unknown tiles are treated as empty**, not solid, so an unstreamed region can
|
||||
never wrongly stop a prediction. The stream radius keeps far enough ahead of
|
||||
the player that this never decides anything visible.
|
||||
|
||||
Hard fog is therefore a *rendering* rule, not a secrecy mechanism. The secrecy
|
||||
lives in what the server declines to send: terrain outside the stream radius,
|
||||
and (next stage) actors outside it.
|
||||
|
||||
## No contact damage
|
||||
|
||||
Nothing hurts you by touching it. Every threat is a bullet you can see and
|
||||
|
||||
@@ -28,6 +28,9 @@ enum Move {
|
||||
## Ticks between direction re-evaluations for APPROACH/STRAFE, so enemies read
|
||||
## as deliberate rather than twitchy.
|
||||
@export var retarget_interval: int = 30
|
||||
## How far this enemy notices a player, in world units. It also needs line of
|
||||
## sight -- range alone would mean shooting through walls.
|
||||
@export var aggro_range: float = 420.0
|
||||
## Index the client renderer uses to pick a shape/colour.
|
||||
@export var visual: int = 0
|
||||
@export var emitters: Array[BulletEmitter] = []
|
||||
|
||||
+23
-5
@@ -160,12 +160,24 @@ func send_welcome(peer_id: int) -> void:
|
||||
s_welcome.rpc_id(peer_id, peer_id, Protocol.VERSION)
|
||||
|
||||
|
||||
## Deliberately carries the map's SIZE and not its seed. Handing over the seed
|
||||
## would let any client regenerate the whole dungeon locally, which is a map
|
||||
## 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) -> void:
|
||||
boss_id: String, spawn: Vector2, map_w: int, map_h: int,
|
||||
portal: Vector2) -> void:
|
||||
if _is_local(peer_id):
|
||||
client.on_enter_instance(id, kind, server_tick, boss_id, spawn)
|
||||
client.on_enter_instance(id, kind, server_tick, boss_id, spawn, map_w, map_h, portal)
|
||||
else:
|
||||
s_enter_instance.rpc_id(peer_id, id, kind, server_tick, boss_id, spawn)
|
||||
s_enter_instance.rpc_id(peer_id, id, kind, server_tick, boss_id, spawn,
|
||||
map_w, map_h, portal)
|
||||
|
||||
|
||||
func send_map_chunks(peer_id: int, instance_id: int, data: PackedByteArray) -> void:
|
||||
if _is_local(peer_id):
|
||||
client.on_map_chunks(instance_id, data)
|
||||
else:
|
||||
s_map_chunks.rpc_id(peer_id, instance_id, data)
|
||||
|
||||
|
||||
func send_snapshot(peer_id: int, data: PackedByteArray) -> void:
|
||||
@@ -231,10 +243,16 @@ 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) -> void:
|
||||
spawn: Vector2, map_w: int, map_h: int, portal: Vector2) -> void:
|
||||
if client == null:
|
||||
return
|
||||
client.on_enter_instance(id, kind, server_tick, boss_id, spawn)
|
||||
client.on_enter_instance(id, kind, server_tick, boss_id, spawn, map_w, map_h, portal)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 1)
|
||||
func s_map_chunks(instance_id: int, data: PackedByteArray) -> void:
|
||||
if client != null:
|
||||
client.on_map_chunks(instance_id, data)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 1)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
class_name Rooms
|
||||
extends RefCounted
|
||||
## Hand-authored room stamps, as text.
|
||||
##
|
||||
## Boss arenas are authored rather than generated: a boss fight is a designed
|
||||
## space -- sightlines, cover, room to dodge a wall pattern -- and a generator
|
||||
## that produces "a room" produces a bad one about as often as a good one. The
|
||||
## corridors and filler rooms around them are generated; the fights are not.
|
||||
##
|
||||
## Legend:
|
||||
## # wall o pillar ~ pit = barricade
|
||||
## . floor B boss spawn D doorway (floor, corridors connect here)
|
||||
|
||||
const LEGEND := {
|
||||
"#": MapGrid.Kind.WALL,
|
||||
"o": MapGrid.Kind.PILLAR,
|
||||
"~": MapGrid.Kind.PIT,
|
||||
"=": MapGrid.Kind.BARRICADE,
|
||||
".": MapGrid.Kind.FLOOR,
|
||||
"B": MapGrid.Kind.FLOOR,
|
||||
"D": MapGrid.Kind.FLOOR,
|
||||
"P": MapGrid.Kind.FLOOR,
|
||||
"S": MapGrid.Kind.FLOOR,
|
||||
"T": MapGrid.Kind.FLOOR,
|
||||
}
|
||||
|
||||
## Legend characters that record a position rather than only painting a tile.
|
||||
const MARKERS := ["B", "D", "P", "S", "T"]
|
||||
|
||||
|
||||
## Wide, with pillars to break the Warden's rings and pits that shape where you
|
||||
## can retreat to. The door is deliberately on one wall only: the boss cannot
|
||||
## follow you out, so the exit is a real decision rather than an accident.
|
||||
static func warden_hall() -> PackedStringArray:
|
||||
return PackedStringArray([
|
||||
"#########################",
|
||||
"#.......................#",
|
||||
"#..~~~...........~~~....#",
|
||||
"#..~~~....o.o....~~~....#",
|
||||
"#.........................",
|
||||
"#....o.................o#",
|
||||
"#.............B.........#",
|
||||
"#....o.................o#",
|
||||
"D.........................",
|
||||
"#..~~~....o.o....~~~....#",
|
||||
"#..~~~..........~~~.....#",
|
||||
"#.......................#",
|
||||
"#########################",
|
||||
])
|
||||
|
||||
|
||||
## Tighter and more claustrophobic, with barricades you can see over but not
|
||||
## shoot through -- a room that rewards knowing where the boss is without
|
||||
## giving you a firing lane to it.
|
||||
static func choir_vault() -> PackedStringArray:
|
||||
return PackedStringArray([
|
||||
"#####################",
|
||||
"#...................#",
|
||||
"#..===.......===....#",
|
||||
"#...................#",
|
||||
"#....o.......o......#",
|
||||
"D.........B.........#",
|
||||
"#....o.......o......#",
|
||||
"#...................#",
|
||||
"#..===.......===....#",
|
||||
"#...................#",
|
||||
"#####################",
|
||||
])
|
||||
|
||||
|
||||
static func size_of(stamp: PackedStringArray) -> Vector2i:
|
||||
if stamp.is_empty():
|
||||
return Vector2i.ZERO
|
||||
var w := 0
|
||||
for row in stamp:
|
||||
w = maxi(w, row.length())
|
||||
return Vector2i(w, stamp.size())
|
||||
|
||||
|
||||
## Paint a stamp into [param grid] with its top-left at [param origin].
|
||||
## Returns the marker positions found, keyed by their legend character.
|
||||
static func stamp(grid: MapGrid, s: PackedStringArray, origin: Vector2i) -> Dictionary:
|
||||
var markers := {}
|
||||
for m in MARKERS:
|
||||
markers[m] = []
|
||||
for y in s.size():
|
||||
var row := s[y]
|
||||
for x in row.length():
|
||||
var ch := row[x]
|
||||
if not LEGEND.has(ch):
|
||||
continue
|
||||
var tx := origin.x + x
|
||||
var ty := origin.y + y
|
||||
grid.set_tile(tx, ty, LEGEND[ch])
|
||||
if markers.has(ch):
|
||||
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
|
||||
static func lobby() -> PackedStringArray:
|
||||
return PackedStringArray([
|
||||
"#########################################",
|
||||
"#.......................................#",
|
||||
"#.......................................#",
|
||||
"#.......................................#",
|
||||
"#...................P...................#",
|
||||
"#.......................................#",
|
||||
"#.....o...........................o.....#",
|
||||
"#.......................................#",
|
||||
"#.......................................#",
|
||||
"#.......................................#",
|
||||
"#.........T.............................#",
|
||||
"#.......................................#",
|
||||
"#.......................................#",
|
||||
"#.......................................#",
|
||||
"#.....o...........................o.....#",
|
||||
"#...................S...................#",
|
||||
"#.......................................#",
|
||||
"#.......................................#",
|
||||
"#.......................................#",
|
||||
"#.......................................#",
|
||||
"#########################################",
|
||||
])
|
||||
@@ -0,0 +1 @@
|
||||
uid://cb5nycudm0o6e
|
||||
@@ -27,6 +27,9 @@ static var listen: bool = false
|
||||
## Server-side dev switch: new dungeons open straight onto the boss, skipping
|
||||
## the trash waves. For iterating on a pattern without clearing two waves first.
|
||||
static var boss_rush: bool = false
|
||||
## Dev switch: depth of newly opened dungeons, which drives map size. Depth
|
||||
## progression is a later-stage concern; this makes big maps testable now.
|
||||
static var dungeon_depth: int = 1
|
||||
static var parsed: bool = false
|
||||
|
||||
|
||||
@@ -51,6 +54,10 @@ static func parse(argv: PackedStringArray = PackedStringArray()) -> void:
|
||||
listen = true
|
||||
"--boss-rush":
|
||||
boss_rush = true
|
||||
"--depth":
|
||||
i += 1
|
||||
if i < argv.size():
|
||||
dungeon_depth = int(argv[i])
|
||||
"--port":
|
||||
i += 1
|
||||
if i < argv.size():
|
||||
|
||||
+22
-9
@@ -12,11 +12,15 @@ static func sanitize_move(raw: Vector2) -> Vector2:
|
||||
|
||||
|
||||
## One tick of player locomotion. Returns the new position.
|
||||
static func step_player(pos: Vector2, move: Vector2, speed: float, bounds: Vector2) -> Vector2:
|
||||
var next := pos + sanitize_move(move) * speed * SimConfig.TICK_DELTA
|
||||
next.x = clampf(next.x, -bounds.x, bounds.x)
|
||||
next.y = clampf(next.y, -bounds.y, bounds.y)
|
||||
return next
|
||||
##
|
||||
## Collision resolution lives in [MapGrid.slide_circle], and the client runs
|
||||
## this exact call during prediction and reconciliation -- so the map the client
|
||||
## holds has to match the server's, which is why maps are replicated in full
|
||||
## even where fog hides them.
|
||||
static func step_player(pos: Vector2, move: Vector2, speed: float, map: MapGrid,
|
||||
radius: float = SimConfig.PLAYER_RADIUS) -> Vector2:
|
||||
var delta := sanitize_move(move) * speed * SimConfig.TICK_DELTA
|
||||
return map.slide_circle(pos, delta, radius)
|
||||
|
||||
|
||||
## Circle-vs-circle overlap test used for every hit in the game.
|
||||
@@ -25,7 +29,16 @@ static func circles_overlap(a: Vector2, ar: float, b: Vector2, br: float) -> boo
|
||||
return a.distance_squared_to(b) <= r * r
|
||||
|
||||
|
||||
## True when a point has drifted outside the arena plus the cull margin.
|
||||
static func outside_arena(p: Vector2, margin: float = SimConfig.BULLET_CULL_MARGIN) -> bool:
|
||||
return absf(p.x) > SimConfig.ARENA_HALF.x + margin \
|
||||
or absf(p.y) > SimConfig.ARENA_HALF.y + margin
|
||||
## Outside the map's extent. Derivable on both sides from the map's dimensions
|
||||
## alone, so it needs no announcement even when tiles have not been streamed.
|
||||
static func outside_map(p: Vector2, map: MapGrid) -> bool:
|
||||
if map == null:
|
||||
return false
|
||||
return not map.world_rect().grow(SimConfig.BULLET_CULL_MARGIN).has_point(p)
|
||||
|
||||
|
||||
## Outside the map, or stopped by geometry.
|
||||
static func bullet_stopped(p: Vector2, map: MapGrid) -> bool:
|
||||
if map == null:
|
||||
return false
|
||||
return outside_map(p, map) or map.bullet_blocked(p)
|
||||
|
||||
+17
-3
@@ -13,8 +13,7 @@ const SNAPSHOT_INTERVAL := 3
|
||||
const INTERPOLATION_DELAY_TICKS := 6
|
||||
|
||||
# --- Arena ------------------------------------------------------------------
|
||||
const ARENA_HALF := Vector2(620.0, 340.0)
|
||||
## Bullets are culled once they leave the arena by this margin.
|
||||
## Bullets are culled once they leave the map by this margin.
|
||||
const BULLET_CULL_MARGIN := 64.0
|
||||
|
||||
# --- Player -----------------------------------------------------------------
|
||||
@@ -113,6 +112,21 @@ const DUNGEON_FORMING_TICKS := 300
|
||||
## player through the portal opens a fresh instance.
|
||||
const DUNGEON_CLEARED_EXIT_TICKS := 1800 # 30 seconds
|
||||
|
||||
# --- Fog of war -------------------------------------------------------------
|
||||
## How far a player can see. Comfortably inside MAP_STREAM_RADIUS so the client
|
||||
## always holds the terrain it is about to reveal.
|
||||
const FOG_VIEW_RADIUS := 460.0
|
||||
|
||||
# --- Map streaming ----------------------------------------------------------
|
||||
## How far around a player the server streams map tiles. Comfortably wider than
|
||||
## the viewport so movement prediction and bullet simulation always run against
|
||||
## known geometry, and comfortably narrower than a dungeon so a modified client
|
||||
## still cannot draw the whole thing.
|
||||
const MAP_STREAM_RADIUS := 900.0
|
||||
## Chunks sent to one peer per snapshot tick. Caps the burst when a player
|
||||
## arrives or sprints into virgin territory.
|
||||
const MAP_CHUNKS_PER_TICK := 6
|
||||
|
||||
# --- Portal -----------------------------------------------------------------
|
||||
const PORTAL_POS := Vector2(0.0, -220.0)
|
||||
## Position is per-world now (SimWorld.portal_pos), taken from the hub's map.
|
||||
const PORTAL_RADIUS := 60.0
|
||||
|
||||
+73
-47
@@ -20,11 +20,17 @@ var peers: Array[int] = []
|
||||
var state: State = State.ACTIVE
|
||||
var age: int = 0
|
||||
var seed_value: int = 0
|
||||
## Drives dungeon size and difficulty. The hub is always depth 0.
|
||||
var depth: int = 0
|
||||
|
||||
## Dungeon progression. -1 is the pre-fight breather.
|
||||
var stage: int = -1
|
||||
var stage_delay: int = 0
|
||||
var boss_id: StringName = &""
|
||||
## Generated layout, used to place enemies room by room.
|
||||
var rooms: Array[Rect2i] = []
|
||||
var boss_spawn := Vector2.ZERO
|
||||
var boss_room := Rect2i()
|
||||
|
||||
|
||||
static func make_lobby(instance_id: int) -> Instance:
|
||||
@@ -33,33 +39,88 @@ static func make_lobby(instance_id: int) -> Instance:
|
||||
inst.kind = Protocol.InstanceKind.LOBBY
|
||||
inst.seed_value = 1
|
||||
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.spawn_point = Vector2(0.0, 120.0)
|
||||
inst.world.portal_pos = built["portal"]
|
||||
inst.world.spawn_point = built["spawn"]
|
||||
inst.state = State.ACTIVE
|
||||
# A single inert dummy so players can feel out the gun before committing to
|
||||
# a run. It has no emitters and no contact damage.
|
||||
inst.world.spawn_enemy(Content.dummy(), Vector2(-220.0, -40.0))
|
||||
# A single inert practice target so players can feel out the gun before
|
||||
# committing to a run.
|
||||
inst.world.spawn_enemy(Content.dummy(), built["dummy"])
|
||||
return inst
|
||||
|
||||
|
||||
static func make_dungeon(instance_id: int, dungeon_seed: int) -> Instance:
|
||||
static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int = 1) -> 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.world = SimWorld.new(dungeon_seed)
|
||||
inst.world.spawn_point = Vector2(0.0, 260.0)
|
||||
var built := MapGen.build(Protocol.InstanceKind.DUNGEON, dungeon_seed, inst.depth)
|
||||
inst.world.set_map(built["grid"])
|
||||
inst.rooms = built["rooms"]
|
||||
inst.boss_spawn = built["boss_pos"]
|
||||
inst.boss_room = built["boss_room"]
|
||||
inst.world.spawn_point = built["spawn"]
|
||||
# Arriving into a fight already in progress needs a moment of protection;
|
||||
# arriving in the hub does not.
|
||||
inst.world.spawn_grace_ticks = SimConfig.SPAWN_GRACE_TICKS
|
||||
inst.boss_id = Content.BOSS_WARDEN
|
||||
inst.state = State.FORMING
|
||||
if GameOpts.boss_rush:
|
||||
# Dev switch: the next stage advance lands on the boss.
|
||||
inst.stage = 1
|
||||
inst._populate()
|
||||
return inst
|
||||
|
||||
|
||||
## Enemies are placed once, per room, at generation -- not spawned in waves.
|
||||
## A dungeon you explore has to be populated before you arrive in it: waves
|
||||
## would make the map's contents depend on when you walk in rather than where,
|
||||
## and aggro range would have nothing left to gate.
|
||||
func _populate() -> void:
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = seed_value ^ 0x5eed
|
||||
var boss := world.spawn_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])
|
||||
if GameOpts.boss_rush:
|
||||
return # dev switch: an empty dungeon with just the fight in it
|
||||
for i in rooms.size():
|
||||
# Room 0 holds the entrance; arriving into a fight you cannot see yet
|
||||
# is not a decision, it is an ambush.
|
||||
if i == 0:
|
||||
continue
|
||||
var r: Rect2i = rooms[i]
|
||||
var count := rng.randi_range(1, 3 + depth / 2)
|
||||
for _n in count:
|
||||
var def := _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)
|
||||
if world.map.circle_blocked(at, def.radius):
|
||||
continue
|
||||
world.spawn_enemy(def, at, rng.randi_range(0, 120))
|
||||
|
||||
|
||||
func _pick_enemy(rng: RandomNumberGenerator) -> EnemyDef:
|
||||
var roll := rng.randf()
|
||||
if roll < 0.4:
|
||||
return Content.drifter()
|
||||
if roll < 0.75:
|
||||
return Content.turret()
|
||||
return Content.stalker()
|
||||
|
||||
|
||||
## Tile-space room rect to world space, for confining a boss to its arena.
|
||||
func _room_rect_world(r: Rect2i) -> Rect2:
|
||||
if r.size == Vector2i.ZERO:
|
||||
return Rect2()
|
||||
var lo := world.map.tile_centre(r.position.x + 1, r.position.y + 1)
|
||||
var hi := world.map.tile_centre(r.end.x - 2, r.end.y - 2)
|
||||
return Rect2(lo, hi - lo)
|
||||
|
||||
|
||||
func add_peer(peer_id: int, display_name: String) -> void:
|
||||
if not peers.has(peer_id):
|
||||
peers.append(peer_id)
|
||||
@@ -118,11 +179,9 @@ func step() -> void:
|
||||
_step_dungeon()
|
||||
|
||||
|
||||
## Straight-line progression: two trash waves, then the boss. Waves gate on
|
||||
## "everything dead" rather than a timer so a slow party is never overrun.
|
||||
## The dungeon is fully populated at generation, so all that is left to track
|
||||
## is the party forming and the boss dying.
|
||||
func _step_dungeon() -> void:
|
||||
# The delay has to tick down before the CLEARED check, or a cleared dungeon
|
||||
# would sit on its exit timer forever and never release its party.
|
||||
if stage_delay > 0:
|
||||
stage_delay -= 1
|
||||
return
|
||||
@@ -132,31 +191,13 @@ func _step_dungeon() -> void:
|
||||
if state == State.FORMING:
|
||||
if age >= SimConfig.DUNGEON_FORMING_TICKS or peers.size() >= SimConfig.DUNGEON_PARTY_MAX:
|
||||
state = State.ACTIVE
|
||||
# stage is left as make_dungeon set it -- resetting it here would
|
||||
# silently undo the --boss-rush dev switch.
|
||||
stage_delay = 90
|
||||
return
|
||||
|
||||
if stage >= 0 and _live_enemy_count() > 0:
|
||||
return
|
||||
if stage == 2:
|
||||
if world.boss != null and world.boss.alive:
|
||||
return
|
||||
if world.boss != null and not world.boss.alive:
|
||||
state = State.CLEARED
|
||||
stage_delay = SimConfig.DUNGEON_CLEARED_EXIT_TICKS
|
||||
GameLog.info("instance", "instance %d CLEARED, returning party in %ds" % [
|
||||
id, SimConfig.DUNGEON_CLEARED_EXIT_TICKS / SimConfig.TICK_RATE])
|
||||
return
|
||||
|
||||
stage += 1
|
||||
stage_delay = 60
|
||||
match stage:
|
||||
0: _spawn_wave_one()
|
||||
1: _spawn_wave_two()
|
||||
2:
|
||||
world.spawn_boss(Content.boss(boss_id))
|
||||
GameLog.info("instance", "BOSS_SPAWNED %s in instance %d" % [boss_id, id])
|
||||
GameLog.debug("instance", "instance %d entered stage %d" % [id, stage])
|
||||
|
||||
|
||||
func _live_enemy_count() -> int:
|
||||
@@ -165,18 +206,3 @@ func _live_enemy_count() -> int:
|
||||
if e.alive:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
func _spawn_wave_one() -> void:
|
||||
for i in 3:
|
||||
world.spawn_enemy(Content.drifter(), Vector2(-320.0 + 320.0 * float(i), -120.0), i * 40)
|
||||
world.spawn_enemy(Content.turret(), Vector2(-420.0, -220.0), 0)
|
||||
world.spawn_enemy(Content.turret(), Vector2(420.0, -220.0), 75)
|
||||
|
||||
|
||||
func _spawn_wave_two() -> void:
|
||||
for i in 4:
|
||||
world.spawn_enemy(Content.stalker(), Vector2(-300.0 + 200.0 * float(i), -260.0), i * 15)
|
||||
for i in 3:
|
||||
world.spawn_enemy(Content.turret(), Vector2(-380.0 + 380.0 * float(i), -60.0), i * 50)
|
||||
world.spawn_enemy(Content.drifter(), Vector2(0.0, -300.0), 20)
|
||||
|
||||
@@ -54,6 +54,8 @@ var roster: Array[Dictionary] = []
|
||||
## 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
|
||||
|
||||
## Backstop for input-numbering drift: if the server stops acknowledging new
|
||||
## inputs, our tick numbering has fallen outside its acceptance window and no
|
||||
@@ -93,7 +95,7 @@ func _physics_process(delta: float) -> void:
|
||||
|
||||
if my_alive:
|
||||
predicted_pos = Movement.step_player(predicted_pos, frame.move,
|
||||
SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
|
||||
SimConfig.PLAYER_SPEED, world.map)
|
||||
|
||||
# Send the last few frames every tick. Inputs are unreliable-ordered, so the
|
||||
# redundancy is what covers a dropped packet without a retransmit stall.
|
||||
@@ -160,7 +162,7 @@ func _bot_input() -> InputFrame:
|
||||
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 = (SimConfig.PORTAL_POS - predicted_pos).normalized()
|
||||
move = (portal_pos - predicted_pos).normalized()
|
||||
if instance_kind == Protocol.InstanceKind.DUNGEON and _bot_tick > 900:
|
||||
buttons |= InputFrame.BTN_ESCAPE
|
||||
return InputFrame.make(input_tick, move, aim, buttons)
|
||||
@@ -185,16 +187,31 @@ func _resync_input_tick(server_tick: int, why: String) -> void:
|
||||
GameLog.warn("client", "input re-sync: %s" % why)
|
||||
|
||||
|
||||
func on_map_chunks(from_instance: int, data: PackedByteArray) -> void:
|
||||
# A chunk still in flight when we changed instances describes the wrong map.
|
||||
if from_instance != instance_id or world.map == null:
|
||||
return
|
||||
NetCodec.decode_map_chunks_into(world.map, data)
|
||||
|
||||
|
||||
func on_roster(data: PackedByteArray) -> void:
|
||||
roster = NetCodec.decode_roster(data)
|
||||
hud_dirty.emit()
|
||||
|
||||
|
||||
func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
|
||||
spawn: Vector2) -> void:
|
||||
spawn: Vector2, map_w: int, map_h: int, portal: Vector2) -> 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
|
||||
# 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
|
||||
# from. Origin matches the server's centre_on_origin().
|
||||
var blank := MapGrid.new(maxi(map_w, 1), maxi(map_h, 1), MapGrid.Kind.UNKNOWN)
|
||||
blank.centre_on_origin()
|
||||
world.set_map(blank)
|
||||
world.pool.clear()
|
||||
snap_prev = {}
|
||||
snap_curr = {}
|
||||
@@ -281,7 +298,7 @@ func _reconcile(rec: Dictionary) -> void:
|
||||
var p: Vector2 = rec["pos"]
|
||||
if my_alive:
|
||||
for f in pending:
|
||||
p = Movement.step_player(p, f.move, SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
|
||||
p = Movement.step_player(p, f.move, SimConfig.PLAYER_SPEED, world.map)
|
||||
var error := predicted_pos.distance_to(p)
|
||||
if error > 24.0:
|
||||
predicted_pos = p # real divergence: take the server's word
|
||||
|
||||
@@ -290,3 +290,39 @@ static func decode_roster(data: PackedByteArray) -> Array[Dictionary]:
|
||||
"alive": b.get_u8() == 1,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# --- Map chunks -------------------------------------------------------------
|
||||
|
||||
static func encode_map_chunks(map: MapGrid, ids: Array) -> PackedByteArray:
|
||||
var b := StreamPeerBuffer.new()
|
||||
b.big_endian = false
|
||||
b.put_u8(mini(ids.size(), 255))
|
||||
for id in ids:
|
||||
b.put_u16(int(id))
|
||||
b.put_data(map.encode_chunk(int(id)))
|
||||
return b.data_array
|
||||
|
||||
|
||||
## Applies straight into [param map]. Chunk sizes come from the map's own
|
||||
## dimensions, which the client learned at enter_instance, so a truncated or
|
||||
## hostile packet cannot make it read past the end.
|
||||
static func decode_map_chunks_into(map: MapGrid, data: PackedByteArray) -> int:
|
||||
if data.size() < 1:
|
||||
return 0
|
||||
var b := StreamPeerBuffer.new()
|
||||
b.big_endian = false
|
||||
b.data_array = data
|
||||
var count := b.get_u8()
|
||||
var applied := 0
|
||||
for _i in count:
|
||||
if b.get_available_bytes() < 2:
|
||||
break
|
||||
var id := b.get_u16()
|
||||
var r := map.chunk_rect(id)
|
||||
var n := r.size.x * r.size.y
|
||||
if n <= 0 or b.get_available_bytes() < n:
|
||||
break
|
||||
map.apply_chunk(id, b.get_data(n)[1])
|
||||
applied += 1
|
||||
return applied
|
||||
|
||||
@@ -11,6 +11,9 @@ extends Node
|
||||
var instances: Dictionary[int, Instance] = {}
|
||||
var peer_instance: Dictionary[int, int] = {}
|
||||
var peer_names: Dictionary[int, String] = {}
|
||||
## Map chunks each peer has been sent, per peer. Reset on every instance
|
||||
## transfer -- knowledge of one dungeon must not carry into the next.
|
||||
var peer_chunks: Dictionary[int, Dictionary] = {}
|
||||
var lobby: Instance
|
||||
|
||||
var _next_instance_id: int = SimConfig.LOBBY_INSTANCE_ID
|
||||
@@ -41,6 +44,7 @@ func _physics_process(_delta: float) -> void:
|
||||
var snap := NetCodec.encode_snapshot(inst.world, inst.exit_countdown_seconds())
|
||||
for peer in inst.peers:
|
||||
Net.send_snapshot(peer, snap)
|
||||
_stream_map(peer, inst)
|
||||
if inst.kind != Protocol.InstanceKind.DUNGEON:
|
||||
continue
|
||||
if inst.state == Instance.State.CLEARED and inst.stage_delay <= 0:
|
||||
@@ -120,6 +124,7 @@ func on_peer_disconnected(peer_id: int) -> void:
|
||||
func _forget_peer(peer_id: int) -> void:
|
||||
peer_instance.erase(peer_id)
|
||||
peer_names.erase(peer_id)
|
||||
peer_chunks.erase(peer_id)
|
||||
_broadcast_roster()
|
||||
|
||||
|
||||
@@ -169,8 +174,14 @@ func instance_of(peer_id: int) -> Instance:
|
||||
func _place(peer_id: int, inst: Instance) -> void:
|
||||
inst.add_peer(peer_id, peer_names.get(peer_id, "player"))
|
||||
peer_instance[peer_id] = inst.id
|
||||
# Size only: the seed stays server-side, or a client could rebuild the map.
|
||||
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)
|
||||
String(inst.boss_id), inst.world.spawn_point,
|
||||
inst.world.map.width, inst.world.map.height, inst.world.portal_pos)
|
||||
# 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)
|
||||
# A player arriving mid-fight has no idea what is already in the air, so
|
||||
# replay the live bullets as spawn events before the next snapshot lands.
|
||||
var backlog := _live_bullet_events(inst.world)
|
||||
@@ -209,7 +220,7 @@ func _send_to_dungeon(peer_id: int) -> void:
|
||||
target = inst
|
||||
break
|
||||
if target == null:
|
||||
target = Instance.make_dungeon(_take_instance_id(), randi())
|
||||
target = Instance.make_dungeon(_take_instance_id(), randi(), GameOpts.dungeon_depth)
|
||||
instances[target.id] = target
|
||||
GameLog.info("server", "opened dungeon instance %d" % target.id)
|
||||
_transfer(peer_id, target)
|
||||
@@ -225,6 +236,33 @@ func _close_dungeon(id: int) -> void:
|
||||
GameLog.info("server", "closed dungeon instance %d" % id)
|
||||
|
||||
|
||||
## Send this peer any map chunks near its player that it has not been given yet.
|
||||
##
|
||||
## This is the anti-map-hack boundary: a client learns terrain by standing near
|
||||
## it and never any other way. The radius is generous -- wider than the fog, so
|
||||
## prediction and bullet simulation always run on known ground -- but it is
|
||||
## still a small fraction of a dungeon, so the worst a modified client gets is
|
||||
## a slightly wider view, not the floor plan.
|
||||
func _stream_map(peer_id: int, inst: Instance) -> void:
|
||||
var p: SimPlayer = inst.world.players.get(peer_id)
|
||||
if p == null:
|
||||
return
|
||||
var known: Dictionary = peer_chunks.get(peer_id, {})
|
||||
var wanted := inst.world.map.chunks_near(p.pos, SimConfig.MAP_STREAM_RADIUS)
|
||||
var batch: Array[int] = []
|
||||
for id in wanted:
|
||||
if known.has(id):
|
||||
continue
|
||||
known[id] = true
|
||||
batch.append(id)
|
||||
if batch.size() >= SimConfig.MAP_CHUNKS_PER_TICK:
|
||||
break
|
||||
peer_chunks[peer_id] = known
|
||||
if not batch.is_empty():
|
||||
Net.send_map_chunks(peer_id, inst.id,
|
||||
NetCodec.encode_map_chunks(inst.world.map, batch))
|
||||
|
||||
|
||||
# --- Roster -----------------------------------------------------------------
|
||||
|
||||
## Tell everyone who is online and where they are, so the hub can show that a
|
||||
|
||||
+26
-2
@@ -20,6 +20,10 @@ var team := PackedByteArray()
|
||||
var kind := PackedByteArray()
|
||||
var alive := PackedByteArray()
|
||||
|
||||
## The geometry bullets die against. Set by the owning SimWorld; identical on
|
||||
## server and client, which is what keeps the replica in step.
|
||||
var map: MapGrid = null
|
||||
|
||||
var live_count := 0
|
||||
## Exclusive upper bound over slots that have ever been used, so the hot loops
|
||||
## do not walk the whole 4096-slot table while the arena is nearly empty.
|
||||
@@ -33,6 +37,13 @@ var _next_uid := 1
|
||||
## arrive from the wire already.
|
||||
var spawn_log := PackedInt32Array()
|
||||
|
||||
## uids of bullets killed by hitting solid geometry this tick. Lifetime and
|
||||
## out-of-bounds deaths are NOT recorded: both sides can derive those from the
|
||||
## spawn event alone. A wall death cannot be derived by a client that has not
|
||||
## been streamed that wall yet, so the server announces those explicitly --
|
||||
## otherwise partial map knowledge shows bullets sailing through walls.
|
||||
var wall_kill_log := PackedInt32Array()
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
var n := SimConfig.MAX_BULLETS
|
||||
@@ -55,6 +66,7 @@ func clear() -> void:
|
||||
uid[i] = 0
|
||||
_free.clear()
|
||||
spawn_log.clear()
|
||||
wall_kill_log.clear()
|
||||
live_count = 0
|
||||
high_water = 0
|
||||
|
||||
@@ -97,6 +109,7 @@ func spawn(p: Vector2, v: Vector2, r: float, lifetime: int, dmg: int,
|
||||
|
||||
func clear_spawn_log() -> void:
|
||||
spawn_log.clear()
|
||||
wall_kill_log.clear()
|
||||
|
||||
|
||||
func despawn(slot: int) -> void:
|
||||
@@ -134,7 +147,17 @@ func step() -> void:
|
||||
var p: Vector2 = pos[i] + v * dt
|
||||
pos[i] = p
|
||||
life[i] -= 1
|
||||
if life[i] <= 0 or Movement.outside_arena(p):
|
||||
if life[i] <= 0:
|
||||
despawn(i)
|
||||
continue
|
||||
# Bounds first: out-of-bounds tiles read as WALL by design (that is what
|
||||
# seals the world), so testing geometry first would report every bullet
|
||||
# that simply left the map as a wall kill and announce it needlessly.
|
||||
if Movement.outside_map(p, map):
|
||||
despawn(i)
|
||||
continue
|
||||
if map != null and map.bullet_blocked(p):
|
||||
wall_kill_log.append(uid[i])
|
||||
despawn(i)
|
||||
|
||||
|
||||
@@ -156,6 +179,7 @@ func advance_slot(slot: int, ticks: int) -> void:
|
||||
vel[slot] = v
|
||||
pos[slot] = pos[slot] + v * dt
|
||||
life[slot] -= 1
|
||||
if life[slot] <= 0 or Movement.outside_arena(pos[slot]):
|
||||
if life[slot] <= 0 or Movement.bullet_stopped(pos[slot], map):
|
||||
despawn(slot)
|
||||
return
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
class_name MapGen
|
||||
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.
|
||||
|
||||
## The single entry point both server and client use.
|
||||
##
|
||||
## 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.
|
||||
static func build(kind: Protocol.InstanceKind, seed_value: int, depth: int) -> Dictionary:
|
||||
if kind == Protocol.InstanceKind.LOBBY:
|
||||
return _build_lobby()
|
||||
return generate(seed_value, depth)
|
||||
|
||||
|
||||
static func _build_lobby() -> Dictionary:
|
||||
var stamp := Rooms.lobby()
|
||||
var size := Rooms.size_of(stamp)
|
||||
var grid := MapGrid.new(size.x, size.y, MapGrid.Kind.WALL)
|
||||
grid.centre_on_origin()
|
||||
var markers := Rooms.stamp(grid, stamp, Vector2i.ZERO)
|
||||
var spawn := grid.tile_centre(size.x / 2, size.y - 4)
|
||||
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)
|
||||
var target := grid.tile_centre(size.x / 4, size.y / 2)
|
||||
if not markers["T"].is_empty():
|
||||
var m: Vector2i = markers["T"][0]
|
||||
target = grid.tile_centre(m.x, m.y)
|
||||
return {
|
||||
"grid": grid,
|
||||
"rooms": [] as Array[Rect2i],
|
||||
"spawn": spawn,
|
||||
"portal": portal,
|
||||
"dummy": target,
|
||||
"boss_pos": Vector2.ZERO,
|
||||
"boss_room": Rect2i(),
|
||||
}
|
||||
|
||||
|
||||
## Result keys: grid, rooms (Array[Rect2i]), spawn (Vector2), boss_pos
|
||||
## (Vector2), boss_room (Rect2i).
|
||||
static func generate(seed_value: int, depth: int) -> Dictionary:
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = seed_value
|
||||
var d := maxi(depth, 1)
|
||||
|
||||
# Size grows with depth. Depth 1 is a little under two screens across --
|
||||
# enough that fog and exploration mean something without a first run
|
||||
# becoming a hike.
|
||||
var w := 56 + d * 10
|
||||
var h := 36 + d * 7
|
||||
var grid := MapGrid.new(w, h, MapGrid.Kind.WALL)
|
||||
grid.centre_on_origin()
|
||||
|
||||
# The boss arena is placed first and everything else works around it, so a
|
||||
# generated corridor can never carve through the authored fight.
|
||||
var stamp := Rooms.warden_hall() if d % 2 == 1 else Rooms.choir_vault()
|
||||
var bs := Rooms.size_of(stamp)
|
||||
var boss_origin := Vector2i(w - bs.x - 2, (h - bs.y) / 2)
|
||||
var markers := Rooms.stamp(grid, stamp, boss_origin)
|
||||
var boss_room := Rect2i(boss_origin, bs)
|
||||
|
||||
var boss_pos := grid.tile_centre(
|
||||
boss_origin.x + bs.x / 2, boss_origin.y + bs.y / 2)
|
||||
if not markers["B"].is_empty():
|
||||
var m: Vector2i = markers["B"][0]
|
||||
boss_pos = grid.tile_centre(m.x, m.y)
|
||||
|
||||
var door := Vector2i(boss_origin.x, boss_origin.y + bs.y / 2)
|
||||
if not markers["D"].is_empty():
|
||||
door = markers["D"][0]
|
||||
|
||||
# Generated rooms, confined to the space left of the arena.
|
||||
var rooms: Array[Rect2i] = []
|
||||
var usable_w := boss_origin.x - 2
|
||||
var attempts := 0
|
||||
var wanted := 5 + d
|
||||
while rooms.size() < wanted and attempts < wanted * 40:
|
||||
attempts += 1
|
||||
var rw := rng.randi_range(6, 12)
|
||||
var rh := rng.randi_range(5, 10)
|
||||
var rx := rng.randi_range(1, maxi(usable_w - rw - 1, 2))
|
||||
var ry := rng.randi_range(1, maxi(h - rh - 2, 2))
|
||||
var candidate := Rect2i(rx, ry, rw, rh)
|
||||
# One tile of padding so two rooms never share a wall and merge into an
|
||||
# ambiguous blob.
|
||||
var padded := candidate.grow(1)
|
||||
var clash := false
|
||||
for existing in rooms:
|
||||
if padded.intersects(existing.grow(1)):
|
||||
clash = true
|
||||
break
|
||||
if clash:
|
||||
continue
|
||||
rooms.append(candidate)
|
||||
grid.fill_rect(candidate, MapGrid.Kind.FLOOR)
|
||||
|
||||
# Connect each room to the previous one, then the last to the arena door,
|
||||
# so every room is reachable by construction rather than by luck.
|
||||
for i in range(1, rooms.size()):
|
||||
_carve_corridor(grid, _centre_of(rooms[i - 1]), _centre_of(rooms[i]))
|
||||
if not rooms.is_empty():
|
||||
_carve_corridor(grid, _centre_of(rooms[rooms.size() - 1]), door)
|
||||
# The door tile itself, and the tile outside it, must be floor.
|
||||
grid.set_tile(door.x, door.y, MapGrid.Kind.FLOOR)
|
||||
grid.set_tile(door.x - 1, door.y, MapGrid.Kind.FLOOR)
|
||||
|
||||
_scatter_cover(grid, rooms, rng)
|
||||
|
||||
var spawn := boss_pos
|
||||
if not rooms.is_empty():
|
||||
var c := _centre_of(rooms[0])
|
||||
# Cover is scattered before the spawn is chosen, so the spawn tile can
|
||||
# have had a pillar dropped on it. Clear it rather than hunting for a
|
||||
# free tile: this is the one tile in the map that must be standable.
|
||||
grid.set_tile(c.x, c.y, MapGrid.Kind.FLOOR)
|
||||
spawn = grid.tile_centre(c.x, c.y)
|
||||
|
||||
return {
|
||||
"grid": grid,
|
||||
"rooms": rooms,
|
||||
"spawn": spawn,
|
||||
"portal": Vector2.ZERO,
|
||||
"dummy": Vector2.ZERO,
|
||||
"boss_pos": boss_pos,
|
||||
"boss_room": boss_room,
|
||||
}
|
||||
|
||||
|
||||
static func _centre_of(r: Rect2i) -> Vector2i:
|
||||
return Vector2i(r.position.x + r.size.x / 2, r.position.y + r.size.y / 2)
|
||||
|
||||
|
||||
## L-shaped, with the corner order chosen by parity so corridors do not all
|
||||
## bend the same way.
|
||||
static func _carve_corridor(grid: MapGrid, from: Vector2i, to: Vector2i) -> void:
|
||||
if (from.x + from.y) % 2 == 0:
|
||||
_carve_h(grid, from.x, to.x, from.y)
|
||||
_carve_v(grid, from.y, to.y, to.x)
|
||||
else:
|
||||
_carve_v(grid, from.y, to.y, from.x)
|
||||
_carve_h(grid, from.x, to.x, to.y)
|
||||
|
||||
|
||||
static func _carve_h(grid: MapGrid, x0: int, x1: int, y: int) -> void:
|
||||
for x in range(mini(x0, x1), maxi(x0, x1) + 1):
|
||||
grid.set_tile(x, y, MapGrid.Kind.FLOOR)
|
||||
|
||||
|
||||
static func _carve_v(grid: MapGrid, y0: int, y1: int, x: int) -> void:
|
||||
for y in range(mini(y0, y1), maxi(y0, y1) + 1):
|
||||
grid.set_tile(x, y, MapGrid.Kind.FLOOR)
|
||||
|
||||
|
||||
## A few pillars and pits inside generated rooms, so open rooms still have
|
||||
## something to fight around. Never placed on a room's edge, where they could
|
||||
## seal a corridor mouth.
|
||||
static func _scatter_cover(grid: MapGrid, rooms: Array[Rect2i], rng: RandomNumberGenerator) -> void:
|
||||
for r in rooms:
|
||||
if r.size.x < 7 or r.size.y < 6:
|
||||
continue
|
||||
var centre := _centre_of(r)
|
||||
var count := rng.randi_range(1, 3)
|
||||
for _i in count:
|
||||
var tx := rng.randi_range(r.position.x + 2, r.end.x - 3)
|
||||
var ty := rng.randi_range(r.position.y + 2, r.end.y - 3)
|
||||
# Room centres are where corridors meet and where the spawn goes;
|
||||
# blocking one can pinch a junction shut.
|
||||
if tx == centre.x and ty == centre.y:
|
||||
continue
|
||||
var kind := MapGrid.Kind.PILLAR if rng.randf() < 0.6 else MapGrid.Kind.PIT
|
||||
grid.set_tile(tx, ty, kind)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dfj588h3drql3
|
||||
@@ -0,0 +1,280 @@
|
||||
class_name MapGrid
|
||||
extends RefCounted
|
||||
## The static geometry of one world: a tile grid with per-tile movement, bullet
|
||||
## and sight blocking.
|
||||
##
|
||||
## A grid rather than freeform shapes because three separate systems need to ask
|
||||
## spatial questions cheaply and identically on both server and client -- circle
|
||||
## collision, bullet collision, and line of sight for fog and interest
|
||||
## management. On a grid all three are array lookups; on polygons they are
|
||||
## intersection tests, and the fog algorithm in particular stops being tractable.
|
||||
##
|
||||
## Coordinates: the grid's top-left tile is world (0, 0), and world space runs
|
||||
## to (width * TILE, height * TILE). The old centre-origin arena is gone -- with
|
||||
## maps of varying size there is no meaningful centre to anchor to.
|
||||
|
||||
const TILE := 32.0
|
||||
|
||||
enum Kind {
|
||||
FLOOR,
|
||||
## Full-height: stops movement, bullets and sight.
|
||||
WALL,
|
||||
## Same as WALL, drawn differently. Kept distinct so generators can place
|
||||
## cover without it reading as a room boundary.
|
||||
PILLAR,
|
||||
## Cross it with a bullet or your eyes, but not with your feet.
|
||||
PIT,
|
||||
## Chest height: blocks movement and bullets, but you can see over it.
|
||||
BARRICADE,
|
||||
## Not yet streamed to this client. Only ever appears in a client's copy --
|
||||
## a server map is fully known by construction. Treated as empty so an
|
||||
## un-streamed region cannot wrongly stop a prediction; the stream radius is
|
||||
## kept well ahead of the player so this never decides anything visible.
|
||||
UNKNOWN,
|
||||
}
|
||||
|
||||
## Tiles per chunk edge. Small enough that a player near one corner of a map
|
||||
## learns a small fraction of it, which is the entire point of streaming rather
|
||||
## than sending the map (or its seed) up front.
|
||||
const CHUNK := 8
|
||||
|
||||
## Parallel flag tables, indexed by Kind. Three independent booleans rather than
|
||||
## one "solid" flag, because the interesting tiles are exactly the ones that
|
||||
## block some things and not others.
|
||||
const BLOCKS_MOVE := [false, true, true, true, true, false]
|
||||
const BLOCKS_BULLET := [false, true, true, false, true, false]
|
||||
const BLOCKS_SIGHT := [false, true, true, false, false, false]
|
||||
|
||||
var width: int = 0
|
||||
var height: int = 0
|
||||
## Row-major, width * height entries of Kind.
|
||||
var tiles := PackedByteArray()
|
||||
## World position of tile (0, 0)'s top-left corner. Generated maps set this to
|
||||
## -world_size()/2 so the world stays centred on the origin, which keeps every
|
||||
## existing coordinate (spawn points, portal, boss placement) meaningful and
|
||||
## avoids an all-positive coordinate space where "0" is a corner.
|
||||
var origin := Vector2.ZERO
|
||||
|
||||
|
||||
func _init(w: int = 1, h: int = 1, fill: Kind = Kind.WALL) -> void:
|
||||
resize(w, h, fill)
|
||||
|
||||
|
||||
func resize(w: int, h: int, fill: Kind = Kind.WALL) -> void:
|
||||
width = maxi(w, 1)
|
||||
height = maxi(h, 1)
|
||||
tiles.resize(width * height)
|
||||
tiles.fill(fill)
|
||||
|
||||
|
||||
func in_bounds(tx: int, ty: int) -> bool:
|
||||
return tx >= 0 and ty >= 0 and tx < width and ty < height
|
||||
|
||||
|
||||
## Out-of-bounds reads as WALL so callers never have to bounds-check before
|
||||
## asking; the world is sealed by construction.
|
||||
func at(tx: int, ty: int) -> Kind:
|
||||
if not in_bounds(tx, ty):
|
||||
return Kind.WALL
|
||||
return tiles[ty * width + tx] as Kind
|
||||
|
||||
|
||||
func set_tile(tx: int, ty: int, kind: Kind) -> void:
|
||||
if in_bounds(tx, ty):
|
||||
tiles[ty * width + tx] = kind
|
||||
|
||||
|
||||
func fill_rect(rect: Rect2i, kind: Kind) -> void:
|
||||
for ty in range(rect.position.y, rect.end.y):
|
||||
for tx in range(rect.position.x, rect.end.x):
|
||||
set_tile(tx, ty, kind)
|
||||
|
||||
|
||||
# --- Space conversion -------------------------------------------------------
|
||||
|
||||
func world_size() -> Vector2:
|
||||
return Vector2(float(width), float(height)) * TILE
|
||||
|
||||
|
||||
## Centre of a tile, which is what actors are placed on.
|
||||
func tile_centre(tx: int, ty: int) -> Vector2:
|
||||
return origin + Vector2(float(tx) + 0.5, float(ty) + 0.5) * TILE
|
||||
|
||||
|
||||
func to_tile(world: Vector2) -> Vector2i:
|
||||
var local := world - origin
|
||||
# floor(), never int(): truncation folds -0.5 onto tile 0 and would let an
|
||||
# actor stand half a tile outside the map.
|
||||
return Vector2i(int(floor(local.x / TILE)), int(floor(local.y / TILE)))
|
||||
|
||||
|
||||
## Centre the map on the world origin.
|
||||
func centre_on_origin() -> void:
|
||||
origin = -world_size() * 0.5
|
||||
|
||||
|
||||
## World-space rectangle the map occupies.
|
||||
func world_rect() -> Rect2:
|
||||
return Rect2(origin, world_size())
|
||||
|
||||
|
||||
# --- Queries ----------------------------------------------------------------
|
||||
|
||||
func blocks_move(tx: int, ty: int) -> bool:
|
||||
return BLOCKS_MOVE[at(tx, ty)]
|
||||
|
||||
|
||||
func blocks_bullet(tx: int, ty: int) -> bool:
|
||||
return BLOCKS_BULLET[at(tx, ty)]
|
||||
|
||||
|
||||
func blocks_sight(tx: int, ty: int) -> bool:
|
||||
return BLOCKS_SIGHT[at(tx, ty)]
|
||||
|
||||
|
||||
## True when a bullet at this world point should die. Bullets are small enough
|
||||
## that a point test against the tile they are in is indistinguishable from a
|
||||
## circle test, and it keeps server and client trivially identical.
|
||||
func bullet_blocked(world: Vector2) -> bool:
|
||||
var t := to_tile(world)
|
||||
return blocks_bullet(t.x, t.y)
|
||||
|
||||
|
||||
## Circle-vs-grid overlap for actor collision.
|
||||
func circle_blocked(centre: Vector2, radius: float) -> bool:
|
||||
var lo := to_tile(centre - Vector2(radius, radius))
|
||||
var hi := to_tile(centre + Vector2(radius, radius))
|
||||
for ty in range(lo.y, hi.y + 1):
|
||||
for tx in range(lo.x, hi.x + 1):
|
||||
if not blocks_move(tx, ty):
|
||||
continue
|
||||
if _circle_hits_tile(centre, radius, tx, ty):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _circle_hits_tile(centre: Vector2, radius: float, tx: int, ty: int) -> bool:
|
||||
# Closest point on the tile's AABB to the circle centre.
|
||||
var lo := origin + Vector2(float(tx), float(ty)) * TILE
|
||||
var closest := Vector2(
|
||||
clampf(centre.x, lo.x, lo.x + TILE),
|
||||
clampf(centre.y, lo.y, lo.y + TILE))
|
||||
return centre.distance_squared_to(closest) < radius * radius
|
||||
|
||||
|
||||
## Move a circle by [param delta], resolving each axis separately so that
|
||||
## running into a wall at an angle slides along it instead of stopping dead.
|
||||
func slide_circle(pos: Vector2, delta: Vector2, radius: float) -> Vector2:
|
||||
var out := pos
|
||||
var try_x := Vector2(out.x + delta.x, out.y)
|
||||
if not circle_blocked(try_x, radius):
|
||||
out = try_x
|
||||
var try_y := Vector2(out.x, out.y + delta.y)
|
||||
if not circle_blocked(try_y, radius):
|
||||
out = try_y
|
||||
return out
|
||||
|
||||
|
||||
## Bresenham-style sight test between two world points. Used for fog on the
|
||||
## client and for aggro on the server, so it has to agree on both.
|
||||
func has_line_of_sight(from: Vector2, to: Vector2) -> bool:
|
||||
var a := to_tile(from)
|
||||
var b := to_tile(to)
|
||||
var dx := absi(b.x - a.x)
|
||||
var dy := -absi(b.y - a.y)
|
||||
var sx := 1 if a.x < b.x else -1
|
||||
var sy := 1 if a.y < b.y else -1
|
||||
var err := dx + dy
|
||||
var x := a.x
|
||||
var y := a.y
|
||||
# Guard against a pathological ray in a huge map costing unbounded time.
|
||||
var steps := 0
|
||||
var limit := width + height + 4
|
||||
while steps < limit:
|
||||
steps += 1
|
||||
if x == b.x and y == b.y:
|
||||
return true
|
||||
# The endpoints themselves never block: standing in a doorway, or
|
||||
# shooting at something embedded in a wall, must still resolve.
|
||||
if not (x == a.x and y == a.y) and blocks_sight(x, y):
|
||||
return false
|
||||
var e2 := 2 * err
|
||||
if e2 >= dy:
|
||||
err += dy
|
||||
x += sx
|
||||
if e2 <= dx:
|
||||
err += dx
|
||||
y += sy
|
||||
return false
|
||||
|
||||
|
||||
# --- Chunked streaming ------------------------------------------------------
|
||||
# The server never sends a whole map, and never sends the seed it was generated
|
||||
# from: either would let a modified client draw the entire dungeon. Tiles are
|
||||
# streamed per peer in chunks around where that player actually is, so a map
|
||||
# hack can reveal a little more than the fog shows and no more.
|
||||
|
||||
func chunks_wide() -> int:
|
||||
return int(ceil(float(width) / float(CHUNK)))
|
||||
|
||||
|
||||
func chunks_high() -> int:
|
||||
return int(ceil(float(height) / float(CHUNK)))
|
||||
|
||||
|
||||
func chunk_count() -> int:
|
||||
return chunks_wide() * chunks_high()
|
||||
|
||||
|
||||
func chunk_id_at(tx: int, ty: int) -> int:
|
||||
return (ty / CHUNK) * chunks_wide() + (tx / CHUNK)
|
||||
|
||||
|
||||
## Tile-space rect a chunk covers, clipped to the map.
|
||||
func chunk_rect(chunk_id: int) -> Rect2i:
|
||||
var cw := chunks_wide()
|
||||
if cw <= 0:
|
||||
return Rect2i()
|
||||
var cx := (chunk_id % cw) * CHUNK
|
||||
var cy := (chunk_id / cw) * CHUNK
|
||||
return Rect2i(cx, cy, mini(CHUNK, width - cx), mini(CHUNK, height - cy))
|
||||
|
||||
|
||||
## Chunk ids whose tiles fall within [param radius] world units of [param at].
|
||||
func chunks_near(at: Vector2, radius: float) -> PackedInt32Array:
|
||||
var out := PackedInt32Array()
|
||||
var lo := to_tile(at - Vector2(radius, radius))
|
||||
var hi := to_tile(at + Vector2(radius, radius))
|
||||
var cw := chunks_wide()
|
||||
var ch := chunks_high()
|
||||
var c_lo_x := clampi(lo.x / CHUNK, 0, cw - 1)
|
||||
var c_hi_x := clampi(hi.x / CHUNK, 0, cw - 1)
|
||||
var c_lo_y := clampi(lo.y / CHUNK, 0, ch - 1)
|
||||
var c_hi_y := clampi(hi.y / CHUNK, 0, ch - 1)
|
||||
for cy in range(c_lo_y, c_hi_y + 1):
|
||||
for cx in range(c_lo_x, c_hi_x + 1):
|
||||
out.append(cy * cw + cx)
|
||||
return out
|
||||
|
||||
|
||||
func encode_chunk(chunk_id: int) -> PackedByteArray:
|
||||
var r := chunk_rect(chunk_id)
|
||||
var out := PackedByteArray()
|
||||
out.resize(r.size.x * r.size.y)
|
||||
var i := 0
|
||||
for ty in range(r.position.y, r.end.y):
|
||||
for tx in range(r.position.x, r.end.x):
|
||||
out[i] = tiles[ty * width + tx]
|
||||
i += 1
|
||||
return out
|
||||
|
||||
|
||||
func apply_chunk(chunk_id: int, data: PackedByteArray) -> void:
|
||||
var r := chunk_rect(chunk_id)
|
||||
if data.size() != r.size.x * r.size.y:
|
||||
return # malformed or from a different map; ignore rather than corrupt
|
||||
var i := 0
|
||||
for ty in range(r.position.y, r.end.y):
|
||||
for tx in range(r.position.x, r.end.x):
|
||||
tiles[ty * width + tx] = data[i]
|
||||
i += 1
|
||||
@@ -0,0 +1 @@
|
||||
uid://cly0bi7sxe5gf
|
||||
@@ -15,6 +15,11 @@ var local_tick := 0
|
||||
## How many times this emitter has fired in this phase. Drives spin/step.
|
||||
var shot_index := 0
|
||||
var rng: RandomNumberGenerator
|
||||
## The space this pattern should fill: a boss's arena, or a box around a trash
|
||||
## enemy. Curtain-style emitters span it. Was a global arena constant, which
|
||||
## stopped meaning anything once maps varied in size and fights happened in
|
||||
## rooms rather than in "the arena".
|
||||
var bounds := Rect2(Vector2(-620.0, -340.0), Vector2(1240.0, 680.0))
|
||||
|
||||
|
||||
func aim_angle() -> float:
|
||||
|
||||
@@ -15,18 +15,22 @@ extends BulletEmitter
|
||||
@export var randomize_gap: bool = false
|
||||
|
||||
|
||||
func _axis() -> Dictionary:
|
||||
## Direction of travel, the axis the curtain is strung along, and how far the
|
||||
## curtain reaches -- all measured from the room the pattern is filling rather
|
||||
## than from a fixed arena.
|
||||
func _axis(bounds: Rect2) -> Dictionary:
|
||||
var half := bounds.size * 0.5
|
||||
match direction:
|
||||
1: return {"dir": Vector2.UP, "along": Vector2.RIGHT, "extent": SimConfig.ARENA_HALF.x, "edge": SimConfig.ARENA_HALF.y}
|
||||
2: return {"dir": Vector2.RIGHT, "along": Vector2.DOWN, "extent": SimConfig.ARENA_HALF.y, "edge": SimConfig.ARENA_HALF.x}
|
||||
3: return {"dir": Vector2.LEFT, "along": Vector2.DOWN, "extent": SimConfig.ARENA_HALF.y, "edge": SimConfig.ARENA_HALF.x}
|
||||
_: return {"dir": Vector2.DOWN, "along": Vector2.RIGHT, "extent": SimConfig.ARENA_HALF.x, "edge": SimConfig.ARENA_HALF.y}
|
||||
1: return {"dir": Vector2.UP, "along": Vector2.RIGHT, "extent": half.x, "edge": half.y}
|
||||
2: return {"dir": Vector2.RIGHT, "along": Vector2.DOWN, "extent": half.y, "edge": half.x}
|
||||
3: return {"dir": Vector2.LEFT, "along": Vector2.DOWN, "extent": half.y, "edge": half.x}
|
||||
_: return {"dir": Vector2.DOWN, "along": Vector2.RIGHT, "extent": half.x, "edge": half.y}
|
||||
|
||||
|
||||
func fire(ctx: EmitContext) -> void:
|
||||
if count <= 0:
|
||||
return
|
||||
var ax := _axis()
|
||||
var ax := _axis(ctx.bounds)
|
||||
var dir: Vector2 = ax["dir"]
|
||||
var along: Vector2 = ax["along"]
|
||||
var extent: float = ax["extent"]
|
||||
@@ -38,7 +42,8 @@ func fire(ctx: EmitContext) -> void:
|
||||
else:
|
||||
gap = posmod(gap_index + gap_step * ctx.shot_index, maxi(count - gap_width + 1, 1))
|
||||
|
||||
var start := -dir * (edge + 8.0)
|
||||
var centre := ctx.bounds.get_center()
|
||||
var start := centre - dir * (edge + 8.0)
|
||||
var angle := dir.angle()
|
||||
for i in count:
|
||||
if i >= gap and i < gap + gap_width:
|
||||
|
||||
@@ -11,6 +11,10 @@ var alive: bool = true
|
||||
var phase_index: int = 0
|
||||
## Ticks since entering the current phase.
|
||||
var phase_tick: int = 0
|
||||
## The arena this boss may occupy, in world space. A boss never leaves it, so a
|
||||
## player can always disengage by walking out -- which is the trade for the
|
||||
## boss room having no door that locks.
|
||||
var room := Rect2()
|
||||
|
||||
|
||||
func hp_fraction() -> float:
|
||||
|
||||
@@ -15,6 +15,10 @@ var local_tick: int = 0
|
||||
## Staggers identical enemies so a pack does not fire in lockstep.
|
||||
var phase_offset: int = 0
|
||||
var target_dir := Vector2.ZERO
|
||||
## Whoever this enemy is currently aggroed on: within range and in line of
|
||||
## sight. Refreshed by the world every tick; null means idle, which is the
|
||||
## normal state for most of a dungeon.
|
||||
var target: SimPlayer = null
|
||||
|
||||
|
||||
func hp_fraction() -> float:
|
||||
|
||||
+91
-33
@@ -25,8 +25,15 @@ var boss: SimBoss = null
|
||||
## Drained by the owner every tick. See [SimEvent].
|
||||
var events: Array[Dictionary] = []
|
||||
|
||||
## The world's static geometry. Never null: worlds without a generated map get
|
||||
## a plain walled room, which keeps every caller free of null checks and lets a
|
||||
## 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
|
||||
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.
|
||||
@@ -39,6 +46,22 @@ var _ctx := EmitContext.new()
|
||||
func _init(seed_value: int = 0) -> void:
|
||||
rng.seed = seed_value
|
||||
_ctx.rng = rng
|
||||
set_map(_default_room())
|
||||
|
||||
|
||||
## A walled box roughly the size of the old fixed arena, so a world built with
|
||||
## no map behaves the way the game did before terrain existed.
|
||||
static func _default_room() -> MapGrid:
|
||||
var g := MapGrid.new(42, 24, MapGrid.Kind.WALL)
|
||||
g.fill_rect(Rect2i(1, 1, 40, 22), MapGrid.Kind.FLOOR)
|
||||
g.centre_on_origin()
|
||||
return g
|
||||
|
||||
|
||||
func set_map(new_map: MapGrid) -> void:
|
||||
map = new_map
|
||||
# The pool culls bullets against the same geometry, on both sides.
|
||||
pool.map = new_map
|
||||
|
||||
|
||||
func next_actor_id() -> int:
|
||||
@@ -140,6 +163,7 @@ func step() -> void:
|
||||
_step_boss()
|
||||
pool.step()
|
||||
_resolve_bullet_hits()
|
||||
_emit_wall_kill_events()
|
||||
_emit_spawn_events()
|
||||
else:
|
||||
# Replica: bullets only. Actor state arrives in snapshots.
|
||||
@@ -167,7 +191,7 @@ func _step_players() -> void:
|
||||
continue
|
||||
|
||||
p.aim = frame.aim
|
||||
p.pos = Movement.step_player(p.pos, frame.move, SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
|
||||
p.pos = Movement.step_player(p.pos, frame.move, SimConfig.PLAYER_SPEED, map)
|
||||
|
||||
if frame.pressed(InputFrame.BTN_FIRE) and p.can_fire():
|
||||
_fire_player_shot(p)
|
||||
@@ -175,7 +199,7 @@ func _step_players() -> void:
|
||||
_step_escape(p, frame)
|
||||
|
||||
if portal_enabled and frame.pressed(InputFrame.BTN_INTERACT):
|
||||
if p.pos.distance_to(SimConfig.PORTAL_POS) <= SimConfig.PORTAL_RADIUS:
|
||||
if p.pos.distance_to(portal_pos) <= SimConfig.PORTAL_RADIUS:
|
||||
events.append({"t": SimEvent.Type.PORTAL_USED, "peer": p.peer_id})
|
||||
|
||||
|
||||
@@ -229,10 +253,30 @@ func _step_enemies() -> void:
|
||||
for e in enemies.values():
|
||||
if not e.alive:
|
||||
continue
|
||||
# Aggro is re-evaluated every tick and gates both movement and fire, so
|
||||
# a dungeon full of enemies is quiet until you walk into it -- which is
|
||||
# what makes exploring a decision rather than a countdown.
|
||||
e.target = _aggro_target(e)
|
||||
_move_enemy(e)
|
||||
_run_emitters(e.def.emitters, e.pos,
|
||||
posmod(e.local_tick + e.phase_offset, maxi(e.def.pattern_loop_ticks, 1)))
|
||||
e.local_tick += 1
|
||||
if e.target != null:
|
||||
_run_emitters(e.def.emitters, e.pos,
|
||||
posmod(e.local_tick + e.phase_offset, maxi(e.def.pattern_loop_ticks, 1)))
|
||||
e.local_tick += 1
|
||||
|
||||
|
||||
## Nearest living player within aggro range and in line of sight. Sight matters
|
||||
## as much as range: an enemy that shoots you through a wall makes cover
|
||||
## meaningless, and one that never loses you makes retreating impossible.
|
||||
func _aggro_target(e: SimEnemy) -> SimPlayer:
|
||||
var t := nearest_player(e.pos)
|
||||
if t == null:
|
||||
return null
|
||||
var range_sq: float = e.def.aggro_range * e.def.aggro_range
|
||||
if e.pos.distance_squared_to(t.pos) > range_sq:
|
||||
return null
|
||||
if not map.has_line_of_sight(e.pos, t.pos):
|
||||
return null
|
||||
return t
|
||||
|
||||
|
||||
func _move_enemy(e: SimEnemy) -> void:
|
||||
@@ -242,41 +286,41 @@ func _move_enemy(e: SimEnemy) -> void:
|
||||
EnemyDef.Move.STATIC:
|
||||
pass
|
||||
EnemyDef.Move.DRIFT:
|
||||
var next := e.pos + e.heading * speed * dt
|
||||
# Bounce off the arena so a drifter never leaves the fight.
|
||||
if absf(next.x) > SimConfig.ARENA_HALF.x - e.def.radius:
|
||||
# Drifters patrol whether or not they have seen anyone; everything
|
||||
# else only moves once aggroed.
|
||||
var delta := e.heading * speed * dt
|
||||
var next := map.slide_circle(e.pos, delta, e.def.radius)
|
||||
# Reflect on whichever axis the map refused. Works for the outer
|
||||
# wall and an interior pillar alike, with no special cases.
|
||||
if absf(next.x - e.pos.x) < absf(delta.x) - 0.001:
|
||||
e.heading.x = -e.heading.x
|
||||
next.x = clampf(next.x, -SimConfig.ARENA_HALF.x + e.def.radius, SimConfig.ARENA_HALF.x - e.def.radius)
|
||||
if absf(next.y) > SimConfig.ARENA_HALF.y - e.def.radius:
|
||||
if absf(next.y - e.pos.y) < absf(delta.y) - 0.001:
|
||||
e.heading.y = -e.heading.y
|
||||
next.y = clampf(next.y, -SimConfig.ARENA_HALF.y + e.def.radius, SimConfig.ARENA_HALF.y - e.def.radius)
|
||||
e.pos = next
|
||||
EnemyDef.Move.ORBIT:
|
||||
var a := float(e.local_tick + e.phase_offset) * dt * (speed / maxf(e.def.move_param, 1.0))
|
||||
e.pos = e.home + Vector2.RIGHT.rotated(a) * e.def.move_param
|
||||
var want := e.home + Vector2.RIGHT.rotated(a) * e.def.move_param
|
||||
e.pos = map.slide_circle(e.pos, want - e.pos, e.def.radius)
|
||||
EnemyDef.Move.APPROACH:
|
||||
if e.target == null:
|
||||
return
|
||||
if e.local_tick % maxi(e.def.retarget_interval, 1) == 0:
|
||||
var t := nearest_player(e.pos)
|
||||
e.target_dir = Vector2.ZERO if t == null else (t.pos - e.pos).normalized()
|
||||
e.pos += e.target_dir * speed * dt
|
||||
e.target_dir = (e.target.pos - e.pos).normalized()
|
||||
e.pos = map.slide_circle(e.pos, e.target_dir * speed * dt, e.def.radius)
|
||||
EnemyDef.Move.STRAFE:
|
||||
if e.target == null:
|
||||
return
|
||||
if e.local_tick % maxi(e.def.retarget_interval, 1) == 0:
|
||||
var t := nearest_player(e.pos)
|
||||
if t == null:
|
||||
e.target_dir = Vector2.ZERO
|
||||
else:
|
||||
var to_player := t.pos - e.pos
|
||||
var d := to_player.length()
|
||||
var radial := 0.0
|
||||
if d > e.def.move_param + 20.0:
|
||||
radial = 1.0
|
||||
elif d < e.def.move_param - 20.0:
|
||||
radial = -1.0
|
||||
var toward := Vector2.ZERO if d < 0.001 else to_player / d
|
||||
e.target_dir = (toward * radial + toward.orthogonal() * 0.7).normalized()
|
||||
e.pos += e.target_dir * speed * dt
|
||||
e.pos.x = clampf(e.pos.x, -SimConfig.ARENA_HALF.x, SimConfig.ARENA_HALF.x)
|
||||
e.pos.y = clampf(e.pos.y, -SimConfig.ARENA_HALF.y, SimConfig.ARENA_HALF.y)
|
||||
var to_player := e.target.pos - e.pos
|
||||
var d := to_player.length()
|
||||
var radial := 0.0
|
||||
if d > e.def.move_param + 20.0:
|
||||
radial = 1.0
|
||||
elif d < e.def.move_param - 20.0:
|
||||
radial = -1.0
|
||||
var toward := Vector2.ZERO if d < 0.001 else to_player / d
|
||||
e.target_dir = (toward * radial + toward.orthogonal() * 0.7).normalized()
|
||||
e.pos = map.slide_circle(e.pos, e.target_dir * speed * dt, e.def.radius)
|
||||
|
||||
|
||||
func _step_boss() -> void:
|
||||
@@ -292,18 +336,25 @@ func _step_boss() -> void:
|
||||
return
|
||||
if boss.phase_tick >= phase.telegraph_ticks:
|
||||
var local := posmod(boss.phase_tick - phase.telegraph_ticks, maxi(phase.loop_ticks, 1))
|
||||
_run_emitters(phase.emitters, boss.pos, local)
|
||||
_run_emitters(phase.emitters, boss.pos, local, boss.room)
|
||||
boss.phase_tick += 1
|
||||
|
||||
|
||||
## Shared emitter driver for enemies and bosses -- the reason a boss pattern can
|
||||
## be dropped onto a trash mob and vice versa.
|
||||
func _run_emitters(emitters: Array[BulletEmitter], origin: Vector2, local_tick: int) -> void:
|
||||
func _run_emitters(emitters: Array[BulletEmitter], origin: Vector2, local_tick: int,
|
||||
bounds: Rect2 = Rect2()) -> void:
|
||||
if emitters.is_empty():
|
||||
return
|
||||
var target := nearest_player(origin)
|
||||
_ctx.pool = pool
|
||||
_ctx.origin = origin
|
||||
# Curtain patterns span the room they are fired in. Without a room, fall
|
||||
# back to a box around the shooter rather than the whole map, or a trash
|
||||
# enemy would sweep bullets across the entire dungeon.
|
||||
_ctx.bounds = bounds if bounds.size != Vector2.ZERO \
|
||||
else Rect2(origin - Vector2(400.0, 300.0), Vector2(800.0, 600.0))
|
||||
_ctx.origin = origin
|
||||
_ctx.local_tick = local_tick
|
||||
_ctx.has_target = target != null
|
||||
_ctx.target = origin + Vector2.DOWN * 200.0 if target == null else target.pos
|
||||
@@ -388,6 +439,13 @@ func _damage_boss(amount: int) -> void:
|
||||
events.append({"t": SimEvent.Type.BOSS_DIED})
|
||||
|
||||
|
||||
## Bullets that died against geometry. A client is only streamed the map near
|
||||
## itself, so it cannot be relied on to work these out for itself.
|
||||
func _emit_wall_kill_events() -> void:
|
||||
for dead_uid in pool.wall_kill_log:
|
||||
events.append({"t": SimEvent.Type.BULLET_DESPAWN, "uid": dead_uid})
|
||||
|
||||
|
||||
func _emit_spawn_events() -> void:
|
||||
for slot in pool.spawn_log:
|
||||
if pool.alive[slot] == 0:
|
||||
|
||||
+15
-2
@@ -11,15 +11,27 @@ var _bound: ClientRuntime = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
world_view.position = get_viewport_rect().size * 0.5
|
||||
_recentre()
|
||||
get_viewport().size_changed.connect(_recentre)
|
||||
menu.return_to_hub_requested.connect(_on_return_to_hub)
|
||||
menu.disconnect_requested.connect(_on_disconnect)
|
||||
hud.respawn_pressed.connect(_on_respawn_pressed)
|
||||
|
||||
|
||||
var _screen_centre := Vector2.ZERO
|
||||
|
||||
|
||||
func _recentre() -> void:
|
||||
world_view.position = get_viewport_rect().size * 0.5
|
||||
_screen_centre = get_viewport_rect().size * 0.5
|
||||
|
||||
|
||||
## Scroll the world so the local player stays centred. Dungeons are larger than
|
||||
## the viewport now, so a fixed camera would simply lose the player off-screen.
|
||||
func _follow_camera() -> void:
|
||||
var focus := Vector2.ZERO
|
||||
if _bound != null:
|
||||
focus = _bound.predicted_pos
|
||||
world_view.position = _screen_centre - focus
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
@@ -27,6 +39,7 @@ func _process(_delta: float) -> void:
|
||||
_bound = Net.client
|
||||
if _bound != null and not _bound.local_hit.is_connected(_on_local_hit):
|
||||
_bound.local_hit.connect(_on_local_hit)
|
||||
_follow_camera()
|
||||
menu.set_in_dungeon(_bound != null
|
||||
and _bound.instance_kind == Protocol.InstanceKind.DUNGEON)
|
||||
|
||||
|
||||
+72
-20
@@ -7,6 +7,11 @@ extends Node2D
|
||||
## decides anything. Every number it draws came from the server.
|
||||
|
||||
const COL_ARENA := Color(0.22, 0.24, 0.34)
|
||||
const COL_FLOOR := Color(0.11, 0.12, 0.17)
|
||||
const COL_WALL := Color(0.28, 0.30, 0.40)
|
||||
const COL_PILLAR := Color(0.36, 0.34, 0.44)
|
||||
const COL_PIT := Color(0.03, 0.03, 0.05)
|
||||
const COL_BARRICADE := Color(0.30, 0.26, 0.22)
|
||||
const COL_LOCAL := Color(0.5, 1.0, 0.8)
|
||||
const COL_REMOTE := Color(0.55, 0.75, 1.0)
|
||||
const COL_DEAD := Color(0.4, 0.4, 0.45, 0.5)
|
||||
@@ -33,40 +38,87 @@ func _process(_delta: float) -> void:
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
_draw_arena()
|
||||
if client == null:
|
||||
return
|
||||
_draw_terrain()
|
||||
if client.instance_kind == Protocol.InstanceKind.LOBBY:
|
||||
_draw_portal()
|
||||
for e in client.enemies():
|
||||
_draw_enemy(e)
|
||||
if _visible(e["pos"]):
|
||||
_draw_enemy(e)
|
||||
_draw_boss()
|
||||
for p in client.remote_players():
|
||||
_draw_remote_player(p)
|
||||
if _visible(p["pos"]):
|
||||
_draw_remote_player(p)
|
||||
_draw_local_player()
|
||||
|
||||
|
||||
func _draw_arena() -> void:
|
||||
var h := SimConfig.ARENA_HALF
|
||||
draw_rect(Rect2(-h, h * 2.0), COL_ARENA, false, 2.0)
|
||||
# Faint grid, purely so movement reads against a background.
|
||||
var step := 80.0
|
||||
var faint := Color(COL_ARENA, 0.25)
|
||||
var x := -h.x + step
|
||||
while x < h.x:
|
||||
draw_line(Vector2(x, -h.y), Vector2(x, h.y), faint, 1.0)
|
||||
x += step
|
||||
var y := -h.y + step
|
||||
while y < h.y:
|
||||
draw_line(Vector2(-h.x, y), Vector2(h.x, y), faint, 1.0)
|
||||
y += step
|
||||
## Terrain, drawn only where the player can currently see it.
|
||||
##
|
||||
## Hard fog: nothing outside line of sight is drawn, including ground already
|
||||
## walked over. Note what this is and is not -- it is a rendering rule, not a
|
||||
## secrecy mechanism. The client holds real tile data because it simulates
|
||||
## bullets, so a modified client could draw what is hidden here. What it cannot
|
||||
## do is see terrain the server never streamed (only a radius around the
|
||||
## player) or actors the server filtered out, which is where the line actually
|
||||
## sits.
|
||||
func _draw_terrain() -> void:
|
||||
var map: MapGrid = client.world.map
|
||||
if map == null:
|
||||
return
|
||||
var eye := client.predicted_pos
|
||||
var view := SimConfig.FOG_VIEW_RADIUS
|
||||
var lo := map.to_tile(eye - Vector2(view, view))
|
||||
var hi := map.to_tile(eye + Vector2(view, view))
|
||||
var view_sq := view * view
|
||||
for ty in range(lo.y, hi.y + 1):
|
||||
for tx in range(lo.x, hi.x + 1):
|
||||
var kind := map.at(tx, ty)
|
||||
if kind == MapGrid.Kind.UNKNOWN:
|
||||
continue
|
||||
var centre := map.tile_centre(tx, ty)
|
||||
if eye.distance_squared_to(centre) > view_sq:
|
||||
continue
|
||||
if not map.has_line_of_sight(eye, centre):
|
||||
continue
|
||||
_draw_tile(kind, centre)
|
||||
|
||||
|
||||
func _draw_tile(kind: MapGrid.Kind, centre: Vector2) -> void:
|
||||
var half := MapGrid.TILE * 0.5
|
||||
var r := Rect2(centre - Vector2(half, half), Vector2(MapGrid.TILE, MapGrid.TILE))
|
||||
match kind:
|
||||
MapGrid.Kind.FLOOR:
|
||||
draw_rect(r.grow(-1.0), COL_FLOOR)
|
||||
MapGrid.Kind.WALL:
|
||||
draw_rect(r, COL_WALL)
|
||||
MapGrid.Kind.PILLAR:
|
||||
draw_rect(r.grow(-2.0), COL_PILLAR)
|
||||
MapGrid.Kind.PIT:
|
||||
draw_rect(r.grow(-1.0), COL_PIT)
|
||||
MapGrid.Kind.BARRICADE:
|
||||
# Drawn short so "you can see over this" reads at a glance.
|
||||
draw_rect(Rect2(r.position + Vector2(0.0, half * 0.5),
|
||||
Vector2(MapGrid.TILE, half)), COL_BARRICADE)
|
||||
|
||||
|
||||
## Actors are only drawn where the player can actually see them. The server
|
||||
## already withholds distant ones; this hides the ones behind walls.
|
||||
func _visible(at: Vector2) -> bool:
|
||||
var map: MapGrid = client.world.map
|
||||
if map == null:
|
||||
return true
|
||||
var eye := client.predicted_pos
|
||||
if eye.distance_squared_to(at) > SimConfig.FOG_VIEW_RADIUS * SimConfig.FOG_VIEW_RADIUS:
|
||||
return false
|
||||
return map.has_line_of_sight(eye, at)
|
||||
|
||||
|
||||
func _draw_portal() -> void:
|
||||
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.003)
|
||||
draw_arc(SimConfig.PORTAL_POS, SimConfig.PORTAL_RADIUS, 0.0, TAU, 48,
|
||||
draw_arc(client.portal_pos, SimConfig.PORTAL_RADIUS, 0.0, TAU, 48,
|
||||
Color(COL_PORTAL, 0.4 + 0.4 * pulse), 3.0)
|
||||
draw_circle(SimConfig.PORTAL_POS, SimConfig.PORTAL_RADIUS * 0.25,
|
||||
draw_circle(client.portal_pos, SimConfig.PORTAL_RADIUS * 0.25,
|
||||
Color(COL_PORTAL, 0.25 + 0.25 * pulse))
|
||||
|
||||
|
||||
@@ -94,7 +146,7 @@ func _draw_target_dummy(pos: Vector2, r: float, col: Color) -> void:
|
||||
|
||||
func _draw_boss() -> void:
|
||||
var b := client.boss_state()
|
||||
if b.is_empty():
|
||||
if b.is_empty() or not _visible(b["pos"]):
|
||||
return
|
||||
var r: float = client.boss_def.radius if client.boss_def != null else 42.0
|
||||
var pos: Vector2 = b["pos"]
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
extends GutTest
|
||||
## Progression through a whole dungeon instance: forming, two trash waves, the
|
||||
## boss, and the cleared state that sends the party home.
|
||||
## A dungeon run, end to end: a generated map populated at creation, explored
|
||||
## rather than survived, and cleared by killing the boss.
|
||||
|
||||
var inst: Instance
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
inst = Instance.make_dungeon(2, 12345)
|
||||
inst = Instance.make_dungeon(2, 12345, 1)
|
||||
inst.add_peer(1, "tester")
|
||||
|
||||
|
||||
@@ -15,9 +15,26 @@ func _step(n: int) -> void:
|
||||
inst.step()
|
||||
|
||||
|
||||
func _kill_all_enemies() -> void:
|
||||
func test_a_dungeon_is_populated_when_it_is_created() -> void:
|
||||
# Not spawned in waves: the map has contents before anyone walks in, which
|
||||
# is what makes exploring a decision instead of a countdown.
|
||||
assert_gt(inst.world.enemies.size(), 0, "the rooms should have occupants")
|
||||
assert_not_null(inst.world.boss)
|
||||
assert_true(inst.world.boss.alive)
|
||||
|
||||
|
||||
func test_the_boss_starts_inside_its_own_room() -> void:
|
||||
assert_true(inst.boss_room.size != Vector2i.ZERO, "a boss room must exist")
|
||||
assert_true(inst.world.boss.room.has_point(inst.world.boss.pos),
|
||||
"the boss has to start inside the arena it is confined to")
|
||||
|
||||
|
||||
func test_nothing_spawns_inside_geometry() -> void:
|
||||
for e in inst.world.enemies.values():
|
||||
e.alive = false
|
||||
assert_false(inst.world.map.circle_blocked(e.pos, e.def.radius),
|
||||
"%s spawned inside a wall" % e.def.id)
|
||||
assert_false(inst.world.map.circle_blocked(inst.world.spawn_point,
|
||||
SimConfig.PLAYER_RADIUS), "the party would arrive inside a wall")
|
||||
|
||||
|
||||
func test_a_forming_dungeon_locks_after_the_window() -> void:
|
||||
@@ -34,53 +51,45 @@ func test_a_full_party_locks_the_dungeon_immediately() -> void:
|
||||
assert_false(inst.accepts_new_party_member())
|
||||
|
||||
|
||||
func test_waves_gate_on_clearing_the_previous_one() -> void:
|
||||
_step(SimConfig.DUNGEON_FORMING_TICKS + 200)
|
||||
assert_eq(inst.stage, 0)
|
||||
var wave_one := inst.world.enemies.size()
|
||||
assert_gt(wave_one, 0)
|
||||
|
||||
_step(200)
|
||||
assert_eq(inst.stage, 0, "a slow party must never be overrun by the next wave")
|
||||
|
||||
_kill_all_enemies()
|
||||
_step(120)
|
||||
assert_eq(inst.stage, 1)
|
||||
assert_gt(inst.world.enemies.size(), wave_one)
|
||||
|
||||
|
||||
func test_the_boss_arrives_after_the_last_wave() -> void:
|
||||
_step(SimConfig.DUNGEON_FORMING_TICKS + 200)
|
||||
for _wave in 2:
|
||||
_kill_all_enemies()
|
||||
_step(120)
|
||||
assert_eq(inst.stage, 2)
|
||||
assert_not_null(inst.world.boss)
|
||||
assert_eq(inst.world.boss.def.id, Content.BOSS_WARDEN)
|
||||
|
||||
|
||||
func test_killing_the_boss_clears_the_instance() -> void:
|
||||
_step(SimConfig.DUNGEON_FORMING_TICKS + 200)
|
||||
for _wave in 2:
|
||||
_kill_all_enemies()
|
||||
_step(120)
|
||||
_step(SimConfig.DUNGEON_FORMING_TICKS + 5)
|
||||
inst.world.boss.alive = false
|
||||
_step(5)
|
||||
assert_eq(inst.state, Instance.State.CLEARED)
|
||||
assert_almost_eq(inst.exit_countdown_seconds(),
|
||||
SimConfig.DUNGEON_CLEARED_EXIT_TICKS / SimConfig.TICK_RATE, 1,
|
||||
"the party gets a visible countdown, not an instant boot")
|
||||
# The exit timer has to run down, or the party would never be released.
|
||||
SimConfig.DUNGEON_CLEARED_EXIT_TICKS / SimConfig.TICK_RATE, 1)
|
||||
_step(SimConfig.DUNGEON_CLEARED_EXIT_TICKS + 10)
|
||||
assert_eq(inst.stage_delay, 0)
|
||||
assert_eq(inst.exit_countdown_seconds(), 0)
|
||||
|
||||
|
||||
## Trash left alive must not keep the run open -- you clear a dungeon by
|
||||
## beating the boss, not by sweeping every corner of the map.
|
||||
func test_leftover_enemies_do_not_block_clearing() -> void:
|
||||
_step(SimConfig.DUNGEON_FORMING_TICKS + 5)
|
||||
assert_gt(inst.world.enemies.size(), 0, "setup: enemies should remain")
|
||||
inst.world.boss.alive = false
|
||||
_step(5)
|
||||
assert_eq(inst.state, Instance.State.CLEARED)
|
||||
|
||||
|
||||
func test_deeper_dungeons_are_larger() -> void:
|
||||
var deep := Instance.make_dungeon(3, 12345, 5)
|
||||
assert_gt(deep.world.map.width, inst.world.map.width)
|
||||
|
||||
|
||||
# --- The hub ----------------------------------------------------------------
|
||||
|
||||
func _lobby() -> Instance:
|
||||
var l := Instance.make_lobby(SimConfig.LOBBY_INSTANCE_ID)
|
||||
l.add_peer(1, "tester")
|
||||
return l
|
||||
|
||||
|
||||
func test_the_lobby_has_a_portal_and_no_hostiles() -> void:
|
||||
var lobby := Instance.make_lobby(SimConfig.LOBBY_INSTANCE_ID)
|
||||
lobby.add_peer(1, "tester")
|
||||
var lobby := _lobby()
|
||||
assert_true(lobby.world.portal_enabled)
|
||||
_step(1)
|
||||
assert_ne(lobby.world.portal_pos, Vector2.ZERO, "the portal comes from the map")
|
||||
for _i in 600:
|
||||
lobby.step()
|
||||
assert_eq(lobby.world.pool.live_count, 0, "nothing in the hub may shoot at you")
|
||||
@@ -88,9 +97,8 @@ func test_the_lobby_has_a_portal_and_no_hostiles() -> void:
|
||||
|
||||
|
||||
func test_interacting_on_the_portal_asks_for_a_dungeon() -> void:
|
||||
var lobby := Instance.make_lobby(SimConfig.LOBBY_INSTANCE_ID)
|
||||
lobby.add_peer(1, "tester")
|
||||
lobby.world.players[1].pos = SimConfig.PORTAL_POS
|
||||
var lobby := _lobby()
|
||||
lobby.world.players[1].pos = lobby.world.portal_pos
|
||||
var frames: Array[InputFrame] = [
|
||||
InputFrame.make(lobby.world.tick + 1, Vector2.ZERO, 0.0, InputFrame.BTN_INTERACT)]
|
||||
lobby.world.queue_input(1, frames)
|
||||
@@ -101,9 +109,9 @@ func test_interacting_on_the_portal_asks_for_a_dungeon() -> void:
|
||||
|
||||
|
||||
func test_interacting_away_from_the_portal_does_nothing() -> void:
|
||||
var lobby := Instance.make_lobby(SimConfig.LOBBY_INSTANCE_ID)
|
||||
lobby.add_peer(1, "tester")
|
||||
lobby.world.players[1].pos = SimConfig.PORTAL_POS + Vector2(0.0, SimConfig.PORTAL_RADIUS + 50.0)
|
||||
var lobby := _lobby()
|
||||
lobby.world.players[1].pos = lobby.world.portal_pos \
|
||||
+ Vector2(0.0, SimConfig.PORTAL_RADIUS + 80.0)
|
||||
var frames: Array[InputFrame] = [
|
||||
InputFrame.make(lobby.world.tick + 1, Vector2.ZERO, 0.0, InputFrame.BTN_INTERACT)]
|
||||
lobby.world.queue_input(1, frames)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
extends GutTest
|
||||
## Aggro is what turns a populated map into an explorable one: a dungeon has to
|
||||
## be quiet until you engage it, and cover has to actually protect you.
|
||||
|
||||
var world: SimWorld
|
||||
var map: MapGrid
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
map = MapGrid.new(40, 20, MapGrid.Kind.WALL)
|
||||
map.fill_rect(Rect2i(1, 1, 38, 18), MapGrid.Kind.FLOOR)
|
||||
map.centre_on_origin()
|
||||
world = SimWorld.new(1)
|
||||
world.set_map(map)
|
||||
|
||||
|
||||
func _turret_at(tx: int, ty: int) -> SimEnemy:
|
||||
return world.spawn_enemy(Content.turret(), map.tile_centre(tx, ty))
|
||||
|
||||
|
||||
func _player_at(tx: int, ty: int) -> SimPlayer:
|
||||
var p := world.add_player(1, "tester")
|
||||
p.pos = map.tile_centre(tx, ty)
|
||||
p.spawn_grace = 0
|
||||
return p
|
||||
|
||||
|
||||
func test_an_enemy_out_of_range_never_fires() -> void:
|
||||
var e := _turret_at(2, 10)
|
||||
_player_at(37, 10)
|
||||
assert_gt(e.pos.distance_to(world.players[1].pos), Content.turret().aggro_range,
|
||||
"setup: the player must actually be out of range")
|
||||
for _i in 400:
|
||||
world.step()
|
||||
assert_eq(world.pool.live_count, 0,
|
||||
"a dungeon has to stay quiet until you walk into it")
|
||||
assert_null(e.target)
|
||||
|
||||
|
||||
func test_an_enemy_in_range_and_in_sight_fires() -> void:
|
||||
var e := _turret_at(10, 10)
|
||||
_player_at(16, 10)
|
||||
for _i in 400:
|
||||
world.step()
|
||||
assert_gt(world.pool.live_count, 0)
|
||||
assert_not_null(e.target)
|
||||
|
||||
|
||||
## Cover is the whole point of walls. An enemy that shoots through them makes
|
||||
## every wall decorative.
|
||||
func test_a_wall_between_them_breaks_aggro() -> void:
|
||||
var e := _turret_at(10, 10)
|
||||
_player_at(16, 10)
|
||||
for ty in range(1, 19):
|
||||
map.set_tile(13, ty, MapGrid.Kind.WALL)
|
||||
for _i in 400:
|
||||
world.step()
|
||||
assert_eq(world.pool.live_count, 0, "no line of sight, no shooting")
|
||||
assert_null(e.target)
|
||||
|
||||
|
||||
func test_a_barricade_does_not_break_aggro() -> void:
|
||||
# You can see over a barricade, so it does not hide you -- it only stops
|
||||
# what you and it are shooting.
|
||||
var e := _turret_at(10, 10)
|
||||
_player_at(16, 10)
|
||||
for ty in range(1, 19):
|
||||
map.set_tile(13, ty, MapGrid.Kind.BARRICADE)
|
||||
for _i in 200:
|
||||
world.step()
|
||||
assert_not_null(e.target, "a chest-high barricade is not concealment")
|
||||
|
||||
|
||||
func test_a_chasing_enemy_stops_when_it_loses_you() -> void:
|
||||
var e := world.spawn_enemy(Content.stalker(), map.tile_centre(10, 10))
|
||||
var p := _player_at(14, 10)
|
||||
for _i in 30:
|
||||
world.step()
|
||||
var closed: float = e.pos.distance_to(p.pos)
|
||||
assert_lt(closed, map.tile_centre(10, 10).distance_to(p.pos),
|
||||
"setup: the stalker should have closed some distance")
|
||||
|
||||
# Walk out of range; it must give up rather than follow across the map.
|
||||
p.pos = map.tile_centre(37, 18)
|
||||
var before: Vector2 = e.pos
|
||||
for _i in 120:
|
||||
world.step()
|
||||
assert_almost_eq(e.pos.distance_to(before), 0.0, 0.001,
|
||||
"an enemy that never disengages makes retreating impossible")
|
||||
@@ -0,0 +1 @@
|
||||
uid://demetqlhmetq6
|
||||
@@ -3,10 +3,17 @@ extends GutTest
|
||||
## would end the run, so its slot bookkeeping is tested directly.
|
||||
|
||||
var pool: BulletPool
|
||||
var map: MapGrid
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
pool = BulletPool.new()
|
||||
# Bullets now die against real geometry rather than a global rectangle, so
|
||||
# the pool needs a map to cull against at all.
|
||||
map = MapGrid.new(42, 24, MapGrid.Kind.WALL)
|
||||
map.fill_rect(Rect2i(1, 1, 40, 22), MapGrid.Kind.FLOOR)
|
||||
map.centre_on_origin()
|
||||
pool.map = map
|
||||
|
||||
|
||||
func _spawn(p := Vector2.ZERO, v := Vector2(100, 0), life := 60) -> int:
|
||||
@@ -57,11 +64,45 @@ func test_bullets_expire_at_end_of_life() -> void:
|
||||
assert_eq(pool.live_count, 0)
|
||||
|
||||
|
||||
func test_bullets_leaving_the_arena_are_culled() -> void:
|
||||
var start := Vector2(SimConfig.ARENA_HALF.x, 0.0)
|
||||
func test_bullets_leaving_the_map_are_culled() -> void:
|
||||
var start := Vector2(map.world_rect().end.x, 0.0)
|
||||
var a := _spawn(start, Vector2(100000, 0), 600)
|
||||
pool.step()
|
||||
assert_eq(pool.alive[a], 0, "a bullet past the cull margin must not linger")
|
||||
assert_eq(pool.wall_kill_log.size(), 0,
|
||||
"leaving the map is derivable from the map size, so it is not announced")
|
||||
|
||||
|
||||
func test_bullets_stopped_by_a_wall_are_logged_for_announcement() -> void:
|
||||
# A client is only streamed the map near itself, so it cannot work out that
|
||||
# a bullet hit a wall it has never been sent. The server has to say so.
|
||||
var a := _spawn(map.tile_centre(2, 12), Vector2(-600.0, 0.0), 600)
|
||||
var id: int = pool.uid[a]
|
||||
for _i in 10:
|
||||
pool.step()
|
||||
assert_eq(pool.alive[a], 0)
|
||||
assert_true(pool.wall_kill_log.has(id), "wall deaths must be announceable")
|
||||
|
||||
|
||||
## Wall collision samples the bullet's position once per tick, so a bullet that
|
||||
## travels more than one tile per tick can step straight over a wall. Nothing in
|
||||
## the game comes close today, but a future "fast projectile" upgrade could, and
|
||||
## it would look like walls randomly failing.
|
||||
func test_bullet_speeds_stay_below_the_tunnelling_threshold() -> void:
|
||||
var limit := MapGrid.TILE / SimConfig.TICK_DELTA
|
||||
assert_lt(SimConfig.PLAYER_BULLET_SPEED * 2.0, limit,
|
||||
"even at the doubled speed an upgrade could grant, a bullet must not " +
|
||||
"cross a whole tile in one tick or it will tunnel through walls")
|
||||
|
||||
|
||||
func test_a_pool_with_no_map_does_not_cull() -> void:
|
||||
# Standalone pools (used by emitter tests) have no geometry; culling
|
||||
# against nothing would silently delete their bullets.
|
||||
var bare := BulletPool.new()
|
||||
var a := bare.spawn(Vector2(1.0e6, 0.0), Vector2.ZERO, 5.0, 60, 10,
|
||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||
bare.step()
|
||||
assert_eq(bare.alive[a], 1)
|
||||
|
||||
|
||||
func test_pool_saturation_returns_minus_one_rather_than_growing() -> void:
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
extends GutTest
|
||||
## Generator properties. The important one is reachability: a map where the
|
||||
## boss cannot be walked to is a run that cannot be finished, and it would show
|
||||
## up as a player wandering confused rather than as an error.
|
||||
|
||||
|
||||
func _flood_reachable(grid: MapGrid, from: Vector2i) -> Dictionary:
|
||||
var seen := {}
|
||||
var queue: Array[Vector2i] = [from]
|
||||
seen[from] = true
|
||||
while not queue.is_empty():
|
||||
var t: Vector2i = queue.pop_back()
|
||||
for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]:
|
||||
var n: Vector2i = t + d
|
||||
if seen.has(n) or not grid.in_bounds(n.x, n.y):
|
||||
continue
|
||||
if grid.blocks_move(n.x, n.y):
|
||||
continue
|
||||
seen[n] = true
|
||||
queue.append(n)
|
||||
return seen
|
||||
|
||||
|
||||
func test_the_boss_is_always_reachable_from_the_spawn() -> void:
|
||||
# Many seeds, because "usually connected" is the failure mode that survives
|
||||
# a single-seed test and ruins one run in twenty.
|
||||
for s in range(1, 40):
|
||||
var m := MapGen.generate(s * 7919, 1 + (s % 5))
|
||||
var grid: MapGrid = m["grid"]
|
||||
var spawn_tile := grid.to_tile(m["spawn"])
|
||||
var boss_tile := grid.to_tile(m["boss_pos"])
|
||||
var reachable := _flood_reachable(grid, spawn_tile)
|
||||
assert_true(reachable.has(boss_tile),
|
||||
"seed %d: the boss room is walled off from the spawn" % s)
|
||||
|
||||
|
||||
func test_generation_is_deterministic() -> void:
|
||||
var a := MapGen.generate(12345, 3)
|
||||
var b := MapGen.generate(12345, 3)
|
||||
assert_eq((a["grid"] as MapGrid).tiles, (b["grid"] as MapGrid).tiles,
|
||||
"same seed and depth must give the same map, or the server cannot " +
|
||||
"just send two numbers")
|
||||
assert_eq(a["spawn"], b["spawn"])
|
||||
assert_eq(a["boss_pos"], b["boss_pos"])
|
||||
|
||||
|
||||
func test_different_seeds_give_different_maps() -> void:
|
||||
var a := MapGen.generate(1, 2)
|
||||
var b := MapGen.generate(2, 2)
|
||||
assert_ne((a["grid"] as MapGrid).tiles, (b["grid"] as MapGrid).tiles)
|
||||
|
||||
|
||||
func test_maps_grow_with_depth() -> void:
|
||||
var shallow: MapGrid = MapGen.generate(99, 1)["grid"]
|
||||
var deep: MapGrid = MapGen.generate(99, 6)["grid"]
|
||||
assert_gt(deep.width, shallow.width)
|
||||
assert_gt(deep.height, shallow.height)
|
||||
|
||||
|
||||
func test_the_map_is_sealed_at_its_border() -> void:
|
||||
var grid: MapGrid = MapGen.generate(4242, 2)["grid"]
|
||||
for x in grid.width:
|
||||
assert_true(grid.blocks_move(x, 0), "top border leaks at x=%d" % x)
|
||||
assert_true(grid.blocks_move(x, grid.height - 1), "bottom border leaks at x=%d" % x)
|
||||
for y in grid.height:
|
||||
assert_true(grid.blocks_move(0, y), "left border leaks at y=%d" % y)
|
||||
assert_true(grid.blocks_move(grid.width - 1, y), "right border leaks at y=%d" % y)
|
||||
|
||||
|
||||
func test_spawn_and_boss_stand_on_open_ground() -> void:
|
||||
for s in range(1, 20):
|
||||
var m := MapGen.generate(s * 104729, 1 + (s % 4))
|
||||
var grid: MapGrid = m["grid"]
|
||||
assert_false(grid.circle_blocked(m["spawn"], SimConfig.PLAYER_RADIUS),
|
||||
"seed %d: player spawns inside geometry" % s)
|
||||
assert_false(grid.circle_blocked(m["boss_pos"], 40.0),
|
||||
"seed %d: boss spawns inside geometry" % s)
|
||||
|
||||
|
||||
func test_rooms_do_not_overlap() -> void:
|
||||
var rooms: Array = MapGen.generate(777, 4)["rooms"]
|
||||
for i in rooms.size():
|
||||
for j in range(i + 1, rooms.size()):
|
||||
assert_false((rooms[i] as Rect2i).intersects(rooms[j]),
|
||||
"rooms %d and %d overlap" % [i, j])
|
||||
@@ -0,0 +1 @@
|
||||
uid://b8h7yr5onlhsb
|
||||
@@ -0,0 +1,104 @@
|
||||
extends GutTest
|
||||
## The grid is the foundation for collision, bullets, fog and interest
|
||||
## management, so its edge cases are worth pinning down directly rather than
|
||||
## discovering them as strange behaviour three systems away.
|
||||
|
||||
var grid: MapGrid
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
# 10x10 room: solid border, open interior.
|
||||
grid = MapGrid.new(10, 10, MapGrid.Kind.WALL)
|
||||
grid.fill_rect(Rect2i(1, 1, 8, 8), MapGrid.Kind.FLOOR)
|
||||
|
||||
|
||||
func test_out_of_bounds_reads_as_wall() -> void:
|
||||
assert_eq(grid.at(-1, 5), MapGrid.Kind.WALL,
|
||||
"the world has to be sealed without every caller bounds-checking")
|
||||
assert_eq(grid.at(999, 999), MapGrid.Kind.WALL)
|
||||
|
||||
|
||||
func test_tile_flags_are_independent() -> void:
|
||||
# The whole reason for three flags rather than one "solid" bit.
|
||||
assert_true(grid.BLOCKS_MOVE[MapGrid.Kind.PIT], "you cannot walk over a pit")
|
||||
assert_false(grid.BLOCKS_BULLET[MapGrid.Kind.PIT], "but you can shoot over it")
|
||||
assert_false(grid.BLOCKS_SIGHT[MapGrid.Kind.PIT], "and see over it")
|
||||
|
||||
assert_true(grid.BLOCKS_MOVE[MapGrid.Kind.BARRICADE])
|
||||
assert_true(grid.BLOCKS_BULLET[MapGrid.Kind.BARRICADE])
|
||||
assert_false(grid.BLOCKS_SIGHT[MapGrid.Kind.BARRICADE],
|
||||
"chest height: you see over it but cannot shoot or walk through")
|
||||
|
||||
|
||||
func test_world_and_tile_coordinates_round_trip() -> void:
|
||||
assert_eq(grid.to_tile(Vector2(0.0, 0.0)), Vector2i(0, 0))
|
||||
assert_eq(grid.to_tile(Vector2(MapGrid.TILE * 3.5, MapGrid.TILE * 2.5)), Vector2i(3, 2))
|
||||
assert_eq(grid.tile_centre(3, 2), Vector2(MapGrid.TILE * 3.5, MapGrid.TILE * 2.5))
|
||||
assert_eq(grid.world_size(), Vector2(10.0, 10.0) * MapGrid.TILE)
|
||||
|
||||
|
||||
func test_negative_world_positions_map_outside_the_grid() -> void:
|
||||
# floor(), not truncation -- int() would fold -0.5 onto tile 0 and let an
|
||||
# actor stand half a tile outside the map.
|
||||
assert_eq(grid.to_tile(Vector2(-1.0, -1.0)), Vector2i(-1, -1))
|
||||
|
||||
|
||||
func test_circle_collision_against_a_wall() -> void:
|
||||
var open := grid.tile_centre(4, 4)
|
||||
assert_false(grid.circle_blocked(open, 6.0))
|
||||
# Hard against the left wall: tile 0 is solid, so a circle at tile 1's
|
||||
# centre minus most of a tile overlaps it.
|
||||
assert_true(grid.circle_blocked(Vector2(MapGrid.TILE + 2.0, grid.tile_centre(1, 4).y), 6.0))
|
||||
|
||||
|
||||
func test_sliding_along_a_wall_preserves_the_free_axis() -> void:
|
||||
# Moving diagonally into the top wall should keep the horizontal motion.
|
||||
var start := grid.tile_centre(4, 1)
|
||||
var moved := grid.slide_circle(start, Vector2(8.0, -40.0), 6.0)
|
||||
assert_almost_eq(moved.x, start.x + 8.0, 0.001, "x is unobstructed and must not be lost")
|
||||
assert_lt(absf(moved.y - start.y), 40.0, "y is blocked by the wall")
|
||||
|
||||
|
||||
func test_a_circle_cannot_be_pushed_through_a_wall() -> void:
|
||||
var start := grid.tile_centre(4, 1)
|
||||
for _i in 60:
|
||||
start = grid.slide_circle(start, Vector2(0.0, -100.0), 6.0)
|
||||
assert_gt(start.y, MapGrid.TILE, "no amount of shoving may cross a solid tile")
|
||||
|
||||
|
||||
func test_bullets_stop_at_walls_but_cross_pits() -> void:
|
||||
grid.set_tile(4, 4, MapGrid.Kind.PIT)
|
||||
assert_false(grid.bullet_blocked(grid.tile_centre(4, 4)), "bullets fly over pits")
|
||||
assert_true(grid.bullet_blocked(grid.tile_centre(0, 0)), "and stop at walls")
|
||||
|
||||
|
||||
func test_line_of_sight_is_blocked_by_walls_and_not_by_pits() -> void:
|
||||
var a := grid.tile_centre(1, 4)
|
||||
var b := grid.tile_centre(8, 4)
|
||||
assert_true(grid.has_line_of_sight(a, b), "clear floor between them")
|
||||
|
||||
grid.set_tile(4, 4, MapGrid.Kind.PIT)
|
||||
assert_true(grid.has_line_of_sight(a, b), "a pit is a hole, not a screen")
|
||||
|
||||
grid.set_tile(4, 4, MapGrid.Kind.PILLAR)
|
||||
assert_false(grid.has_line_of_sight(a, b), "a pillar blocks it")
|
||||
|
||||
grid.set_tile(4, 4, MapGrid.Kind.BARRICADE)
|
||||
assert_true(grid.has_line_of_sight(a, b), "you can see over a barricade")
|
||||
|
||||
|
||||
func test_line_of_sight_is_symmetric() -> void:
|
||||
grid.set_tile(5, 4, MapGrid.Kind.WALL)
|
||||
var a := grid.tile_centre(1, 4)
|
||||
var b := grid.tile_centre(8, 4)
|
||||
assert_eq(grid.has_line_of_sight(a, b), grid.has_line_of_sight(b, a),
|
||||
"asymmetric sight would mean an enemy can shoot you from cover you " +
|
||||
"cannot shoot back into")
|
||||
|
||||
|
||||
func test_standing_inside_a_wall_can_still_see_out() -> void:
|
||||
# Endpoints must not block, or an actor clipped into geometry goes blind
|
||||
# and its aggro check silently fails.
|
||||
var inside := grid.tile_centre(0, 0)
|
||||
var outside := grid.tile_centre(1, 1)
|
||||
assert_true(grid.has_line_of_sight(inside, outside))
|
||||
@@ -0,0 +1 @@
|
||||
uid://bxisk7s3jdti7
|
||||
+36
-13
@@ -2,30 +2,41 @@ extends GutTest
|
||||
## Movement is the one function the client is allowed to run ahead of the
|
||||
## server, so client prediction is only correct while these hold.
|
||||
|
||||
var map: MapGrid
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
# A plain walled room, big enough that these tests are about movement
|
||||
# rather than about walls.
|
||||
map = MapGrid.new(42, 24, MapGrid.Kind.WALL)
|
||||
map.fill_rect(Rect2i(1, 1, 40, 22), MapGrid.Kind.FLOOR)
|
||||
map.centre_on_origin()
|
||||
|
||||
|
||||
func test_diagonal_is_not_faster_than_cardinal() -> void:
|
||||
var straight := Movement.step_player(Vector2.ZERO, Vector2(1, 0), SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
|
||||
var diagonal := Movement.step_player(Vector2.ZERO, Vector2(1, 1), SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
|
||||
var straight := Movement.step_player(Vector2.ZERO, Vector2(1, 0), SimConfig.PLAYER_SPEED, map)
|
||||
var diagonal := Movement.step_player(Vector2.ZERO, Vector2(1, 1), SimConfig.PLAYER_SPEED, map)
|
||||
assert_almost_eq(diagonal.length(), straight.length(), 0.001,
|
||||
"a diagonal must cover the same distance as a cardinal move")
|
||||
|
||||
|
||||
func test_oversized_input_vector_is_clamped() -> void:
|
||||
# The wire format cannot express this, but a patched client could try.
|
||||
var cheated := Movement.step_player(Vector2.ZERO, Vector2(1000, 0), SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
|
||||
var honest := Movement.step_player(Vector2.ZERO, Vector2(1, 0), SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
|
||||
var cheated := Movement.step_player(Vector2.ZERO, Vector2(1000, 0), SimConfig.PLAYER_SPEED, map)
|
||||
var honest := Movement.step_player(Vector2.ZERO, Vector2(1, 0), SimConfig.PLAYER_SPEED, map)
|
||||
assert_eq(cheated, honest, "an over-long move vector must buy no extra speed")
|
||||
|
||||
|
||||
func test_speed_matches_config() -> void:
|
||||
var moved := Movement.step_player(Vector2.ZERO, Vector2.RIGHT, SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
|
||||
var moved := Movement.step_player(Vector2.ZERO, Vector2.RIGHT, SimConfig.PLAYER_SPEED, map)
|
||||
assert_almost_eq(moved.x, SimConfig.PLAYER_SPEED * SimConfig.TICK_DELTA, 0.001)
|
||||
|
||||
|
||||
func test_player_is_clamped_to_the_arena() -> void:
|
||||
var far := Vector2(SimConfig.ARENA_HALF.x - 1.0, 0.0)
|
||||
var out := Movement.step_player(far, Vector2.RIGHT, 10000.0, SimConfig.ARENA_HALF)
|
||||
assert_almost_eq(out.x, SimConfig.ARENA_HALF.x, 0.001)
|
||||
func test_a_player_cannot_walk_through_the_map_edge() -> void:
|
||||
var far := Vector2(map.world_rect().end.x - MapGrid.TILE * 1.5, 0.0)
|
||||
var out := Movement.step_player(far, Vector2.RIGHT, 10000.0, map)
|
||||
assert_lt(out.x, map.world_rect().end.x - MapGrid.TILE,
|
||||
"the border wall has to stop even an absurd step")
|
||||
|
||||
|
||||
func test_circles_overlap_at_the_boundary() -> void:
|
||||
@@ -33,7 +44,19 @@ func test_circles_overlap_at_the_boundary() -> void:
|
||||
assert_false(Movement.circles_overlap(Vector2.ZERO, 5.0, Vector2(10.1, 0.0), 5.0))
|
||||
|
||||
|
||||
func test_outside_arena_respects_the_cull_margin() -> void:
|
||||
var edge := SimConfig.ARENA_HALF.x + SimConfig.BULLET_CULL_MARGIN
|
||||
assert_false(Movement.outside_arena(Vector2(edge - 1.0, 0.0)))
|
||||
assert_true(Movement.outside_arena(Vector2(edge + 1.0, 0.0)))
|
||||
func test_outside_map_respects_the_cull_margin() -> void:
|
||||
var edge := map.world_rect().end.x + SimConfig.BULLET_CULL_MARGIN
|
||||
assert_false(Movement.outside_map(Vector2(edge - 1.0, 0.0), map))
|
||||
assert_true(Movement.outside_map(Vector2(edge + 1.0, 0.0), map))
|
||||
|
||||
|
||||
## Out-of-map death is derivable from the map's dimensions, which every client
|
||||
## is told; wall death is not, because tiles are streamed. Only the second one
|
||||
## needs announcing, and conflating them would either desync bullets or double
|
||||
## the despawn traffic.
|
||||
func test_map_bounds_and_wall_deaths_are_distinguishable() -> void:
|
||||
var outside := Vector2(map.world_rect().end.x + 100.0, 0.0)
|
||||
assert_true(Movement.outside_map(outside, map))
|
||||
var in_wall := map.tile_centre(0, 12)
|
||||
assert_false(Movement.outside_map(in_wall, map), "a border wall is inside the map")
|
||||
assert_true(Movement.bullet_stopped(in_wall, map))
|
||||
|
||||
Reference in New Issue
Block a user