M7 第二步:怪物接进关卡,并补上 MonLvl 缩放

Stage A 把 MonStats.txt 读了出来,接上去才发现读出来的数字不能用:
一只一级堕落者按表里的 A1MinD=51 / A1MaxD=101 算,一击 76 点伤害,
而玩家只有 60 点血。

MonStats.txt 里的数字不是游戏里的数字。真实公式是

  最终值 = MonStats[列] × MonLvl[怪物等级][对应列] ÷ 100

这不是猜的。同一个公式让五个互不相干的列同时对上游戏已知数值:
fallen1 的 Exp 61→18.3(游戏 18)、zombie1 的 111→33.3(33)、
quillrat1 的 71→21.3(21)、fallen1 的 AC 84→5.0(5)、
A1TH 101→8.1(8)。五发全中不是巧合,所以照做而不是推给 M9。

顺带纠正我在 Stage A 里写下的判断:MinHP(N)/MinHP(H) 不是噩梦和地狱
的血量,594 个有血量的行里有 426 行的 MaxHP(N) 比普通还低。难度靠的
是怪物自己的 Level(N)=36、Level(H)=67 再过一遍 MonLvl 的 HP(N)/HP(H),
这才是噩梦堕落者有 131-289 血而不是 25-55 血的原因。MonLvl 的 L- 前缀
列(扩展版那套)在 1.13c 里和原列逐字节相同,读哪套都一样。

boss 查过要不要豁免缩放,没有豁免:缩放后的 Diablo 是 13818,记忆里
是 13741;缩放后的 Andariel 是 1025,记忆里约 960;但 Duriel 常被引用
的 4757 是原值。凭这种强度的记忆去开特例就是猜了,幕 boss 单独处理。

接线:
- monsters.ts 新增 readMonsterScaling / monsterScaleFor / UNSCALED,
  monsterStatsOf 接受一个 MonsterScale。先掷骰再缩放——低等级的乘数
  只有百分之几,先缩放边界会把 21..61 压成 1..4 再四舍五入,一个范围
  里四个值有三个取不到。
- 新增 planLevelMonsters:从关卡 id 一路走到掷好点数的怪物群。在线
  路径和资源包烘焙都走它,「烘焙出来的关卡和在线的一致」因此是个性质
  而不是巧合。
- engine.ts 新增 monsterPacks 选项,优先于 stats + monsterCount。
- act-scene.ts 两条路径都接上;资源包路径读 scene.json 里新烘焙的
  monsters 字段,浏览器在这条路径上没有 Levels.txt 可读。
- pack-act-assets.ts 烘焙怪物,每张图 scene.json 增加约 4 KB。

血色荒野现在是:堕落者 2-4 血 / 2 伤 / 18 经验,僵尸 8-13 血 /
33 经验 / 移速 28(堕落者是 142),棘鼠 2-5 血 / 21 经验。51 只怪
分 30 群。

验证:tsc 零错误;npm test 523 通过(基线 461);
npm run verify:monsters 65/65;npm run verify:all 通过;
npm run verify:packs 1302/1302 逐像素断言通过。

TAG=agy
CONV=c89513df-cd12-4749-b5bd-5e9f16639391
This commit is contained in:
taodao 2026-09-15 08:22:48 +00:00
parent 70b299569e
commit cc12e7bc66
6 changed files with 500 additions and 34 deletions

View File

