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:
parent
d890f558b1
commit
5956b00e72
|
|
@ -1,13 +1,12 @@
|
||||||
import type { MonsterStats } from './combat'
|
|
||||||
import type { ItemBase, Affix } from './items'
|
import type { ItemBase, Affix } from './items'
|
||||||
import type { SkillDef } from './skills'
|
import type { SkillDef } from './skills'
|
||||||
import type { NpcDef, QuestDef } from './quests'
|
import type { NpcDef, QuestDef } from './quests'
|
||||||
|
|
||||||
export const DEMO_MONSTERS: readonly MonsterStats[] = [
|
// `DEMO_MONSTERS` used to live here: three hand-written monsters the act scene
|
||||||
{ id: 'demo-fallen', name: 'Fallen', hp: 12, damage: 3, cooldownTicks: 24, reach: 36, aggroRadius: 220, speed: 80, xp: 8 },
|
// dropped into any level whose plan came back empty. They stood in for missing
|
||||||
{ id: 'demo-zombie', name: 'Zombie', hp: 30, damage: 6, cooldownTicks: 32, reach: 40, aggroRadius: 170, speed: 46, xp: 15 },
|
// data, carried invented stats, and — having no `MonStats.txt` ids — rendered as
|
||||||
{ id: 'demo-skeleton', name: 'Skeleton', hp: 18, damage: 4, cooldownTicks: 28, reach: 38, aggroRadius: 260, speed: 70, xp: 12 },
|
// 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. */
|
/** Fallback experience thresholds, index 1 = level 1. */
|
||||||
export const DEMO_EXPERIENCE: readonly number[] = [0, 0, 20, 60, 140, 280]
|
export const DEMO_EXPERIENCE: readonly number[] = [0, 0, 20, 60, 140, 280]
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ import type { CollisionGrid, IsoDraw } from '../game/d2map.ts'
|
||||||
import { updateAnimatableTiles } from '../game/animated-tiles.ts'
|
import { updateAnimatableTiles } from '../game/animated-tiles.ts'
|
||||||
import type { AnimatableTile, AnimatedFrame } from '../game/animated-tiles.ts'
|
import type { AnimatableTile, AnimatedFrame } from '../game/animated-tiles.ts'
|
||||||
import { createIsoTerrain } from '../game/iso-terrain.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 type { MonsterPack, MonsterStats, SafeZone } from '../game/combat.ts'
|
||||||
import { MonsterStreamingManager } from '../game/monster-streaming.ts'
|
import { MonsterStreamingManager } from '../game/monster-streaming.ts'
|
||||||
import type { StreamingRoomDef } 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 { SUB_TILES_PER_TILE, depthInsertIndex } from '../game/map.ts'
|
||||||
import { GameEngine, syncEngineState } from "../game/engine.ts"
|
import { GameEngine, syncEngineState } from "../game/engine.ts"
|
||||||
import type { NpcEntity } 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 { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
|
||||||
import type { NpcDef } from '../game/quests.ts'
|
import type { NpcDef } from '../game/quests.ts'
|
||||||
import { buildAtlas, buildIndexedAtlas } from '../render/atlas.ts'
|
import { buildAtlas, buildIndexedAtlas } from '../render/atlas.ts'
|
||||||
|
|
@ -103,15 +103,6 @@ const PAGE_CONCURRENCY = 3
|
||||||
/** Placeholder marker size, in scene pixels. */
|
/** Placeholder marker size, in scene pixels. */
|
||||||
const MARKER_WIDTH = 16
|
const MARKER_WIDTH = 16
|
||||||
const MARKER_HEIGHT = 26
|
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.
|
* Side of a streaming room, in cells.
|
||||||
*
|
*
|
||||||
|
|
@ -249,6 +240,15 @@ export interface ActSceneState {
|
||||||
monsterArtErrors: number
|
monsterArtErrors: number
|
||||||
/** COF layers dropped inside art that did load: monsters missing body parts. */
|
/** COF layers dropped inside art that did load: monsters missing body parts. */
|
||||||
monsterArtLayerFailures: number
|
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 {
|
declare global {
|
||||||
|
|
@ -268,7 +268,7 @@ const state: ActSceneState = {
|
||||||
drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0,
|
drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0,
|
||||||
zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null,
|
zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null,
|
||||||
dialog: [], npcs: 0, npcsNear: [], animatedTiles: 0,
|
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
|
if (typeof window !== 'undefined') window.__d2webAct = state
|
||||||
|
|
||||||
|
|
@ -344,25 +344,14 @@ interface MapRuntime {
|
||||||
* `MonStats.txt`.
|
* `MonStats.txt`.
|
||||||
*
|
*
|
||||||
* Empty when the level has none — a town — and empty for a source that has
|
* 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
|
* not planned one. There is no fallback: a level nothing planned for stays
|
||||||
* still there for.
|
* empty and says so, rather than being filled with invented monsters.
|
||||||
*/
|
*/
|
||||||
readonly monsterPacks: readonly MonsterPack[]
|
readonly monsterPacks: readonly MonsterPack[]
|
||||||
/**
|
/**
|
||||||
* The level's monster types, by `MonStats.txt` id, for the status line.
|
* The level's monster types, by `MonStats.txt` id, for the status line.
|
||||||
*/
|
*/
|
||||||
readonly monsterTypes: readonly string[]
|
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 objects: number
|
||||||
readonly frames: number
|
readonly frames: number
|
||||||
/** Whether the real character art is in use. */
|
/** Whether the real character art is in use. */
|
||||||
|
|
@ -772,8 +761,6 @@ async function buildPackRuntime(
|
||||||
npcs: scene.npcs ?? [],
|
npcs: scene.npcs ?? [],
|
||||||
monsterPacks: scene.monsters?.packs ?? [],
|
monsterPacks: scene.monsters?.packs ?? [],
|
||||||
monsterTypes: scene.monsters?.types ?? [],
|
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,
|
objects: scene.objects.length,
|
||||||
frames: scene.frames.length,
|
frames: scene.frames.length,
|
||||||
notes: [
|
notes: [
|
||||||
|
|
@ -991,8 +978,6 @@ async function loadLiveRuntime(
|
||||||
npcs: [],
|
npcs: [],
|
||||||
monsterPacks: population.packs,
|
monsterPacks: population.packs,
|
||||||
monsterTypes: population.types,
|
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,
|
objects: level.objects.length,
|
||||||
frames: scene.frames.length,
|
frames: scene.frames.length,
|
||||||
notes,
|
notes,
|
||||||
|
|
@ -1213,6 +1198,29 @@ export function monsterArtStatusText(load: {
|
||||||
return parts.length === 0 ? '' : ` · ${parts.join(' · ')}`
|
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}.
|
* Publish a load's failures into {@link ActSceneState}.
|
||||||
*
|
*
|
||||||
|
|
@ -1685,7 +1693,9 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
spawn: runtime.spawn,
|
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,
|
xpTable: DEMO_EXPERIENCE,
|
||||||
itemBases: DEMO_BASES,
|
itemBases: DEMO_BASES,
|
||||||
prefixAffixes: DEMO_AFFIXES,
|
prefixAffixes: DEMO_AFFIXES,
|
||||||
|
|
@ -1711,10 +1721,10 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
||||||
pickupRadius: 64,
|
pickupRadius: 64,
|
||||||
inventoryCols: 10,
|
inventoryCols: 10,
|
||||||
inventoryRows: 4,
|
inventoryRows: 4,
|
||||||
// `monsterCount` / `monsterPacks` are the fallback for levels the baker
|
// With rooms present the engine skips frame-0 spawning entirely and lets
|
||||||
// gave no pack plan: with rooms present the engine skips frame-0 spawning
|
// the streaming manager do it room by room; with none it places the
|
||||||
// entirely and lets the streaming manager do it room by room.
|
// planned packs at once. A level that planned nothing gets nothing.
|
||||||
monsterCount: runtime.monsterCount,
|
monsterCount: 0,
|
||||||
monsterPacks: resolvedPacks,
|
monsterPacks: resolvedPacks,
|
||||||
monsterSpread: Math.max(runtime.widthPx, runtime.heightPx) / 4,
|
monsterSpread: Math.max(runtime.widthPx, runtime.heightPx) / 4,
|
||||||
roomPacks: streamingRooms,
|
roomPacks: streamingRooms,
|
||||||
|
|
@ -1874,24 +1884,22 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Levels the baker gave no pack plan keep the old behaviour: there is
|
// Nothing to stream: the planned packs go down at once. A level the baker
|
||||||
// nothing to stream, so the handful of placeholders go down at once.
|
// 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
|
engine.streamingManager = undefined
|
||||||
if (nextResolvedPacks.length > 0) {
|
if (nextResolvedPacks.length > 0) {
|
||||||
spawnMonsterPacks(engine.world, nextResolvedPacks, landing, spread, engine.terrain, nextSafeZones)
|
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) {
|
if (next.palette !== null) {
|
||||||
renderer.setPalette(next.palette.rgb)
|
renderer.setPalette(next.palette.rgb)
|
||||||
const paletteForEntities = next.palette
|
const paletteForEntities = next.palette
|
||||||
const packEntityBase = next.source === 'pack' ? next.base : undefined
|
const packEntityBase = next.source === 'pack' ? next.base : undefined
|
||||||
const packEntityAct = next.source === 'pack' ? next.act : undefined
|
const packEntityAct = next.source === 'pack' ? next.act : undefined
|
||||||
const activeMonsterIds = next.monsterTypes.length > 0
|
const activeMonsterIds = next.monsterTypes
|
||||||
? next.monsterTypes
|
|
||||||
: (next.monsterCount > 0 ? next.monsterStats.map(s => s.id) : [])
|
|
||||||
if (activeMonsterIds.length > 0) {
|
if (activeMonsterIds.length > 0) {
|
||||||
void loadMonsterArtMap(
|
void loadMonsterArtMap(
|
||||||
DEFAULT_BASES,
|
DEFAULT_BASES,
|
||||||
|
|
@ -2396,6 +2404,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
||||||
+ (skippedDraws > 0 ? ` · 待页 ${String(skippedDraws)}` : '')
|
+ (skippedDraws > 0 ? ` · 待页 ${String(skippedDraws)}` : '')
|
||||||
// A level whose monsters are all red boxes used to look exactly like a
|
// A level whose monsters are all red boxes used to look exactly like a
|
||||||
// healthy one here.
|
// healthy one here.
|
||||||
|
+ monsterPlanStatusText(state.monstersPlanned)
|
||||||
+ monsterArtStatusText({
|
+ monsterArtStatusText({
|
||||||
missing: state.missingMonsterArt,
|
missing: state.missingMonsterArt,
|
||||||
errors: state.monsterArtErrors,
|
errors: state.monsterArtErrors,
|
||||||
|
|
@ -2405,6 +2414,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
loop.start()
|
loop.start()
|
||||||
|
reportMonsterPlan(runtime)
|
||||||
state.ready = true
|
state.ready = true
|
||||||
state.loadMs = Math.round(performance.now() - started)
|
state.loadMs = Math.round(performance.now() - started)
|
||||||
// Only the live path fills the selectors here: the pack path already built them from
|
// 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 packEntityBase = runtime.source === 'pack' ? runtime.base : undefined
|
||||||
const packEntityAct = runtime.source === 'pack' ? runtime.act : undefined
|
const packEntityAct = runtime.source === 'pack' ? runtime.act : undefined
|
||||||
// Collect monster types to load
|
// Collect monster types to load
|
||||||
const activeMonsterIds = runtime.monsterTypes.length > 0
|
const activeMonsterIds = runtime.monsterTypes
|
||||||
? runtime.monsterTypes
|
|
||||||
: (runtime.monsterCount > 0 ? runtime.monsterStats.map(s => s.id) : [])
|
|
||||||
|
|
||||||
if (activeMonsterIds.length > 0) {
|
if (activeMonsterIds.length > 0) {
|
||||||
void loadMonsterArtMap(
|
void loadMonsterArtMap(
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue