Replaces the fixed centred arena with per-world tile geometry, which is the foundation the remaining features sit on. - MapGrid: tile grid with three independent flags -- blocks movement, bullets, sight -- so a pit stops feet but not bullets or eyes, and a barricade stops feet and bullets but not eyes. Circle collision with per-axis sliding, and Bresenham line of sight shared by fog and aggro. - MapGen: rooms and corridors generated from (seed, depth), with hand-authored boss arenas from Rooms stamped in first so a corridor can never carve through a designed fight. Reachability from spawn to boss is asserted over 40 seeds -- "usually connected" is the failure mode that ruins one run in twenty. - Dungeons are populated at creation, per room, instead of gating on waves. You explore and choose your fights; the run ends when the boss dies, not when the map is swept. Boss rooms have no lock, so walking out is always available. - Aggro: enemies need range AND line of sight, so a dungeon stays quiet until engaged and cover actually protects. - Hard fog, scrolling camera, and terrain rendering. Maps are streamed per peer in chunks around that peer's player, and the seed is deliberately NOT sent -- a client holding it could regenerate the whole dungeon, which is a map hack for free. Stream radius (900u) is wider than view radius (460u) because the client predicts movement against walls and simulates bullets that die on them; the accepted cost is a cheater seeing a little further than the fog, never the floor plan. Partial map knowledge means wall deaths must be announced rather than derived. A test caught the subtle half of that: out-of-bounds tiles read as WALL by design, so checking geometry before bounds reported every bullet leaving the map as a wall kill. 128 tests (was 103); check.sh, test.sh and smoke.sh pass. 0.26 ms/tick with 4 players and a boss, ~65x headroom.
This commit is contained in:
+91
-33
@@ -25,8 +25,15 @@ var boss: SimBoss = null
|
||||
## Drained by the owner every tick. See [SimEvent].
|
||||
var events: Array[Dictionary] = []
|
||||
|
||||
## The world's static geometry. Never null: worlds without a generated map get
|
||||
## a plain walled room, which keeps every caller free of null checks and lets a
|
||||
## unit test build a world without thinking about terrain.
|
||||
var map: MapGrid = null
|
||||
|
||||
## Set on a lobby world so the interact button can open a dungeon.
|
||||
var portal_enabled: bool = false
|
||||
## Where the hub's dungeon portal sits. Per-world now that maps vary in size.
|
||||
var portal_pos := Vector2.ZERO
|
||||
var spawn_point := Vector2(0.0, 240.0)
|
||||
## Arrival protection granted to players entering this world. 0 in the hub,
|
||||
## SimConfig.SPAWN_GRACE_TICKS in a dungeon.
|
||||
@@ -39,6 +46,22 @@ var _ctx := EmitContext.new()
|
||||
func _init(seed_value: int = 0) -> void:
|
||||
rng.seed = seed_value
|
||||
_ctx.rng = rng
|
||||
set_map(_default_room())
|
||||
|
||||
|
||||
## A walled box roughly the size of the old fixed arena, so a world built with
|
||||
## no map behaves the way the game did before terrain existed.
|
||||
static func _default_room() -> MapGrid:
|
||||
var g := MapGrid.new(42, 24, MapGrid.Kind.WALL)
|
||||
g.fill_rect(Rect2i(1, 1, 40, 22), MapGrid.Kind.FLOOR)
|
||||
g.centre_on_origin()
|
||||
return g
|
||||
|
||||
|
||||
func set_map(new_map: MapGrid) -> void:
|
||||
map = new_map
|
||||
# The pool culls bullets against the same geometry, on both sides.
|
||||
pool.map = new_map
|
||||
|
||||
|
||||
func next_actor_id() -> int:
|
||||
@@ -140,6 +163,7 @@ func step() -> void:
|
||||
_step_boss()
|
||||
pool.step()
|
||||
_resolve_bullet_hits()
|
||||
_emit_wall_kill_events()
|
||||
_emit_spawn_events()
|
||||
else:
|
||||
# Replica: bullets only. Actor state arrives in snapshots.
|
||||
@@ -167,7 +191,7 @@ func _step_players() -> void:
|
||||
continue
|
||||
|
||||
p.aim = frame.aim
|
||||
p.pos = Movement.step_player(p.pos, frame.move, SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
|
||||
p.pos = Movement.step_player(p.pos, frame.move, SimConfig.PLAYER_SPEED, map)
|
||||
|
||||
if frame.pressed(InputFrame.BTN_FIRE) and p.can_fire():
|
||||
_fire_player_shot(p)
|
||||
@@ -175,7 +199,7 @@ func _step_players() -> void:
|
||||
_step_escape(p, frame)
|
||||
|
||||
if portal_enabled and frame.pressed(InputFrame.BTN_INTERACT):
|
||||
if p.pos.distance_to(SimConfig.PORTAL_POS) <= SimConfig.PORTAL_RADIUS:
|
||||
if p.pos.distance_to(portal_pos) <= SimConfig.PORTAL_RADIUS:
|
||||
events.append({"t": SimEvent.Type.PORTAL_USED, "peer": p.peer_id})
|
||||
|
||||
|
||||
@@ -229,10 +253,30 @@ func _step_enemies() -> void:
|
||||
for e in enemies.values():
|
||||
if not e.alive:
|
||||
continue
|
||||
# Aggro is re-evaluated every tick and gates both movement and fire, so
|
||||
# a dungeon full of enemies is quiet until you walk into it -- which is
|
||||
# what makes exploring a decision rather than a countdown.
|
||||
e.target = _aggro_target(e)
|
||||
_move_enemy(e)
|
||||
_run_emitters(e.def.emitters, e.pos,
|
||||
posmod(e.local_tick + e.phase_offset, maxi(e.def.pattern_loop_ticks, 1)))
|
||||
e.local_tick += 1
|
||||
if e.target != null:
|
||||
_run_emitters(e.def.emitters, e.pos,
|
||||
posmod(e.local_tick + e.phase_offset, maxi(e.def.pattern_loop_ticks, 1)))
|
||||
e.local_tick += 1
|
||||
|
||||
|
||||
## Nearest living player within aggro range and in line of sight. Sight matters
|
||||
## as much as range: an enemy that shoots you through a wall makes cover
|
||||
## meaningless, and one that never loses you makes retreating impossible.
|
||||
func _aggro_target(e: SimEnemy) -> SimPlayer:
|
||||
var t := nearest_player(e.pos)
|
||||
if t == null:
|
||||
return null
|
||||
var range_sq: float = e.def.aggro_range * e.def.aggro_range
|
||||
if e.pos.distance_squared_to(t.pos) > range_sq:
|
||||
return null
|
||||
if not map.has_line_of_sight(e.pos, t.pos):
|
||||
return null
|
||||
return t
|
||||
|
||||
|
||||
func _move_enemy(e: SimEnemy) -> void:
|
||||
@@ -242,41 +286,41 @@ func _move_enemy(e: SimEnemy) -> void:
|
||||
EnemyDef.Move.STATIC:
|
||||
pass
|
||||
EnemyDef.Move.DRIFT:
|
||||
var next := e.pos + e.heading * speed * dt
|
||||
# Bounce off the arena so a drifter never leaves the fight.
|
||||
if absf(next.x) > SimConfig.ARENA_HALF.x - e.def.radius:
|
||||
# Drifters patrol whether or not they have seen anyone; everything
|
||||
# else only moves once aggroed.
|
||||
var delta := e.heading * speed * dt
|
||||
var next := map.slide_circle(e.pos, delta, e.def.radius)
|
||||
# Reflect on whichever axis the map refused. Works for the outer
|
||||
# wall and an interior pillar alike, with no special cases.
|
||||
if absf(next.x - e.pos.x) < absf(delta.x) - 0.001:
|
||||
e.heading.x = -e.heading.x
|
||||
next.x = clampf(next.x, -SimConfig.ARENA_HALF.x + e.def.radius, SimConfig.ARENA_HALF.x - e.def.radius)
|
||||
if absf(next.y) > SimConfig.ARENA_HALF.y - e.def.radius:
|
||||
if absf(next.y - e.pos.y) < absf(delta.y) - 0.001:
|
||||
e.heading.y = -e.heading.y
|
||||
next.y = clampf(next.y, -SimConfig.ARENA_HALF.y + e.def.radius, SimConfig.ARENA_HALF.y - e.def.radius)
|
||||
e.pos = next
|
||||
EnemyDef.Move.ORBIT:
|
||||
var a := float(e.local_tick + e.phase_offset) * dt * (speed / maxf(e.def.move_param, 1.0))
|
||||
e.pos = e.home + Vector2.RIGHT.rotated(a) * e.def.move_param
|
||||
var want := e.home + Vector2.RIGHT.rotated(a) * e.def.move_param
|
||||
e.pos = map.slide_circle(e.pos, want - e.pos, e.def.radius)
|
||||
EnemyDef.Move.APPROACH:
|
||||
if e.target == null:
|
||||
return
|
||||
if e.local_tick % maxi(e.def.retarget_interval, 1) == 0:
|
||||
var t := nearest_player(e.pos)
|
||||
e.target_dir = Vector2.ZERO if t == null else (t.pos - e.pos).normalized()
|
||||
e.pos += e.target_dir * speed * dt
|
||||
e.target_dir = (e.target.pos - e.pos).normalized()
|
||||
e.pos = map.slide_circle(e.pos, e.target_dir * speed * dt, e.def.radius)
|
||||
EnemyDef.Move.STRAFE:
|
||||
if e.target == null:
|
||||
return
|
||||
if e.local_tick % maxi(e.def.retarget_interval, 1) == 0:
|
||||
var t := nearest_player(e.pos)
|
||||
if t == null:
|
||||
e.target_dir = Vector2.ZERO
|
||||
else:
|
||||
var to_player := t.pos - e.pos
|
||||
var d := to_player.length()
|
||||
var radial := 0.0
|
||||
if d > e.def.move_param + 20.0:
|
||||
radial = 1.0
|
||||
elif d < e.def.move_param - 20.0:
|
||||
radial = -1.0
|
||||
var toward := Vector2.ZERO if d < 0.001 else to_player / d
|
||||
e.target_dir = (toward * radial + toward.orthogonal() * 0.7).normalized()
|
||||
e.pos += e.target_dir * speed * dt
|
||||
e.pos.x = clampf(e.pos.x, -SimConfig.ARENA_HALF.x, SimConfig.ARENA_HALF.x)
|
||||
e.pos.y = clampf(e.pos.y, -SimConfig.ARENA_HALF.y, SimConfig.ARENA_HALF.y)
|
||||
var to_player := e.target.pos - e.pos
|
||||
var d := to_player.length()
|
||||
var radial := 0.0
|
||||
if d > e.def.move_param + 20.0:
|
||||
radial = 1.0
|
||||
elif d < e.def.move_param - 20.0:
|
||||
radial = -1.0
|
||||
var toward := Vector2.ZERO if d < 0.001 else to_player / d
|
||||
e.target_dir = (toward * radial + toward.orthogonal() * 0.7).normalized()
|
||||
e.pos = map.slide_circle(e.pos, e.target_dir * speed * dt, e.def.radius)
|
||||
|
||||
|
||||
func _step_boss() -> void:
|
||||
@@ -292,18 +336,25 @@ func _step_boss() -> void:
|
||||
return
|
||||
if boss.phase_tick >= phase.telegraph_ticks:
|
||||
var local := posmod(boss.phase_tick - phase.telegraph_ticks, maxi(phase.loop_ticks, 1))
|
||||
_run_emitters(phase.emitters, boss.pos, local)
|
||||
_run_emitters(phase.emitters, boss.pos, local, boss.room)
|
||||
boss.phase_tick += 1
|
||||
|
||||
|
||||
## Shared emitter driver for enemies and bosses -- the reason a boss pattern can
|
||||
## be dropped onto a trash mob and vice versa.
|
||||
func _run_emitters(emitters: Array[BulletEmitter], origin: Vector2, local_tick: int) -> void:
|
||||
func _run_emitters(emitters: Array[BulletEmitter], origin: Vector2, local_tick: int,
|
||||
bounds: Rect2 = Rect2()) -> void:
|
||||
if emitters.is_empty():
|
||||
return
|
||||
var target := nearest_player(origin)
|
||||
_ctx.pool = pool
|
||||
_ctx.origin = origin
|
||||
# Curtain patterns span the room they are fired in. Without a room, fall
|
||||
# back to a box around the shooter rather than the whole map, or a trash
|
||||
# enemy would sweep bullets across the entire dungeon.
|
||||
_ctx.bounds = bounds if bounds.size != Vector2.ZERO \
|
||||
else Rect2(origin - Vector2(400.0, 300.0), Vector2(800.0, 600.0))
|
||||
_ctx.origin = origin
|
||||
_ctx.local_tick = local_tick
|
||||
_ctx.has_target = target != null
|
||||
_ctx.target = origin + Vector2.DOWN * 200.0 if target == null else target.pos
|
||||
@@ -388,6 +439,13 @@ func _damage_boss(amount: int) -> void:
|
||||
events.append({"t": SimEvent.Type.BOSS_DIED})
|
||||
|
||||
|
||||
## Bullets that died against geometry. A client is only streamed the map near
|
||||
## itself, so it cannot be relied on to work these out for itself.
|
||||
func _emit_wall_kill_events() -> void:
|
||||
for dead_uid in pool.wall_kill_log:
|
||||
events.append({"t": SimEvent.Type.BULLET_DESPAWN, "uid": dead_uid})
|
||||
|
||||
|
||||
func _emit_spawn_events() -> void:
|
||||
for slot in pool.spawn_log:
|
||||
if pool.alive[slot] == 0:
|
||||
|
||||
Reference in New Issue
Block a user