feat(scene): acts.html 接入房间级惰性刷怪与休眠/唤醒流式加载

把已合入 main 但从未被生产代码调用的两块能力接到 acts.html 的
act-scene 上,按 D2Game.dll 的做法组织怪物生命周期:

- act-scene:新增 buildStreamingRooms(),把关卡切成房间并把怪物群
  分配给房间,交给 GameEngine 的 roomPacks。房间按「场景像素空间」
  切分而不是格子空间——一块矩形格子投影出来是菱形,相邻矩形的
  AABB 几乎完全重叠,那样切会让全图房间同时「靠近玩家」,流式加载
  就没有意义了。落在菱形外、没有任何可走地面的房间直接丢弃,否则
  分到它头上的怪物群会被静默吞掉。
- monster-streaming:populateRoom 改用 monster-rooms 的
  spawnPacksInRoom,怪物在房间内抱团成营地,而不是沿用旧的全局
  螺旋撒点。这把 feat/monster-streaming 和 feat/monster-room-placement
  两条分支真正合到了一起。落进安全区(传送点/关卡入口)的个体会被
  推到安全区边缘。
- 重新打开怪物仇恨。它当初被关掉是因为全图怪物同时醒着,会被拖成
  一条长队;现在玩家没走近的怪物要么还没实例化、要么在睡眠中。
- swapLevel 过图时重建房间表,而不是在门口一次性刷完整张图。

tests/wilderness-roads:角落断言改为按槽位号统计。这条断言早于
6b9c3d3(Act 1 北/西/三个角改用岩壁边界),岩壁族还带字母后缀
(Wild Cliff Border 6A),按全名查表就落空了。

Verified: typecheck + 923 tests pass;另用真实烘焙数据回放,
冰冷之原 45 群 57 只怪 → 45 个房间,出生点只实例化 18 房 26 只,
走遍全图后恰好 45 房 57 只,无遗漏、无重复。
This commit is contained in:
troytt 2026-09-17 11:14:26 +00:00
parent 374bf2fff6
commit 4ece5aa3c1
4 changed files with 284 additions and 31 deletions

View File

