class_name Instance extends RefCounted ## One live world on the server: the shared lobby hub, or one dungeon run for a ## party. Instances are cheap -- a [SimWorld] and a peer list -- so a single ## server process hosts the hub plus every concurrent dungeon. enum State { ## Dungeon is open for players to join from the hub. FORMING, ## Fight in progress. ACTIVE, ## Boss dead; players are being returned to the hub. CLEARED, } var id: int = 0 var kind: Protocol.InstanceKind = Protocol.InstanceKind.LOBBY var world: SimWorld var peers: Array[int] = [] var state: State = State.ACTIVE var age: int = 0 var seed_value: int = 0 ## Drives dungeon size and difficulty. The hub is always depth 0. var depth: int = 0 ## Which kind of run this is -- see [Dungeons]. Empty for the hub. var dungeon_id: StringName = &"" ## Dungeon progression. -1 is the pre-fight breather. var stage: int = -1 var stage_delay: int = 0 var boss_id: StringName = &"" ## Generated layout, used to place enemies room by room. var rooms: Array[Rect2i] = [] var boss_spawn := Vector2.ZERO var boss_room := Rect2i() static func make_lobby(instance_id: int) -> Instance: var inst := Instance.new() inst.id = instance_id inst.kind = Protocol.InstanceKind.LOBBY inst.seed_value = 1 inst.world = SimWorld.new(inst.seed_value) var built := MapGen.build(Protocol.InstanceKind.LOBBY, inst.seed_value, 0) inst.world.set_map(built["grid"]) inst.world.portals = built["portals"] inst.world.upgrade_npc = built["npc"] inst.world.has_upgrade_npc = true inst.world.spawn_point = built["spawn"] # Nothing here to escape from, and completing the channel in the hub means # transferring to the hub -- see SimWorld.escape_enabled. inst.world.escape_enabled = false inst.state = State.ACTIVE # A single inert practice target so players can feel out the gun before # committing to a run. inst.world.spawn_enemy(Content.dummy(), built["dummy"]) return inst ## [param dungeon] picks the flavour of run. The map, the rooms, the enemies and ## the boss are the same whichever is chosen; only how much health they have and ## how freely they drop differs. See [Dungeons]. static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int = 1, dungeon: StringName = &"") -> Instance: var inst := Instance.new() inst.id = instance_id inst.kind = Protocol.InstanceKind.DUNGEON inst.seed_value = dungeon_seed inst.depth = maxi(dungeon_depth, 1) inst.dungeon_id = dungeon if not dungeon.is_empty() else Dungeons.default_id() inst.world = SimWorld.new(dungeon_seed) var flavour := Dungeons.get_or_default(inst.dungeon_id) var built := MapGen.build(Protocol.InstanceKind.DUNGEON, dungeon_seed, inst.depth, flavour.arena) inst.world.set_map(built["grid"]) inst.rooms = built["rooms"] inst.boss_spawn = built["boss_pos"] inst.boss_room = built["boss_room"] inst.world.spawn_point = built["spawn"] # Arriving into a fight already in progress needs a moment of protection; # arriving in the hub does not. inst.world.spawn_grace_ticks = SimConfig.SPAWN_GRACE_TICKS inst.boss_id = built["boss_id"] inst.state = State.FORMING inst._populate() return inst ## Enemies are placed once, per room, at generation -- not spawned in waves. ## A dungeon you explore has to be populated before you arrive in it: waves ## would make the map's contents depend on when you walk in rather than where, ## and aggro range would have nothing left to gate. func _populate() -> void: var rng := RandomNumberGenerator.new() rng.seed = seed_value ^ 0x5eed var flavour := Dungeons.get_or_default(dungeon_id) var boss := world.spawn_boss(flavour.apply_to_boss(Content.boss(boss_id))) boss.pos = boss_spawn boss.room = _room_rect_world(boss_room) GameLog.info("instance", "BOSS_SPAWNED %s in instance %d" % [boss_id, id]) if GameOpts.boss_rush: return # dev switch: an empty dungeon with just the fight in it for i in rooms.size(): # Room 0 holds the entrance; arriving into a fight you cannot see yet # is not a decision, it is an ambush. if i == 0: continue var r: Rect2i = rooms[i] var count := rng.randi_range(1, 3 + depth / 2) for _n in count: var def := flavour.apply_to_enemy(_pick_enemy(rng)) var tx := rng.randi_range(r.position.x + 1, r.end.x - 2) var ty := rng.randi_range(r.position.y + 1, r.end.y - 2) var at := world.map.tile_centre(tx, ty) if world.map.circle_blocked(at, def.radius): continue world.spawn_enemy(def, at, rng.randi_range(0, 120)) func _pick_enemy(rng: RandomNumberGenerator) -> EnemyDef: var roll := rng.randf() if roll < 0.4: return Content.drifter() if roll < 0.75: return Content.turret() return Content.stalker() ## Tile-space room rect to world space, for confining a boss to its arena. func _room_rect_world(r: Rect2i) -> Rect2: if r.size == Vector2i.ZERO: return Rect2() var lo := world.map.tile_centre(r.position.x + 1, r.position.y + 1) var hi := world.map.tile_centre(r.end.x - 2, r.end.y - 2) return Rect2(lo, hi - lo) func add_peer(peer_id: int, display_name: String) -> void: if not peers.has(peer_id): peers.append(peer_id) world.add_player(peer_id, display_name) func remove_peer(peer_id: int) -> void: peers.erase(peer_id) world.remove_player(peer_id) ## Drop a peer from the send list but leave its player in the world. Used when a ## connection dies: the body stays behind, still killable, channelling its way ## out (see SimPlayer.linkdead), while we stop trying to send to a dead socket. func detach_peer(peer_id: int) -> void: peers.erase(peer_id) var p: SimPlayer = world.players.get(peer_id) if p != null: p.linkdead = true ## Peers we still talk to. Linkdead bodies are not counted -- an instance whose ## only occupant has dropped should wind down, not wait on a ghost. func is_empty() -> bool: return peers.is_empty() ## True while a dropped player is still resolving its escape channel. Keeps the ## instance alive just long enough for that to finish. func has_linkdead() -> bool: for p in world.players.values(): if p.linkdead: return true return false ## Whole seconds until a cleared dungeon returns its party, or ## Protocol.COUNTDOWN_NONE when that does not apply. func exit_countdown_seconds() -> int: if state != State.CLEARED: return Protocol.COUNTDOWN_NONE return mini(int(ceil(float(stage_delay) / float(SimConfig.TICK_RATE))), Protocol.COUNTDOWN_NONE - 1) ## [param dungeon] has to match: walking into the Proving Grounds portal must ## never drop you into a standard run that happens to still be forming, however ## conveniently timed. func accepts_new_party_member(dungeon: StringName) -> bool: return kind == Protocol.InstanceKind.DUNGEON \ and dungeon_id == dungeon \ and state == State.FORMING \ and peers.size() < SimConfig.DUNGEON_PARTY_MAX func step() -> void: age += 1 world.step() if kind == Protocol.InstanceKind.DUNGEON: _step_dungeon() ## The dungeon is fully populated at generation, so all that is left to track ## is the party forming and the boss dying. func _step_dungeon() -> void: if stage_delay > 0: stage_delay -= 1 return if state == State.CLEARED: return if state == State.FORMING: if age >= SimConfig.DUNGEON_FORMING_TICKS or peers.size() >= SimConfig.DUNGEON_PARTY_MAX: state = State.ACTIVE return if world.boss != null and not world.boss.alive: state = State.CLEARED stage_delay = SimConfig.DUNGEON_CLEARED_EXIT_TICKS GameLog.info("instance", "instance %d CLEARED, returning party in %ds" % [ id, SimConfig.DUNGEON_CLEARED_EXIT_TICKS / SimConfig.TICK_RATE]) func _live_enemy_count() -> int: var n := 0 for e in world.enemies.values(): if e.alive: n += 1 return n