Compare commits
No commits in common. "c2bda2921130ff703676b2420ace49c4fe162d97" and "8e46b6415621b80d77619c8766690b6cac769737" have entirely different histories.
c2bda29211
...
8e46b64156
|
|
@ -136,8 +136,6 @@ export interface Monster {
|
||||||
hitFlash: number
|
hitFlash: number
|
||||||
/** Ticks left of the death animation. */
|
/** Ticks left of the death animation. */
|
||||||
corpseTicks: number
|
corpseTicks: number
|
||||||
/** Whether the monster is sleeping (inactive off-screen room). */
|
|
||||||
sleeping?: boolean | undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The player's combat-relevant state. */
|
/** The player's combat-relevant state. */
|
||||||
|
|
@ -809,7 +807,6 @@ function tickPlayer(
|
||||||
*/
|
*/
|
||||||
function tickMonsters(world: CombatWorld, options: CombatOptions, terrain: CombatTerrain, targets: readonly CombatPlayer[]): void {
|
function tickMonsters(world: CombatWorld, options: CombatOptions, terrain: CombatTerrain, targets: readonly CombatPlayer[]): void {
|
||||||
for (const monster of world.monsters) {
|
for (const monster of world.monsters) {
|
||||||
if (monster.sleeping) continue
|
|
||||||
if (monster.state === 'dead') {
|
if (monster.state === 'dead') {
|
||||||
if (monster.corpseTicks > 0) monster.corpseTicks -= 1
|
if (monster.corpseTicks > 0) monster.corpseTicks -= 1
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ import { QuestLog, npcDialog } from './quests.ts'
|
||||||
import type { NpcDef, QuestDef } from './quests.ts'
|
import type { NpcDef, QuestDef } from './quests.ts'
|
||||||
import { Rng } from './rng.ts'
|
import { Rng } from './rng.ts'
|
||||||
import { captureSnapshot, parseSnapshot, restoreSnapshot, serializeSnapshot } from './save.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 {
|
export interface EngineInput {
|
||||||
readonly movement: { readonly x: number; readonly y: number }
|
readonly movement: { readonly x: number; readonly y: number }
|
||||||
|
|
@ -117,24 +115,6 @@ export interface GameEngineOptions {
|
||||||
* itself.
|
* itself.
|
||||||
*/
|
*/
|
||||||
snapshotStore?: SnapshotStore
|
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 {
|
export interface GameEngineMetrics {
|
||||||
|
|
@ -165,7 +145,6 @@ export class GameEngine {
|
||||||
selectedSkill = 0
|
selectedSkill = 0
|
||||||
npcEntities: NpcEntity[] = []
|
npcEntities: NpcEntity[] = []
|
||||||
dialog: string[] = []
|
dialog: string[] = []
|
||||||
streamingManager?: MonsterStreamingManager
|
|
||||||
/**
|
/**
|
||||||
* Whether the save input was held on the previous tick.
|
* Whether the save input was held on the previous tick.
|
||||||
*
|
*
|
||||||
|
|
@ -185,27 +164,10 @@ export class GameEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(public terrain: WorldMapProvider, public opts: GameEngineOptions) {
|
constructor(public terrain: WorldMapProvider, public opts: GameEngineOptions) {
|
||||||
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)
|
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 spread = opts.monsterSpread ?? 260
|
||||||
const count = opts.monsterCount ?? 8
|
const count = opts.monsterCount ?? 8
|
||||||
if (this.streamingManager !== undefined) {
|
if (opts.monsterPacks !== undefined && opts.monsterPacks.length > 0) {
|
||||||
// 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)
|
spawnMonsterPacks(this.world, opts.monsterPacks, opts.spawn, spread, this.terrain, opts.safeZones)
|
||||||
} else if (count > 0) {
|
} else if (count > 0) {
|
||||||
spawnMonsters(this.world, opts.stats, count, opts.spawn, spread, this.terrain)
|
spawnMonsters(this.world, opts.stats, count, opts.spawn, spread, this.terrain)
|
||||||
|
|
@ -236,8 +198,6 @@ export class GameEngine {
|
||||||
const beforeX = player.x
|
const beforeX = player.x
|
||||||
const beforeY = player.y
|
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)
|
if (this.world.tick % 25 === 0) player.mana = Math.min(player.maxMana, player.mana + 1)
|
||||||
|
|
||||||
const wanted = this.opts.skills[this.selectedSkill]
|
const wanted = this.opts.skills[this.selectedSkill]
|
||||||
|
|
@ -419,9 +379,6 @@ export class GameEngine {
|
||||||
this.questLog = pieces.quests
|
this.questLog = pieces.quests
|
||||||
this.ground = pieces.ground
|
this.ground = pieces.ground
|
||||||
this.projectiles = []
|
this.projectiles = []
|
||||||
if (this.streamingManager !== undefined) {
|
|
||||||
this.streamingManager.world = this.world
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -527,6 +484,3 @@ export function syncEngineState(engine: GameEngine, state: EngineViewState): voi
|
||||||
state.y = engine.world.player.y
|
state.y = engine.world.player.y
|
||||||
state.bagItems = engine.bag.contents.length
|
state.bagItems = engine.bag.contents.length
|
||||||
}
|
}
|
||||||
|
|
||||||
export { MonsterStreamingManager } from './monster-streaming.ts'
|
|
||||||
export type { MonsterStreamingOptions, StreamingRoomDef, StreamingRoomState } from './monster-streaming.ts'
|
|
||||||
|
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
/**
|
|
||||||
* 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,571 +0,0 @@
|
||||||
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