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 ## Drained by the owner every tick. See [SimEvent]. var events: Array[Dictionary] = [] ## The world's static geometry. Never null: worlds without a generated map get ## a plain walled room, which keeps every caller free of null checks and lets a ## unit test build a world without thinking about terrain. var map: MapGrid = null ## Set on a lobby world so the interact button can open a dungeon. var portal_enabled: bool = false ## Where the hub's dungeon portal sits. Per-world now that maps vary in size. var portal_pos := Vector2.ZERO var spawn_point := Vector2(0.0, 240.0) ## Arrival protection granted to players entering this world. 0 in the hub, ## SimConfig.SPAWN_GRACE_TICKS in a dungeon. 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) 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 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() _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) 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) if portal_enabled and frame.pressed(InputFrame.BTN_INTERACT): if p.pos.distance_to(portal_pos) <= SimConfig.PORTAL_RADIUS: events.append({"t": SimEvent.Type.PORTAL_USED, "peer": p.peer_id}) ## 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 func _fire_player_shot(p: SimPlayer) -> void: p.fire_cooldown = SimConfig.PLAYER_FIRE_COOLDOWN events.append({"t": SimEvent.Type.PLAYER_FIRED, "peer": p.peer_id}) var dir := Vector2.RIGHT.rotated(p.aim) pool.spawn( p.pos + dir * SimConfig.PLAYER_MUZZLE_OFFSET, dir * SimConfig.PLAYER_BULLET_SPEED, SimConfig.PLAYER_BULLET_RADIUS, SimConfig.PLAYER_BULLET_LIFETIME, SimConfig.PLAYER_BULLET_DAMAGE, SimConfig.TEAM_PLAYER, SimConfig.KIND_PLAYER_SHOT) 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 # A boss never leaves its arena. Enforced here rather than left to each # boss's movement code, 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. Currently a no-op (every boss is stationary), which is # exactly when an invariant is cheapest to establish. 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 ## 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 _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): _damage_boss(pool.damage[i]) 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]) consumed = true break if consumed: _kill_bullet(i) ## 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}) func _damage_enemy(e: SimEnemy, amount: int) -> void: e.hp = maxi(e.hp - amount, 0) events.append({"t": SimEvent.Type.ENEMY_HIT, "id": e.id, "dmg": amount, "hp": e.hp}) if e.hp <= 0: e.alive = false # 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)}) func _damage_boss(amount: int) -> void: var phase := boss.current_phase() var mult := 1.0 if phase == null else phase.damage_taken_mult var applied := maxi(1, roundi(float(amount) * mult)) boss.hp = maxi(boss.hp - applied, 0) events.append({"t": SimEvent.Type.ENEMY_HIT, "id": boss.id, "dmg": applied, "hp": boss.hp}) if boss.hp <= 0: boss.alive = false events.append({"t": SimEvent.Type.BOSS_DIED, "def": String(boss.def.id)}) ## 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