Merge branch 'feat/monster-room-placement'
This commit is contained in:
commit
274ffae71e
|
|
@ -22,6 +22,22 @@
|
|||
*/
|
||||
import { numberCell, textCell } from './tables.ts'
|
||||
import type { DataTable } from './tables.ts'
|
||||
import {
|
||||
allocatePacksToRooms,
|
||||
partitionMapIntoRooms,
|
||||
spawnMonsterPacksByRooms,
|
||||
spawnPacksInRoom,
|
||||
} from './monster-rooms.ts'
|
||||
import type { MapRoom } from './monster-rooms.ts'
|
||||
|
||||
export {
|
||||
allocatePacksToRooms,
|
||||
partitionMapIntoRooms,
|
||||
spawnMonsterPacksByRooms,
|
||||
spawnPacksInRoom,
|
||||
type MapRoom,
|
||||
}
|
||||
|
||||
|
||||
/** Movement request and intent for one tick. */
|
||||
export interface CombatInput {
|
||||
|
|
@ -120,6 +136,8 @@ export interface Monster {
|
|||
hitFlash: number
|
||||
/** Ticks left of the death animation. */
|
||||
corpseTicks: number
|
||||
/** Whether the monster is sleeping (inactive off-screen room). */
|
||||
sleeping?: boolean | undefined
|
||||
}
|
||||
|
||||
/** The player's combat-relevant state. */
|
||||
|
|
@ -481,7 +499,11 @@ export function spawnMonsterPacks(
|
|||
spread: number,
|
||||
terrain: CombatTerrain,
|
||||
safeZones?: readonly SafeZone[],
|
||||
rooms?: readonly MapRoom[],
|
||||
): number {
|
||||
if (rooms !== undefined && rooms.length > 0) {
|
||||
return spawnMonsterPacksByRooms(world, packs, rooms, terrain, safeZones !== undefined ? { safeZones } : undefined)
|
||||
}
|
||||
if (packs.length === 0) return 0
|
||||
let placed = 0
|
||||
let campIndex = 0
|
||||
|
|
@ -787,6 +809,7 @@ function tickPlayer(
|
|||
*/
|
||||
function tickMonsters(world: CombatWorld, options: CombatOptions, terrain: CombatTerrain, targets: readonly CombatPlayer[]): void {
|
||||
for (const monster of world.monsters) {
|
||||
if (monster.sleeping) continue
|
||||
if (monster.state === 'dead') {
|
||||
if (monster.corpseTicks > 0) monster.corpseTicks -= 1
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { QuestLog, npcDialog } from './quests.ts'
|
|||
import type { NpcDef, QuestDef } from './quests.ts'
|
||||
import { Rng } from './rng.ts'
|
||||
import { captureSnapshot, parseSnapshot, restoreSnapshot, serializeSnapshot } from './save.ts'
|
||||
import { MonsterStreamingManager } from './monster-streaming.ts'
|
||||
import type { MonsterStreamingOptions, StreamingRoomDef, StreamingRoomState } from './monster-streaming.ts'
|
||||
|
||||
export interface EngineInput {
|
||||
readonly movement: { readonly x: number; readonly y: number }
|
||||
|
|
@ -115,6 +117,24 @@ export interface GameEngineOptions {
|
|||
* itself.
|
||||
*/
|
||||
snapshotStore?: SnapshotStore
|
||||
/**
|
||||
* Optional pre-constructed MonsterStreamingManager for lazy room population
|
||||
* and active/sleeping monster streaming.
|
||||
*/
|
||||
streamingManager?: MonsterStreamingManager | undefined
|
||||
/**
|
||||
* Pre-planned rooms for monster streaming. When configured without an explicit
|
||||
* streamingManager, a MonsterStreamingManager is initialized automatically.
|
||||
*/
|
||||
roomPacks?: readonly StreamingRoomDef[] | undefined
|
||||
/**
|
||||
* Alias for roomPacks.
|
||||
*/
|
||||
streamingRooms?: readonly StreamingRoomDef[] | undefined
|
||||
/**
|
||||
* Tuning options for MonsterStreamingManager when created from roomPacks.
|
||||
*/
|
||||
streamingOptions?: MonsterStreamingOptions | undefined
|
||||
}
|
||||
|
||||
export interface GameEngineMetrics {
|
||||
|
|
@ -145,6 +165,7 @@ export class GameEngine {
|
|||
selectedSkill = 0
|
||||
npcEntities: NpcEntity[] = []
|
||||
dialog: string[] = []
|
||||
streamingManager?: MonsterStreamingManager
|
||||
/**
|
||||
* Whether the save input was held on the previous tick.
|
||||
*
|
||||
|
|
@ -164,10 +185,27 @@ export class GameEngine {
|
|||
}
|
||||
|
||||
constructor(public terrain: WorldMapProvider, public opts: GameEngineOptions) {
|
||||
this.world = createWorld(opts.spawn.x, opts.spawn.y)
|
||||
const rooms = opts.roomPacks ?? opts.streamingRooms
|
||||
if (opts.streamingManager !== undefined) {
|
||||
this.streamingManager = opts.streamingManager
|
||||
this.world = opts.streamingManager.world
|
||||
} else {
|
||||
this.world = createWorld(opts.spawn.x, opts.spawn.y)
|
||||
if (rooms !== undefined && rooms.length > 0) {
|
||||
this.streamingManager = new MonsterStreamingManager(
|
||||
this.world,
|
||||
this.terrain,
|
||||
rooms,
|
||||
opts.streamingOptions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const spread = opts.monsterSpread ?? 260
|
||||
const count = opts.monsterCount ?? 8
|
||||
if (opts.monsterPacks !== undefined && opts.monsterPacks.length > 0) {
|
||||
if (this.streamingManager !== undefined) {
|
||||
// Monsters are lazily populated by streamingManager; avoid frame-0 eager population.
|
||||
} else if (opts.monsterPacks !== undefined && opts.monsterPacks.length > 0) {
|
||||
spawnMonsterPacks(this.world, opts.monsterPacks, opts.spawn, spread, this.terrain, opts.safeZones)
|
||||
} else if (count > 0) {
|
||||
spawnMonsters(this.world, opts.stats, count, opts.spawn, spread, this.terrain)
|
||||
|
|
@ -198,6 +236,8 @@ export class GameEngine {
|
|||
const beforeX = player.x
|
||||
const beforeY = player.y
|
||||
|
||||
this.streamingManager?.update(player.x, player.y)
|
||||
|
||||
if (this.world.tick % 25 === 0) player.mana = Math.min(player.maxMana, player.mana + 1)
|
||||
|
||||
const wanted = this.opts.skills[this.selectedSkill]
|
||||
|
|
@ -379,6 +419,9 @@ export class GameEngine {
|
|||
this.questLog = pieces.quests
|
||||
this.ground = pieces.ground
|
||||
this.projectiles = []
|
||||
if (this.streamingManager !== undefined) {
|
||||
this.streamingManager.world = this.world
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -484,3 +527,6 @@ export function syncEngineState(engine: GameEngine, state: EngineViewState): voi
|
|||
state.y = engine.world.player.y
|
||||
state.bagItems = engine.bag.contents.length
|
||||
}
|
||||
|
||||
export { MonsterStreamingManager } from './monster-streaming.ts'
|
||||
export type { MonsterStreamingOptions, StreamingRoomDef, StreamingRoomState } from './monster-streaming.ts'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,544 @@
|
|||
/**
|
||||
* Room-Local Monster Clustering and Placement.
|
||||
*
|
||||
* Replaces global spiral monster placement with authentic Diablo II room-based
|
||||
* spatial clustering and allocation. In Diablo II, monsters are partitioned into
|
||||
* rooms (sectors of ~20-30 cells outdoors, or discrete maze preset pieces indoors),
|
||||
* avoiding player safe zones (towns, waypoint clearings, level entrances), with
|
||||
* landmark SuperUniques fixed to their specific room and minions clustered tightly
|
||||
* in camp rings around their leader.
|
||||
*/
|
||||
import type { CombatTerrain, CombatWorld, MonsterPack, SafeZone } from './combat.ts'
|
||||
import { Rng } from './rng.ts'
|
||||
|
||||
/**
|
||||
* A discrete spatial room or sector in a level.
|
||||
*/
|
||||
export interface MapRoom {
|
||||
readonly id: string | number
|
||||
readonly x: number // world/scene pixels
|
||||
readonly y: number
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly cellX: number // grid cell coordinates
|
||||
readonly cellY: number
|
||||
readonly cellsX: number
|
||||
readonly cellsY: number
|
||||
readonly isSafeZone?: boolean | undefined
|
||||
readonly isPreset?: boolean | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for partitioning a level map into rooms.
|
||||
*/
|
||||
export interface PartitionOptions {
|
||||
/** Room cell size for outdoor grid sector partitioning (default ~24 cells). */
|
||||
readonly roomCellSize?: number | undefined
|
||||
/** Discrete maze rooms from the dungeon generator (e.g. from maze.ts). */
|
||||
readonly mazeRooms?: readonly {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly id?: string | number | undefined
|
||||
readonly isPreset?: boolean | undefined
|
||||
readonly isSafeZone?: boolean | undefined
|
||||
}[] | undefined
|
||||
/** Circular exclusion zones where ordinary monster camps must not spawn. */
|
||||
readonly safeZones?: readonly {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly radius: number
|
||||
}[] | undefined
|
||||
/** Cell pixel width (default 80 px). */
|
||||
readonly cellPixelWidth?: number | undefined
|
||||
/** Cell pixel height (default 80 px). */
|
||||
readonly cellPixelHeight?: number | undefined
|
||||
/** Uniform cell pixel size shorthand (default 80 px). */
|
||||
readonly cellPixelSize?: number | undefined
|
||||
/** Origin X offset in pixels (default 0). */
|
||||
readonly originX?: number | undefined
|
||||
/** Origin Y offset in pixels (default 0). */
|
||||
readonly originY?: number | undefined
|
||||
}
|
||||
|
||||
/** Default camp radius for room-local monster clustering (48~64 pixels). */
|
||||
export const PACK_RADIUS_PX = 64
|
||||
|
||||
/**
|
||||
* Check if a room boundary overlaps any safe zone circle.
|
||||
* Tests in both pixel space and cell space for maximum compatibility.
|
||||
*/
|
||||
function checkSafeZoneOverlap(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
cellX: number,
|
||||
cellY: number,
|
||||
cellsX: number,
|
||||
cellsY: number,
|
||||
safeZones?: readonly { readonly x: number; readonly y: number; readonly radius: number }[],
|
||||
): boolean {
|
||||
if (safeZones === undefined || safeZones.length === 0) return false
|
||||
for (const sz of safeZones) {
|
||||
// Check in world pixel space
|
||||
const clampPxX = Math.max(x, Math.min(sz.x, x + width))
|
||||
const clampPxY = Math.max(y, Math.min(sz.y, y + height))
|
||||
const pxDistSq = (sz.x - clampPxX) ** 2 + (sz.y - clampPxY) ** 2
|
||||
if (pxDistSq <= sz.radius * sz.radius) return true
|
||||
|
||||
// Check in cell space (in case safeZone coordinates were given in cells)
|
||||
const clampCellX = Math.max(cellX, Math.min(sz.x, cellX + cellsX))
|
||||
const clampCellY = Math.max(cellY, Math.min(sz.y, cellY + cellsY))
|
||||
const cellDistSq = (sz.x - clampCellX) ** 2 + (sz.y - clampCellY) ** 2
|
||||
if (cellDistSq <= sz.radius * sz.radius) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Partition a map into rooms.
|
||||
*
|
||||
* If `mazeRooms` are supplied (from dungeon generator), map them directly into `MapRoom`s.
|
||||
* Otherwise (wilderness / outdoors), partition the level into authentic ~20-30 cell rooms
|
||||
* (e.g. 24x24 cells = 120x120 subtiles), matching D2's room grid.
|
||||
* Automatically flag rooms overlapping `safeZones` as `isSafeZone = true`.
|
||||
*
|
||||
* @param cellsX - total level width in cells.
|
||||
* @param cellsY - total level height in cells.
|
||||
* @param options - partitioning options.
|
||||
* @returns partitioned MapRooms.
|
||||
*/
|
||||
export function partitionMapIntoRooms(
|
||||
cellsX: number,
|
||||
cellsY: number,
|
||||
options?: PartitionOptions,
|
||||
): MapRoom[] {
|
||||
const cellWidth = options?.cellPixelWidth ?? options?.cellPixelSize ?? 80
|
||||
const cellHeight = options?.cellPixelHeight ?? options?.cellPixelSize ?? 80
|
||||
const originX = options?.originX ?? 0
|
||||
const originY = options?.originY ?? 0
|
||||
|
||||
if (options?.mazeRooms !== undefined && options.mazeRooms.length > 0) {
|
||||
return options.mazeRooms.map((mr, index) => {
|
||||
const cellX = mr.x
|
||||
const cellY = mr.y
|
||||
const rCellsX = mr.width
|
||||
const rCellsY = mr.height
|
||||
const x = cellX * cellWidth + originX
|
||||
const y = cellY * cellHeight + originY
|
||||
const width = rCellsX * cellWidth
|
||||
const height = rCellsY * cellHeight
|
||||
const id = mr.id ?? index
|
||||
const isPreset = mr.isPreset ?? true
|
||||
const isSafeZone = mr.isSafeZone ?? checkSafeZoneOverlap(
|
||||
x, y, width, height, cellX, cellY, rCellsX, rCellsY, options.safeZones,
|
||||
)
|
||||
|
||||
return {
|
||||
id,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
cellX,
|
||||
cellY,
|
||||
cellsX: rCellsX,
|
||||
cellsY: rCellsY,
|
||||
isPreset,
|
||||
...(isSafeZone ? { isSafeZone: true } : {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Wilderness / outdoors: grid partition into ~24x24 cell rooms
|
||||
const roomCellSize = options?.roomCellSize ?? 24
|
||||
const rooms: MapRoom[] = []
|
||||
let index = 0
|
||||
|
||||
for (let cy = 0; cy < cellsY; cy += roomCellSize) {
|
||||
const rCellsY = Math.min(roomCellSize, cellsY - cy)
|
||||
for (let cx = 0; cx < cellsX; cx += roomCellSize) {
|
||||
const rCellsX = Math.min(roomCellSize, cellsX - cx)
|
||||
const x = cx * cellWidth + originX
|
||||
const y = cy * cellHeight + originY
|
||||
const width = rCellsX * cellWidth
|
||||
const height = rCellsY * cellHeight
|
||||
const isSafeZone = checkSafeZoneOverlap(
|
||||
x, y, width, height, cx, cy, rCellsX, rCellsY, options?.safeZones,
|
||||
)
|
||||
|
||||
rooms.push({
|
||||
id: index++,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
cellX: cx,
|
||||
cellY: cy,
|
||||
cellsX: rCellsX,
|
||||
cellsY: rCellsY,
|
||||
isPreset: false,
|
||||
...(isSafeZone ? { isSafeZone: true } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return rooms
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate monster packs to rooms according to Diablo II rules:
|
||||
* - Distributes non-landmark packs across non-safe rooms (avoiding town, stair buffers).
|
||||
* - Fixed landmark packs (SuperUniques with `landmarkTile` or `fixedPosition`) route directly
|
||||
* to the room covering their coordinates.
|
||||
* - Avoids over-crowding by balancing pack counts across candidate rooms based on room area.
|
||||
*
|
||||
* @param packs - list of monster packs to distribute.
|
||||
* @param rooms - candidate rooms.
|
||||
* @param seed - optional random seed for deterministic distribution.
|
||||
* @returns Map from each MapRoom to its allocated MonsterPack array.
|
||||
*/
|
||||
export function allocatePacksToRooms(
|
||||
packs: readonly MonsterPack[],
|
||||
rooms: readonly MapRoom[],
|
||||
seed?: number,
|
||||
): Map<MapRoom, MonsterPack[]> {
|
||||
const allocation = new Map<MapRoom, MonsterPack[]>()
|
||||
for (const room of rooms) {
|
||||
allocation.set(room, [])
|
||||
}
|
||||
if (rooms.length === 0 || packs.length === 0) {
|
||||
return allocation
|
||||
}
|
||||
|
||||
const landmarkPacks: MonsterPack[] = []
|
||||
const normalPacks: MonsterPack[] = []
|
||||
|
||||
for (const pack of packs) {
|
||||
if (pack.fixedPosition !== undefined || pack.landmarkTile !== undefined) {
|
||||
landmarkPacks.push(pack)
|
||||
} else {
|
||||
normalPacks.push(pack)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Fixed landmark SuperUniques route directly to their covering room
|
||||
for (const pack of landmarkPacks) {
|
||||
let targetRoom: MapRoom | undefined
|
||||
|
||||
if (pack.fixedPosition !== undefined) {
|
||||
const fx = pack.fixedPosition.x
|
||||
const fy = pack.fixedPosition.y
|
||||
targetRoom = rooms.find(r => fx >= r.x && fx <= r.x + r.width && fy >= r.y && fy <= r.y + r.height)
|
||||
if (targetRoom === undefined) {
|
||||
// Fallback: nearest room center
|
||||
let minDist = Infinity
|
||||
for (const r of rooms) {
|
||||
const cx = r.x + r.width / 2
|
||||
const cy = r.y + r.height / 2
|
||||
const d = Math.hypot(fx - cx, fy - cy)
|
||||
if (d < minDist) {
|
||||
minDist = d
|
||||
targetRoom = r
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (pack.landmarkTile !== undefined) {
|
||||
const tx = pack.landmarkTile.tileX
|
||||
const ty = pack.landmarkTile.tileY
|
||||
targetRoom = rooms.find(r => tx >= r.cellX && tx < r.cellX + r.cellsX && ty >= r.cellY && ty < r.cellY + r.cellsY)
|
||||
if (targetRoom === undefined) {
|
||||
targetRoom = rooms.find(r => tx >= r.cellX && tx <= r.cellX + r.cellsX && ty >= r.cellY && ty <= r.cellY + r.cellsY)
|
||||
}
|
||||
if (targetRoom === undefined) {
|
||||
// Fallback: nearest room by cell center
|
||||
let minDist = Infinity
|
||||
for (const r of rooms) {
|
||||
const cx = r.cellX + r.cellsX / 2
|
||||
const cy = r.cellY + r.cellsY / 2
|
||||
const d = Math.hypot(tx - cx, ty - cy)
|
||||
if (d < minDist) {
|
||||
minDist = d
|
||||
targetRoom = r
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetRoom !== undefined && allocation.has(targetRoom)) {
|
||||
allocation.get(targetRoom)!.push(pack)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Normal packs: distribute to non-safe rooms, balancing by room area
|
||||
let candidateRooms = rooms.filter(r => !r.isSafeZone)
|
||||
if (candidateRooms.length === 0) {
|
||||
// Fallback if all rooms are marked safe
|
||||
candidateRooms = rooms.slice()
|
||||
}
|
||||
|
||||
const rng = new Rng(seed ?? 0x5eed)
|
||||
|
||||
for (const pack of normalPacks) {
|
||||
// Score based on pack density: (currentCount + 1) / area
|
||||
let minScore = Infinity
|
||||
for (const r of candidateRooms) {
|
||||
const count = allocation.get(r)!.length
|
||||
const area = Math.max(1, r.cellsX * r.cellsY)
|
||||
const score = (count + 1) / area
|
||||
if (score < minScore) {
|
||||
minScore = score
|
||||
}
|
||||
}
|
||||
|
||||
const bestRooms: MapRoom[] = []
|
||||
for (const r of candidateRooms) {
|
||||
const count = allocation.get(r)!.length
|
||||
const area = Math.max(1, r.cellsX * r.cellsY)
|
||||
const score = (count + 1) / area
|
||||
if (Math.abs(score - minScore) < 1e-9) {
|
||||
bestRooms.push(r)
|
||||
}
|
||||
}
|
||||
|
||||
const picked = bestRooms[rng.int(0, bestRooms.length - 1)]!
|
||||
allocation.get(picked)!.push(pack)
|
||||
}
|
||||
|
||||
return allocation
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a single monster pack inside a given room.
|
||||
*/
|
||||
function spawnSinglePackInRoom(
|
||||
world: CombatWorld,
|
||||
pack: MonsterPack,
|
||||
room: MapRoom,
|
||||
terrain: CombatTerrain,
|
||||
): number {
|
||||
if (pack.members.length === 0) return 0
|
||||
|
||||
const margin = Math.min(16, Math.max(2, room.width * 0.05), Math.max(2, room.height * 0.05))
|
||||
const minSpawnX = room.x + margin
|
||||
const maxSpawnX = room.x + room.width - margin
|
||||
const minSpawnY = room.y + margin
|
||||
const maxSpawnY = room.y + room.height - margin
|
||||
|
||||
// 1. Leader placement near room center or fixed landmark position
|
||||
const startX = pack.fixedPosition !== undefined
|
||||
? pack.fixedPosition.x
|
||||
: room.x + room.width / 2
|
||||
const startY = pack.fixedPosition !== undefined
|
||||
? pack.fixedPosition.y
|
||||
: room.y + room.height / 2
|
||||
|
||||
let leaderX = Math.max(minSpawnX, Math.min(maxSpawnX, startX))
|
||||
let leaderY = Math.max(minSpawnY, Math.min(maxSpawnY, startY))
|
||||
let leaderFound = false
|
||||
|
||||
// Check initial point
|
||||
if (terrain.overlap(leaderX, leaderY) === 0) {
|
||||
leaderFound = true
|
||||
} else {
|
||||
// Spiral search outward
|
||||
const maxRadius = Math.max(room.width, room.height) / 2
|
||||
for (let step = 1; step <= 64; step += 1) {
|
||||
const angle = step * 2.399963
|
||||
const radius = Math.min(maxRadius, step * 8)
|
||||
const sx = Math.max(minSpawnX, Math.min(maxSpawnX, startX + Math.cos(angle) * radius))
|
||||
const sy = Math.max(minSpawnY, Math.min(maxSpawnY, startY + Math.sin(angle) * radius))
|
||||
if (terrain.overlap(sx, sy) === 0) {
|
||||
leaderX = sx
|
||||
leaderY = sy
|
||||
leaderFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Grid fallback if spiral failed
|
||||
if (!leaderFound) {
|
||||
const stepX = Math.max(8, (maxSpawnX - minSpawnX) / 16)
|
||||
const stepY = Math.max(8, (maxSpawnY - minSpawnY) / 16)
|
||||
for (let gy = minSpawnY; gy <= maxSpawnY; gy += stepY) {
|
||||
for (let gx = minSpawnX; gx <= maxSpawnX; gx += stepX) {
|
||||
if (terrain.overlap(gx, gy) === 0) {
|
||||
leaderX = gx
|
||||
leaderY = gy
|
||||
leaderFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (leaderFound) break
|
||||
}
|
||||
}
|
||||
|
||||
if (!leaderFound) return 0
|
||||
|
||||
const leaderDef = pack.members[0]!
|
||||
world.monsters.push({
|
||||
index: world.monsters.length,
|
||||
stats: leaderDef,
|
||||
x: leaderX,
|
||||
y: leaderY,
|
||||
hp: leaderDef.hp,
|
||||
cooldown: 0,
|
||||
state: 'idle',
|
||||
facing: 0,
|
||||
hitFlash: 0,
|
||||
corpseTicks: 0,
|
||||
})
|
||||
let placed = 1
|
||||
|
||||
// 2. Minions placed in tight camp ring (PACK_RADIUS_PX = 48~64) around leader
|
||||
const minions = pack.members.slice(1)
|
||||
let minionPlaced = 0
|
||||
let attempt = 0
|
||||
const maxAttempts = minions.length * 32
|
||||
const baseAngle = typeof room.id === 'number' ? room.id * 1.5 : 0.8
|
||||
const effectiveRadius = Math.min(PACK_RADIUS_PX, Math.max(16, Math.min(room.width, room.height) / 2 - margin))
|
||||
|
||||
while (minionPlaced < minions.length && attempt < maxAttempts) {
|
||||
attempt += 1
|
||||
const angle = baseAngle + attempt * 2.399963
|
||||
const distFrac = Math.sqrt(attempt / (minions.length * 12))
|
||||
const radius = Math.min(effectiveRadius, 16 + (effectiveRadius - 16) * distFrac)
|
||||
let mx = leaderX + Math.cos(angle) * radius
|
||||
let my = leaderY + Math.sin(angle) * radius
|
||||
|
||||
// Clamps within room bounding boxes
|
||||
mx = Math.max(minSpawnX, Math.min(maxSpawnX, mx))
|
||||
my = Math.max(minSpawnY, Math.min(maxSpawnY, my))
|
||||
|
||||
// Respects terrain collisions
|
||||
if (terrain.overlap(mx, my) > 0) continue
|
||||
|
||||
const minionDef = minions[minionPlaced]!
|
||||
world.monsters.push({
|
||||
index: world.monsters.length,
|
||||
stats: minionDef,
|
||||
x: mx,
|
||||
y: my,
|
||||
hp: minionDef.hp,
|
||||
cooldown: 0,
|
||||
state: 'idle',
|
||||
facing: 0,
|
||||
hitFlash: 0,
|
||||
corpseTicks: 0,
|
||||
})
|
||||
minionPlaced += 1
|
||||
placed += 1
|
||||
}
|
||||
|
||||
return placed
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns monster pack(s) inside the boundaries of the specified `room`.
|
||||
*
|
||||
* Leader is positioned at a clear open sub-tile near the room's interior center;
|
||||
* minions are placed in a tight camp ring (PACK_RADIUS_PX = 48~64) around the leader.
|
||||
* Respects terrain collisions (terrain.overlap(x, y) === 0) and clamps within room
|
||||
* bounding boxes (x in [minX + margin, maxX - margin]).
|
||||
*
|
||||
* @param world - the world to populate.
|
||||
* @param pack - the pack or packs to spawn.
|
||||
* @param room - the room boundary to spawn within.
|
||||
* @param terrain - collision predicate.
|
||||
* @returns how many monsters were placed.
|
||||
*/
|
||||
export function spawnPacksInRoom(
|
||||
world: CombatWorld,
|
||||
pack: MonsterPack | readonly MonsterPack[],
|
||||
room: MapRoom,
|
||||
terrain: CombatTerrain,
|
||||
): number {
|
||||
if (Array.isArray(pack)) {
|
||||
let count = 0
|
||||
for (const p of pack) {
|
||||
count += spawnSinglePackInRoom(world, p, room, terrain)
|
||||
}
|
||||
return count
|
||||
}
|
||||
return spawnSinglePackInRoom(world, pack as MonsterPack, room, terrain)
|
||||
}
|
||||
|
||||
/** Alias for `spawnPacksInRoom` with singular naming. */
|
||||
export const spawnPackInRoom = spawnPacksInRoom
|
||||
|
||||
/**
|
||||
* Options for `spawnMonsterPacksByRooms`.
|
||||
*/
|
||||
export interface SpawnMonsterPacksByRoomsOptions {
|
||||
readonly seed?: number | undefined
|
||||
readonly safeZones?: readonly SafeZone[] | undefined
|
||||
readonly spread?: number | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Modern, clean replacement for `spawnMonsterPacks`, allocating packs across rooms
|
||||
* and clustering members tightly within their assigned room boundaries.
|
||||
*
|
||||
* Also maintains backwards compatibility if plain packs with a center point are passed.
|
||||
*
|
||||
* @param world - world to populate.
|
||||
* @param packs - monster packs to spawn.
|
||||
* @param roomsOrAround - room array or fallback center point.
|
||||
* @param terrain - collision predicate.
|
||||
* @param options - allocation options or safe zones.
|
||||
* @returns total monsters placed.
|
||||
*/
|
||||
export function spawnMonsterPacksByRooms(
|
||||
world: CombatWorld,
|
||||
packs: readonly MonsterPack[],
|
||||
roomsOrAround: readonly MapRoom[] | { readonly x: number; readonly y: number },
|
||||
terrain: CombatTerrain,
|
||||
options?: SpawnMonsterPacksByRoomsOptions | readonly SafeZone[],
|
||||
): number {
|
||||
if (packs.length === 0) return 0
|
||||
|
||||
let safeZones: readonly SafeZone[] | undefined
|
||||
let seed: number | undefined
|
||||
let spread = 600
|
||||
|
||||
if (Array.isArray(options)) {
|
||||
safeZones = options
|
||||
} else if (options !== undefined) {
|
||||
const opts = options as SpawnMonsterPacksByRoomsOptions
|
||||
safeZones = opts.safeZones
|
||||
seed = opts.seed
|
||||
if (opts.spread !== undefined) {
|
||||
spread = opts.spread
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(roomsOrAround)) {
|
||||
const around = roomsOrAround as { readonly x: number; readonly y: number }
|
||||
const syntheticRooms = partitionMapIntoRooms(64, 64, {
|
||||
roomCellSize: 16,
|
||||
cellPixelSize: 80,
|
||||
originX: around.x - spread,
|
||||
originY: around.y - spread,
|
||||
safeZones,
|
||||
})
|
||||
return spawnMonsterPacksByRooms(world, packs, syntheticRooms, terrain, {
|
||||
safeZones,
|
||||
seed,
|
||||
})
|
||||
}
|
||||
|
||||
const rooms = roomsOrAround as readonly MapRoom[]
|
||||
if (rooms.length === 0) return 0
|
||||
|
||||
const allocation = allocatePacksToRooms(packs, rooms, seed)
|
||||
let totalPlaced = 0
|
||||
for (const [room, roomPacks] of allocation) {
|
||||
for (const pack of roomPacks) {
|
||||
totalPlaced += spawnPacksInRoom(world, pack, room, terrain)
|
||||
}
|
||||
}
|
||||
return totalPlaced
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
/**
|
||||
* 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 { spawnMonsterPacks } from './combat.ts'
|
||||
import type { CombatTerrain, CombatWorld, MonsterPack, SafeZone } from './combat.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[]
|
||||
}
|
||||
|
||||
/** 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,
|
||||
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`.
|
||||
* Sets `bPopulated = true` to guarantee idempotency.
|
||||
*/
|
||||
private populateRoom(room: StreamingRoomState): void {
|
||||
if (room.bPopulated) return
|
||||
|
||||
const startIndex = this.world.monsters.length
|
||||
const centerX = (room.bounds.minX + room.bounds.maxX) / 2
|
||||
const centerY = (room.bounds.minY + room.bounds.maxY) / 2
|
||||
const width = Math.abs(room.bounds.maxX - room.bounds.minX)
|
||||
const height = Math.abs(room.bounds.maxY - room.bounds.minY)
|
||||
const spread = Math.max(width, height) / 2 || 100
|
||||
|
||||
spawnMonsterPacks(
|
||||
this.world,
|
||||
room.packs,
|
||||
{ x: centerX, y: centerY },
|
||||
spread,
|
||||
this.terrain,
|
||||
this.options?.safeZones,
|
||||
)
|
||||
|
||||
const endIndex = this.world.monsters.length
|
||||
for (let i = startIndex; i < endIndex; i += 1) {
|
||||
room.monsterIndices.push(i)
|
||||
}
|
||||
|
||||
room.bPopulated = true
|
||||
this.options?.onSpawnRoom?.(room)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,404 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
allocatePacksToRooms,
|
||||
partitionMapIntoRooms,
|
||||
spawnMonsterPacksByRooms,
|
||||
spawnPacksInRoom,
|
||||
PACK_RADIUS_PX,
|
||||
type MapRoom,
|
||||
} from '../src/game/monster-rooms.ts'
|
||||
import { createWorld } from '../src/game/combat.ts'
|
||||
import type { CombatTerrain, MonsterPack, MonsterStats } from '../src/game/combat.ts'
|
||||
|
||||
/** Flat open ground everywhere. */
|
||||
const OPEN: CombatTerrain = { overlap: () => 0 }
|
||||
|
||||
/**
|
||||
* Creates a simple monster stat record.
|
||||
*/
|
||||
function stats(id: string, hp = 30): MonsterStats {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
hp,
|
||||
damage: 5,
|
||||
cooldownTicks: 25,
|
||||
reach: 40,
|
||||
aggroRadius: 200,
|
||||
speed: 100,
|
||||
xp: 10,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a test monster pack.
|
||||
*/
|
||||
function pack(id: string, count: number, extra?: Partial<MonsterPack>): MonsterPack {
|
||||
return {
|
||||
members: Array.from({ length: count }, (_, i) => stats(id, 30 + i)),
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Room-Local Monster Clustering and Placement', () => {
|
||||
describe('partitionMapIntoRooms', () => {
|
||||
it('partitions outdoor grid sectors into authentic ~20-30 cell rooms', () => {
|
||||
// 72x48 cells partitioned with roomCellSize = 24 -> 3 x 2 = 6 rooms
|
||||
const rooms = partitionMapIntoRooms(72, 48, { roomCellSize: 24, cellPixelSize: 80 })
|
||||
expect(rooms).toHaveLength(6)
|
||||
|
||||
// All rooms should be 24x24 cells and 1920x1920 pixels
|
||||
for (const room of rooms) {
|
||||
expect(room.cellsX).toBe(24)
|
||||
expect(room.cellsY).toBe(24)
|
||||
expect(room.width).toBe(1920)
|
||||
expect(room.height).toBe(1920)
|
||||
expect(room.isPreset).toBe(false)
|
||||
}
|
||||
|
||||
// Check cell grid coordinates
|
||||
expect(rooms[0]?.cellX).toBe(0)
|
||||
expect(rooms[0]?.cellY).toBe(0)
|
||||
expect(rooms[1]?.cellX).toBe(24)
|
||||
expect(rooms[1]?.cellY).toBe(0)
|
||||
expect(rooms[2]?.cellX).toBe(48)
|
||||
expect(rooms[2]?.cellY).toBe(0)
|
||||
expect(rooms[3]?.cellX).toBe(0)
|
||||
expect(rooms[3]?.cellY).toBe(24)
|
||||
})
|
||||
|
||||
it('handles map dimensions that do not divide evenly by roomCellSize', () => {
|
||||
// 50x30 cells with roomCellSize = 24 -> 3 cols (24, 24, 2) x 2 rows (24, 6) = 6 rooms
|
||||
const rooms = partitionMapIntoRooms(50, 30, { roomCellSize: 24, cellPixelSize: 80 })
|
||||
expect(rooms).toHaveLength(6)
|
||||
|
||||
const col2Row0 = rooms.find(r => r.cellX === 48 && r.cellY === 0)
|
||||
expect(col2Row0).toBeDefined()
|
||||
expect(col2Row0?.cellsX).toBe(2)
|
||||
expect(col2Row0?.cellsY).toBe(24)
|
||||
expect(col2Row0?.width).toBe(160)
|
||||
|
||||
const col0Row1 = rooms.find(r => r.cellX === 0 && r.cellY === 24)
|
||||
expect(col0Row1).toBeDefined()
|
||||
expect(col0Row1?.cellsX).toBe(24)
|
||||
expect(col0Row1?.cellsY).toBe(6)
|
||||
expect(col0Row1?.height).toBe(480)
|
||||
})
|
||||
|
||||
it('maps discrete maze rooms directly into MapRooms', () => {
|
||||
const mazeRooms = [
|
||||
{ x: 0, y: 0, width: 24, height: 24 },
|
||||
{ x: 24, y: 0, width: 24, height: 24 },
|
||||
{ x: 24, y: 24, width: 12, height: 12 },
|
||||
]
|
||||
const rooms = partitionMapIntoRooms(48, 48, { mazeRooms, cellPixelSize: 80 })
|
||||
expect(rooms).toHaveLength(3)
|
||||
|
||||
expect(rooms[0]?.isPreset).toBe(true)
|
||||
expect(rooms[0]?.cellX).toBe(0)
|
||||
expect(rooms[0]?.cellY).toBe(0)
|
||||
expect(rooms[0]?.width).toBe(1920)
|
||||
|
||||
expect(rooms[2]?.cellsX).toBe(12)
|
||||
expect(rooms[2]?.cellsY).toBe(12)
|
||||
expect(rooms[2]?.width).toBe(960)
|
||||
expect(rooms[2]?.height).toBe(960)
|
||||
})
|
||||
|
||||
it('automatically flags rooms overlapping safe zones as isSafeZone', () => {
|
||||
const safeZones = [
|
||||
{ x: 500, y: 500, radius: 160 },
|
||||
]
|
||||
const rooms = partitionMapIntoRooms(72, 48, { roomCellSize: 24, cellPixelSize: 80, safeZones })
|
||||
expect(rooms).toHaveLength(6)
|
||||
|
||||
// Room 0 covers [0, 1920] x [0, 1920], overlaps (500, 500)
|
||||
expect(rooms[0]?.isSafeZone).toBe(true)
|
||||
|
||||
// Distant rooms should not be safe zones
|
||||
const distantRoom = rooms.find(r => r.cellX === 48 && r.cellY === 24)
|
||||
expect(distantRoom?.isSafeZone).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('allocatePacksToRooms', () => {
|
||||
it('distributes normal packs to non-safe rooms and avoids safe rooms', () => {
|
||||
const rooms: MapRoom[] = [
|
||||
{ id: 0, x: 0, y: 0, width: 1000, height: 1000, cellX: 0, cellY: 0, cellsX: 10, cellsY: 10, isSafeZone: true },
|
||||
{ id: 1, x: 1000, y: 0, width: 1000, height: 1000, cellX: 10, cellY: 0, cellsX: 10, cellsY: 10 },
|
||||
{ id: 2, x: 2000, y: 0, width: 1000, height: 1000, cellX: 20, cellY: 0, cellsX: 10, cellsY: 10 },
|
||||
]
|
||||
const packs = [
|
||||
pack('fallen', 3),
|
||||
pack('zombie', 4),
|
||||
pack('skeleton', 3),
|
||||
pack('quillrat', 2),
|
||||
]
|
||||
|
||||
const allocation = allocatePacksToRooms(packs, rooms, 12345)
|
||||
|
||||
// Safe room (id: 0) must receive NO normal packs
|
||||
expect(allocation.get(rooms[0]!)).toHaveLength(0)
|
||||
|
||||
// Remaining packs must be distributed between room 1 and room 2
|
||||
const room1Count = allocation.get(rooms[1]!)?.length ?? 0
|
||||
const room2Count = allocation.get(rooms[2]!)?.length ?? 0
|
||||
expect(room1Count + room2Count).toBe(4)
|
||||
expect(room1Count).toBeGreaterThan(0)
|
||||
expect(room2Count).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('balances pack counts across candidate rooms based on room area', () => {
|
||||
// Room Small: 10x10 cells (area 100)
|
||||
// Room Large: 20x20 cells (area 400 - 4x larger)
|
||||
const roomSmall: MapRoom = { id: 'small', x: 0, y: 0, width: 800, height: 800, cellX: 0, cellY: 0, cellsX: 10, cellsY: 10 }
|
||||
const roomLarge: MapRoom = { id: 'large', x: 800, y: 0, width: 1600, height: 1600, cellX: 10, cellY: 0, cellsX: 20, cellsY: 20 }
|
||||
const rooms = [roomSmall, roomLarge]
|
||||
|
||||
const packs = Array.from({ length: 10 }, (_, i) => pack(`mob_${String(i)}`, 3))
|
||||
const allocation = allocatePacksToRooms(packs, rooms, 42)
|
||||
|
||||
const smallCount = allocation.get(roomSmall)?.length ?? 0
|
||||
const largeCount = allocation.get(roomLarge)?.length ?? 0
|
||||
|
||||
expect(smallCount + largeCount).toBe(10)
|
||||
// Large room has 4x area, so it should receive significantly more packs without overcrowding small room
|
||||
expect(largeCount).toBeGreaterThan(smallCount * 2)
|
||||
})
|
||||
|
||||
it('routes landmark SuperUnique packs with landmarkTile to covering room', () => {
|
||||
const roomA: MapRoom = { id: 'A', x: 0, y: 0, width: 1920, height: 1920, cellX: 0, cellY: 0, cellsX: 24, cellsY: 24 }
|
||||
const roomB: MapRoom = { id: 'B', x: 1920, y: 0, width: 1920, height: 1920, cellX: 24, cellY: 0, cellsX: 24, cellsY: 24 }
|
||||
const rooms = [roomA, roomB]
|
||||
|
||||
// Bishibosh at landmark tile (tileX: 30, tileY: 10) falls inside room B (cellX in [24, 48))
|
||||
const bishiboshPack = pack('bishibosh', 5, {
|
||||
superUniqueId: 'Bishibosh',
|
||||
landmarkTile: { tileX: 30, tileY: 10 },
|
||||
})
|
||||
const normalPack = pack('fallen', 3)
|
||||
|
||||
const allocation = allocatePacksToRooms([bishiboshPack, normalPack], rooms, 100)
|
||||
|
||||
const roomBPacks = allocation.get(roomB) ?? []
|
||||
expect(roomBPacks).toContain(bishiboshPack)
|
||||
})
|
||||
|
||||
it('routes landmark SuperUnique packs with fixedPosition to covering room', () => {
|
||||
const roomA: MapRoom = { id: 'A', x: 0, y: 0, width: 1000, height: 1000, cellX: 0, cellY: 0, cellsX: 10, cellsY: 10 }
|
||||
const roomB: MapRoom = { id: 'B', x: 1000, y: 0, width: 1000, height: 1000, cellX: 10, cellY: 0, cellsX: 10, cellsY: 10 }
|
||||
const rooms = [roomA, roomB]
|
||||
|
||||
// Rakanishu fixed at world pixel coordinates (1500, 500) inside room B
|
||||
const rakanishuPack = pack('rakanishu', 4, {
|
||||
superUniqueId: 'Rakanishu',
|
||||
fixedPosition: { x: 1500, y: 500 },
|
||||
})
|
||||
|
||||
const allocation = allocatePacksToRooms([rakanishuPack], rooms)
|
||||
expect(allocation.get(roomB)).toContain(rakanishuPack)
|
||||
expect(allocation.get(roomA)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('is deterministic with a given seed', () => {
|
||||
const rooms: MapRoom[] = [
|
||||
{ id: 1, x: 0, y: 0, width: 800, height: 800, cellX: 0, cellY: 0, cellsX: 10, cellsY: 10 },
|
||||
{ id: 2, x: 800, y: 0, width: 800, height: 800, cellX: 10, cellY: 0, cellsX: 10, cellsY: 10 },
|
||||
{ id: 3, x: 1600, y: 0, width: 800, height: 800, cellX: 20, cellY: 0, cellsX: 10, cellsY: 10 },
|
||||
]
|
||||
const packs = Array.from({ length: 9 }, (_, i) => pack(`mob_${String(i)}`, 2))
|
||||
|
||||
const run1 = allocatePacksToRooms(packs, rooms, 9999)
|
||||
const run2 = allocatePacksToRooms(packs, rooms, 9999)
|
||||
|
||||
for (const room of rooms) {
|
||||
const p1 = run1.get(room)!.map(p => p.members[0]?.id)
|
||||
const p2 = run2.get(room)!.map(p => p.members[0]?.id)
|
||||
expect(p1).toEqual(p2)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('spawnPacksInRoom', () => {
|
||||
it('places monster pack strictly within room boundaries', () => {
|
||||
const world = createWorld(0, 0)
|
||||
const room: MapRoom = {
|
||||
id: 'test_room',
|
||||
x: 1000,
|
||||
y: 1000,
|
||||
width: 400,
|
||||
height: 400,
|
||||
cellX: 10,
|
||||
cellY: 10,
|
||||
cellsX: 5,
|
||||
cellsY: 5,
|
||||
}
|
||||
const testPack = pack('fallen', 6)
|
||||
|
||||
const placed = spawnPacksInRoom(world, testPack, room, OPEN)
|
||||
expect(placed).toBe(6)
|
||||
expect(world.monsters).toHaveLength(6)
|
||||
|
||||
// Verify every monster is strictly inside room bounds
|
||||
for (const monster of world.monsters) {
|
||||
expect(monster.x).toBeGreaterThanOrEqual(room.x)
|
||||
expect(monster.x).toBeLessThanOrEqual(room.x + room.width)
|
||||
expect(monster.y).toBeGreaterThanOrEqual(room.y)
|
||||
expect(monster.y).toBeLessThanOrEqual(room.y + room.height)
|
||||
}
|
||||
})
|
||||
|
||||
it('respects terrain collisions and does not spill through walls', () => {
|
||||
const world = createWorld(0, 0)
|
||||
const room: MapRoom = {
|
||||
id: 'walled_room',
|
||||
x: 500,
|
||||
y: 500,
|
||||
width: 400,
|
||||
height: 400,
|
||||
cellX: 5,
|
||||
cellY: 5,
|
||||
cellsX: 5,
|
||||
cellsY: 5,
|
||||
}
|
||||
// Vertical solid wall slicing through x in [680, 750]
|
||||
const walledTerrain: CombatTerrain = {
|
||||
overlap: (x: number) => (x >= 680 && x <= 750 ? 1 : 0),
|
||||
}
|
||||
const testPack = pack('zombie', 8)
|
||||
|
||||
const placed = spawnPacksInRoom(world, testPack, room, walledTerrain)
|
||||
expect(placed).toBeGreaterThan(0)
|
||||
|
||||
for (const monster of world.monsters) {
|
||||
// Must never land on solid wall
|
||||
expect(walledTerrain.overlap(monster.x, monster.y)).toBe(0)
|
||||
expect(monster.x < 680 || monster.x > 750).toBe(true)
|
||||
// Must stay inside room
|
||||
expect(monster.x).toBeGreaterThanOrEqual(room.x)
|
||||
expect(monster.x).toBeLessThanOrEqual(room.x + room.width)
|
||||
expect(monster.y).toBeGreaterThanOrEqual(room.y)
|
||||
expect(monster.y).toBeLessThanOrEqual(room.y + room.height)
|
||||
}
|
||||
})
|
||||
|
||||
it('clusters minions tightly around leader within PACK_RADIUS_PX', () => {
|
||||
const world = createWorld(0, 0)
|
||||
const room: MapRoom = {
|
||||
id: 'cluster_room',
|
||||
x: 2000,
|
||||
y: 2000,
|
||||
width: 800,
|
||||
height: 800,
|
||||
cellX: 20,
|
||||
cellY: 20,
|
||||
cellsX: 10,
|
||||
cellsY: 10,
|
||||
}
|
||||
const testPack = pack('fallen', 7)
|
||||
|
||||
spawnPacksInRoom(world, testPack, room, OPEN)
|
||||
expect(world.monsters).toHaveLength(7)
|
||||
|
||||
const leader = world.monsters[0]!
|
||||
const minions = world.monsters.slice(1)
|
||||
|
||||
for (const minion of minions) {
|
||||
const dist = Math.hypot(minion.x - leader.x, minion.y - leader.y)
|
||||
expect(dist).toBeLessThanOrEqual(PACK_RADIUS_PX)
|
||||
}
|
||||
})
|
||||
|
||||
it('positions leader near interior center of room on clear ground', () => {
|
||||
const world = createWorld(0, 0)
|
||||
const room: MapRoom = {
|
||||
id: 'center_test',
|
||||
x: 1000,
|
||||
y: 1000,
|
||||
width: 400,
|
||||
height: 400,
|
||||
cellX: 10,
|
||||
cellY: 10,
|
||||
cellsX: 5,
|
||||
cellsY: 5,
|
||||
}
|
||||
const testPack = pack('boss', 1)
|
||||
|
||||
spawnPacksInRoom(world, testPack, room, OPEN)
|
||||
expect(world.monsters).toHaveLength(1)
|
||||
|
||||
const leader = world.monsters[0]!
|
||||
const roomCenterX = room.x + room.width / 2 // 1200
|
||||
const roomCenterY = room.y + room.height / 2 // 1200
|
||||
|
||||
// Leader should land right at or very close to center
|
||||
expect(Math.abs(leader.x - roomCenterX)).toBeLessThan(16)
|
||||
expect(Math.abs(leader.y - roomCenterY)).toBeLessThan(16)
|
||||
})
|
||||
|
||||
it('places fixedPosition SuperUnique at or near designated landmark coordinates', () => {
|
||||
const world = createWorld(0, 0)
|
||||
const room: MapRoom = {
|
||||
id: 'landmark_room',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
cellX: 0,
|
||||
cellY: 0,
|
||||
cellsX: 10,
|
||||
cellsY: 10,
|
||||
}
|
||||
const testPack = pack('treehead', 1, {
|
||||
superUniqueId: 'Treehead WoodFist',
|
||||
fixedPosition: { x: 350, y: 450 },
|
||||
})
|
||||
|
||||
spawnPacksInRoom(world, testPack, room, OPEN)
|
||||
expect(world.monsters).toHaveLength(1)
|
||||
|
||||
const leader = world.monsters[0]!
|
||||
expect(leader.x).toBe(350)
|
||||
expect(leader.y).toBe(450)
|
||||
})
|
||||
})
|
||||
|
||||
describe('spawnMonsterPacksByRooms', () => {
|
||||
it('populates world with all packs distributed across partitioned rooms', () => {
|
||||
const world = createWorld(0, 0)
|
||||
const rooms = partitionMapIntoRooms(48, 48, { roomCellSize: 24, cellPixelSize: 80 })
|
||||
const packs = [
|
||||
pack('fallen', 4),
|
||||
pack('zombie', 3),
|
||||
pack('skeleton', 5),
|
||||
]
|
||||
|
||||
const placed = spawnMonsterPacksByRooms(world, packs, rooms, OPEN, { seed: 101 })
|
||||
expect(placed).toBe(12)
|
||||
expect(world.monsters).toHaveLength(12)
|
||||
|
||||
// Verify every monster lies within at least one room's bounding box
|
||||
for (const m of world.monsters) {
|
||||
const inAnyRoom = rooms.some(
|
||||
r => m.x >= r.x && m.x <= r.x + r.width && m.y >= r.y && m.y <= r.y + r.height,
|
||||
)
|
||||
expect(inAnyRoom).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('maintains backwards compatibility when fallback around coordinates are passed', () => {
|
||||
const world = createWorld(0, 0)
|
||||
const packs = [pack('fallen', 3)]
|
||||
|
||||
const placed = spawnMonsterPacksByRooms(
|
||||
world,
|
||||
packs,
|
||||
{ x: 500, y: 500 },
|
||||
OPEN,
|
||||
{ spread: 300 },
|
||||
)
|
||||
expect(placed).toBe(3)
|
||||
expect(world.monsters).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,571 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createWorld, damageMonster, tickCombat } from '../src/game/combat.ts'
|
||||
import type { CombatOptions, CombatTerrain, MonsterPack, MonsterStats } from '../src/game/combat.ts'
|
||||
import { GameEngine } from '../src/game/engine.ts'
|
||||
import type { GameEngineOptions, WorldMapProvider } from '../src/game/engine.ts'
|
||||
import { MonsterStreamingManager, distanceToRoom } from '../src/game/monster-streaming.ts'
|
||||
import type { StreamingRoomDef, StreamingRoomState } from '../src/game/monster-streaming.ts'
|
||||
|
||||
/** Open ground everywhere. */
|
||||
const OPEN: CombatTerrain = { overlap: () => 0 }
|
||||
|
||||
/** Minimal map provider for engine testing. */
|
||||
const OPEN_MAP: WorldMapProvider = {
|
||||
widthPx: 10000,
|
||||
heightPx: 10000,
|
||||
overlap: () => 0,
|
||||
}
|
||||
|
||||
/** Helper to construct test monster stats. */
|
||||
function stats(id: string, hp = 30, damage = 5): MonsterStats {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
hp,
|
||||
damage,
|
||||
cooldownTicks: 25,
|
||||
reach: 40,
|
||||
aggroRadius: 200,
|
||||
speed: 100,
|
||||
xp: 10,
|
||||
}
|
||||
}
|
||||
|
||||
/** Helper to construct a test monster pack. */
|
||||
function pack(id: string, count: number, hp = 30): MonsterPack {
|
||||
return {
|
||||
members: Array.from({ length: count }, (_, i) => stats(id, hp + i)),
|
||||
}
|
||||
}
|
||||
|
||||
/** Default combat options for simulation. */
|
||||
const DEFAULT_COMBAT_OPTIONS: CombatOptions = {
|
||||
playerSpeed: 100,
|
||||
playerReach: 40,
|
||||
playerCooldownTicks: 25,
|
||||
playerDamage: 10,
|
||||
playerManaPerAttack: 0,
|
||||
respawnTicks: 100,
|
||||
}
|
||||
|
||||
describe('distanceToRoom', () => {
|
||||
const bounds = { minX: 1000, minY: 1000, maxX: 2000, maxY: 2000 }
|
||||
|
||||
it('returns 0 when point is inside room bounds', () => {
|
||||
expect(distanceToRoom(1500, 1500, bounds)).toBe(0)
|
||||
expect(distanceToRoom(1000, 1000, bounds)).toBe(0)
|
||||
expect(distanceToRoom(2000, 2000, bounds)).toBe(0)
|
||||
})
|
||||
|
||||
it('calculates orthogonal distance correctly', () => {
|
||||
expect(distanceToRoom(500, 1500, bounds)).toBe(500)
|
||||
expect(distanceToRoom(2600, 1500, bounds)).toBe(600)
|
||||
expect(distanceToRoom(1500, 300, bounds)).toBe(700)
|
||||
expect(distanceToRoom(1500, 2800, bounds)).toBe(800)
|
||||
})
|
||||
|
||||
it('calculates diagonal distance to closest corner', () => {
|
||||
// Distance from (700, 600) to (1000, 1000) is hypot(300, 400) = 500
|
||||
expect(distanceToRoom(700, 600, bounds)).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MonsterStreamingManager - Lazy Population (bPopulated)', () => {
|
||||
it('populates only spawn neighborhood rooms on initialization, leaving distant rooms unpopulated', () => {
|
||||
const world = createWorld(100, 100)
|
||||
const rooms: StreamingRoomDef[] = [
|
||||
{
|
||||
id: 'spawn_room',
|
||||
bounds: { minX: 0, minY: 0, maxX: 400, maxY: 400 },
|
||||
packs: [pack('fallen1', 3)],
|
||||
},
|
||||
{
|
||||
id: 'adjacent_room',
|
||||
bounds: { minX: 600, minY: 0, maxX: 1000, maxY: 400 }, // distance = 500 <= 1600
|
||||
packs: [pack('zombie1', 2)],
|
||||
},
|
||||
{
|
||||
id: 'distant_room_1',
|
||||
bounds: { minX: 3000, minY: 3000, maxX: 3500, maxY: 3500 }, // distance ~4100 > 1600
|
||||
packs: [pack('quillrat1', 4)],
|
||||
},
|
||||
{
|
||||
id: 'distant_room_2',
|
||||
bounds: { minX: 6000, minY: 6000, maxX: 6500, maxY: 6500 }, // distance ~8300 > 1600
|
||||
packs: [pack('skeleton1', 3)],
|
||||
},
|
||||
]
|
||||
|
||||
const mgr = new MonsterStreamingManager(world, OPEN, rooms, {
|
||||
activationRadius: 1600,
|
||||
deactivationRadius: 2400,
|
||||
})
|
||||
|
||||
// Only spawn_room (3) and adjacent_room (2) are populated
|
||||
expect(world.monsters).toHaveLength(5)
|
||||
|
||||
const [spawnRoom, adjRoom, distRoom1, distRoom2] = mgr.rooms as [
|
||||
StreamingRoomState,
|
||||
StreamingRoomState,
|
||||
StreamingRoomState,
|
||||
StreamingRoomState,
|
||||
]
|
||||
|
||||
expect(spawnRoom.bPopulated).toBe(true)
|
||||
expect(spawnRoom.active).toBe(true)
|
||||
expect(spawnRoom.monsterIndices).toEqual([0, 1, 2])
|
||||
|
||||
expect(adjRoom.bPopulated).toBe(true)
|
||||
expect(adjRoom.active).toBe(true)
|
||||
expect(adjRoom.monsterIndices).toEqual([3, 4])
|
||||
|
||||
// Distant rooms must NOT be populated and must have 0 monster indices
|
||||
expect(distRoom1.bPopulated).toBe(false)
|
||||
expect(distRoom1.active).toBe(false)
|
||||
expect(distRoom1.monsterIndices).toHaveLength(0)
|
||||
|
||||
expect(distRoom2.bPopulated).toBe(false)
|
||||
expect(distRoom2.active).toBe(false)
|
||||
expect(distRoom2.monsterIndices).toHaveLength(0)
|
||||
|
||||
// Active monsters are awake
|
||||
expect(world.monsters.every(m => !m.sleeping)).toBe(true)
|
||||
})
|
||||
|
||||
it('triggers room activation and population when player approaches', () => {
|
||||
const world = createWorld(100, 100)
|
||||
const rooms: StreamingRoomDef[] = [
|
||||
{
|
||||
id: 'spawn_room',
|
||||
bounds: { minX: 0, minY: 0, maxX: 400, maxY: 400 },
|
||||
packs: [pack('fallen1', 2)],
|
||||
},
|
||||
{
|
||||
id: 'distant_room',
|
||||
bounds: { minX: 3000, minY: 3000, maxX: 3500, maxY: 3500 },
|
||||
packs: [pack('zombie1', 3)],
|
||||
},
|
||||
]
|
||||
|
||||
const mgr = new MonsterStreamingManager(world, OPEN, rooms, {
|
||||
activationRadius: 1600,
|
||||
deactivationRadius: 2400,
|
||||
})
|
||||
|
||||
expect(world.monsters).toHaveLength(2)
|
||||
expect(mgr.rooms[1]!.bPopulated).toBe(false)
|
||||
|
||||
// Move player close to distant room (distance to [3000, 3000] is ~282px <= 1600)
|
||||
world.player.x = 2800
|
||||
world.player.y = 2800
|
||||
mgr.update(world.player.x, world.player.y)
|
||||
|
||||
const distantRoom = mgr.rooms[1]!
|
||||
expect(distantRoom.bPopulated).toBe(true)
|
||||
expect(distantRoom.active).toBe(true)
|
||||
expect(distantRoom.monsterIndices).toEqual([2, 3, 4])
|
||||
expect(world.monsters).toHaveLength(5)
|
||||
|
||||
// Distant monsters are active and not sleeping
|
||||
for (const idx of distantRoom.monsterIndices) {
|
||||
expect(world.monsters[idx]!.sleeping).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('invokes onSpawnRoom callback exactly once when a room is populated', () => {
|
||||
const world = createWorld(100, 100)
|
||||
const onSpawnRoom = vi.fn()
|
||||
const rooms: StreamingRoomDef[] = [
|
||||
{
|
||||
id: 'room_a',
|
||||
bounds: { minX: 0, minY: 0, maxX: 300, maxY: 300 },
|
||||
packs: [pack('fallen1', 2)],
|
||||
},
|
||||
{
|
||||
id: 'room_b',
|
||||
bounds: { minX: 3000, minY: 3000, maxX: 3300, maxY: 3300 },
|
||||
packs: [pack('zombie1', 2)],
|
||||
},
|
||||
]
|
||||
|
||||
const mgr = new MonsterStreamingManager(world, OPEN, rooms, {
|
||||
activationRadius: 1600,
|
||||
deactivationRadius: 2400,
|
||||
onSpawnRoom,
|
||||
})
|
||||
|
||||
// room_a spawned on init
|
||||
expect(onSpawnRoom).toHaveBeenCalledTimes(1)
|
||||
expect(onSpawnRoom).toHaveBeenCalledWith(expect.objectContaining({ id: 'room_a', bPopulated: true }))
|
||||
|
||||
// Move closer to room_b
|
||||
mgr.update(3000, 3000)
|
||||
expect(onSpawnRoom).toHaveBeenCalledTimes(2)
|
||||
expect(onSpawnRoom).toHaveBeenCalledWith(expect.objectContaining({ id: 'room_b', bPopulated: true }))
|
||||
|
||||
// Repeated updates do NOT trigger onSpawnRoom again
|
||||
mgr.update(3000, 3000)
|
||||
mgr.update(0, 0)
|
||||
mgr.update(3000, 3000)
|
||||
expect(onSpawnRoom).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MonsterStreamingManager - Active / Sleeping Room Streaming', () => {
|
||||
it('marks living monsters in distant rooms as sleeping and skips their AI in tickMonsters', () => {
|
||||
const world = createWorld(100, 100)
|
||||
const rooms: StreamingRoomDef[] = [
|
||||
{
|
||||
id: 'room_spawn',
|
||||
bounds: { minX: 0, minY: 0, maxX: 300, maxY: 300 },
|
||||
packs: [pack('fallen1', 2)],
|
||||
},
|
||||
{
|
||||
id: 'room_far',
|
||||
bounds: { minX: 3000, minY: 3000, maxX: 3300, maxY: 3300 },
|
||||
packs: [pack('zombie1', 2)],
|
||||
},
|
||||
]
|
||||
|
||||
const mgr = new MonsterStreamingManager(world, OPEN, rooms, {
|
||||
activationRadius: 1600,
|
||||
deactivationRadius: 2400,
|
||||
})
|
||||
|
||||
// Populate room_far
|
||||
mgr.update(3100, 3100)
|
||||
expect(mgr.rooms[0]!.active).toBe(false)
|
||||
expect(mgr.rooms[1]!.active).toBe(true)
|
||||
|
||||
// Monsters in room_spawn (indices 0, 1) are now sleeping
|
||||
const sleepingMonster = world.monsters[0]!
|
||||
expect(sleepingMonster.sleeping).toBe(true)
|
||||
|
||||
// Set cooldown and hitFlash on sleeping monster to verify tickMonsters skips it completely
|
||||
sleepingMonster.cooldown = 15
|
||||
sleepingMonster.hitFlash = 3
|
||||
const initialMonsterX = sleepingMonster.x
|
||||
const initialMonsterY = sleepingMonster.y
|
||||
|
||||
// Advance simulation tick: sleeping monster must skip all AI and timers
|
||||
tickCombat(
|
||||
world,
|
||||
{ movement: { x: 0, y: 0 }, attack: false },
|
||||
DEFAULT_COMBAT_OPTIONS,
|
||||
OPEN,
|
||||
[0, 100, 200],
|
||||
)
|
||||
|
||||
expect(sleepingMonster.cooldown).toBe(15) // Cooldown preserved without decrementing
|
||||
expect(sleepingMonster.hitFlash).toBe(3) // hitFlash preserved without decrementing
|
||||
expect(sleepingMonster.x).toBe(initialMonsterX)
|
||||
expect(sleepingMonster.y).toBe(initialMonsterY)
|
||||
|
||||
// Active monsters in room_far (e.g. index 2) are not sleeping
|
||||
const activeMonster = world.monsters[2]!
|
||||
expect(activeMonster.sleeping).toBe(false)
|
||||
})
|
||||
|
||||
it('wakes sleeping monsters when player re-enters activationRadius', () => {
|
||||
const world = createWorld(100, 100)
|
||||
const rooms: StreamingRoomDef[] = [
|
||||
{
|
||||
id: 'room_spawn',
|
||||
bounds: { minX: 0, minY: 0, maxX: 300, maxY: 300 },
|
||||
packs: [pack('fallen1', 2)],
|
||||
},
|
||||
{
|
||||
id: 'room_far',
|
||||
bounds: { minX: 3000, minY: 3000, maxX: 3300, maxY: 3300 },
|
||||
packs: [pack('zombie1', 2)],
|
||||
},
|
||||
]
|
||||
|
||||
const mgr = new MonsterStreamingManager(world, OPEN, rooms, {
|
||||
activationRadius: 1600,
|
||||
deactivationRadius: 2400,
|
||||
})
|
||||
|
||||
// Move player far away: room_spawn deactivates
|
||||
mgr.update(4000, 4000)
|
||||
expect(mgr.rooms[0]!.active).toBe(false)
|
||||
expect(world.monsters[0]!.sleeping).toBe(true)
|
||||
|
||||
// Move player back: room_spawn wakes up
|
||||
mgr.update(100, 100)
|
||||
expect(mgr.rooms[0]!.active).toBe(true)
|
||||
expect(world.monsters[0]!.sleeping).toBe(false)
|
||||
expect(world.monsters[1]!.sleeping).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves monster damage, cooldowns, and positions across sleeping and wake cycles', () => {
|
||||
const world = createWorld(100, 100)
|
||||
const rooms: StreamingRoomDef[] = [
|
||||
{
|
||||
id: 'room_spawn',
|
||||
bounds: { minX: 0, minY: 0, maxX: 300, maxY: 300 },
|
||||
packs: [pack('fallen1', 2, 50)],
|
||||
},
|
||||
]
|
||||
|
||||
const mgr = new MonsterStreamingManager(world, OPEN, rooms, {
|
||||
activationRadius: 1600,
|
||||
deactivationRadius: 2400,
|
||||
})
|
||||
|
||||
const target = world.monsters[0]!
|
||||
const initialHp = target.hp
|
||||
damageMonster(world, 0, 18) // HP becomes initialHp - 18
|
||||
target.cooldown = 14
|
||||
const preservedX = target.x
|
||||
const preservedY = target.y
|
||||
|
||||
// Deactivate room
|
||||
mgr.update(4000, 4000)
|
||||
expect(mgr.rooms[0]!.active).toBe(false)
|
||||
expect(target.sleeping).toBe(true)
|
||||
|
||||
// Simulate several ticks while monster is sleeping
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
tickCombat(
|
||||
world,
|
||||
{ movement: { x: 0, y: 0 }, attack: false },
|
||||
DEFAULT_COMBAT_OPTIONS,
|
||||
OPEN,
|
||||
[0, 100, 200],
|
||||
)
|
||||
}
|
||||
|
||||
// Wake room back up
|
||||
mgr.update(100, 100)
|
||||
expect(mgr.rooms[0]!.active).toBe(true)
|
||||
expect(target.sleeping).toBe(false)
|
||||
expect(target.hp).toBe(initialHp - 18)
|
||||
expect(target.cooldown).toBe(14)
|
||||
expect(target.x).toBe(preservedX)
|
||||
expect(target.y).toBe(preservedY)
|
||||
})
|
||||
|
||||
it('respects hysteresis between activationRadius and deactivationRadius', () => {
|
||||
const world = createWorld(100, 100)
|
||||
const rooms: StreamingRoomDef[] = [
|
||||
{
|
||||
id: 'room_spawn',
|
||||
bounds: { minX: 0, minY: 0, maxX: 300, maxY: 300 },
|
||||
packs: [pack('fallen1', 1)],
|
||||
},
|
||||
{
|
||||
id: 'room_mid',
|
||||
bounds: { minX: 2000, minY: 0, maxX: 2200, maxY: 300 }, // distance from (100, 100) is 1900
|
||||
packs: [pack('zombie1', 1)],
|
||||
},
|
||||
]
|
||||
|
||||
const mgr = new MonsterStreamingManager(world, OPEN, rooms, {
|
||||
activationRadius: 1600,
|
||||
deactivationRadius: 2400,
|
||||
})
|
||||
|
||||
const roomSpawn = mgr.rooms[0]!
|
||||
const roomMid = mgr.rooms[1]!
|
||||
|
||||
// Initial state: player at 100, 100
|
||||
// roomSpawn is active (dist = 0 <= 1600)
|
||||
// roomMid is at dist 1900 (between 1600 and 2400), so it stays unpopulated & inactive
|
||||
expect(roomSpawn.active).toBe(true)
|
||||
expect(roomMid.bPopulated).toBe(false)
|
||||
expect(roomMid.active).toBe(false)
|
||||
|
||||
// Player moves to dist 2000 from roomSpawn:
|
||||
// roomSpawn was already active, so it STAYS active (dist 2000 <= 2400)
|
||||
mgr.update(2100, 100)
|
||||
// Now player is inside roomMid! (x: 2100 is between 2000 and 2200)
|
||||
expect(roomMid.bPopulated).toBe(true)
|
||||
expect(roomMid.active).toBe(true)
|
||||
// roomSpawn distance is 2100 - 300 = 1800 <= 2400, so it remains active
|
||||
expect(roomSpawn.active).toBe(true)
|
||||
|
||||
// Player moves farther to x: 2800 (distance from roomSpawn: 2500 > 2400)
|
||||
mgr.update(2800, 100)
|
||||
expect(roomSpawn.active).toBe(false)
|
||||
|
||||
// Player steps back to x: 2300 (distance from roomSpawn: 2000, between 1600 and 2400)
|
||||
// roomSpawn was sleeping, so it STAYS sleeping until <= 1600!
|
||||
mgr.update(2300, 100)
|
||||
expect(roomSpawn.active).toBe(false)
|
||||
|
||||
// Player steps closer to x: 1800 (distance from roomSpawn: 1500 <= 1600)
|
||||
// roomSpawn wakes up!
|
||||
mgr.update(1800, 100)
|
||||
expect(roomSpawn.active).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MonsterStreamingManager - Idempotency & Non-Respawn', () => {
|
||||
it('never re-populates a room once bPopulated is true: dead monsters stay dead when returning', () => {
|
||||
const world = createWorld(100, 100)
|
||||
const rooms: StreamingRoomDef[] = [
|
||||
{
|
||||
id: 'room_a',
|
||||
bounds: { minX: 0, minY: 0, maxX: 400, maxY: 400 },
|
||||
packs: [pack('fallen1', 3)],
|
||||
},
|
||||
{
|
||||
id: 'room_b',
|
||||
bounds: { minX: 3000, minY: 3000, maxX: 3400, maxY: 3400 },
|
||||
packs: [pack('zombie1', 3)],
|
||||
},
|
||||
]
|
||||
|
||||
const mgr = new MonsterStreamingManager(world, OPEN, rooms, {
|
||||
activationRadius: 1600,
|
||||
deactivationRadius: 2400,
|
||||
})
|
||||
|
||||
expect(world.monsters).toHaveLength(3)
|
||||
|
||||
// Approach room_b to populate it
|
||||
mgr.update(3200, 3200)
|
||||
expect(world.monsters).toHaveLength(6)
|
||||
const roomB = mgr.rooms[1]!
|
||||
expect(roomB.bPopulated).toBe(true)
|
||||
|
||||
// Kill all monsters in room_b
|
||||
for (const idx of roomB.monsterIndices) {
|
||||
damageMonster(world, idx, 9999)
|
||||
expect(world.monsters[idx]!.state).toBe('dead')
|
||||
}
|
||||
|
||||
const totalMonstersBeforeLeave = world.monsters.length
|
||||
expect(totalMonstersBeforeLeave).toBe(6)
|
||||
|
||||
// Player leaves room_b far away
|
||||
mgr.update(100, 100)
|
||||
expect(roomB.active).toBe(false)
|
||||
|
||||
// Player returns to room_b
|
||||
mgr.update(3200, 3200)
|
||||
expect(roomB.active).toBe(true)
|
||||
expect(roomB.bPopulated).toBe(true)
|
||||
|
||||
// Verification: NO new monsters are spawned
|
||||
expect(world.monsters).toHaveLength(totalMonstersBeforeLeave)
|
||||
for (const idx of roomB.monsterIndices) {
|
||||
expect(world.monsters[idx]!.state).toBe('dead')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('GameEngine Integration with MonsterStreamingManager', () => {
|
||||
const dummyOpts: GameEngineOptions = {
|
||||
spawn: { x: 100, y: 100 },
|
||||
stats: [stats('fallen1'), stats('zombie1')],
|
||||
xpTable: [0, 0, 10, 30, 60],
|
||||
itemBases: [],
|
||||
prefixAffixes: [],
|
||||
suffixAffixes: [],
|
||||
skills: [],
|
||||
npcDefs: [],
|
||||
questDefs: [],
|
||||
combatOptions: DEFAULT_COMBAT_OPTIONS,
|
||||
talkRadius: 50,
|
||||
pickupRadius: 50,
|
||||
inventoryCols: 10,
|
||||
inventoryRows: 4,
|
||||
monsterCount: 0,
|
||||
}
|
||||
|
||||
it('automatically initializes MonsterStreamingManager when roomPacks are provided', () => {
|
||||
const rooms: StreamingRoomDef[] = [
|
||||
{
|
||||
id: 'spawn_room',
|
||||
bounds: { minX: 0, minY: 0, maxX: 500, maxY: 500 },
|
||||
packs: [pack('fallen1', 2)],
|
||||
},
|
||||
{
|
||||
id: 'distant_room',
|
||||
bounds: { minX: 3000, minY: 3000, maxX: 3500, maxY: 3500 },
|
||||
packs: [pack('zombie1', 3)],
|
||||
},
|
||||
]
|
||||
|
||||
const engine = new GameEngine(OPEN_MAP, {
|
||||
...dummyOpts,
|
||||
roomPacks: rooms,
|
||||
})
|
||||
|
||||
expect(engine.streamingManager).toBeDefined()
|
||||
expect(engine.streamingManager?.rooms).toHaveLength(2)
|
||||
// Only spawn room monsters are created initially
|
||||
expect(engine.world.monsters).toHaveLength(2)
|
||||
expect(engine.streamingManager?.rooms[0]!.bPopulated).toBe(true)
|
||||
expect(engine.streamingManager?.rooms[1]!.bPopulated).toBe(false)
|
||||
})
|
||||
|
||||
it('calls streamingManager.update in GameEngine.tick to stream rooms as player moves', () => {
|
||||
const rooms: StreamingRoomDef[] = [
|
||||
{
|
||||
id: 'spawn_room',
|
||||
bounds: { minX: 0, minY: 0, maxX: 500, maxY: 500 },
|
||||
packs: [pack('fallen1', 2)],
|
||||
},
|
||||
{
|
||||
id: 'distant_room',
|
||||
bounds: { minX: 1200, minY: 100, maxX: 1500, maxY: 500 }, // distance ~700 from (500, 100)
|
||||
packs: [pack('zombie1', 3)],
|
||||
},
|
||||
]
|
||||
|
||||
const engine = new GameEngine(OPEN_MAP, {
|
||||
...dummyOpts,
|
||||
roomPacks: rooms,
|
||||
streamingOptions: {
|
||||
activationRadius: 800,
|
||||
deactivationRadius: 1400,
|
||||
},
|
||||
})
|
||||
|
||||
// Initially at (100, 100), distance to distant_room is 1100 > 800 -> unpopulated
|
||||
expect(engine.world.monsters).toHaveLength(2)
|
||||
expect(engine.streamingManager?.rooms[1]!.bPopulated).toBe(false)
|
||||
|
||||
// Teleport player closer to distant room
|
||||
engine.world.player.x = 800
|
||||
engine.world.player.y = 100
|
||||
|
||||
// Next engine.tick should update streaming manager and populate distant room
|
||||
engine.tick({
|
||||
movement: { x: 0, y: 0 },
|
||||
attacking: false,
|
||||
pickingUp: false,
|
||||
talking: false,
|
||||
digits: [],
|
||||
saving: false,
|
||||
loading: false,
|
||||
})
|
||||
|
||||
expect(engine.streamingManager?.rooms[1]!.bPopulated).toBe(true)
|
||||
expect(engine.world.monsters).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('accepts pre-constructed streamingManager in GameEngineOptions', () => {
|
||||
const world = createWorld(100, 100)
|
||||
const rooms: StreamingRoomDef[] = [
|
||||
{
|
||||
id: 'room_1',
|
||||
bounds: { minX: 0, minY: 0, maxX: 500, maxY: 500 },
|
||||
packs: [pack('fallen1', 2)],
|
||||
},
|
||||
]
|
||||
const streamingManager = new MonsterStreamingManager(world, OPEN, rooms)
|
||||
|
||||
const engine = new GameEngine(OPEN_MAP, {
|
||||
...dummyOpts,
|
||||
streamingManager,
|
||||
})
|
||||
|
||||
expect(engine.streamingManager).toBe(streamingManager)
|
||||
expect(engine.world).toBe(world)
|
||||
expect(engine.world.monsters).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue