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:
parent
70b299569e
commit
cc12e7bc66
|
|
@ -38,6 +38,7 @@ import { decodeDt1 } from '../src/formats/dt1.ts'
|
|||
import type { Dt1 } from '../src/formats/dt1.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 { planLevelMonsters } from '../src/game/monsters.ts'
|
||||
import type { IsoMapScene } from '../src/game/d2map.ts'
|
||||
import { loadObjectsTable, resolveDs1Object, MONSTER_ROOT } from '../src/game/objects.ts'
|
||||
import { decodeDcc } from '../src/formats/dcc.ts'
|
||||
|
|
@ -64,6 +65,16 @@ const PAGE_SIZE = 2048
|
|||
const HOT_RADIUS_CELLS = 8
|
||||
/** Where object art lives. */
|
||||
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. */
|
||||
/**
|
||||
* Mode directories tried in order when picking an object's art.
|
||||
|
|
@ -850,6 +861,18 @@ async function bakeDs1Variant(
|
|||
}
|
||||
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 = {
|
||||
version: 1,
|
||||
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]),
|
||||
objects,
|
||||
npcs,
|
||||
monsters: {
|
||||
types: population.types,
|
||||
budget: population.budget,
|
||||
packs: population.packs,
|
||||
},
|
||||
collision: { width: scene.gridWidth, height: scene.gridHeight, runs },
|
||||
spawn: spawn === null ? null : [Math.round(spawn.x), Math.round(spawn.y)],
|
||||
stats: {
|
||||
|
|
@ -889,6 +917,8 @@ async function bakeDs1Variant(
|
|||
walkable: 1 - [...scene.blocked].reduce((sum, value) => sum + value, 0) / scene.blocked.length,
|
||||
objects: objects.length,
|
||||
npcs: npcs.length,
|
||||
monsters: population.packs.reduce((sum, pack) => sum + pack.members.length, 0),
|
||||
monsterPacks: population.packs.length,
|
||||
objectsWithArt,
|
||||
objectsUnresolved: missingObjects,
|
||||
objectsArtPending: (objects as any[]).filter(o => o.member !== null && o.frame === null).length,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ import {
|
|||
readLevelMonsterPlan,
|
||||
readMonsterKinds,
|
||||
readMonsterArt,
|
||||
readMonsterScaling,
|
||||
monsterScaleFor,
|
||||
readSuperUniques,
|
||||
selectLevelTypes,
|
||||
} from '../src/game/monsters.ts'
|
||||
|
|
@ -128,7 +130,7 @@ let withHealth = 0
|
|||
let withDamage = 0
|
||||
const statRng = new Rng(1)
|
||||
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.maxHp > 0) withHealth += 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 &&
|
||||
stats.cooldownTicks > 0 && stats.reach > 0 && stats.aggroRadius > 0 &&
|
||||
stats.xp >= 0 &&
|
||||
stats.hp >= Math.min(kind.minHp, kind.maxHp) &&
|
||||
stats.hp <= Math.max(1, Math.max(kind.minHp, kind.maxHp))
|
||||
stats.hp <= Math.max(1, Math.max(kind.minHp, kind.maxHp) * 400)
|
||||
if (!sane) {
|
||||
statFailures += 1
|
||||
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.
|
||||
// ---------------------------------------------------------------------------
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 ==`)
|
||||
interface LevelExpectation {
|
||||
readonly id: number
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { tickCombat, createWorld, spawnMonsters, damageMonster, rebindPlayer } from './combat.ts'
|
||||
import type { CombatWorld, CombatOptions, MonsterStats } from './combat.ts'
|
||||
import { tickCombat, createWorld, spawnMonsters, spawnMonsterPacks, damageMonster, rebindPlayer } from './combat.ts'
|
||||
import type { CombatWorld, CombatOptions, MonsterPack, MonsterStats } from './combat.ts'
|
||||
import { Inventory, rollDrop, goldItem, totalStat } from './items.ts'
|
||||
import type { Item, ItemBase, Affix } from './items.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.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
|
|
@ -148,9 +160,12 @@ export class GameEngine {
|
|||
|
||||
constructor(public terrain: WorldMapProvider, public opts: GameEngineOptions) {
|
||||
this.world = createWorld(opts.spawn.x, opts.spawn.y)
|
||||
spawnMonsters(
|
||||
this.world, opts.stats, opts.monsterCount ?? 8, opts.spawn, opts.monsterSpread ?? 260, this.terrain,
|
||||
)
|
||||
const spread = opts.monsterSpread ?? 260
|
||||
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.bag = new Inventory(opts.inventoryCols, opts.inventoryRows)
|
||||
this.questLog = new QuestLog(opts.questDefs)
|
||||
|
|
|
|||
|
|
@ -22,26 +22,57 @@
|
|||
* resistances, `TreasureClass*`, and every `Levels.txt` monster column.
|
||||
*
|
||||
* 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
|
||||
* tick equivalent in the tables. They are derived from the player's own
|
||||
* numbers by a documented ratio rather than invented.
|
||||
* - **Density → monster count.** `MonDen` is consumed by the real level
|
||||
* generator per *room*; we have no rooms until M8. The conversion here is an
|
||||
* 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 { cell } from './acts.ts'
|
||||
import type { MonsterStats } from './combat.ts'
|
||||
import type { MonsterPack, MonsterStats } from './combat.ts'
|
||||
import { Rng } from './rng.ts'
|
||||
|
||||
/** Which difficulty a number is being read for. */
|
||||
|
|
@ -353,6 +384,75 @@ export function readMonsterArt(table: D2Table, kinds: ReadonlyMap<string, Monste
|
|||
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. */
|
||||
export interface LevelMonsterPlan {
|
||||
/** `Id`. */
|
||||
|
|
@ -724,20 +824,33 @@ const PIXELS_PER_SUBTILE = 16
|
|||
* @param kind - the table row.
|
||||
* @param rng - seeded, so the same spawn rolls the same health.
|
||||
* @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.
|
||||
*/
|
||||
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
|
||||
// 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 {
|
||||
id: kind.id,
|
||||
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))),
|
||||
damage: Math.max(1, Math.round((kind.attack1.minDamage + kind.attack1.maxDamage) / 2)),
|
||||
hp: Math.max(1, Math.round((rolled * scale.health) / 100)),
|
||||
damage: Math.max(1, Math.round((averageDamage * scale.damage) / 100)),
|
||||
cooldownTicks: DEFAULT_ATTACK_COOLDOWN_TICKS,
|
||||
reach: DEFAULT_REACH_PX,
|
||||
aggroRadius: kind.aiDistance > 0 ? kind.aiDistance * PIXELS_PER_SUBTILE : DEFAULT_AGGRO_PX,
|
||||
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,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { httpRangeSource } from '../mpq/source.ts'
|
|||
import { ViewportCamera } from "../sim/camera.ts"
|
||||
import { MountedArchives } from '../mpq/mount.ts'
|
||||
import { loadActTables, resolveActTown, resolveLevel } from '../game/acts.ts'
|
||||
import { planLevelMonsters } from '../game/monsters.ts'
|
||||
import type { LevelInfo } from '../game/acts.ts'
|
||||
import { decodeDs1 } from '../formats/ds1.ts'
|
||||
import { decodeDt1 } from '../formats/dt1.ts'
|
||||
|
|
@ -31,7 +32,7 @@ import {
|
|||
} from '../game/d2map.ts'
|
||||
import type { CollisionGrid } from '../game/d2map.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 { GameEngine, syncEngineState } from "../game/engine.ts"
|
||||
import type { NpcEntity } from "../game/engine.ts"
|
||||
|
|
@ -239,19 +240,28 @@ interface MapRuntime {
|
|||
readonly spawn: { x: number; y: number }
|
||||
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
|
||||
* `MonStats.txt` rows. It is wired now so the path from level to spawned
|
||||
* monster exists and can be tested; swapping the source is then a one-line
|
||||
* change rather than a new integration.
|
||||
* 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.
|
||||
*/
|
||||
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 monsters to place.
|
||||
*
|
||||
* Placeholder until #22 derives it from `MonDen` / `NumMon`.
|
||||
*/
|
||||
/** How many of {@link ActRuntime.monsterStats} to place in that fallback. */
|
||||
readonly monsterCount: number
|
||||
readonly objects: number
|
||||
readonly frames: number
|
||||
|
|
@ -360,6 +370,21 @@ interface PackSceneJson {
|
|||
readonly roofs?: readonly (readonly number[])[]
|
||||
readonly objects: 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 spawn: readonly number[] | null
|
||||
readonly stats: { readonly missingTiles: number; readonly walkable: number }
|
||||
|
|
@ -488,6 +513,8 @@ async function loadPackRuntime(
|
|||
objectDrawables,
|
||||
spawn,
|
||||
npcs: scene.npcs ?? [],
|
||||
monsterPacks: scene.monsters?.packs ?? [],
|
||||
monsterTypes: scene.monsters?.types ?? [],
|
||||
monsterStats: DEMO_MONSTERS,
|
||||
monsterCount: PLACEHOLDER_MONSTER_COUNT,
|
||||
objects: scene.objects.length,
|
||||
|
|
@ -628,6 +655,20 @@ async function loadLiveRuntime(
|
|||
cellY: row.cellY,
|
||||
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 {
|
||||
source: 'live',
|
||||
base,
|
||||
|
|
@ -649,6 +690,8 @@ async function loadLiveRuntime(
|
|||
objectDrawables: [],
|
||||
spawn: findIsoSpawn(scene) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 },
|
||||
npcs: [],
|
||||
monsterPacks: population.packs,
|
||||
monsterTypes: population.types,
|
||||
monsterStats: DEMO_MONSTERS,
|
||||
monsterCount: PLACEHOLDER_MONSTER_COUNT,
|
||||
objects: level.objects.length,
|
||||
|
|
@ -841,6 +884,7 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
|
|||
inventoryCols: 10,
|
||||
inventoryRows: 4,
|
||||
monsterCount: runtime.monsterCount,
|
||||
monsterPacks: runtime.monsterPacks,
|
||||
monsterSpread: Math.max(runtime.widthPx, runtime.heightPx) / 4,
|
||||
// This scene knows where its NPCs stand — the DS1 carries their
|
||||
// coordinates — so the engine must not build a ring we would discard.
|
||||
|
|
|
|||
|
|
@ -16,10 +16,13 @@ import {
|
|||
planMonsterGroups,
|
||||
readEliteModifiers,
|
||||
readLevelMonsterPlan,
|
||||
monsterScaleFor,
|
||||
readMonsterArt,
|
||||
readMonsterKinds,
|
||||
readMonsterScaling,
|
||||
readSuperUniques,
|
||||
selectLevelTypes,
|
||||
UNSCALED,
|
||||
} from '../src/game/monsters.ts'
|
||||
import type { MonsterKind } from '../src/game/monsters.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(
|
||||
[
|
||||
'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')
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue