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