7af439341d
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.
487 lines
16 KiB
GDScript
487 lines
16 KiB
GDScript
class_name SimWorld
|
|
extends RefCounted
|
|
## The whole game simulation for one instance (a lobby or one dungeon run).
|
|
##
|
|
## It is a plain RefCounted with no nodes, no physics server and no rendering,
|
|
## which buys three things: the dedicated server runs it headless at negligible
|
|
## cost, a unit test can drive a thousand ticks in milliseconds, and the client
|
|
## can run the exact same class in replica mode for prediction.
|
|
##
|
|
## Authority: [member authoritative] is true on the server only. In replica mode
|
|
## the world never runs AI, never fires emitters and never resolves a hit -- it
|
|
## only integrates bullets it was told about and holds actor state for drawing.
|
|
## That is the whole anti-cheat story: a client physically has no code path that
|
|
## can decide it dealt or avoided damage.
|
|
|
|
var tick: int = 0
|
|
var authoritative: bool = true
|
|
var pool := BulletPool.new()
|
|
var rng := RandomNumberGenerator.new()
|
|
|
|
var players: Dictionary[int, SimPlayer] = {}
|
|
var enemies: Dictionary[int, SimEnemy] = {}
|
|
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.
|
|
var spawn_grace_ticks: int = 0
|
|
|
|
var _next_actor_id: int = 1
|
|
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:
|
|
var id := _next_actor_id
|
|
_next_actor_id += 1
|
|
return id
|
|
|
|
|
|
# --- Population -------------------------------------------------------------
|
|
|
|
func add_player(peer_id: int, display_name: String) -> SimPlayer:
|
|
var p := SimPlayer.new()
|
|
p.peer_id = peer_id
|
|
p.display_name = display_name
|
|
p.reset_for_instance(spawn_point, spawn_grace_ticks)
|
|
players[peer_id] = p
|
|
return p
|
|
|
|
|
|
func remove_player(peer_id: int) -> void:
|
|
players.erase(peer_id)
|
|
|
|
|
|
func spawn_enemy(def: EnemyDef, at: Vector2, phase_offset: int = 0) -> SimEnemy:
|
|
var e := SimEnemy.new()
|
|
e.id = next_actor_id()
|
|
e.def = def
|
|
e.pos = at
|
|
e.home = at
|
|
e.hp = def.max_hp
|
|
e.phase_offset = phase_offset
|
|
e.heading = Vector2.RIGHT.rotated(rng.randf() * TAU)
|
|
enemies[e.id] = e
|
|
return e
|
|
|
|
|
|
func spawn_boss(def: BossDef) -> SimBoss:
|
|
var b := SimBoss.new()
|
|
b.id = next_actor_id()
|
|
b.def = def
|
|
b.pos = def.spawn_pos
|
|
b.hp = def.max_hp
|
|
boss = b
|
|
return b
|
|
|
|
|
|
func alive_player_count() -> int:
|
|
var n := 0
|
|
for p in players.values():
|
|
if p.alive:
|
|
n += 1
|
|
return n
|
|
|
|
|
|
## Nearest living player to [param from]. Aimed emitters and chasing enemies use
|
|
## this; it is the only targeting primitive in the game.
|
|
func nearest_player(from: Vector2) -> SimPlayer:
|
|
var best: SimPlayer = null
|
|
var best_d := INF
|
|
for p in players.values():
|
|
if not p.alive:
|
|
continue
|
|
var d := from.distance_squared_to(p.pos)
|
|
if d < best_d:
|
|
best_d = d
|
|
best = p
|
|
return best
|
|
|
|
|
|
# --- Input ------------------------------------------------------------------
|
|
|
|
## Accept a batch of client inputs. Everything questionable is dropped here
|
|
## rather than deeper in, so there is a single place to audit.
|
|
func queue_input(peer_id: int, frames: Array[InputFrame]) -> void:
|
|
var p: SimPlayer = players.get(peer_id)
|
|
if p == null:
|
|
return
|
|
for f in frames:
|
|
if f.tick <= p.last_input_tick:
|
|
continue # replay or out-of-order duplicate
|
|
if f.tick < tick - SimConfig.INPUT_MAX_AGE:
|
|
continue # too old to matter
|
|
if f.tick > tick + SimConfig.INPUT_MAX_LEAD:
|
|
continue # client claiming to be far in the future
|
|
if p.input_queue.size() >= SimConfig.INPUT_MAX_AGE:
|
|
p.input_queue.pop_front() # flood guard
|
|
p.input_queue.append(f)
|
|
p.input_queue.sort_custom(func(a: InputFrame, b: InputFrame) -> bool: return a.tick < b.tick)
|
|
|
|
|
|
# --- Tick -------------------------------------------------------------------
|
|
|
|
func step() -> void:
|
|
tick += 1
|
|
if authoritative:
|
|
pool.clear_spawn_log()
|
|
_step_players()
|
|
_step_enemies()
|
|
_step_boss()
|
|
pool.step()
|
|
_resolve_bullet_hits()
|
|
_emit_wall_kill_events()
|
|
_emit_spawn_events()
|
|
else:
|
|
# Replica: bullets only. Actor state arrives in snapshots.
|
|
pool.step()
|
|
|
|
|
|
func _step_players() -> void:
|
|
for p in players.values():
|
|
if p.spawn_grace > 0:
|
|
p.spawn_grace -= 1
|
|
if p.fire_cooldown > 0:
|
|
p.fire_cooldown -= 1
|
|
|
|
var frame := _take_input(p)
|
|
|
|
if not p.alive:
|
|
if p.respawn_lockout > 0:
|
|
p.respawn_lockout -= 1
|
|
# No timed respawn: a downed player waits for the hub. Asking to go
|
|
# is an input like any other, so a dead client cannot be revived by
|
|
# anything except its own request reaching the server -- and not
|
|
# before the lockout expires, however early its UI lets it ask.
|
|
if p.can_request_respawn() and (frame.pressed(InputFrame.BTN_INTERACT) or p.linkdead):
|
|
events.append({"t": SimEvent.Type.RESPAWN_REQUESTED, "peer": p.peer_id})
|
|
continue
|
|
|
|
p.aim = frame.aim
|
|
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)
|
|
|
|
_step_escape(p, frame)
|
|
|
|
if portal_enabled and frame.pressed(InputFrame.BTN_INTERACT):
|
|
if p.pos.distance_to(portal_pos) <= SimConfig.PORTAL_RADIUS:
|
|
events.append({"t": SimEvent.Type.PORTAL_USED, "peer": p.peer_id})
|
|
|
|
|
|
## Pull the next input for this player, or coast on the last one. Coasting is
|
|
## capped so a client that stops sending cannot keep moving forever.
|
|
func _take_input(p: SimPlayer) -> InputFrame:
|
|
if not p.input_queue.is_empty():
|
|
var f: InputFrame = p.input_queue.pop_front()
|
|
p.last_input_tick = f.tick
|
|
p.held_input = f
|
|
p.starved_ticks = 0
|
|
return f
|
|
p.starved_ticks += 1
|
|
if p.starved_ticks > SimConfig.INPUT_MAX_AGE:
|
|
var idle := InputFrame.new()
|
|
idle.aim = p.held_input.aim
|
|
p.held_input = idle
|
|
return p.held_input
|
|
|
|
|
|
func _fire_player_shot(p: SimPlayer) -> void:
|
|
p.fire_cooldown = SimConfig.PLAYER_FIRE_COOLDOWN
|
|
var dir := Vector2.RIGHT.rotated(p.aim)
|
|
pool.spawn(
|
|
p.pos + dir * SimConfig.PLAYER_MUZZLE_OFFSET,
|
|
dir * SimConfig.PLAYER_BULLET_SPEED,
|
|
SimConfig.PLAYER_BULLET_RADIUS,
|
|
SimConfig.PLAYER_BULLET_LIFETIME,
|
|
SimConfig.PLAYER_BULLET_DAMAGE,
|
|
SimConfig.TEAM_PLAYER,
|
|
SimConfig.KIND_PLAYER_SHOT)
|
|
|
|
|
|
func _step_escape(p: SimPlayer, frame: InputFrame) -> void:
|
|
# A dropped connection is treated as holding the button down. Pulling the
|
|
# plug then costs exactly what pressing escape costs -- one second of
|
|
# standing there, still killable -- instead of an instant, safe exit.
|
|
if p.linkdead or frame.pressed(InputFrame.BTN_ESCAPE):
|
|
if p.escape_ticks == 0:
|
|
events.append({"t": SimEvent.Type.ESCAPE_STARTED, "peer": p.peer_id})
|
|
p.escape_ticks += 1
|
|
if p.escape_ticks >= SimConfig.ESCAPE_CHANNEL_TICKS:
|
|
p.escape_ticks = 0
|
|
events.append({"t": SimEvent.Type.ESCAPE_COMPLETED, "peer": p.peer_id})
|
|
elif p.escape_ticks > 0:
|
|
p.escape_ticks = 0
|
|
events.append({"t": SimEvent.Type.ESCAPE_CANCELLED, "peer": p.peer_id})
|
|
|
|
|
|
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)
|
|
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:
|
|
var dt := SimConfig.TICK_DELTA
|
|
var speed: float = e.def.speed
|
|
match e.def.move:
|
|
EnemyDef.Move.STATIC:
|
|
pass
|
|
EnemyDef.Move.DRIFT:
|
|
# 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
|
|
if absf(next.y - e.pos.y) < absf(delta.y) - 0.001:
|
|
e.heading.y = -e.heading.y
|
|
e.pos = next
|
|
EnemyDef.Move.ORBIT:
|
|
var a := float(e.local_tick + e.phase_offset) * dt * (speed / maxf(e.def.move_param, 1.0))
|
|
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:
|
|
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 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:
|
|
if boss == null or not boss.alive or boss.def == null:
|
|
return
|
|
var wanted := boss.def.phase_index_for(boss.hp_fraction())
|
|
if wanted != boss.phase_index:
|
|
boss.phase_index = wanted
|
|
boss.phase_tick = 0
|
|
events.append({"t": SimEvent.Type.BOSS_PHASE, "phase": wanted})
|
|
var phase := boss.current_phase()
|
|
if phase == null:
|
|
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, 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,
|
|
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
|
|
for e in emitters:
|
|
if e == null or not e.should_fire(local_tick):
|
|
continue
|
|
_ctx.shot_index = e.shot_index_at(local_tick)
|
|
e.fire(_ctx)
|
|
|
|
|
|
# --- Hit resolution (server only) -------------------------------------------
|
|
|
|
func _resolve_bullet_hits() -> void:
|
|
for i in pool.high_water:
|
|
if pool.alive[i] == 0:
|
|
continue
|
|
var bp: Vector2 = pool.pos[i]
|
|
var br: float = pool.radius[i]
|
|
if pool.team[i] == SimConfig.TEAM_ENEMY:
|
|
for p in players.values():
|
|
# invulnerable() covers arrival grace as well as post-hit
|
|
# i-frames -- a player who just walked into a live bullet field
|
|
# must not be hit by what was already in the air.
|
|
if not p.alive or p.invulnerable():
|
|
continue
|
|
if Movement.circles_overlap(bp, br, p.pos, SimConfig.PLAYER_RADIUS):
|
|
_damage_player(p, pool.damage[i])
|
|
_kill_bullet(i)
|
|
break
|
|
else:
|
|
var consumed := false
|
|
if boss != null and boss.alive \
|
|
and Movement.circles_overlap(bp, br, boss.pos, boss.def.radius):
|
|
_damage_boss(pool.damage[i])
|
|
consumed = true
|
|
if not consumed:
|
|
for e in enemies.values():
|
|
if not e.alive:
|
|
continue
|
|
if Movement.circles_overlap(bp, br, e.pos, e.def.radius):
|
|
_damage_enemy(e, pool.damage[i])
|
|
consumed = true
|
|
break
|
|
if consumed:
|
|
_kill_bullet(i)
|
|
|
|
|
|
## Bullets removed early must be announced -- clients cannot derive a hit.
|
|
func _kill_bullet(slot: int) -> void:
|
|
events.append({"t": SimEvent.Type.BULLET_DESPAWN, "uid": pool.uid[slot]})
|
|
pool.despawn(slot)
|
|
|
|
|
|
## Damage deliberately does NOT interrupt an escape channel -- see the note on
|
|
## SimConfig.ESCAPE_CHANNEL_TICKS for why that would reward pulling the plug.
|
|
func _damage_player(p: SimPlayer, amount: int) -> void:
|
|
p.hp = maxi(p.hp - amount, 0)
|
|
events.append({"t": SimEvent.Type.PLAYER_HIT, "peer": p.peer_id, "dmg": amount, "hp": p.hp})
|
|
if p.hp <= 0:
|
|
p.alive = false
|
|
p.escape_ticks = 0
|
|
p.respawn_lockout = SimConfig.RESPAWN_LOCKOUT_TICKS
|
|
events.append({"t": SimEvent.Type.PLAYER_DIED, "peer": p.peer_id})
|
|
|
|
|
|
func _damage_enemy(e: SimEnemy, amount: int) -> void:
|
|
e.hp = maxi(e.hp - amount, 0)
|
|
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": e.id, "dmg": amount, "hp": e.hp})
|
|
if e.hp <= 0:
|
|
e.alive = false
|
|
events.append({"t": SimEvent.Type.ENEMY_DIED, "id": e.id})
|
|
|
|
|
|
func _damage_boss(amount: int) -> void:
|
|
var phase := boss.current_phase()
|
|
var mult := 1.0 if phase == null else phase.damage_taken_mult
|
|
var applied := maxi(1, roundi(float(amount) * mult))
|
|
boss.hp = maxi(boss.hp - applied, 0)
|
|
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": boss.id, "dmg": applied, "hp": boss.hp})
|
|
if boss.hp <= 0:
|
|
boss.alive = false
|
|
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:
|
|
continue # spawned and killed inside the same tick
|
|
events.append({
|
|
"t": SimEvent.Type.BULLET_SPAWN,
|
|
"uid": pool.uid[slot],
|
|
"pos": pool.pos[slot],
|
|
"vel": pool.vel[slot],
|
|
"r": pool.radius[slot],
|
|
"life": pool.life[slot],
|
|
"kind": pool.kind[slot],
|
|
"team": pool.team[slot],
|
|
"accel": pool.accel[slot],
|
|
"turn": pool.turn[slot],
|
|
})
|
|
|
|
|
|
func drain_events() -> Array[Dictionary]:
|
|
var out := events
|
|
events = []
|
|
return out
|
|
|
|
|
|
# --- Replica side -----------------------------------------------------------
|
|
|
|
## Apply a server event to a non-authoritative world.
|
|
func apply_event(ev: Dictionary) -> void:
|
|
match int(ev["t"]):
|
|
SimEvent.Type.BULLET_SPAWN:
|
|
pool.spawn(ev["pos"], ev["vel"], ev["r"], ev["life"], 0,
|
|
ev["team"], ev["kind"], ev["accel"], ev["turn"], ev["uid"])
|
|
SimEvent.Type.BULLET_DESPAWN:
|
|
var slot := pool.find_by_uid(int(ev["uid"]))
|
|
if slot >= 0:
|
|
pool.despawn(slot)
|
|
_:
|
|
pass
|