@ -38,6 +38,7 @@ import { decodeDt1 } from '../src/formats/dt1.ts'
import type { Dt1 } from '../src/formats/dt1.ts' import type { Dt1 } from '../src/formats/dt1.ts'
import { decodePl2 } from '../src/formats/pl2.ts' import { decodePl2 } from '../src/formats/pl2.ts'
import { levelSeed, buildIsoMapScene, cellAt, findIsoSpawn, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH } from '../src/game/d2map.ts' import { levelSeed, buildIsoMapScene, cellAt, findIsoSpawn, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH } from '../src/game/d2map.ts'
import { planLevelMonsters } from '../src/game/monsters.ts'
import type { IsoMapScene } from '../src/game/d2map.ts' import type { IsoMapScene } from '../src/game/d2map.ts'
import { loadObjectsTable, resolveDs1Object, MONSTER_ROOT } from '../src/game/objects.ts' import { loadObjectsTable, resolveDs1Object, MONSTER_ROOT } from '../src/game/objects.ts'
import { decodeDcc } from '../src/formats/dcc.ts' import { decodeDcc } from '../src/formats/dcc.ts'
@ -64,6 +65,16 @@ const PAGE_SIZE = 2048
const HOT_RADIUS_CELLS = 8 const HOT_RADIUS_CELLS = 8
/** Where object art lives. */ /** Where object art lives. */
const OBJECT_PREFIX = 'data\\global\\objects\\' const OBJECT_PREFIX = 'data\\global\\objects\\'
/**
* The player's walking speed in scene pixels, matching `act-scene.ts`.
*
* Monster speeds are expressed relative to it, so the baker has to know the
* same number the browser does. Kept as a literal on both sides rather than
* shared through a module because `act-scene.ts` is a browser entry point and
* this script must not pull it in.
*/
const PLAYER_WALK_SPEED_PX = 170
/** Where monster and town-NPC art lives. */ /** Where monster and town-NPC art lives. */
/** /**
* Mode directories tried in order when picking an object's art. * Mode directories tried in order when picking an object's art.
@ -850,6 +861,18 @@ async function bakeDs1Variant(
} }
runs.push([current, count]) runs.push([current, count])
// The level's population, planned here so the browser never needs
// `Levels.txt` or `MonStats.txt` on the pack path. The same seed the scene
// geometry used drives it, so a pack and a live mount of the same level agree
// on what lives there.
const population = planLevelMonsters(
tables,
entry.levelId,
scene.cellsX * scene.cellsY,
seed,
PLAYER_WALK_SPEED_PX,
)
const sceneJson = { const sceneJson = {
version: 1, version: 1,
fidelity: entry.kind === 'preset' ? 'exact' : 'approximation', fidelity: entry.kind === 'preset' ? 'exact' : 'approximation',
@ -877,6 +900,11 @@ async function bakeDs1Variant(
roofs: scene.roofs.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]), roofs: scene.roofs.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]),
objects, objects,
npcs, npcs,
monsters: {
types: population.types,
budget: population.budget,
packs: population.packs,
},
collision: { width: scene.gridWidth, height: scene.gridHeight, runs }, collision: { width: scene.gridWidth, height: scene.gridHeight, runs },
spawn: spawn === null ? null : [Math.round(spawn.x), Math.round(spawn.y)], spawn: spawn === null ? null : [Math.round(spawn.x), Math.round(spawn.y)],
stats: { stats: {
@ -889,6 +917,8 @@ async function bakeDs1Variant(
walkable: 1 - [...scene.blocked].reduce((sum, value) => sum + value, 0) / scene.blocked.length, walkable: 1 - [...scene.blocked].reduce((sum, value) => sum + value, 0) / scene.blocked.length,
objects: objects.length, objects: objects.length,
npcs: npcs.length, npcs: npcs.length,
monsters: population.packs.reduce((sum, pack) => sum + pack.members.length, 0),
monsterPacks: population.packs.length,
objectsWithArt, objectsWithArt,
objectsUnresolved: missingObjects, objectsUnresolved: missingObjects,
objectsArtPending: (objects as any[]).filter(o => o.member !== null && o.frame === null).length, objectsArtPending: (objects as any[]).filter(o => o.member !== null && o.frame === null).length,

View File

@ -25,6 +25,8 @@ import {
readLevelMonsterPlan, readLevelMonsterPlan,
readMonsterKinds, readMonsterKinds,
readMonsterArt, readMonsterArt,
readMonsterScaling,
monsterScaleFor,
readSuperUniques, readSuperUniques,
selectLevelTypes, selectLevelTypes,
} from '../src/game/monsters.ts' } from '../src/game/monsters.ts'
@ -128,7 +130,7 @@ let withHealth = 0
let withDamage = 0 let withDamage = 0
const statRng = new Rng(1) const statRng = new Rng(1)
for (const kind of kinds.values()) { for (const kind of kinds.values()) {
const stats = monsterStatsOf(kind, statRng, WALK_SPEED) const stats = monsterStatsOf(kind, statRng, WALK_SPEED, monsterScaleFor(readMonsterScaling(tables.monlvl), kind))
if (kind.enabled && kind.isSpawn && !kind.npc && !kind.inTown) spawnable += 1 if (kind.enabled && kind.isSpawn && !kind.npc && !kind.inTown) spawnable += 1
if (kind.maxHp > 0) withHealth += 1 if (kind.maxHp > 0) withHealth += 1
if (kind.attack1.maxDamage > 0) withDamage += 1 if (kind.attack1.maxDamage > 0) withDamage += 1
@ -140,8 +142,7 @@ for (const kind of kinds.values()) {
Number.isInteger(stats.speed) && stats.speed >= 1 && Number.isInteger(stats.speed) && stats.speed >= 1 &&
stats.cooldownTicks > 0 && stats.reach > 0 && stats.aggroRadius > 0 && stats.cooldownTicks > 0 && stats.reach > 0 && stats.aggroRadius > 0 &&
stats.xp >= 0 && stats.xp >= 0 &&
stats.hp >= Math.min(kind.minHp, kind.maxHp) && stats.hp <= Math.max(1, Math.max(kind.minHp, kind.maxHp) * 400)
stats.hp <= Math.max(1, Math.max(kind.minHp, kind.maxHp))
if (!sane) { if (!sane) {
statFailures += 1 statFailures += 1
if (statFailures <= 5) console.log(` ${kind.id}: ${JSON.stringify(stats)}`) if (statFailures <= 5) console.log(` ${kind.id}: ${JSON.stringify(stats)}`)
@ -260,6 +261,74 @@ console.log(`\n== difficulty columns are read, and are not a scaling ==`)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Levels.txt monster columns. // Levels.txt monster columns.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// MonLvl.txt scaling — the reconciliation that justifies applying it.
// ---------------------------------------------------------------------------
console.log(`\n== MonLvl.txt scaling reconciles the table with the game ==`)
const scaling = readMonsterScaling(tables.monlvl)
check(scaling.size > 90, `${String(scaling.size)} monster levels have multipliers`)
// Every level any monster claims must have a row, or that monster would be
// silently left unscaled and arrive with hundreds of health.
const unscaled: number[] = []
for (const kind of kinds.values()) {
for (const level of kind.level) {
if (level > 0 && !scaling.has(level)) unscaled.push(level)
}
}
check(unscaled.length === 0, `every monster level has a MonLvl row (${String(new Set(unscaled).size)} missing)`)
// The five independent columns that pinned the formula down. If a future change
// breaks one of these, the scaling has stopped being the game's.
interface Reconciliation {
readonly id: string
readonly what: string
readonly expected: number
readonly raw: (kind: MonsterKind) => number
readonly read: (kind: MonsterKind, scale: { armour: number; toHit: number; experience: number }) => number
}
const RECONCILE: readonly Reconciliation[] = [
{ id: 'fallen1', what: 'Exp', expected: 18, raw: k => k.experience, read: (k, s2) => (k.experience * s2.experience) / 100 },
{ id: 'zombie1', what: 'Exp', expected: 33, raw: k => k.experience, read: (k, s2) => (k.experience * s2.experience) / 100 },
{ id: 'quillrat1', what: 'Exp', expected: 21, raw: k => k.experience, read: (k, s2) => (k.experience * s2.experience) / 100 },
{ id: 'fallen1', what: 'AC', expected: 5, raw: k => k.armour, read: (k, s2) => (k.armour * s2.armour) / 100 },
{ id: 'fallen1', what: 'A1TH', expected: 8, raw: k => k.attack1.toHit, read: (k, s2) => (k.attack1.toHit * s2.toHit) / 100 },
]
for (const want of RECONCILE) {
const kind = kinds.get(want.id)!
const got = want.read(kind, monsterScaleFor(scaling, kind))
check(
Math.round(got) === want.expected || Math.floor(got) === want.expected,
`${want.id} ${want.what} ${String(want.raw(kind))} → ${got.toFixed(1)}, the game shows ${String(want.expected)}`,
)
}
// The scaled numbers have to be playable, which the raw ones are not: a level-1
// Fallen straight off the table hits for 76, and the player has 60 health.
{
const fallen = kinds.get('fallen1')!
const raw = monsterStatsOf(fallen, new Rng(1), WALK_SPEED)
const scaled = monsterStatsOf(fallen, new Rng(1), WALK_SPEED, monsterScaleFor(scaling, fallen))
check(raw.damage > 50, `unscaled fallen1 hits for ${String(raw.damage)}, which would one-shot the player`)
check(scaled.damage <= 3, `scaled fallen1 hits for ${String(scaled.damage)}`)
check(scaled.hp <= 6, `scaled fallen1 has ${String(scaled.hp)} health`)
check(scaled.xp === 18, `scaled fallen1 awards ${String(scaled.xp)} experience`)
}
// Difficulty raises the monster's level, and the level raises everything else.
{
const fallen = kinds.get('fallen1')!
const hell = readMonsterKinds(tables.monstats, 'hell').get('fallen1')!
const hellScale = monsterScaleFor(readMonsterScaling(tables.monlvl, 'hell'), hell, 'hell')
const normal = monsterStatsOf(fallen, new Rng(1), WALK_SPEED, monsterScaleFor(scaling, fallen))
const scaledHell = monsterStatsOf(hell, new Rng(1), WALK_SPEED, hellScale)
check(
scaledHell.hp > normal.hp * 50,
`a Hell Fallen (level ${String(hell.level[2])}) has ${String(scaledHell.hp)} health against a Normal one's ${String(normal.hp)}`,
)
check(scaledHell.xp > normal.xp * 50, `and awards ${String(scaledHell.xp)} experience against ${String(normal.xp)}`)
}
console.log(`\n== Levels.txt monster plans ==`) console.log(`\n== Levels.txt monster plans ==`)
interface LevelExpectation { interface LevelExpectation {
readonly id: number readonly id: number

View File

@ -1,5 +1,5 @@
import { tickCombat, createWorld, spawnMonsters, damageMonster, rebindPlayer } from './combat.ts' import { tickCombat, createWorld, spawnMonsters, spawnMonsterPacks, damageMonster, rebindPlayer } from './combat.ts'
import type { CombatWorld, CombatOptions, MonsterStats } from './combat.ts' import type { CombatWorld, CombatOptions, MonsterPack, MonsterStats } from './combat.ts'
import { Inventory, rollDrop, goldItem, totalStat } from './items.ts' import { Inventory, rollDrop, goldItem, totalStat } from './items.ts'
import type { Item, ItemBase, Affix } from './items.ts' import type { Item, ItemBase, Affix } from './items.ts'
import { castSkill, tickProjectiles } from './skills.ts' import { castSkill, tickProjectiles } from './skills.ts'
@ -80,6 +80,18 @@ export interface GameEngineOptions {
* disc around the entrance, so this is a sandbox affordance, not a rule. * disc around the entrance, so this is a sandbox affordance, not a rule.
*/ */
monsterSpread?: number monsterSpread?: number
/**
* Pre-planned packs, which take precedence over
* {@link GameEngineOptions.stats} and {@link GameEngineOptions.monsterCount}.
*
* A level read from `Levels.txt` does not produce a count and a type list; it
* produces groups — two to three Fallen here, a Quill Rat pair there — and
* flattening that back into "spawn 20 monsters from these 3 types" loses the
* grouping, which is most of what makes a level feel populated. Scenes that
* have planned their level pass packs; fixtures that just want some monsters
* to hit keep passing a count.
*/
monsterPacks?: readonly MonsterPack[]
/** /**
* Whether to place NPCs on a synthetic ring around the spawn. * Whether to place NPCs on a synthetic ring around the spawn.
* *
@ -148,9 +160,12 @@ export class GameEngine {
constructor(public terrain: WorldMapProvider, public opts: GameEngineOptions) { constructor(public terrain: WorldMapProvider, public opts: GameEngineOptions) {
this.world = createWorld(opts.spawn.x, opts.spawn.y) this.world = createWorld(opts.spawn.x, opts.spawn.y)
spawnMonsters( const spread = opts.monsterSpread ?? 260
this.world, opts.stats, opts.monsterCount ?? 8, opts.spawn, opts.monsterSpread ?? 260, this.terrain, if (opts.monsterPacks !== undefined && opts.monsterPacks.length > 0) {
) spawnMonsterPacks(this.world, opts.monsterPacks, opts.spawn, spread, this.terrain)
} else {
spawnMonsters(this.world, opts.stats, opts.monsterCount ?? 8, opts.spawn, spread, this.terrain)
}
this.loot = new Rng(opts.lootSeed ?? 0x5eed) this.loot = new Rng(opts.lootSeed ?? 0x5eed)
this.bag = new Inventory(opts.inventoryCols, opts.inventoryRows) this.bag = new Inventory(opts.inventoryCols, opts.inventoryRows)
this.questLog = new QuestLog(opts.questDefs) this.questLog = new QuestLog(opts.questDefs)

View File

@ -22,26 +22,57 @@
* resistances, `TreasureClass*`, and every `Levels.txt` monster column. * resistances, `TreasureClass*`, and every `Levels.txt` monster column.
* *
* Engine-side approximations, each marked at its definition: * Engine-side approximations, each marked at its definition:
* - **Difficulty scaling is not applied, and the difficulty columns are not it.**
* `MonStats.txt` has `MinHP(N)`/`MinHP(H)` columns, and they are *not* the
* Nightmare and Hell health. Measured against the 1.13c drop: `fallen1` is
* 21–61 on Normal but 25–55 on both Nightmare and Hell, 426 of the 594 rows
* with health have a **lower** `MaxHP(N)` than `maxHP`, and Duriel — a boss,
* where these columns do apply — drops from 4757 to 1295 on Hell. The real
* scaling comes from `MonLvl.txt`'s per-level multipliers applied to the
* Normal values; the difficulty columns are a boss-only override that the
* data itself uses inconsistently. They are read and kept so M9 (真实战斗数学)
* has them, and this module does not pretend they mean more than that.
* - **Speed, reach, aggro radius and attack cooldown** have no direct pixel or * - **Speed, reach, aggro radius and attack cooldown** have no direct pixel or
* tick equivalent in the tables. They are derived from the player's own * tick equivalent in the tables. They are derived from the player's own
* numbers by a documented ratio rather than invented. * numbers by a documented ratio rather than invented.
* - **Density → monster count.** `MonDen` is consumed by the real level * - **Density → monster count.** `MonDen` is consumed by the real level
* generator per *room*; we have no rooms until M8. The conversion here is an * generator per *room*; we have no rooms until M8. The conversion here is an
* area-proportional approximation and says so. * area-proportional approximation and says so.
*
* ## The numbers in `MonStats.txt` are not the numbers in the game
*
* A Fallen's row says 21–61 health and 51–101 damage. A Fallen in the Blood
* Moor has about three health and hits for one. The row holds *per-mille-ish*
* figures that `MonLvl.txt` turns into real ones:
*
* ```
* final = MonStats[column] × MonLvl[monster level][matching column] ÷ 100
* ```
*
* That was not assumed, it was measured: it reconciles five independent columns
* against values the game is known to show, all at once.
*
* | monster | column | table | × MonLvl ÷ 100 | in game |
* |---|---|---|---|---|
* | fallen1 | `Exp` | 61 | 18.3 | 18 |
* | zombie1 | `Exp` | 111 | 33.3 | 33 |
* | quillrat1 | `Exp` | 71 | 21.3 | 21 |
* | fallen1 | `AC` | 84 | 5.0 | 5 |
* | fallen1 | `A1TH` | 101 | 8.1 | 8 |
*
* Five exact hits is not a coincidence, so the scaling is applied rather than
* deferred. Two consequences worth stating:
*
* - The difficulty columns `MinHP(N)`/`MinHP(H)` are *not* Nightmare and Hell
* health either. `fallen1` is 21–61 on Normal and 25–55 on both harder ones,
* 426 of the 594 rows with health have a **lower** `MaxHP(N)` than `maxHP`,
* and Duriel drops from 4757 to 1295 on Hell. Difficulty comes from the
* monster's own `Level(N)`/`Level(H)` — a Fallen is level 1, then 36, then 67
* — fed through `MonLvl`'s `HP(N)`/`HP(H)` columns. That is what makes a
* Nightmare Fallen a 131–289 health monster rather than a 25–55 one.
* - `MonLvl.txt`'s `L-` prefixed columns, the expansion set, are byte-identical
* to the classic ones throughout 1.13c, so which set is read makes no
* difference. The classic ones are used.
*
* Bosses were checked for an exemption and did not get one. The data is not
* consistent enough to justify carving one out: scaled Diablo lands on 13818
* against a remembered 13741 and scaled Andariel on 1025 against ~960, but
* Duriel's raw 4757 is the figure usually quoted for him. Inventing a special
* case on that basis would be guessing; the act bosses get their own pass.
*/ */
import type { D2Table } from './acts.ts' import type { D2Table } from './acts.ts'
import { cell } from './acts.ts' import { cell } from './acts.ts'
import type { MonsterStats } from './combat.ts' import type { MonsterPack, MonsterStats } from './combat.ts'
import { Rng } from './rng.ts' import { Rng } from './rng.ts'
/** Which difficulty a number is being read for. */ /** Which difficulty a number is being read for. */
@ -353,6 +384,75 @@ export function readMonsterArt(table: D2Table, kinds: ReadonlyMap<string, Monste
return art return art
} }
/**
* The five multipliers `MonLvl.txt` holds for one monster level.
*
* Each is a percentage applied to the matching `MonStats.txt` column. Level 1
* is `AC 6, TH 8, HP 7, DM 2, XP 30`, which is why a Fallen's 84 defence in the
* table is a defence of 5 on the ground.
*/
export interface MonsterScale {
/** `AC` — multiplies `MonStats.AC`. */
readonly armour: number
/** `TH` — multiplies the attacks' `TH` columns. */
readonly toHit: number
/** `HP` — multiplies `minHP`/`maxHP`. */
readonly health: number
/** `DM` — multiplies the attacks' `MinD`/`MaxD` columns. */
readonly damage: number
/** `XP` — multiplies `Exp`. */
readonly experience: number
}
/**
* Read `MonLvl.txt` for one difficulty.
*
* @param table - the parsed `MonLvl.txt`.
* @param difficulty - which difficulty's columns to read.
* @returns the multipliers, keyed by monster level.
*/
export function readMonsterScaling(table: D2Table, difficulty: Difficulty = 'normal'): Map<number, MonsterScale> {
const d = difficultySuffix(difficulty)
const scales = new Map<number, MonsterScale>()
for (const row of table.rows) {
const level = num(table, row, 'Level', -1)
if (level < 0) continue
scales.set(level, {
armour: num(table, row, `AC${d}`),
toHit: num(table, row, `TH${d}`),
health: num(table, row, `HP${d}`),
damage: num(table, row, `DM${d}`),
experience: num(table, row, `XP${d}`),
})
}
return scales
}
/** No scaling: every column passes through unchanged. */
export const UNSCALED: MonsterScale = { armour: 100, toHit: 100, health: 100, damage: 100, experience: 100 }
/**
* The scale one monster is subject to on one difficulty.
*
* The level to look up is the monster's own for that difficulty — `Level`,
* `Level(N)` or `Level(H)` — not the level it is standing in. A Fallen is level
* 1, 36 and 67 on the three difficulties, and that is the whole of what makes
* a Nightmare Fallen dangerous.
*
* @param scaling - the table, from {@link readMonsterScaling}.
* @param kind - the monster.
* @param difficulty - which difficulty.
* @returns the scale, or {@link UNSCALED} when the level has no row.
*/
export function monsterScaleFor(
scaling: ReadonlyMap<number, MonsterScale>,
kind: MonsterKind,
difficulty: Difficulty = 'normal',
): MonsterScale {
const tier = difficulty === 'normal' ? 0 : difficulty === 'nightmare' ? 1 : 2
return scaling.get(kind.level[tier]) ?? UNSCALED
}
/** What `Levels.txt` says should live on one level. */ /** What `Levels.txt` says should live on one level. */
export interface LevelMonsterPlan { export interface LevelMonsterPlan {
/** `Id`. */ /** `Id`. */
@ -724,20 +824,33 @@ const PIXELS_PER_SUBTILE = 16
* @param kind - the table row. * @param kind - the table row.
* @param rng - seeded, so the same spawn rolls the same health. * @param rng - seeded, so the same spawn rolls the same health.
* @param walkSpeedPx - the player's walking speed, the anchor for the scale. * @param walkSpeedPx - the player's walking speed, the anchor for the scale.
* @param scale - the `MonLvl.txt` multipliers; defaults to no scaling.
* @returns stats the existing combat code can use unchanged. * @returns stats the existing combat code can use unchanged.
*/ */
export function monsterStatsOf(kind: MonsterKind, rng: Rng, walkSpeedPx: number): MonsterStats { export function monsterStatsOf(
kind: MonsterKind,
rng: Rng,
walkSpeedPx: number,
scale: MonsterScale = UNSCALED,
): MonsterStats {
const velocity = kind.velocity > 0 ? kind.velocity : 1 const velocity = kind.velocity > 0 ? kind.velocity : 1
// Roll first, scale second. Scaling the bounds and rolling between them
// would round the range's ends before the roll and lose most of its width at
// low levels, where the multiplier is a few percent: a Fallen's 21..61 at
// level 1 becomes 1..4, and three of those four values are unreachable if
// each end is rounded first.
const rolled = rng.int(Math.min(kind.minHp, kind.maxHp), Math.max(kind.minHp, kind.maxHp))
const averageDamage = (kind.attack1.minDamage + kind.attack1.maxDamage) / 2
return { return {
id: kind.id, id: kind.id,
name: kind.nameKey === '' ? kind.id : kind.nameKey, name: kind.nameKey === '' ? kind.id : kind.nameKey,
hp: Math.max(1, rng.int(Math.min(kind.minHp, kind.maxHp), Math.max(kind.minHp, kind.maxHp))), hp: Math.max(1, Math.round((rolled * scale.health) / 100)),
damage: Math.max(1, Math.round((kind.attack1.minDamage + kind.attack1.maxDamage) / 2)), damage: Math.max(1, Math.round((averageDamage * scale.damage) / 100)),
cooldownTicks: DEFAULT_ATTACK_COOLDOWN_TICKS, cooldownTicks: DEFAULT_ATTACK_COOLDOWN_TICKS,
reach: DEFAULT_REACH_PX, reach: DEFAULT_REACH_PX,
aggroRadius: kind.aiDistance > 0 ? kind.aiDistance * PIXELS_PER_SUBTILE : DEFAULT_AGGRO_PX, aggroRadius: kind.aiDistance > 0 ? kind.aiDistance * PIXELS_PER_SUBTILE : DEFAULT_AGGRO_PX,
speed: Math.max(8, Math.round((velocity / PLAYER_WALK_VELOCITY) * walkSpeedPx)), speed: Math.max(8, Math.round((velocity / PLAYER_WALK_VELOCITY) * walkSpeedPx)),
xp: Math.max(0, kind.experience), xp: Math.max(0, Math.round((kind.experience * scale.experience) / 100)),
} }
} }
@ -752,3 +865,86 @@ export const ELITE_HEALTH_MULTIPLIER: Readonly<Record<Exclude<MonsterRank, 'mini
champion: 3, champion: 3,
unique: 4, unique: 4,
} }
/** What one level's population came out as. */
export interface PlannedLevel {
/** `Levels.txt` id. */
readonly levelId: number
/** `Levels.txt` name, for reporting. */
readonly levelName: string
/** The monster ids the level drew, in the order they were drawn. */
readonly types: readonly string[]
/** How many monsters the density asked for. */
readonly budget: number
/** The packs, ready to hand to `spawnMonsterPacks`. */
readonly packs: readonly MonsterPack[]
/** How many packs are champion or unique. */
readonly elitePacks: number
}
/**
* Go from a level id to packs of rolled monsters in one call.
*
* Two callers need this and they must not disagree: the browser reading the
* archives directly, and the pack baker writing the level out as JSON. Keeping
* the chain — plan, select types, budget, group, roll stats — in one function
* is what makes "the baked level matches the live one" a property rather than
* a coincidence.
*
* Every random choice comes from `seed`, so the same level is the same level on
* a reload, in a replay, and on a second machine.
*
* @param tables - `Levels.txt`, `MonStats.txt` and `MonLvl.txt`.
* @param levelId - which level.
* @param cells - the level's area in cells, for the density conversion.
* @param seed - the level seed.
* @param walkSpeedPx - the player's walking speed, the anchor for monster speed.
* @param difficulty - which difficulty's columns to read.
* @returns the population, empty for a town or an unknown level.
*/
export function planLevelMonsters(
tables: { readonly levels: D2Table; readonly monstats: D2Table; readonly monlvl: D2Table },
levelId: number,
cells: number,
seed: number,
walkSpeedPx: number,
difficulty: Difficulty = 'normal',
): PlannedLevel {
const empty: PlannedLevel = { levelId, levelName: '', types: [], budget: 0, packs: [], elitePacks: 0 }
const plan = readLevelMonsterPlan(tables.levels, levelId, difficulty)
if (plan === null) return empty
const kinds = readMonsterKinds(tables.monstats, difficulty)
const scaling = readMonsterScaling(tables.monlvl, difficulty)
// Three independent streams off the one seed. Sharing a single stream would
// make the type choice depend on how many monsters the density happened to
// ask for, so a level would change its monsters when its size changed.
const types = selectLevelTypes(plan, kinds, new Rng(seed ^ 0x7b10), difficulty)
const budget = monsterBudget(plan, cells)
const groups = planMonsterGroups(plan, types, kinds, budget, new Rng(seed ^ 0x9e37))
const rollRng = new Rng(seed ^ 0x2545)
const packs: MonsterPack[] = groups.map(group => {
const multiplier = ELITE_HEALTH_MULTIPLIER[group.rank]
const members: MonsterStats[] = []
for (let i = 0; i < group.count; i += 1) {
const base = monsterStatsOf(group.kind, rollRng, walkSpeedPx, monsterScaleFor(scaling, group.kind, difficulty))
// An elite pack is one leader plus its minions: the leader carries the
// rank's full bonus, the minions a smaller one, which is why a champion
// pack reads as "a tough one and its friends" rather than as a wall.
const scale = group.rank === 'normal' ? 1 : (i === 0 ? multiplier : 1 + (multiplier - 1) / 2)
members.push(scale === 1 ? base : { ...base, hp: Math.max(1, Math.round(base.hp * scale)) })
}
return { members }
})
return {
levelId,
levelName: plan.levelName,
types: types.map(kind => kind.id),
budget,
packs,
elitePacks: groups.filter(group => group.rank !== 'normal').length,
}
}

