fix(scene): 删除 DEMO_MONSTERS 假刷怪路径

关卡怪物计划为空时,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
This commit is contained in:
troytt 2026-09-17 11:45:46 +00:00
parent d890f558b1
commit 5956b00e72
3 changed files with 103 additions and 50 deletions

View File

@ -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]

View File

@ -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(

View File

@ -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')
})
})