@ -165,7 +165,14 @@ export class GameEngine {
selectedSkill = 0
npcEntities: NpcEntity[] = []
dialog: string[] = []
streamingManager?: MonsterStreamingManager
/**
* Lazy room population driver, when the level supplied a room plan.
*
* Explicitly assignable to `undefined`: walking into a level the baker gave
* no packs has to clear the previous level's manager, or its rooms would keep
* waking monsters that belong to a map nobody is standing on.
*/
streamingManager?: MonsterStreamingManager | undefined
/**
* Whether the save input was held on the previous tick.
*

View File

@ -13,14 +13,20 @@
* 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'
import { spawnPacksInRoom } from './monster-rooms.ts'
import type { MapRoom } from './monster-rooms.ts'
/** Definition of a room holding unspawned monster pack blueprints. */
export interface StreamingRoomDef {
readonly id: string | number
readonly bounds: { readonly minX: number; readonly minY: number; readonly maxX: number; readonly maxY: number }
readonly packs: readonly MonsterPack[]
/**
* Optional source `MapRoom` (carrying cell coordinates / safe-zone flags) used when
* the room is lazily populated. When omitted one is synthesised from `bounds`.
*/
readonly room?: MapRoom | undefined
}
/** Runtime state of a streaming room. */
@ -91,6 +97,7 @@ export class MonsterStreamingManager {
id: r.id,
bounds: r.bounds,
packs: r.packs,
room: r.room,
bPopulated: false,
active: false,
monsterIndices: [],
@ -139,33 +146,73 @@ export class MonsterStreamingManager {
/**
* Lazily instantiates a room's monster packs into `world.monsters`.
* Sets `bPopulated = true` to guarantee idempotency.
*
* Packs are placed as tight camps inside the room boundary (see
* `spawnPacksInRoom`), mirroring D2's per-room monster groups, rather than
* being scattered across the whole level. Sets `bPopulated = true` to
* guarantee idempotency.
*/
private populateRoom(room: StreamingRoomState): void {
if (room.bPopulated) return
const startIndex = this.world.monsters.length
const 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
const mapRoom = room.room ?? boundsToMapRoom(room.id, room.bounds)
spawnMonsterPacks(
this.world,
room.packs,
{ x: centerX, y: centerY },
spread,
this.terrain,
this.options?.safeZones,
)
spawnPacksInRoom(this.world, room.packs, mapRoom, this.terrain)
const endIndex = this.world.monsters.length
for (let i = startIndex; i < endIndex; i += 1) {
room.monsterIndices.push(i)
}
// Push any monster that landed inside a safe zone (waypoint / level entrance)
// out to the safe zone perimeter so towns and warps stay clear.
const safeZones = this.options?.safeZones
if (safeZones !== undefined && safeZones.length > 0) {
for (let i = startIndex; i < endIndex; i += 1) {
const monster = this.world.monsters[i]
if (monster === undefined) continue
for (const zone of safeZones) {
const dx = monster.x - zone.x
const dy = monster.y - zone.y
const dist = Math.hypot(dx, dy)
if (dist >= zone.radius) continue
const angle = dist > 1e-6 ? Math.atan2(dy, dx) : (i * 2.399963)
monster.x = zone.x + Math.cos(angle) * (zone.radius + 8)
monster.y = zone.y + Math.sin(angle) * (zone.radius + 8)
}
}
}
room.bPopulated = true
this.options?.onSpawnRoom?.(room)
}
}
/**
* Builds a minimal `MapRoom` from pixel bounds when the caller did not supply one.
*
* @param id - room identifier (also seeds the camp ring angle).
* @param bounds - axis-aligned pixel bounds of the room.
* @returns a `MapRoom` usable by `spawnPacksInRoom`.
*/
function boundsToMapRoom(
id: string | number,
bounds: { readonly minX: number; readonly minY: number; readonly maxX: number; readonly maxY: number },
): MapRoom {
const minX = Math.min(bounds.minX, bounds.maxX)
const maxX = Math.max(bounds.minX, bounds.maxX)
const minY = Math.min(bounds.minY, bounds.maxY)
const maxY = Math.max(bounds.minY, bounds.maxY)
return {
id,
x: minX,
y: minY,
width: Math.max(1, maxX - minX),
height: Math.max(1, maxY - minY),
cellX: 0,
cellY: 0,
cellsX: 0,
cellsY: 0,
}
}

View File

@ -29,7 +29,7 @@ import type { Palette } from '../formats/pal.ts'
import type { SpriteSheet } from '../formats/sprite.ts'
import {
ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH,
buildIsoMapScene, cellCentre, findIsoSpawn, isBlockedAt, levelSeed,
buildIsoMapScene, cellAt, cellCentre, findIsoSpawn, isBlockedAt, levelSeed,
} from '../game/d2map.ts'
import type { CollisionGrid, IsoDraw } from '../game/d2map.ts'
import { updateAnimatableTiles } from '../game/animated-tiles.ts'
@ -37,6 +37,10 @@ import type { AnimatableTile, AnimatedFrame } from '../game/animated-tiles.ts'
import { createIsoTerrain } from '../game/iso-terrain.ts'
import { spawnMonsterPacks, spawnMonsters } from '../game/combat.ts'
import type { MonsterPack, MonsterStats, SafeZone } from '../game/combat.ts'
import { MonsterStreamingManager } from '../game/monster-streaming.ts'
import type { StreamingRoomDef } from '../game/monster-streaming.ts'
import { allocatePacksToRooms } from '../game/monster-rooms.ts'
import type { MapRoom } from '../game/monster-rooms.ts'
import { SUB_TILES_PER_TILE, depthInsertIndex } from '../game/map.ts'
import { GameEngine, syncEngineState } from "../game/engine.ts"
import type { NpcEntity } from "../game/engine.ts"
@ -108,6 +112,32 @@ const MARKER_HEIGHT = 26
* works at all.
*/
const PLACEHOLDER_MONSTER_COUNT = 12
/**
* Side of a streaming room, in cells.
*
* `D2Common`'s DRLG cuts a wilderness level into rooms of a handful of tiles
* each; a generated level here is 8 cells per generator block, so 24 cells is
* three blocks — about a screen and a half, which is the granularity at which
* populating a room is cheap enough to do inside one frame.
*/
const ROOM_CELL_SIZE = 24
/**
* Distance at which a room is populated and woken, in scene pixels.
*
* The viewport is ~1280×720, and the isometric projection makes a screen
* roughly 1300px wide in scene space, so 1600 is "just past the edge of the
* screen": monsters exist before they can be seen, never in front of the
* player's eyes.
*/
const ROOM_ACTIVATION_RADIUS = 1600
/**
* Distance at which a room goes back to sleep, in scene pixels.
*
* Deliberately larger than the activation radius. The gap is hysteresis: a
* player pacing across a room boundary would otherwise toggle a room's AI on
* and off every few frames.
*/
const ROOM_DEACTIVATION_RADIUS = 2400
/**
* `localStorage` key holding the single save slot.
*
@ -1347,6 +1377,125 @@ async function loadCharacterArt(
}
}
/**
* Whether any walkable sub-tile lies inside a scene-pixel rectangle.
*
* A level's pixel extent is the bounding box of an isometric diamond, so
* roughly half of it is off-map. Rooms carved out of that half can never hold a
* monster — `spawnPacksInRoom` would search their whole area, find nothing and
* silently drop the pack — so they are discarded before packs are handed out.
*
* @param grid - the level's collision grid.
* @param minX - rectangle left edge, scene pixels.
* @param minY - rectangle top edge, scene pixels.
* @param maxX - rectangle right edge, scene pixels.
* @param maxY - rectangle bottom edge, scene pixels.
* @returns true when at least one sampled point is walkable.
*/
function roomHasWalkableGround(
grid: CollisionGrid,
minX: number,
minY: number,
maxX: number,
maxY: number,
): boolean {
const samples = 9
for (let iy = 1; iy < samples; iy += 1) {
const y = minY + (maxY - minY) * (iy / samples)
for (let ix = 1; ix < samples; ix += 1) {
const x = minX + (maxX - minX) * (ix / samples)
if (!isBlockedAt(grid, x, y)) return true
}
}
return false
}
/**
* Cuts a level into streaming rooms and allocates its monster packs to them.
*
* This is the level-generation half of `D2Game.dll`'s lazy population: the DRLG
* hands the game a room list, each room remembers *what* should live in it, and
* nothing is instantiated until the player walks close enough. `GameEngine`
* owns the other half (see `MonsterStreamingManager`).
*
* Rooms are laid out over **scene-pixel space**, not cell space. A rectangle of
* cells projects to a diamond, and the axis-aligned boxes of neighbouring cell
* rectangles overlap almost completely — which would make every room in the
* level "near" the player at once and defeat the streaming entirely.
*
* @param runtime - the level being entered.
* @param packs - the level's packs, with landmark tiles already resolved to
* scene positions.
* @param safeZones - waypoints, entrances and the landing spot; rooms touching
* one are left empty so a player never arrives inside a camp.
* @returns the rooms that hold at least one pack, in load order.
*/
function buildStreamingRooms(
runtime: MapRuntime,
packs: readonly MonsterPack[],
safeZones: readonly SafeZone[],
): StreamingRoomDef[] {
if (packs.length === 0) return []
const grid = runtime.grid
const roomWidth = ROOM_CELL_SIZE * ORTHO_CELL_WIDTH / 2
const roomHeight = ROOM_CELL_SIZE * ORTHO_CELL_HEIGHT / 2
const cellsPerRoomX = Math.max(1, Math.round(roomWidth / ORTHO_CELL_WIDTH))
const cellsPerRoomY = Math.max(1, Math.round(roomHeight / ORTHO_CELL_HEIGHT))
const rooms: MapRoom[] = []
let id = 0
for (let y = 0; y < runtime.heightPx; y += roomHeight) {
for (let x = 0; x < runtime.widthPx; x += roomWidth) {
const maxX = Math.min(x + roomWidth, runtime.widthPx)
const maxY = Math.min(y + roomHeight, runtime.heightPx)
if (maxX - x < 32 || maxY - y < 32) continue
if (!roomHasWalkableGround(grid, x, y, maxX, maxY)) continue
// A safe zone is a circle; the cheap box-vs-box test is deliberate. An
// over-eager match only costs one empty room, while a missed one puts a
// pack on top of a waypoint.
const isSafeZone = safeZones.some(zone =>
zone.x + zone.radius > x && zone.x - zone.radius < maxX
&& zone.y + zone.radius > y && zone.y - zone.radius < maxY)
// Cell coordinates are bookkeeping for `allocatePacksToRooms`' area
// weighting; every room is the same size, so the value only has to be
// consistent, not exact.
const cell = cellAt(grid, x + (maxX - x) / 2, y + (maxY - y) / 2)
rooms.push({
id: id,
x,
y,
width: maxX - x,
height: maxY - y,
cellX: Math.floor(cell.x - cellsPerRoomX / 2),
cellY: Math.floor(cell.y - cellsPerRoomY / 2),
cellsX: cellsPerRoomX,
cellsY: cellsPerRoomY,
isSafeZone,
})
id += 1
}
}
if (rooms.length === 0) return []
const allocation = allocatePacksToRooms(packs, rooms, levelSeed(runtime.level))
const defs: StreamingRoomDef[] = []
for (const room of rooms) {
const roomPacks = allocation.get(room)
if (roomPacks === undefined || roomPacks.length === 0) continue
defs.push({
id: room.id,
bounds: { minX: room.x, minY: room.y, maxX: room.x + room.width, maxY: room.y + room.height },
packs: roomPacks,
room,
})
}
return defs
}
/**
* Run the loop for a runtime.
*
@ -1419,6 +1568,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
}
return pack
})
const streamingRooms = buildStreamingRooms(runtime, resolvedPacks, safeZones)
const engine = new GameEngine(
createIsoTerrain(runtime.grid, runtime.widthPx, runtime.heightPx, {
width: FEET_WIDTH,
@ -1441,16 +1591,29 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
playerDamage: 7,
playerManaPerAttack: 2,
respawnTicks: 75,
disableMonsterAggro: true,
// Aggro is safe again now that rooms stream: a monster the player has
// not walked up to is either unspawned or asleep, so "chase the player"
// can no longer drag the whole level into a single conga line. It was
// only ever disabled because every pack on the map was awake at once.
disableMonsterAggro: false,
},
lootSeed: 0x5eed,
talkRadius: 80,
pickupRadius: 64,
inventoryCols: 10,
inventoryRows: 4,
// `monsterCount` / `monsterPacks` are the fallback for levels the baker
// gave no pack plan: with rooms present the engine skips frame-0 spawning
// entirely and lets the streaming manager do it room by room.
monsterCount: runtime.monsterCount,
monsterPacks: resolvedPacks,
monsterSpread: Math.max(runtime.widthPx, runtime.heightPx) / 4,
roomPacks: streamingRooms,
streamingOptions: {
activationRadius: ROOM_ACTIVATION_RADIUS,
deactivationRadius: ROOM_DEACTIVATION_RADIUS,
safeZones,
},
safeZones,
// This scene knows where its NPCs stand — the DS1 carries their
// coordinates — so the engine must not build a ring we would discard.
@ -1585,10 +1748,31 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
return pack
})
const spread = Math.max(next.widthPx, next.heightPx) / 4
if (nextResolvedPacks.length > 0) {
spawnMonsterPacks(engine.world, nextResolvedPacks, landing, spread, engine.terrain, nextSafeZones)
} else if (next.monsterCount > 0) {
spawnMonsters(engine.world, next.monsterStats, next.monsterCount, landing, spread, engine.terrain)
// A level change means a new room list. `engine.terrain` was already
// swapped above, which matters: the manager captures it and uses it for
// every later collision test, so building it any earlier would place the
// destination's monsters against the departed level's walls.
const nextRooms = buildStreamingRooms(next, nextResolvedPacks, nextSafeZones)
if (nextRooms.length > 0) {
engine.streamingManager = new MonsterStreamingManager(
engine.world,
engine.terrain,
nextRooms,
{
activationRadius: ROOM_ACTIVATION_RADIUS,
deactivationRadius: ROOM_DEACTIVATION_RADIUS,
safeZones: nextSafeZones,
},
)
} else {
// Levels the baker gave no pack plan keep the old behaviour: there is
// nothing to stream, so the handful of placeholders go down at once.
engine.streamingManager = undefined
if (nextResolvedPacks.length > 0) {
spawnMonsterPacks(engine.world, nextResolvedPacks, landing, spread, engine.terrain, nextSafeZones)
} else if (next.monsterCount > 0) {
spawnMonsters(engine.world, next.monsterStats, next.monsterCount, landing, spread, engine.terrain)
}
}
if (next.palette !== null) {

View File

@ -129,16 +129,31 @@ describe.skipIf(!hasD2)('Act 1 Wilderness Dirt Road & Preset Generation (Steps 1
substitutions: [],
})
const usage = res.stats.borderUsage as Record<string, number>
// Corners: Border 5 (BL), Border 6 (TL), Border 7 (TR), Border 8 (BR)
expect(usage['Act 1 - Wild Border 5']).toBe(1)
expect(usage['Act 1 - Wild Border 6']).toBe(1)
expect(usage['Act 1 - Wild Border 7']).toBe(1)
expect(usage['Act 1 - Wild Border 8']).toBe(1)
// Corners: Border 5 (BL), Border 6 (TL), Border 7 (TR), Border 8 (BR).
//
// Counted by slot number rather than by exact name. Act 1's north, west and
// three of its corners prefer rock cliff pieces over wooden fence ones
// (`Act 1 - Wild Cliff Border 6A`), and the cliff family also carries
// lettered variants — all of which are still "the NW corner piece". What
// must hold is that each corner block is stamped exactly once; stamping
// twice is what leaves a corner with two overlapping silhouettes.
const slotUsage = (slot: number): number => {
const pattern = new RegExp(`\\bBorder\\s*0?${String(slot)}[A-Z]?$`, 'i')
let total = 0
for (const [name, count] of Object.entries(usage)) {
if (pattern.test(name)) total += count
}
return total
}
expect(slotUsage(5)).toBe(1)
expect(slotUsage(6)).toBe(1)
expect(slotUsage(7)).toBe(1)
expect(slotUsage(8)).toBe(1)
// Edges: Border 1 (Bottom), Border 2 (Left), Border 3 (Top), Border 4 (Right)
expect(usage['Act 1 - Wild Border 1']).toBeGreaterThan(0)
expect(usage['Act 1 - Wild Border 2']).toBeGreaterThan(0)
expect(usage['Act 1 - Wild Border 3']).toBeGreaterThan(0)
expect(usage['Act 1 - Wild Border 4']).toBeGreaterThan(0)
expect(slotUsage(1)).toBeGreaterThan(0)
expect(slotUsage(2)).toBeGreaterThan(0)
expect(slotUsage(3)).toBeGreaterThan(0)
expect(slotUsage(4)).toBeGreaterThan(0)
})
it.each([