fix(scene): handle level transition entity cleanup, art reloading & wilderness floor gaps

This commit is contained in:
troytt 2026-09-17 05:48:38 +00:00
parent e9f2ceec9b
commit 4ea77e2c00
2 changed files with 120 additions and 7 deletions

View File

@ -3728,7 +3728,15 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
if (row === undefined) continue
for (let x = 0; x < canvas.width; x += 1) {
const c = row[x]
if (c !== undefined && c.floors.some(f => !f.hidden && f.prop1 !== 0)) finalGroundCells += 1
if (c !== undefined) {
if (!c.floors.some(f => !f.hidden && f.prop1 !== 0)) {
const floor = c.floors[0]
if (floor !== undefined) {
Object.assign(floor, { prop1: 2, style: groundTile.style, sequence: groundTile.sequence, hidden: false })
}
}
finalGroundCells += 1
}
}
}
stats.groundCells = finalGroundCells

View File

@ -33,6 +33,7 @@ import {
} from '../game/d2map.ts'
import type { CollisionGrid } from '../game/d2map.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 { SUB_TILES_PER_TILE, depthInsertIndex } from '../game/map.ts'
import { GameEngine, syncEngineState } from "../game/engine.ts"
@ -568,6 +569,46 @@ async function buildPackRuntime(
}
}
const floors = scene.floors.map(toDrawable)
const WILDERNESS_LEVEL_IDS = new Set([2, 3, 4, 5, 6, 7, 17, 41, 42, 43, 44, 45, 46, 76, 77, 78, 79, 80, 81, 82, 83, 104, 105, 106, 110, 111, 112, 117, 134])
if (entry.levelId !== undefined && WILDERNESS_LEVEL_IDS.has(entry.levelId) && floors.length > 0 && floors.length < scene.cellsX * scene.cellsY) {
const covered = new Uint8Array(scene.cellsX * scene.cellsY)
const freq = new Map<number, number>()
for (let i = 0; i < scene.floors.length; i += 1) {
const row = scene.floors[i]!
const cx = row[3] ?? 0
const cy = row[4] ?? 0
if (cx >= 0 && cx < scene.cellsX && cy >= 0 && cy < scene.cellsY) {
covered[cy * scene.cellsX + cx] = 1
}
const fIdx = row[0] ?? 0
freq.set(fIdx, (freq.get(fIdx) ?? 0) + 1)
}
let defaultFrameIndex = scene.floors[0]?.[0] ?? 0
let maxCount = -1
for (const [fIdx, count] of freq) {
if (count > maxCount) {
maxCount = count
defaultFrameIndex = fIdx
}
}
const defaultFrame = frames[defaultFrameIndex] ?? { x: 0, y: 0, width: 1, height: 1 }
const defaultPage = scene.framePlacement[defaultFrameIndex]?.[0] ?? 0
for (let cy = 0; cy < scene.cellsY; cy += 1) {
for (let cx = 0; cx < scene.cellsX; cx += 1) {
if (covered[cy * scene.cellsX + cx] === 0) {
floors.push({
frame: defaultFrame,
x: scene.originX + (cx - cy) * ORTHO_CELL_WIDTH - 80,
y: scene.originY + (cx + cy) * ORTHO_CELL_HEIGHT,
cellX: cx,
cellY: cy,
page: defaultPage,
})
}
}
}
floors.sort((a, b) => (a.cellX + a.cellY) - (b.cellX + b.cellY) || a.cellY - b.cellY)
}
const walls = scene.walls.map(toDrawable)
const roofs = (scene.roofs ?? []).map(toDrawable)
@ -1253,12 +1294,12 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
const camera = new ViewportCamera(canvas, runtime.widthPx, runtime.heightPx)
camera.attach()
// Culling bounds, built once: the draw lists are immutable for the life of the
// runtime, so the only per-frame cost is the rejection test itself.
const floorBounds = buildBounds(runtime.floors)
const wallBounds = buildBounds(runtime.walls)
const roofBounds = buildBounds(runtime.roofs)
const objectBounds = buildBounds(runtime.objectDrawables)
// Culling bounds, rebuilt on level swap: the draw lists are immutable for the
// life of a single runtime, so the per-frame cost is just the rejection test.
let floorBounds = buildBounds(runtime.floors)
let wallBounds = buildBounds(runtime.walls)
let roofBounds = buildBounds(runtime.roofs)
let objectBounds = buildBounds(runtime.objectDrawables)
// `runtime.pages` is filled in by background loaders, so the count has to be
// re-read every frame. A loop is used rather than `filter().length` because
@ -1413,8 +1454,15 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
// long walk leaks a page set per level crossed.
for (const page of runtime.pages) if (page !== null) renderer.deleteAtlas(page)
for (const page of runtime.objectPages) if (page !== null) renderer.deleteAtlas(page)
for (const art of monsterArtMap.values()) renderer.deleteAtlas(art.handle)
monsterArtMap = new Map()
runtime = next
floorBounds = buildBounds(next.floors)
wallBounds = buildBounds(next.walls)
roofBounds = buildBounds(next.roofs)
objectBounds = buildBounds(next.objectDrawables)
camera.mapWidthPx = next.widthPx
camera.mapHeightPx = next.heightPx
engine.terrain = createIsoTerrain(next.grid, next.widthPx, next.heightPx, {
@ -1434,6 +1482,63 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
engine.npcEntities.push({ def, x: n.x, y: n.y, hasSprite: hasPackedSprite(n) })
}
// Clear outgoing monsters and ground items, then populate the destination level
engine.world.monsters.length = 0
engine.ground.length = 0
const nextSafeZones: SafeZone[] = [
{ x: landing.x, y: landing.y, radius: 180 },
]
for (const wp of next.waypoints) {
const pos = subTileToScene(wp.x, wp.y, next.grid)
nextSafeZones.push({ x: pos.x, y: pos.y, radius: 160 })
}
for (const ent of next.entrances) {
const pos = subTileToScene(ent.x, ent.y, next.grid)
nextSafeZones.push({ x: pos.x, y: pos.y, radius: 160 })
}
const nextResolvedPacks: MonsterPack[] = next.monsterPacks.map(pack => {
if (pack.landmarkTile !== undefined && pack.fixedPosition === undefined) {
return {
...pack,
fixedPosition: cellCentre(next.grid, pack.landmarkTile.tileX, pack.landmarkTile.tileY),
}
}
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)
}
if (next.palette !== null) {
renderer.setPalette(next.palette.rgb)
const paletteForEntities = next.palette
const packEntityBase = next.source === 'pack' ? next.base : undefined
const packEntityAct = next.source === 'pack' ? next.act : undefined
const activeMonsterIds = next.monsterTypes.length > 0
? next.monsterTypes
: (next.monsterCount > 0 ? next.monsterStats.map(s => s.id) : [])
if (activeMonsterIds.length > 0) {
void loadMonsterArtMap(
DEFAULT_BASES,
activeMonsterIds,
paletteForEntities,
renderer,
packEntityBase,
packEntityAct,
).then((loadedMap) => {
monsterArtMap = loadedMap
})
}
}
if (next.source === 'pack') {
history.replaceState(null, '', `?act=${String(next.act)}&level=${encodeURIComponent(next.quadrant)}`)
void populatePackSelectors(next.base, { act: next.act, label: next.quadrant })
}
registerWaypoints(next)
state.level = next.level
state.act = next.act