diff --git a/src/game/engine.ts b/src/game/engine.ts index 1eccaa9..202e2ee 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -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. * diff --git a/src/game/monster-streaming.ts b/src/game/monster-streaming.ts index b3c5719..1136218 100644 --- a/src/game/monster-streaming.ts +++ b/src/game/monster-streaming.ts @@ -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, + } +} diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index cd1652b..390b340 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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) { diff --git a/tests/wilderness-roads.test.ts b/tests/wilderness-roads.test.ts index dc08b97..803a76a 100644 --- a/tests/wilderness-roads.test.ts +++ b/tests/wilderness-roads.test.ts @@ -129,16 +129,31 @@ describe.skipIf(!hasD2)('Act 1 Wilderness Dirt Road & Preset Generation (Steps 1 substitutions: [], }) const usage = res.stats.borderUsage as Record - // 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([