219 lines
7.7 KiB
TypeScript
219 lines
7.7 KiB
TypeScript
/**
|
|
* Diablo II D2Game.dll style Active/Sleeping Room Streaming and Lazy Room Population.
|
|
*
|
|
* Rather than eagerly instantiating every monster pack on frame 0, rooms begin
|
|
* unpopulated (`bPopulated = false`). When the player approaches within
|
|
* `activationRadius`, monsters are instantiated into `world.monsters` once and
|
|
* only once (idempotent).
|
|
*
|
|
* When the player moves far away (> `deactivationRadius`), the room transitions to
|
|
* sleeping (`active = false`) and all living monsters have `monster.sleeping = true`,
|
|
* skipping all AI, aggro, cooldown calculations, and pathfinding in `tickMonsters`.
|
|
*
|
|
* When the player returns, monsters wake up (`monster.sleeping = false`) with their
|
|
* exact positions, health, and cooldowns preserved. Dead monsters remain dead.
|
|
*/
|
|
import type { CombatTerrain, CombatWorld, MonsterPack, SafeZone } from './combat.ts'
|
|
import { spawnPacksInRoom } from './monster-rooms.ts'
|
|
import type { MapRoom } from './monster-rooms.ts'
|
|
|
|
/** Definition of a room holding unspawned monster pack blueprints. */
|
|
export interface StreamingRoomDef {
|
|
readonly id: string | number
|
|
readonly bounds: { readonly minX: number; readonly minY: number; readonly maxX: number; readonly maxY: number }
|
|
readonly packs: readonly MonsterPack[]
|
|
/**
|
|
* Optional source `MapRoom` (carrying cell coordinates / safe-zone flags) used when
|
|
* the room is lazily populated. When omitted one is synthesised from `bounds`.
|
|
*/
|
|
readonly room?: MapRoom | undefined
|
|
}
|
|
|
|
/** Runtime state of a streaming room. */
|
|
export interface StreamingRoomState extends StreamingRoomDef {
|
|
bPopulated: boolean
|
|
active: boolean
|
|
monsterIndices: number[]
|
|
}
|
|
|
|
/** Configuration options for the streaming manager. */
|
|
export interface MonsterStreamingOptions {
|
|
/** Distance in pixels to populate and activate a room (default: 1600px ~ 1.5-2 screens). */
|
|
activationRadius?: number
|
|
/** Distance in pixels to deactivate and put a room to sleep (default: 2400px ~ 2.5-3 screens). */
|
|
deactivationRadius?: number
|
|
/** Optional callback invoked whenever a room is populated for the first time. */
|
|
onSpawnRoom?: (room: StreamingRoomState) => void
|
|
/** Optional safe zones to avoid when placing monsters inside rooms. */
|
|
safeZones?: readonly SafeZone[]
|
|
}
|
|
|
|
/**
|
|
* Calculates Euclidean distance from a point to an axis-aligned room bounding box.
|
|
* Returns 0 if the point is within the bounding box.
|
|
*
|
|
* @param px - point x coordinate.
|
|
* @param py - point y coordinate.
|
|
* @param bounds - room axis-aligned bounds.
|
|
* @returns Euclidean distance to the nearest point on the room boundary (or 0 if inside).
|
|
*/
|
|
export function distanceToRoom(
|
|
px: number,
|
|
py: number,
|
|
bounds: { readonly minX: number; readonly minY: number; readonly maxX: number; readonly maxY: number },
|
|
): number {
|
|
const minX = Math.min(bounds.minX, bounds.maxX)
|
|
const maxX = Math.max(bounds.minX, bounds.maxX)
|
|
const minY = Math.min(bounds.minY, bounds.maxY)
|
|
const maxY = Math.max(bounds.minY, bounds.maxY)
|
|
const dx = Math.max(minX - px, 0, px - maxX)
|
|
const dy = Math.max(minY - py, 0, py - maxY)
|
|
return Math.hypot(dx, dy)
|
|
}
|
|
|
|
/**
|
|
* Manages lazy room population (`bPopulated`) and active/sleeping room streaming.
|
|
*/
|
|
export class MonsterStreamingManager {
|
|
public world: CombatWorld
|
|
public terrain: CombatTerrain
|
|
public rooms: StreamingRoomState[]
|
|
public activationRadius: number
|
|
public deactivationRadius: number
|
|
public options?: MonsterStreamingOptions | undefined
|
|
|
|
constructor(
|
|
world: CombatWorld,
|
|
terrain: CombatTerrain,
|
|
rooms: readonly StreamingRoomDef[],
|
|
options?: MonsterStreamingOptions,
|
|
) {
|
|
this.world = world
|
|
this.terrain = terrain
|
|
this.activationRadius = options?.activationRadius ?? 1600
|
|
this.deactivationRadius = options?.deactivationRadius ?? 2400
|
|
this.options = options
|
|
this.rooms = rooms.map(r => ({
|
|
id: r.id,
|
|
bounds: r.bounds,
|
|
packs: r.packs,
|
|
room: r.room,
|
|
bPopulated: false,
|
|
active: false,
|
|
monsterIndices: [],
|
|
}))
|
|
|
|
// On initialization, only the room containing the player spawn and adjacent rooms
|
|
// within activationRadius are populated. Distant rooms remain unpopulated.
|
|
this.update(this.world.player.x, this.world.player.y)
|
|
}
|
|
|
|
/**
|
|
* Evaluates player distance to all rooms, populating unpopulated rooms within
|
|
* activationRadius, waking active rooms, and putting distant rooms to sleep.
|
|
*
|
|
* @param playerX - player world x position.
|
|
* @param playerY - player world y position.
|
|
*/
|
|
update(playerX: number = this.world.player.x, playerY: number = this.world.player.y): void {
|
|
for (const room of this.rooms) {
|
|
const dist = distanceToRoom(playerX, playerY, room.bounds)
|
|
|
|
if (dist <= this.activationRadius) {
|
|
if (!room.bPopulated) {
|
|
this.populateRoom(room)
|
|
}
|
|
room.active = true
|
|
for (const idx of room.monsterIndices) {
|
|
const monster = this.world.monsters[idx]
|
|
if (monster) {
|
|
monster.sleeping = false
|
|
}
|
|
}
|
|
} else if (dist > this.deactivationRadius) {
|
|
if (room.active) {
|
|
room.active = false
|
|
for (const idx of room.monsterIndices) {
|
|
const monster = this.world.monsters[idx]
|
|
if (monster && monster.state !== 'dead') {
|
|
monster.sleeping = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lazily instantiates a room's monster packs into `world.monsters`.
|
|
*
|
|
* Packs are placed as tight camps inside the room boundary (see
|
|
* `spawnPacksInRoom`), mirroring D2's per-room monster groups, rather than
|
|
* being scattered across the whole level. Sets `bPopulated = true` to
|
|
* guarantee idempotency.
|
|
*/
|
|
private populateRoom(room: StreamingRoomState): void {
|
|
if (room.bPopulated) return
|
|
|
|
const startIndex = this.world.monsters.length
|
|
const mapRoom = room.room ?? boundsToMapRoom(room.id, room.bounds)
|
|
|
|
spawnPacksInRoom(this.world, room.packs, mapRoom, this.terrain)
|
|
|
|
const endIndex = this.world.monsters.length
|
|
for (let i = startIndex; i < endIndex; i += 1) {
|
|
room.monsterIndices.push(i)
|
|
}
|
|
|
|
// Push any monster that landed inside a safe zone (waypoint / level entrance)
|
|
// out to the safe zone perimeter so towns and warps stay clear.
|
|
const safeZones = this.options?.safeZones
|
|
if (safeZones !== undefined && safeZones.length > 0) {
|
|
for (let i = startIndex; i < endIndex; i += 1) {
|
|
const monster = this.world.monsters[i]
|
|
if (monster === undefined) continue
|
|
for (const zone of safeZones) {
|
|
const dx = monster.x - zone.x
|
|
const dy = monster.y - zone.y
|
|
const dist = Math.hypot(dx, dy)
|
|
if (dist >= zone.radius) continue
|
|
const angle = dist > 1e-6 ? Math.atan2(dy, dx) : (i * 2.399963)
|
|
monster.x = zone.x + Math.cos(angle) * (zone.radius + 8)
|
|
monster.y = zone.y + Math.sin(angle) * (zone.radius + 8)
|
|
}
|
|
}
|
|
}
|
|
|
|
room.bPopulated = true
|
|
this.options?.onSpawnRoom?.(room)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Builds a minimal `MapRoom` from pixel bounds when the caller did not supply one.
|
|
*
|
|
* @param id - room identifier (also seeds the camp ring angle).
|
|
* @param bounds - axis-aligned pixel bounds of the room.
|
|
* @returns a `MapRoom` usable by `spawnPacksInRoom`.
|
|
*/
|
|
function boundsToMapRoom(
|
|
id: string | number,
|
|
bounds: { readonly minX: number; readonly minY: number; readonly maxX: number; readonly maxY: number },
|
|
): MapRoom {
|
|
const minX = Math.min(bounds.minX, bounds.maxX)
|
|
const maxX = Math.max(bounds.minX, bounds.maxX)
|
|
const minY = Math.min(bounds.minY, bounds.maxY)
|
|
const maxY = Math.max(bounds.minY, bounds.maxY)
|
|
return {
|
|
id,
|
|
x: minX,
|
|
y: minY,
|
|
width: Math.max(1, maxX - minX),
|
|
height: Math.max(1, maxY - minY),
|
|
cellX: 0,
|
|
cellY: 0,
|
|
cellsX: 0,
|
|
cellsY: 0,
|
|
}
|
|
}
|