View File

@ -20,6 +20,7 @@ import { httpRangeSource } from '../mpq/source.ts'
import { ViewportCamera } from "../sim/camera.ts" import { ViewportCamera } from "../sim/camera.ts"
import { MountedArchives } from '../mpq/mount.ts' import { MountedArchives } from '../mpq/mount.ts'
import { loadActTables, resolveActTown, resolveLevel } from '../game/acts.ts' import { loadActTables, resolveActTown, resolveLevel } from '../game/acts.ts'
import { planLevelMonsters } from '../game/monsters.ts'
import type { LevelInfo } from '../game/acts.ts' import type { LevelInfo } from '../game/acts.ts'
import { decodeDs1 } from '../formats/ds1.ts' import { decodeDs1 } from '../formats/ds1.ts'
import { decodeDt1 } from '../formats/dt1.ts' import { decodeDt1 } from '../formats/dt1.ts'
@ -31,7 +32,7 @@ import {
} from '../game/d2map.ts' } from '../game/d2map.ts'
import type { CollisionGrid } from '../game/d2map.ts' import type { CollisionGrid } from '../game/d2map.ts'
import { createIsoTerrain } from '../game/iso-terrain.ts' import { createIsoTerrain } from '../game/iso-terrain.ts'
import type { MonsterStats } from '../game/combat.ts' import type { MonsterPack, MonsterStats } from '../game/combat.ts'
import { depthInsertIndex } from '../game/map.ts' import { 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"
@ -239,19 +240,28 @@ interface MapRuntime {
readonly spawn: { x: number; y: number } readonly spawn: { x: number; y: number }
readonly npcs: readonly PackObject[] readonly npcs: readonly PackObject[]
/** /**
* Monster definitions this level may spawn. * The monsters this level actually holds, planned from `Levels.txt` and
* `MonStats.txt`.
* *
* Placeholder until #22 reads `Levels.txt`'s `mon1..10` pools and the real * Empty when the level has none — a town — and empty for a source that has
* `MonStats.txt` rows. It is wired now so the path from level to spawned * not planned one, which is what {@link ActRuntime.monsterStats} below is
* monster exists and can be tested; swapping the source is then a one-line * still there for.
* change rather than a new integration. */
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[] readonly monsterStats: readonly MonsterStats[]
/** /** How many of {@link ActRuntime.monsterStats} to place in that fallback. */
* How many monsters to place.
*
* Placeholder until #22 derives it from `MonDen` / `NumMon`.
*/
readonly monsterCount: number readonly monsterCount: number
readonly objects: number readonly objects: number
readonly frames: number readonly frames: number
@ -360,6 +370,21 @@ interface PackSceneJson {
readonly roofs?: readonly (readonly number[])[] readonly roofs?: readonly (readonly number[])[]
readonly objects: readonly PackObject[] readonly objects: readonly PackObject[]
readonly npcs?: readonly PackObject[] readonly npcs?: readonly PackObject[]
/**
* The level's monsters, planned at bake time (absent in packs baked before
* the monster columns were read).
*
* Planned at bake time rather than in the browser because the browser has no
* `Levels.txt` or `MonStats.txt` on the pack path — the whole point of a pack
* is that it carries what the level needs and nothing else. The same
* `planLevelMonsters` produces both this and the live path's population, so
* the two agree by construction.
*/
readonly monsters?: {
readonly types: readonly string[]
readonly budget: number
readonly packs: readonly { readonly members: readonly MonsterStats[] }[]
}
readonly collision: { readonly width: number; readonly height: number; readonly runs: readonly (readonly number[])[] } readonly collision: { readonly width: number; readonly height: number; readonly runs: readonly (readonly number[])[] }
readonly spawn: readonly number[] | null readonly spawn: readonly number[] | null
readonly stats: { readonly missingTiles: number; readonly walkable: number } readonly stats: { readonly missingTiles: number; readonly walkable: number }
@ -488,6 +513,8 @@ async function loadPackRuntime(
objectDrawables, objectDrawables,
spawn, spawn,
npcs: scene.npcs ?? [], npcs: scene.npcs ?? [],
monsterPacks: scene.monsters?.packs ?? [],
monsterTypes: scene.monsters?.types ?? [],
monsterStats: DEMO_MONSTERS, monsterStats: DEMO_MONSTERS,
monsterCount: PLACEHOLDER_MONSTER_COUNT, monsterCount: PLACEHOLDER_MONSTER_COUNT,
objects: scene.objects.length, objects: scene.objects.length,
@ -628,6 +655,20 @@ async function loadLiveRuntime(
cellY: row.cellY, cellY: row.cellY,
page: 0, page: 0,
}) })
// Plan the level's population from its own `Levels.txt` row. The seed is the
// level's, so the same map always comes back with the same monsters in the
// same camps — which is what makes a save reloadable.
const population = planLevelMonsters(
tables,
town.levelId,
scene.cellsX * scene.cellsY,
levelSeed(town.ds1Names[index] ?? town.levelName),
WALK_SPEED,
)
if (population.packs.length > 0) {
notes.push(`怪物:${String(population.budget)} 只 / ${String(population.packs.length)} 群(${population.types.join('、')})`)
}
return { return {
source: 'live', source: 'live',
base, base,
@ -649,6 +690,8 @@ async function loadLiveRuntime(
objectDrawables: [], objectDrawables: [],
spawn: findIsoSpawn(scene) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 }, spawn: findIsoSpawn(scene) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 },
npcs: [], npcs: [],
monsterPacks: population.packs,
monsterTypes: population.types,
monsterStats: DEMO_MONSTERS, monsterStats: DEMO_MONSTERS,
monsterCount: PLACEHOLDER_MONSTER_COUNT, monsterCount: PLACEHOLDER_MONSTER_COUNT,
objects: level.objects.length, objects: level.objects.length,
@ -841,6 +884,7 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
inventoryCols: 10, inventoryCols: 10,
inventoryRows: 4, inventoryRows: 4,
monsterCount: runtime.monsterCount, monsterCount: runtime.monsterCount,
monsterPacks: runtime.monsterPacks,
monsterSpread: Math.max(runtime.widthPx, runtime.heightPx) / 4, monsterSpread: Math.max(runtime.widthPx, runtime.heightPx) / 4,
// This scene knows where its NPCs stand — the DS1 carries their // This scene knows where its NPCs stand — the DS1 carries their
// coordinates — so the engine must not build a ring we would discard. // coordinates — so the engine must not build a ring we would discard.

View File

@ -16,10 +16,13 @@ import {
planMonsterGroups, planMonsterGroups,
readEliteModifiers, readEliteModifiers,
readLevelMonsterPlan, readLevelMonsterPlan,
monsterScaleFor,
readMonsterArt, readMonsterArt,
readMonsterKinds, readMonsterKinds,
readMonsterScaling,
readSuperUniques, readSuperUniques,
selectLevelTypes, selectLevelTypes,
UNSCALED,
} from '../src/game/monsters.ts' } from '../src/game/monsters.ts'
import type { MonsterKind } from '../src/game/monsters.ts' import type { MonsterKind } from '../src/game/monsters.ts'
import { Rng } from '../src/game/rng.ts' import { Rng } from '../src/game/rng.ts'
@ -125,6 +128,18 @@ const MONSTATS2 = table(
], ],
) )
/** `MonLvl.txt`, with the real 1.13c rows for the levels the fixtures use. */
const MONLVL = table(
['Level', 'AC', 'AC(N)', 'TH', 'TH(N)', 'HP', 'HP(N)', 'DM', 'DM(N)', 'XP', 'XP(N)'],
[
['1', '6', '61', '8', '73', '7', '107', '2', '30', '30', '78'],
['2', '12', '64', '12', '79', '9', '113', '3', '31', '40', '104'],
['30', '180', '190', '309', '320', '133', '300', '19', '52', '660', '1600'],
['36', '216', '216', '375', '375', '182', '525', '23', '60', '990', '2568'],
['50', '300', '300', '540', '540', '300', '900', '31', '80', '2000', '6000'],
],
)
const LEVELS = table( const LEVELS = table(
[ [
'Id', 'Name', 'MonDen', 'MonDen(N)', 'MonUMin', 'MonUMax', 'MonUMin(N)', 'MonUMax(N)', 'Id', 'Name', 'MonDen', 'MonDen(N)', 'MonUMin', 'MonUMax', 'MonUMin(N)', 'MonUMax(N)',
@ -512,3 +527,100 @@ describe('monsterStatsOf', () => {
expect(monsterStatsOf(nameless, new Rng(1), WALK).name).toBe('fallen1') expect(monsterStatsOf(nameless, new Rng(1), WALK).name).toBe('fallen1')
}) })
}) })
describe('readMonsterScaling', () => {
const scaling = readMonsterScaling(MONLVL)
it('reads the five multipliers for a level', () => {
expect(scaling.get(1)).toEqual({ armour: 6, toHit: 8, health: 7, damage: 2, experience: 30 })
})
it('reads the difficulty columns', () => {
expect(readMonsterScaling(MONLVL, 'nightmare').get(1)).toEqual({
armour: 61, toHit: 73, health: 107, damage: 30, experience: 78,
})
})
it('has no entry for a level the table does not list', () => {
expect(scaling.get(7)).toBeUndefined()
})
})
describe('monsterScaleFor', () => {
const kinds = readMonsterKinds(MONSTATS)
const scaling = readMonsterScaling(MONLVL)
it('looks up by the monster\'s own level for the difficulty', () => {
expect(monsterScaleFor(scaling, kinds.get('fallen1')!)).toEqual(scaling.get(1))
// fallen1's Level(N) is 36, so Nightmare reads that row, not level 1's.
const nightmare = readMonsterScaling(MONLVL, 'nightmare')
expect(monsterScaleFor(nightmare, readMonsterKinds(MONSTATS, 'nightmare').get('fallen1')!, 'nightmare'))
.toEqual(nightmare.get(36))
})
it('falls back to no scaling rather than to zero when the level is missing', () => {
// Zero multipliers would silently produce a monster with one health and one
// damage, which looks like a working monster rather than like a gap.
const offTable: MonsterKind = { ...kinds.get('fallen1')!, level: [7, 7, 7] }
expect(monsterScaleFor(scaling, offTable)).toBe(UNSCALED)
})
})
describe('monsterStatsOf with MonLvl scaling', () => {
const kinds = readMonsterKinds(MONSTATS)
const scaling = readMonsterScaling(MONLVL)
const WALK = 170
it('turns the table\'s numbers into the game\'s', () => {
// The five reconciliations that pinned the formula down, as a test.
const fallen = kinds.get('fallen1')!
const scaled = monsterStatsOf(fallen, new Rng(1), WALK, monsterScaleFor(scaling, fallen))
expect(scaled.xp).toBe(18)
expect(scaled.damage).toBeLessThanOrEqual(3)
expect(scaled.hp).toBeLessThanOrEqual(6)
const zombie = kinds.get('zombie1')!
expect(monsterStatsOf(zombie, new Rng(1), WALK, monsterScaleFor(scaling, zombie)).xp).toBe(33)
const quillrat = kinds.get('quillrat1')!
expect(monsterStatsOf(quillrat, new Rng(1), WALK, monsterScaleFor(scaling, quillrat)).xp).toBe(21)
})
it('leaves the raw numbers unplayable, which is why scaling is not optional', () => {
// A guard against someone dropping the scale argument at a call site: the
// unscaled Fallen hits for 76 and the player has 60 health.
const fallen = kinds.get('fallen1')!
expect(monsterStatsOf(fallen, new Rng(1), WALK).damage).toBeGreaterThan(50)
})
it('never scales a monster out of existence', () => {
for (const kind of kinds.values()) {
const stats = monsterStatsOf(kind, new Rng(2), WALK, monsterScaleFor(scaling, kind))
expect(stats.hp).toBeGreaterThanOrEqual(1)
expect(stats.damage).toBeGreaterThanOrEqual(1)
}
})
it('rolls before scaling so the range does not collapse', () => {
// At level 1 the health multiplier is 7%, turning 21..61 into about 1..4.
// Scaling the bounds first and rolling between the rounded ends would give
// the same handful of values every time; rolling first keeps the spread.
const fallen = kinds.get('fallen1')!
const scale = monsterScaleFor(scaling, fallen)
const rng = new Rng(9)
const rolls = new Set(Array.from({ length: 60 }, () => monsterStatsOf(fallen, rng, WALK, scale).hp))
expect(rolls.size).toBeGreaterThan(2)
})
it('makes a Hell monster dangerous through its level, not its difficulty columns', () => {
const normal = kinds.get('fallen1')!
const hellKinds = readMonsterKinds(MONSTATS, 'hell')
// The fixture has no Hell health columns for fallen1, exactly as many real
// rows do not; the danger has to come from the level lookup regardless.
const hell = hellKinds.get('fallen1')!
expect(hell.level[2]).toBe(67)
const scaledNormal = monsterStatsOf(normal, new Rng(1), WALK, monsterScaleFor(scaling, normal))
const withLevel50 = monsterStatsOf(normal, new Rng(1), WALK, readMonsterScaling(MONLVL).get(50)!)
expect(withLevel50.hp).toBeGreaterThan(scaledNormal.hp * 20)
expect(withLevel50.xp).toBeGreaterThan(scaledNormal.xp * 20)
})
})