From 5956b00e72ec760ffec83a82e03e95017d9ee7ac Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 11:45:46 +0000 Subject: [PATCH] =?UTF-8?q?fix(scene):=20=E5=88=A0=E9=99=A4=20DEMO=5FMONST?= =?UTF-8?q?ERS=20=E5=81=87=E5=88=B7=E6=80=AA=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 关卡怪物计划为空时,act 场景会塞进 12 个手写的 DEMO_MONSTERS, 并且判断是否为城镇靠的是显示名里有没有英文子串 "town"。 于是:换个名字或换种语言的城镇会凭空冒出 12 只怪;真正的数据 失败(monsters 表读不出来、计划为空)也会伪装成"有内容"。 更糟的是 demo-* id 不在任何美术表里,画出来是红方块,和美术 加载失败的表现一模一样。 改为: - 删掉 demo-data.ts 里的 DEMO_MONSTERS 与 act-scene.ts 里的 PLACEHOLDER_MONSTER_COUNT、monsterStats/monsterCount 字段, 以及切关时 `next.monsterCount > 0` 的兜底 spawnMonsters 调用; 引擎选项显式传 stats: [] / monsterCount: 0。 - 新增 ActSceneState.monstersPlanned 与 monsterPlanStatusText(), 开机与切关后各调用一次 reportMonsterPlan(),HUD 上如实显示 "本关未规划到怪物",而不是拿假数据盖住。 TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/demo-data.ts | 11 ++-- src/scene/act-scene.ts | 96 +++++++++++++++------------- tests/act-scene-monster-plan.test.ts | 46 +++++++++++++ 3 files changed, 103 insertions(+), 50 deletions(-) create mode 100644 tests/act-scene-monster-plan.test.ts diff --git a/src/game/demo-data.ts b/src/game/demo-data.ts index 3badba2..d5bac70 100644 --- a/src/game/demo-data.ts +++ b/src/game/demo-data.ts @@ -1,13 +1,12 @@ -import type { MonsterStats } from './combat' import type { ItemBase, Affix } from './items' import type { SkillDef } from './skills' import type { NpcDef, QuestDef } from './quests' -export const DEMO_MONSTERS: readonly MonsterStats[] = [ - { id: 'demo-fallen', name: 'Fallen', hp: 12, damage: 3, cooldownTicks: 24, reach: 36, aggroRadius: 220, speed: 80, xp: 8 }, - { id: 'demo-zombie', name: 'Zombie', hp: 30, damage: 6, cooldownTicks: 32, reach: 40, aggroRadius: 170, speed: 46, xp: 15 }, - { id: 'demo-skeleton', name: 'Skeleton', hp: 18, damage: 4, cooldownTicks: 28, reach: 38, aggroRadius: 260, speed: 70, xp: 12 }, -] +// `DEMO_MONSTERS` used to live here: three hand-written monsters the act scene +// dropped into any level whose plan came back empty. They stood in for missing +// data, carried invented stats, and — having no `MonStats.txt` ids — rendered as +// red boxes, so a data failure looked like content. A level that plans nothing +// is now empty and says so (issue #58 item 5). /** Fallback experience thresholds, index 1 = level 1. */ export const DEMO_EXPERIENCE: readonly number[] = [0, 0, 20, 60, 140, 280] diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 30b8922..1768c82 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -35,7 +35,7 @@ import type { CollisionGrid, IsoDraw } from '../game/d2map.ts' import { updateAnimatableTiles } from '../game/animated-tiles.ts' import type { AnimatableTile, AnimatedFrame } from '../game/animated-tiles.ts' import { createIsoTerrain } from '../game/iso-terrain.ts' -import { spawnMonsterPacks, spawnMonsters } from '../game/combat.ts' +import { spawnMonsterPacks } 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' @@ -44,7 +44,7 @@ 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" -import { DEMO_MONSTERS, DEMO_EXPERIENCE, DEMO_BASES, DEMO_AFFIXES, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from "../game/demo-data.ts" +import { DEMO_EXPERIENCE, DEMO_BASES, DEMO_AFFIXES, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from "../game/demo-data.ts" import { buildNpcDef, hasPackedSprite } from '../game/npc.ts' import type { NpcDef } from '../game/quests.ts' import { buildAtlas, buildIndexedAtlas } from '../render/atlas.ts' @@ -103,15 +103,6 @@ const PAGE_CONCURRENCY = 3 /** Placeholder marker size, in scene pixels. */ const MARKER_WIDTH = 16 const MARKER_HEIGHT = 26 -/** - * Monsters placed per level, until `Levels.txt` supplies the real number. - * - * `MonDen` (density) and `NumMon` (how many kinds) are both real columns that - * #22 will read; 12 is only large enough that a walk across a level runs into - * something, which is what M6 needs to demonstrate that the level → spawn path - * works at all. - */ -const PLACEHOLDER_MONSTER_COUNT = 12 /** * Side of a streaming room, in cells. * @@ -249,6 +240,15 @@ export interface ActSceneState { monsterArtErrors: number /** COF layers dropped inside art that did load: monsters missing body parts. */ monsterArtLayerFailures: number + /** + * Monsters the level's own plan produced. + * + * Zero is a real answer — a town has none — and it is now the *only* answer + * for a level nothing planned for. The scene used to drop 12 hand-written + * `DEMO_MONSTERS` into that case, which also rendered as red boxes because + * their ids are in no art table. + */ + monstersPlanned: number } declare global { @@ -268,7 +268,7 @@ const state: ActSceneState = { drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0, zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null, dialog: [], npcs: 0, npcsNear: [], animatedTiles: 0, - missingMonsterArt: [], monsterArtErrors: 0, monsterArtLayerFailures: 0, + missingMonsterArt: [], monsterArtErrors: 0, monsterArtLayerFailures: 0, monstersPlanned: 0, } if (typeof window !== 'undefined') window.__d2webAct = state @@ -344,25 +344,14 @@ interface MapRuntime { * `MonStats.txt`. * * Empty when the level has none — a town — and empty for a source that has - * not planned one, which is what {@link ActRuntime.monsterStats} below is - * still there for. + * not planned one. There is no fallback: a level nothing planned for stays + * empty and says so, rather than being filled with invented monsters. */ readonly monsterPacks: readonly MonsterPack[] /** * The level's monster types, by `MonStats.txt` id, for the status line. */ readonly monsterTypes: readonly string[] - /** - * Fallback monster definitions, used only when the level produced no packs. - * - * A level with no `Levels.txt` row — a hand-built fixture, or a pack baked - * before the monster columns were read — would otherwise be empty. Spawning a - * few placeholders there keeps the page useful to poke at, and the status - * line says which of the two happened. - */ - readonly monsterStats: readonly MonsterStats[] - /** How many of {@link ActRuntime.monsterStats} to place in that fallback. */ - readonly monsterCount: number readonly objects: number readonly frames: number /** Whether the real character art is in use. */ @@ -772,8 +761,6 @@ async function buildPackRuntime( npcs: scene.npcs ?? [], monsterPacks: scene.monsters?.packs ?? [], monsterTypes: scene.monsters?.types ?? [], - monsterStats: DEMO_MONSTERS, - monsterCount: (entry.slug.includes('town') || scene.levelName.toLowerCase().includes('town') || (scene.monsters?.packs && scene.monsters.packs.length > 0) || scene.monsters !== undefined) ? 0 : PLACEHOLDER_MONSTER_COUNT, objects: scene.objects.length, frames: scene.frames.length, notes: [ @@ -991,8 +978,6 @@ async function loadLiveRuntime( npcs: [], monsterPacks: population.packs, monsterTypes: population.types, - monsterStats: DEMO_MONSTERS, - monsterCount: (town.levelName.toLowerCase().includes('town') || population.packs.length > 0 || population.budget === 0) ? 0 : PLACEHOLDER_MONSTER_COUNT, objects: level.objects.length, frames: scene.frames.length, notes, @@ -1213,6 +1198,29 @@ export function monsterArtStatusText(load: { return parts.length === 0 ? '' : ` · ${parts.join(' · ')}` } +/** + * The HUD fragment describing a level's planned population. + * + * @param planned - how many monsters the plan produced. + * @returns the fragment, ready to append to the HUD line. + */ +export function monsterPlanStatusText(planned: number): string { + return planned === 0 ? ' · 怪物 0(本关未规划到怪物)' : ` · 怪物 ${String(planned)}` +} + +/** + * Publish a level's planned population into {@link ActSceneState}. + * + * @param runtime - the level being entered. + */ +function reportMonsterPlan(runtime: MapRuntime): void { + const planned = runtime.monsterPacks.reduce((sum, pack) => sum + pack.members.length, 0) + state.monstersPlanned = planned + if (planned === 0) { + console.info(`level ${runtime.level}: no monsters planned; the level stays empty`) + } +} + /** * Publish a load's failures into {@link ActSceneState}. * @@ -1685,7 +1693,9 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: }), { spawn: runtime.spawn, - stats: runtime.monsterStats, + // The scene plans its own population; there is no synthetic roster to fall + // back on, so the engine's frame-0 spawner has nothing to draw from. + stats: [], xpTable: DEMO_EXPERIENCE, itemBases: DEMO_BASES, prefixAffixes: DEMO_AFFIXES, @@ -1711,10 +1721,10 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: 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, + // With rooms present the engine skips frame-0 spawning entirely and lets + // the streaming manager do it room by room; with none it places the + // planned packs at once. A level that planned nothing gets nothing. + monsterCount: 0, monsterPacks: resolvedPacks, monsterSpread: Math.max(runtime.widthPx, runtime.heightPx) / 4, roomPacks: streamingRooms, @@ -1874,24 +1884,22 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: }, ) } 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. + // Nothing to stream: the planned packs go down at once. A level the baker + // gave no plan for stays empty — and the status line says so, rather than + // 12 invented monsters standing in for the data that is missing. 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) } } + reportMonsterPlan(next) 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) : []) + const activeMonsterIds = next.monsterTypes if (activeMonsterIds.length > 0) { void loadMonsterArtMap( DEFAULT_BASES, @@ -2396,6 +2404,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: + (skippedDraws > 0 ? ` · 待页 ${String(skippedDraws)}` : '') // A level whose monsters are all red boxes used to look exactly like a // healthy one here. + + monsterPlanStatusText(state.monstersPlanned) + monsterArtStatusText({ missing: state.missingMonsterArt, errors: state.monsterArtErrors, @@ -2405,6 +2414,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: }, }) loop.start() + reportMonsterPlan(runtime) state.ready = true state.loadMs = Math.round(performance.now() - started) // Only the live path fills the selectors here: the pack path already built them from @@ -2430,9 +2440,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: const packEntityBase = runtime.source === 'pack' ? runtime.base : undefined const packEntityAct = runtime.source === 'pack' ? runtime.act : undefined // Collect monster types to load - const activeMonsterIds = runtime.monsterTypes.length > 0 - ? runtime.monsterTypes - : (runtime.monsterCount > 0 ? runtime.monsterStats.map(s => s.id) : []) + const activeMonsterIds = runtime.monsterTypes if (activeMonsterIds.length > 0) { void loadMonsterArtMap( diff --git a/tests/act-scene-monster-plan.test.ts b/tests/act-scene-monster-plan.test.ts new file mode 100644 index 0000000..f0a9010 --- /dev/null +++ b/tests/act-scene-monster-plan.test.ts @@ -0,0 +1,46 @@ +/** + * The act scene must not invent monsters (issue #58 item 5). + * + * `DEMO_MONSTERS` was three hand-written monsters the scene spawned 12 of + * whenever a level's plan came back empty — including in a town whose name did + * not happen to contain the English substring "town". They also rendered as red + * boxes, because `demo-*` ids are in no art table, so a data failure looked like + * content twice over. + */ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { monsterPlanStatusText } from '../src/scene/act-scene.ts' +import * as demoData from '../src/game/demo-data.ts' + +const ACT_SCENE_SOURCE = readFileSync(new URL('../src/scene/act-scene.ts', import.meta.url), 'utf8') + +/** + * Comments are stripped before scanning: the docstrings deliberately still name + * the deleted fallback to explain why it is gone, and that is not a code path. + */ +const ACT_SCENE_CODE = ACT_SCENE_SOURCE.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '') + +describe('no synthetic monster fallback', () => { + it('demo-data no longer exports a monster roster', () => { + expect(Object.keys(demoData)).not.toContain('DEMO_MONSTERS') + // The other demo tables are still used by the scene's engine options. + expect(Object.keys(demoData)).toContain('DEMO_SKILLS') + }) + + it('the act scene has no placeholder spawn path left', () => { + expect(ACT_SCENE_CODE).not.toContain('DEMO_MONSTERS') + expect(ACT_SCENE_CODE).not.toContain('PLACEHOLDER_MONSTER_COUNT') + // The spawn gate was an English substring match on the *display* name, so a + // renamed or translated town spawned 12 fake monsters in the square. (The + // pack index's `slug.includes('town')` level lookup is a different thing + // and is still there.) + expect(ACT_SCENE_CODE).not.toContain("toLowerCase().includes('town')") + expect(ACT_SCENE_CODE).not.toContain('spawnMonsters(') + }) + + it('reports an empty plan rather than hiding it', () => { + expect(monsterPlanStatusText(0)).toContain('本关未规划到怪物') + expect(monsterPlanStatusText(0)).toContain('怪物 0') + expect(monsterPlanStatusText(37)).toBe(' · 怪物 37') + }) +})