ci / verify (push) Successful in 46s
Replaces the fixed centred arena with per-world tile geometry, which is the foundation the remaining features sit on. - MapGrid: tile grid with three independent flags -- blocks movement, bullets, sight -- so a pit stops feet but not bullets or eyes, and a barricade stops feet and bullets but not eyes. Circle collision with per-axis sliding, and Bresenham line of sight shared by fog and aggro. - MapGen: rooms and corridors generated from (seed, depth), with hand-authored boss arenas from Rooms stamped in first so a corridor can never carve through a designed fight. Reachability from spawn to boss is asserted over 40 seeds -- "usually connected" is the failure mode that ruins one run in twenty. - Dungeons are populated at creation, per room, instead of gating on waves. You explore and choose your fights; the run ends when the boss dies, not when the map is swept. Boss rooms have no lock, so walking out is always available. - Aggro: enemies need range AND line of sight, so a dungeon stays quiet until engaged and cover actually protects. - Hard fog, scrolling camera, and terrain rendering. Maps are streamed per peer in chunks around that peer's player, and the seed is deliberately NOT sent -- a client holding it could regenerate the whole dungeon, which is a map hack for free. Stream radius (900u) is wider than view radius (460u) because the client predicts movement against walls and simulates bullets that die on them; the accepted cost is a cheater seeing a little further than the fog, never the floor plan. Partial map knowledge means wall deaths must be announced rather than derived. A test caught the subtle half of that: out-of-bounds tiles read as WALL by design, so checking geometry before bounds reported every bullet leaving the map as a wall kill. 128 tests (was 103); check.sh, test.sh and smoke.sh pass. 0.26 ms/tick with 4 players and a boss, ~65x headroom.
45 lines
1.7 KiB
GDScript
45 lines
1.7 KiB
GDScript
class_name Movement
|
|
extends RefCounted
|
|
## Pure movement helpers. These run on the server (authoritative) and on the
|
|
## client (prediction). They must stay side-effect free so a client can replay
|
|
## them over a history of inputs and land on the same result as the server.
|
|
|
|
## Clamp a raw analogue/keyboard vector to a unit disc.
|
|
static func sanitize_move(raw: Vector2) -> Vector2:
|
|
if raw.length_squared() > 1.0:
|
|
return raw.normalized()
|
|
return raw
|
|
|
|
|
|
## One tick of player locomotion. Returns the new position.
|
|
##
|
|
## 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.
|
|
static func circles_overlap(a: Vector2, ar: float, b: Vector2, br: float) -> bool:
|
|
var r := ar + br
|
|
return a.distance_squared_to(b) <= r * r
|
|
|
|
|
|
## 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)
|