diff --git a/CLAUDE.md b/CLAUDE.md index ec10a23..c8100dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/docs/NETCODE.md b/docs/NETCODE.md index acd59ac..f1e3186 100644 --- a/docs/NETCODE.md +++ b/docs/NETCODE.md @@ -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 diff --git a/src/actors/enemies/enemy_def.gd b/src/actors/enemies/enemy_def.gd index 8a401b5..1d5fbac 100644 --- a/src/actors/enemies/enemy_def.gd +++ b/src/actors/enemies/enemy_def.gd @@ -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] = [] diff --git a/src/autoload/net.gd b/src/autoload/net.gd index 4176f0d..c0b59df 100644 --- a/src/autoload/net.gd +++ b/src/autoload/net.gd @@ -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) diff --git a/src/content/rooms.gd b/src/content/rooms.gd new file mode 100644 index 0000000..987d27a --- /dev/null +++ b/src/content/rooms.gd @@ -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...................#", + "#.......................................#", + "#.......................................#", + "#.......................................#", + "#.......................................#", + "#########################################", + ]) diff --git a/src/content/rooms.gd.uid b/src/content/rooms.gd.uid new file mode 100644 index 0000000..bb5b4a9 --- /dev/null +++ b/src/content/rooms.gd.uid @@ -0,0 +1 @@ +uid://cb5nycudm0o6e diff --git a/src/core/game_opts.gd b/src/core/game_opts.gd index c726c84..a0035de 100644 --- a/src/core/game_opts.gd +++ b/src/core/game_opts.gd @@ -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(): diff --git a/src/core/movement.gd b/src/core/movement.gd index 92607de..8ef544f 100644 --- a/src/core/movement.gd +++ b/src/core/movement.gd @@ -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) diff --git a/src/core/sim_config.gd b/src/core/sim_config.gd index 0528fdc..000eda4 100644 --- a/src/core/sim_config.gd +++ b/src/core/sim_config.gd @@ -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 diff --git a/src/instances/instance.gd b/src/instances/instance.gd index 4742bb3..c8c3cbe 100644 --- a/src/instances/instance.gd +++ b/src/instances/instance.gd @@ -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) diff --git a/src/net/client_runtime.gd b/src/net/client_runtime.gd index c9f37e8..114a4ac 100644 --- a/src/net/client_runtime.gd +++ b/src/net/client_runtime.gd @@ -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 diff --git a/src/net/net_codec.gd b/src/net/net_codec.gd index 1da7f5a..a4bb5b0 100644 --- a/src/net/net_codec.gd +++ b/src/net/net_codec.gd @@ -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 diff --git a/src/net/server_runtime.gd b/src/net/server_runtime.gd index eddcd55..67dcb71 100644 --- a/src/net/server_runtime.gd +++ b/src/net/server_runtime.gd @@ -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 diff --git a/src/sim/bullet_pool.gd b/src/sim/bullet_pool.gd index 852c82c..111ab3a 100644 --- a/src/sim/bullet_pool.gd +++ b/src/sim/bullet_pool.gd @@ -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 + diff --git a/src/sim/map_gen.gd b/src/sim/map_gen.gd new file mode 100644 index 0000000..3d59871 --- /dev/null +++ b/src/sim/map_gen.gd @@ -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) diff --git a/src/sim/map_gen.gd.uid b/src/sim/map_gen.gd.uid new file mode 100644 index 0000000..4a354e6 --- /dev/null +++ b/src/sim/map_gen.gd.uid @@ -0,0 +1 @@ +uid://dfj588h3drql3 diff --git a/src/sim/map_grid.gd b/src/sim/map_grid.gd new file mode 100644 index 0000000..319c605 --- /dev/null +++ b/src/sim/map_grid.gd @@ -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 diff --git a/src/sim/map_grid.gd.uid b/src/sim/map_grid.gd.uid new file mode 100644 index 0000000..132c4a9 --- /dev/null +++ b/src/sim/map_grid.gd.uid @@ -0,0 +1 @@ +uid://cly0bi7sxe5gf diff --git a/src/sim/patterns/emit_context.gd b/src/sim/patterns/emit_context.gd index ccfd0e5..80705fb 100644 --- a/src/sim/patterns/emit_context.gd +++ b/src/sim/patterns/emit_context.gd @@ -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: diff --git a/src/sim/patterns/wall_gap_emitter.gd b/src/sim/patterns/wall_gap_emitter.gd index 6db5825..10945e4 100644 --- a/src/sim/patterns/wall_gap_emitter.gd +++ b/src/sim/patterns/wall_gap_emitter.gd @@ -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: diff --git a/src/sim/sim_boss.gd b/src/sim/sim_boss.gd index 4a7b99d..b1e7c45 100644 --- a/src/sim/sim_boss.gd +++ b/src/sim/sim_boss.gd @@ -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: diff --git a/src/sim/sim_enemy.gd b/src/sim/sim_enemy.gd index 35eb139..6e829cf 100644 --- a/src/sim/sim_enemy.gd +++ b/src/sim/sim_enemy.gd @@ -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: diff --git a/src/sim/sim_world.gd b/src/sim/sim_world.gd index 146498a..13d5ee9 100644 --- a/src/sim/sim_world.gd +++ b/src/sim/sim_world.gd @@ -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: diff --git a/src/view/game_scene.gd b/src/view/game_scene.gd index 426c40b..debc5f6 100644 --- a/src/view/game_scene.gd +++ b/src/view/game_scene.gd @@ -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) diff --git a/src/view/world_view.gd b/src/view/world_view.gd index f02a863..48e9095 100644 --- a/src/view/world_view.gd +++ b/src/view/world_view.gd @@ -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"] diff --git a/tests/integration/test_dungeon_flow.gd b/tests/integration/test_dungeon_flow.gd index 19fa38c..25af6fb 100644 --- a/tests/integration/test_dungeon_flow.gd +++ b/tests/integration/test_dungeon_flow.gd @@ -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) diff --git a/tests/unit/test_aggro.gd b/tests/unit/test_aggro.gd new file mode 100644 index 0000000..13ea503 --- /dev/null +++ b/tests/unit/test_aggro.gd @@ -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") diff --git a/tests/unit/test_aggro.gd.uid b/tests/unit/test_aggro.gd.uid new file mode 100644 index 0000000..e6da13e --- /dev/null +++ b/tests/unit/test_aggro.gd.uid @@ -0,0 +1 @@ +uid://demetqlhmetq6 diff --git a/tests/unit/test_bullet_pool.gd b/tests/unit/test_bullet_pool.gd index 0fb2056..34bb7c2 100644 --- a/tests/unit/test_bullet_pool.gd +++ b/tests/unit/test_bullet_pool.gd @@ -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: diff --git a/tests/unit/test_map_gen.gd b/tests/unit/test_map_gen.gd new file mode 100644 index 0000000..0f7ffef --- /dev/null +++ b/tests/unit/test_map_gen.gd @@ -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]) diff --git a/tests/unit/test_map_gen.gd.uid b/tests/unit/test_map_gen.gd.uid new file mode 100644 index 0000000..201c9e5 --- /dev/null +++ b/tests/unit/test_map_gen.gd.uid @@ -0,0 +1 @@ +uid://b8h7yr5onlhsb diff --git a/tests/unit/test_map_grid.gd b/tests/unit/test_map_grid.gd new file mode 100644 index 0000000..33ce2eb --- /dev/null +++ b/tests/unit/test_map_grid.gd @@ -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)) diff --git a/tests/unit/test_map_grid.gd.uid b/tests/unit/test_map_grid.gd.uid new file mode 100644 index 0000000..becd20c --- /dev/null +++ b/tests/unit/test_map_grid.gd.uid @@ -0,0 +1 @@ +uid://bxisk7s3jdti7 diff --git a/tests/unit/test_movement.gd b/tests/unit/test_movement.gd index 58a0454..048bf2a 100644 --- a/tests/unit/test_movement.gd +++ b/tests/unit/test_movement.gd @@ -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))