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:
+73
-47
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user