872922e9e2
ci / verify (push) Successful in 48s
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>
903 lines
32 KiB
GDScript
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
|