Files
transcience/src/sim/sim_world.gd
T
claude 872922e9e2
ci / verify (push) Successful in 48s
Fix boss jitter; add a settings screen for controls and volume
The jitter had two causes, and the larger one is embarrassing: boss_state()
handed back the newest snapshot raw while players and enemies both went
through the interpolator. The boss therefore stepped at the 20Hz snapshot rate
instead of the frame rate. Invisible for as long as every boss stood still,
and the first one that moved looked broken. It is interpolated now -- but not
across an instance change, where the previous snapshot describes a different
fight in a different room and lerping to it would fling the new boss across
the map for a frame.

The smaller cause was server-side: a CHASE boss corrects by the SIGN of its
distance error, so at the standoff the sign flipped every tick and the boss
vibrated a couple of pixels at 60Hz. It has a dead band now.

The settings screen covers rebindable controls and volume, reachable from both
the main menu and the in-game menu. Bindings are stored as physical keycodes
-- following key position, the choice setup_input_map.gd already made -- and
labelled back through the active layout so an AZERTY player reads the letter on
the key their fingers are on. A rebind replaces every event on the action
rather than the first, because an action that kept its alternates would still
answer to the key you just moved away from. A key already in use is refused and
the clash is named. Reset restores what the PROJECT shipped, captured once
before anything overrides it -- captured later it would restore the last
session's choice, which is the thing being undone.

Effects play on an SFX bus created at runtime, so both sliders are real mixer
settings rather than a number multiplied into every play() call.

Worth recording: the test suite AND the smoke test both passed while a client
logged twelve engine errors on every startup. ConfigFile.get_value(s, k, null)
does not mean "no default" -- it means the key is absent and no default was
given, and the engine logs an error per action. Nothing caught it because the
smoke refutations matched SCRIPT ERROR and friends, and a plain ERROR: is none
of those. It surfaced from running the client and reading the output. smoke.sh
now asserts no plain engine errors either, excluding by name the one line Godot
prints on every clean exit, and reintroducing the bug makes it fail.

One mutation caught nothing and should not have: the early return in
linear_to_db_clamped was dead code, since the clamp beneath it already prevents
negative infinity. Removed rather than left looking tested.

check.sh clean, 439 tests, SMOKE PASS (23 assertions), all four diagnostics
green, and a real client boots with zero engine errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:56:44 +02:00

