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] = [] ## The rectangle each tunnel leg was carved over -- see MapGen. Overlaps the ## rooms at its ends, so _tunnel_tiles() decides what is actually tunnel. var tunnels: Array[Rect2i] = [] var boss_spawn := Vector2.ZERO var boss_room := Rect2i() ## Chance that any one tunnel leg has a group of enemies in it. Rolled per leg, ## and most legs are too short or too near the entrance to qualify anyway, so a ## depth-1 dungeon has a tunnel group about one run in three. Rare on purpose: ## tunnels are where you go between fights, and a tunnel that is usually empty ## is what makes the one that is not a surprise. const TUNNEL_GROUP_CHANCE := 0.1 const TUNNEL_GROUP_MIN := 2 const TUNNEL_GROUP_MAX := 3 ## Members stand within this many tiles of the group's centre, so a group reads ## as one encounter rather than a tunnel's worth of strays. const TUNNEL_GROUP_SPREAD := 2 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 # The hub is where a run's damage is paid off. Arriving used to heal you to # full, which made health free between runs; ten seconds of standing here # does the same job at a price. inst.world.regen_percent_per_sec = SimConfig.LOBBY_HP_REGEN_PERCENT_PER_SEC 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.tunnels = built["tunnels"] 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)) # After every room, so adding tunnel groups leaves each room's population # exactly what that seed always produced. _populate_tunnels(rng, flavour) ## Now and then, a group of enemies in a tunnel. ## ## Never within aggro range of the entrance, by the same reasoning room 0 is ## left empty: arriving into a fight you cannot see yet is not a decision, it ## is an ambush. And never a lone straggler -- if a leg cannot fit at least ## TUNNEL_GROUP_MIN, it gets nobody. func _populate_tunnels(rng: RandomNumberGenerator, flavour: DungeonDef) -> void: var clear_of_spawn := _widest_aggro() + MapGrid.TILE * 2.0 for leg in tunnels: # Every leg rolls the same three numbers whether or not it is used, so # one tunnel's dice never depend on another tunnel's shape. var roll := rng.randf() var pick := rng.randi() var size := rng.randi_range(TUNNEL_GROUP_MIN, TUNNEL_GROUP_MAX) if roll >= TUNNEL_GROUP_CHANCE: continue var open := _tunnel_tiles(leg) if open.size() < TUNNEL_GROUP_MAX * 2: continue var centre: Vector2i = open[pick % open.size()] var def := flavour.apply_to_enemy(_pick_enemy(rng)) var spots: Array[Vector2] = [] for t in open: if absi(t.x - centre.x) > TUNNEL_GROUP_SPREAD \ or absi(t.y - centre.y) > TUNNEL_GROUP_SPREAD: continue var at := world.map.tile_centre(t.x, t.y) # Per member, not per group: members stand up to two tiles off the # centre, so a centre that clears the entrance does not mean every # member does. if at.distance_to(world.spawn_point) < clear_of_spawn: continue if not world.map.circle_blocked(at, def.radius): spots.append(at) if spots.size() < TUNNEL_GROUP_MIN: continue for i in range(spots.size() - 1, 0, -1): var j := rng.randi_range(0, i) var held := spots[i] spots[i] = spots[j] spots[j] = held for i in mini(size, spots.size()): # A fresh definition per member: apply_to_enemy mutates in place, # and sharing one object would scale it once per member. var member := def if i == 0 else flavour.apply_to_enemy(Content.enemy(def.id)) world.spawn_enemy(member, spots[i], rng.randi_range(0, 120)) ## Floor tiles of [param leg] that are tunnel rather than room: clear of every ## room by a tile, so a group is never standing in a doorway, and outside the ## boss arena. func _tunnel_tiles(leg: Rect2i) -> Array[Vector2i]: var out: Array[Vector2i] = [] for ty in range(leg.position.y, leg.end.y): for tx in range(leg.position.x, leg.end.x): if world.map.at(tx, ty) != MapGrid.Kind.FLOOR: continue var here := Vector2i(tx, ty) if boss_room.has_point(here): continue var in_room := false for r in rooms: if r.grow(1).has_point(here): in_room = true break if not in_room: out.append(here) return out ## The furthest any enemy in the game notices a player from. static func _widest_aggro() -> float: var widest := 0.0 for id in Content.ALL_ENEMIES: widest = maxf(widest, Content.enemy(id).aggro_range) return widest 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