Files
transcience/src/core/movement.gd
T
claude 7af439341d
ci / verify (push) Successful in 46s
Stage 1: tile maps, walls, fog of war, aggro, scrolling camera
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.
2026-09-03 20:49:27 +02:00

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)