903 lines
32 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
## Items lying on the ground, by actor id. Shares the id space with enemies and
## the boss, so nothing has to reason about two kinds of id.
var loot: Dictionary[int, SimLoot] = {}
## 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
## Dungeon entrances standing in this world. Empty everywhere but the hub.
## A list rather than a single position because which dungeon an entrance opens
## is the whole reason there is more than one.
var portals: Array[SimPortal] = []
## Where the hub's upgrade NPC stands. Only meaningful when
## [member has_upgrade_npc] is true -- the map is centred on the origin, so
## Vector2.ZERO is a real position and cannot double as "there isn't one".
var upgrade_npc := Vector2.ZERO
var has_upgrade_npc: bool = false
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)
# Loot instanced to this peer goes with them. Nobody else can see or take
# it, so leaving it behind would be an invisible entity the instance carries
# until it closes.
for id in loot.keys():
if loot[id].owner_peer == peer_id:
loot.erase(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
## The portal [param at] is standing on, or null. Nearest wins, so two
## entrances placed close enough to overlap still resolve to one answer instead
## of to whichever happens to be first in the list.
func portal_at(at: Vector2) -> SimPortal:
var best: SimPortal = null
var best_d := SimConfig.PORTAL_RADIUS * SimConfig.PORTAL_RADIUS
for portal in portals:
var d := at.distance_squared_to(portal.pos)
if d <= best_d:
best_d = d
best = portal
return best
## Whether [param at] is close enough to spend a level-up. Checked on the
## server for the same reason the portal is: where a player is standing is the
## one thing a modified client cannot fake.
func at_upgrade_npc(at: Vector2) -> bool:
return has_upgrade_npc \
and at.distance_to(upgrade_npc) <= SimConfig.UPGRADE_NPC_RADIUS
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()
_step_poison()
_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():
p.regenerate()
if p.spawn_grace > 0:
p.spawn_grace -= 1
if p.fire_cooldown > 0:
p.fire_cooldown -= 1
var frame := _take_input(p)
var edge := _button_edge(p, frame)
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)
# Item actions are edge-triggered; movement and fire are not. Holding
# the key must spend one potion, and the buttons arrive repeated (the
# client sends the last few frames every tick, and a starved server
# coasts on the last one), so a level-triggered read would empty the
# whole inventory in four ticks.
if edge & InputFrame.BTN_USE:
_use_slot(p, frame.slot)
if edge & InputFrame.BTN_DROP:
_drop_slot(p, frame.slot)
# Pickup shares the interact button with the portal. Loot wins when both
# are in reach, and only for the tick it actually took something -- a
# full inventory must not leave you standing on the portal unable to use
# it.
var took_item := false
if edge & InputFrame.BTN_INTERACT:
took_item = _try_pickup(p)
if not took_item and frame.pressed(InputFrame.BTN_INTERACT):
var portal := portal_at(p.pos)
if portal != null:
events.append({"t": SimEvent.Type.PORTAL_USED, "peer": p.peer_id,
"dungeon": String(portal.dungeon)})
## 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
## Buttons newly pressed on this frame, for the actions that must not repeat.
## The slot counts as part of the edge: tapping slot 2 while slot 1 is still
## held is a second, distinct action, not a swallowed one.
func _button_edge(p: SimPlayer, frame: InputFrame) -> int:
var edge := frame.buttons & ~p.prev_buttons
if frame.slot != p.prev_slot:
edge |= frame.buttons & (InputFrame.BTN_USE | InputFrame.BTN_DROP)
p.prev_buttons = frame.buttons
p.prev_slot = frame.slot
return edge
## One trigger pull. How many bullets that is, and what they carry, comes
## entirely from the shooter's [PlayerStats] -- there is no per-upgrade branch
## here, which is what lets a new upgrade be a table entry in [Upgrades].
func _fire_player_shot(p: SimPlayer) -> void:
p.fire_cooldown = p.stats.fire_cooldown
events.append({"t": SimEvent.Type.PLAYER_FIRED, "peer": p.peer_id})
var forward := Vector2.RIGHT.rotated(p.aim)
var muzzle := p.pos + forward * SimConfig.PLAYER_MUZZLE_OFFSET
# The aimed shot plus Spread's cone, alternating sides so an odd count still
# comes out symmetric about the aim.
_spawn_player_bullet(p, muzzle, forward)
for i in p.stats.side_shots:
var step := float(i / 2 + 1) * SimConfig.SPREAD_STEP_DEG
var side_sign := 1.0 if i % 2 == 0 else -1.0
_spawn_player_bullet(p, muzzle,
forward.rotated(deg_to_rad(step * side_sign)))
# Doubleshot's extras travel parallel rather than fanned, so they are an
# offset at the muzzle and not an angle.
for i in p.stats.parallel_shots:
var gap := float(i / 2 + 1) * SimConfig.PARALLEL_OFFSET
var lateral_sign := 1.0 if i % 2 == 0 else -1.0
_spawn_player_bullet(p, muzzle + forward.orthogonal() * gap * lateral_sign,
forward)
func _spawn_player_bullet(p: SimPlayer, at: Vector2, dir: Vector2) -> void:
var slot := pool.spawn(
at,
dir * p.stats.bullet_speed,
SimConfig.PLAYER_BULLET_RADIUS,
SimConfig.PLAYER_BULLET_LIFETIME,
p.stats.damage,
SimConfig.TEAM_PLAYER,
SimConfig.KIND_PLAYER_SHOT)
pool.set_mods(slot, p.stats.split_charges, p.stats.poison_fraction,
p.stats.erase_chance)
# --- Items and loot ---------------------------------------------------------
## Radius used to check that a dropped item is not inside a wall. Smaller than
## anything that walks, because loot only has to be reachable, not roomy.
const LOOT_CLEARANCE := 6.0
## Put [param item] on the ground. [param owner_peer] of 0 is world-shared;
## anything else is visible and takeable only by that peer.
func spawn_loot(item: StringName, at: Vector2, owner_peer: int = 0) -> SimLoot:
if Items.get_def(item) == null:
return null
_make_room_for_loot()
var l := SimLoot.new()
l.id = next_actor_id()
l.item = item
l.pos = at
l.owner_peer = owner_peer
l.born_tick = tick
loot[l.id] = l
return l
## Keep ground loot bounded. Only the hub can realistically reach the cap --
## dungeons close and take their litter with them -- so the oldest item is the
## right thing to lose: it is the one that has been ignored the longest.
func _make_room_for_loot() -> void:
while loot.size() >= SimConfig.MAX_LOOT_PER_INSTANCE:
var oldest := -1
for id in loot:
if oldest < 0 or loot[id].born_tick < loot[oldest].born_tick:
oldest = id
if oldest < 0:
return
loot.erase(oldest)
## Roll a loot table and put what it produced on the floor. Called on death, so
## it runs inside hit resolution and uses the world's own RNG -- loot is part of
## the simulation, not something the instance layer sprinkles on afterwards.
func _drop_loot(table: Array[LootDrop], at: Vector2) -> void:
for entry in table:
if entry == null or entry.item == Items.NONE:
continue
# Rolled unconditionally, including for guaranteed drops. Skipping the
# roll at chance 1.0 would make the RNG stream depend on the loot
# table's contents, so editing a number in content.gd would silently
# change every later roll in the world.
if rng.randf() > entry.chance:
continue
if not entry.instanced:
_place_loot(entry.item, at, Vector2.ZERO, 0)
continue
# One copy per player who was alive for the kill. Laid out on a ring so
# that a debug view of every copy at once is legible; in play each
# player is only ever sent their own, so they all appear in the middle.
var owners := _living_peers()
for i in owners.size():
var angle := TAU * float(i) / float(owners.size())
_place_loot(entry.item, at,
Vector2.RIGHT.rotated(angle) * SimConfig.LOOT_INSTANCED_SPREAD,
owners[i])
## Spawn at [param at] + [param offset], falling back to [param at] when the
## offset would put the item inside geometry -- unreachable loot is worse than
## two items in the same place.
func _place_loot(item: StringName, at: Vector2, offset: Vector2, owner_peer: int) -> void:
var want := at + offset
if offset != Vector2.ZERO and map.circle_blocked(want, LOOT_CLEARANCE):
want = at
spawn_loot(item, want, owner_peer)
func _living_peers() -> Array[int]:
var out: Array[int] = []
for p in players.values():
if p.alive:
out.append(p.peer_id)
out.sort() # stable ordering, so the ring layout is not dictionary order
return out
## Take the nearest item this player is allowed to have. Returns whether one
## was actually picked up.
func _try_pickup(p: SimPlayer) -> bool:
var best: SimLoot = null
var best_d := SimConfig.LOOT_PICKUP_RADIUS * SimConfig.LOOT_PICKUP_RADIUS
for l in loot.values():
if not l.visible_to(p.peer_id):
continue
var d := p.pos.distance_squared_to(l.pos)
if d <= best_d:
best_d = d
best = l
if best == null:
return false
if p.add_item(best.item) < 0:
return false # bags full; the item stays exactly where it was
loot.erase(best.id)
events.append({"t": SimEvent.Type.ITEM_PICKED_UP, "peer": p.peer_id, "item": best.item})
return true
func _use_slot(p: SimPlayer, slot_index: int) -> void:
if slot_index < 0 or slot_index >= p.inventory.size():
return
var item := p.inventory[slot_index]
var def := Items.get_def(item)
if def == null:
return
match def.effect:
ItemDef.Effect.HEAL:
# Refused rather than wasted. Spending a potion at full health is
# not a decision anyone makes on purpose, so it must not be one a
# mistimed keypress can make for them.
if p.heal_percent(def.effect_value) <= 0:
return
_:
pass
p.take_slot(slot_index)
events.append({"t": SimEvent.Type.ITEM_USED, "peer": p.peer_id, "item": item})
func _drop_slot(p: SimPlayer, slot_index: int) -> void:
if slot_index < 0 or slot_index >= p.inventory.size():
return
if p.inventory[slot_index] == Items.NONE:
return
var item := p.take_slot(slot_index)
# Anything dropped becomes world-shared, even if it arrived as an instanced
# drop. That is what makes dropping worth having: an item you do not want
# should be able to reach someone who does.
_place_loot(item, p.pos, Vector2.ZERO, 0)
events.append({"t": SimEvent.Type.ITEM_DROPPED, "peer": p.peer_id, "item": item})
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
_move_boss(phase)
# A boss never leaves its arena. Enforced here rather than inside each
# movement mode, because boss rooms deliberately do not lock: the player can
# always walk out, and the fight only stays a fight if the boss cannot
# follow. It was a no-op while every boss stood still, which is exactly when
# an invariant is cheapest to establish -- now it is load-bearing.
if boss.room.size != Vector2.ZERO:
boss.pos = boss.room.position + Vector2(
clampf(boss.pos.x - boss.room.position.x, 0.0, boss.room.size.x),
clampf(boss.pos.y - boss.room.position.y, 0.0, boss.room.size.y))
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
## One tick of boss movement, driven entirely by the phase's data. Every mode
## here is generic: adding a boss that moves is writing a [BossPhase], not
## touching this function.
func _move_boss(phase: BossPhase) -> void:
if not phase.moves():
return
var dt := SimConfig.TICK_DELTA
var step := Vector2.ZERO
match phase.move:
BossPhase.Move.ORBIT:
var centre := boss.room.get_center() if boss.room.size != Vector2.ZERO \
else boss.def.spawn_pos
var angle := float(boss.phase_tick) * dt \
* (phase.move_speed / maxf(phase.move_param, 1.0))
step = centre + Vector2.RIGHT.rotated(angle) * phase.move_param - boss.pos
BossPhase.Move.CHASE:
var quarry := nearest_player(boss.pos)
if quarry == null:
return
var to_player := quarry.pos - boss.pos
var gap := to_player.length()
if gap < 0.001:
return
# Signed, so it backs off when you close inside its preferred
# distance. A boss that ends up standing on you is a boss whose
# bullets you cannot see coming.
var error := gap - phase.move_param
# Close enough. Without this the sign flips every tick once it
# arrives and the boss buzzes on the spot at the tick rate.
if absf(error) <= SimConfig.BOSS_CHASE_DEADBAND:
return
step = (to_player / gap) * signf(error) * phase.move_speed * dt
BossPhase.Move.WAYPOINTS:
if phase.waypoints.is_empty():
return
if boss.waypoint_wait > 0:
boss.waypoint_wait -= 1
return
var goal := _waypoint_world(phase, boss.waypoint_index)
var to_goal := goal - boss.pos
if to_goal.length() <= phase.move_speed * dt:
boss.pos = goal
boss.waypoint_index = (boss.waypoint_index + 1) % phase.waypoints.size()
boss.waypoint_wait = phase.waypoint_dwell
return
step = to_goal
_:
return
# Clamped here rather than in each branch, so no mode can teleport: ORBIT in
# particular computes an absolute destination and would otherwise snap to
# its circle on the first tick of the phase.
var reach := phase.move_speed * dt
if step.length() > reach:
step = step.normalized() * reach
# Slid rather than assigned, so a boss cannot walk through the pillars its
# own arena was designed around.
boss.pos = map.slide_circle(boss.pos, step, boss.def.radius)
## A phase waypoint, given as a fraction of the arena, in world coordinates.
## Fractions rather than absolutes so one phase can be dropped into any room --
## the Warden's hall and the Choir Vault are different sizes.
func _waypoint_world(phase: BossPhase, index: int) -> Vector2:
var w: Vector2 = phase.waypoints[index]
if boss.room.size == Vector2.ZERO:
return boss.def.spawn_pos
return boss.room.position + Vector2(
clampf(w.x, 0.0, 1.0) * boss.room.size.x,
clampf(w.y, 0.0, 1.0) * boss.room.size.y)
## 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
# Re-pointed every call: drain_events() replaces the array wholesale, so a
# context holding the old one would announce into a list nobody reads.
_ctx.events = events
_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):
# Poison is taken from the damage the boss ACTUALLY took, so a
# phase that takes 30% extra is poisoned 30% harder too.
var applied := _damage_boss(pool.damage[i])
if pool.poison[i] > 0.0:
boss.poison_track().add(float(applied) * pool.poison[i], tick)
_split_shot(i, boss.pos, boss.def.radius)
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])
if pool.poison[i] > 0.0:
e.poison_track().add(
float(pool.damage[i]) * pool.poison[i], tick)
_split_shot(i, e.pos, e.def.radius)
consumed = true
break
if consumed:
_kill_bullet(i)
elif pool.erase[i] > 0.0:
_try_erase(i)
## Split Shot: two children of the shot that just landed, leaving the target at
## 45 degrees to either side.
##
## They are born just PAST the target rather than on it. A child spawned inside
## the thing that was just hit would be resolved against it again on the same
## tick -- a free second hit, and with several charges a free chain of them.
##
## Children inherit the remaining lifetime rather than a fresh one, so splitting
## cannot extend a shot's reach indefinitely; a split at the end of a shot's
## life produces two short-lived children, which is the conservative reading of
## "two of the same bullet".
func _split_shot(slot: int, from: Vector2, target_radius: float) -> void:
if pool.split[slot] <= 0:
return
var v: Vector2 = pool.vel[slot]
var speed := v.length()
if speed < 0.001:
return
var charges := pool.split[slot] - 1
var clearance := target_radius + pool.radius[slot] + 2.0
for side_sign in [1.0, -1.0]:
var dir := (v / speed).rotated(deg_to_rad(SimConfig.SPLIT_ANGLE_DEG) * side_sign)
var child := pool.spawn(from + dir * clearance, dir * speed,
pool.radius[slot], pool.life[slot], pool.damage[slot],
pool.team[slot], pool.kind[slot])
pool.set_mods(child, charges, pool.poison[slot], pool.erase[slot])
## Eraser: at most ONE roll per shot per tick, against the first enemy
## projectile it is overlapping.
##
## Rolling once per overlapping pair would multiply the stated 1% by however
## many bullets happened to occupy the same place, which in a boss ring is a
## lot. One opportunity per tick, taken or not, keeps the number the player was
## promised close to the number they get.
##
## The scan is O(bullets) per erasing shot, and runs only for shots that carry
## the upgrade -- which is why it is worth nothing until a legendary is drawn.
func _try_erase(slot: int) -> void:
var p: Vector2 = pool.pos[slot]
var r: float = pool.radius[slot]
for j in pool.high_water:
if pool.alive[j] == 0 or pool.team[j] != SimConfig.TEAM_ENEMY:
continue
if not Movement.circles_overlap(p, r, pool.pos[j], pool.radius[j]):
continue
if rng.randf() < pool.erase[slot]:
_kill_bullet(j)
return
## One tick of every live poison dose. Cheap regardless of how many are running
## -- see [PoisonTrack] for why.
func _step_poison() -> void:
for e in enemies.values():
if not e.alive or e.poison == null:
continue
var dealt: int = e.poison.step(tick)
if dealt > 0:
_damage_enemy(e, dealt, true)
if boss != null and boss.alive and boss.poison != null:
var on_boss: int = boss.poison.step(tick)
if on_boss > 0:
_damage_boss(on_boss, true, false)
## 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})
## [param silent] suppresses the ENEMY_HIT event. Poison ticks use it: they
## land many times a second on the reliable channel, and the client learns hp
## from the snapshot anyway. Death is still announced either way, because that
## is what the experience award is keyed on.
func _damage_enemy(e: SimEnemy, amount: int, silent: bool = false) -> void:
if e.def.indestructible:
return
e.hp = maxi(e.hp - amount, 0)
if not silent:
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": e.id, "dmg": amount, "hp": e.hp})
if e.hp <= 0:
e.alive = false
_drop_loot(e.def.loot, e.pos)
# The def id rides along so the instance layer can score it without
# looking up an actor that is about to stop existing.
events.append({"t": SimEvent.Type.ENEMY_DIED, "id": e.id, "def": String(e.def.id)})
## Returns the damage actually applied, after the phase's armour multiplier, so
## the caller can derive poison from the same number.
##
## [param scale] is false for poison ticks, whose damage was already scaled when
## the dose was applied -- scaling again would compound the multiplier.
func _damage_boss(amount: int, silent: bool = false, scale: bool = true) -> int:
var phase := boss.current_phase()
var mult := 1.0 if phase == null or not scale else phase.damage_taken_mult
var applied := maxi(1, roundi(float(amount) * mult))
boss.hp = maxi(boss.hp - applied, 0)
if not silent:
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": boss.id, "dmg": applied, "hp": boss.hp})
if boss.hp <= 0:
boss.alive = false
_drop_loot(boss.def.loot, boss.pos)
events.append({"t": SimEvent.Type.BOSS_DIED, "def": String(boss.def.id)})
return applied
## 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