From 4ea77e2c001f59bd703b0260e33e4b59d49ea8d5 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 05:48:38 +0000 Subject: [PATCH] fix(scene): handle level transition entity cleanup, art reloading & wilderness floor gaps --- src/game/wilderness.ts | 10 +++- src/scene/act-scene.ts | 117 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 120 insertions(+), 7 deletions(-) diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index d860ad4..5ff9582 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -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 diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index ba634ec..3e71dac 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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() + 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