feat(map-gen/population): implement wilderness population phase, waypoints, shrines, superuniques & safe zones (fixes #36)
This commit is contained in:
parent
93a854d9ad
commit
e9f2ceec9b
|
|
@ -1154,6 +1154,7 @@ async function bakeDs1Variant(
|
||||||
generatorLinks: {
|
generatorLinks: {
|
||||||
readonly entrances: readonly WildernessEntrance[]
|
readonly entrances: readonly WildernessEntrance[]
|
||||||
readonly mazeWarps: readonly MazeWarp[]
|
readonly mazeWarps: readonly MazeWarp[]
|
||||||
|
readonly landmarks?: readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[]
|
||||||
} = { entrances: [], mazeWarps: [] },
|
} = { entrances: [], mazeWarps: [] },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const scene: IsoMapScene = buildIsoMapScene(level, libraries, seed)
|
const scene: IsoMapScene = buildIsoMapScene(level, libraries, seed)
|
||||||
|
|
@ -1370,6 +1371,8 @@ async function bakeDs1Variant(
|
||||||
scene.cellsX * scene.cellsY,
|
scene.cellsX * scene.cellsY,
|
||||||
seed,
|
seed,
|
||||||
PLAYER_WALK_SPEED_PX,
|
PLAYER_WALK_SPEED_PX,
|
||||||
|
'normal',
|
||||||
|
generatorLinks.landmarks ? { landmarks: generatorLinks.landmarks } : undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
const sceneLinks = buildSceneLinks(
|
const sceneLinks = buildSceneLinks(
|
||||||
|
|
@ -1611,7 +1614,13 @@ for (const entry of LEVELS) {
|
||||||
await bakeDs1Variant(
|
await bakeDs1Variant(
|
||||||
entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries,
|
entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries,
|
||||||
result.level, `generated:${label}`, label, seed,
|
result.level, `generated:${label}`, label, seed,
|
||||||
{ entrances: (result.stats.entrances ?? []) as WildernessEntrance[], mazeWarps: [] },
|
{
|
||||||
|
entrances: (result.stats.entrances ?? []) as WildernessEntrance[],
|
||||||
|
mazeWarps: [],
|
||||||
|
...(Array.isArray(result.stats.landmarkEntities)
|
||||||
|
? { landmarks: result.stats.landmarkEntities as readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[] }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`failed to bake wilderness ${label}: ${(err as Error).message}`)
|
console.error(`failed to bake wilderness ${label}: ${(err as Error).message}`)
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,12 @@ export interface MonsterStats {
|
||||||
readonly speed: number
|
readonly speed: number
|
||||||
/** Experience awarded on death. */
|
/** Experience awarded on death. */
|
||||||
readonly xp: number
|
readonly xp: number
|
||||||
|
/** Elite rank: normal, champion, unique, or minion. */
|
||||||
|
readonly rank?: 'normal' | 'champion' | 'unique' | 'minion'
|
||||||
|
/** Rolled elite/superunique modifiers from MonUMod.txt. */
|
||||||
|
readonly modifiers?: readonly string[]
|
||||||
|
/** SuperUnique identifier if this is a fixed SuperUnique boss (e.g. 'Bishibosh'). */
|
||||||
|
readonly superUniqueId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One monster's mutable state. */
|
/** One monster's mutable state. */
|
||||||
|
|
@ -404,6 +410,13 @@ export function spawnMonsters(
|
||||||
return placed
|
return placed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A circular exclusion zone where ordinary monster camps must not spawn. */
|
||||||
|
export interface SafeZone {
|
||||||
|
readonly x: number
|
||||||
|
readonly y: number
|
||||||
|
readonly radius: number
|
||||||
|
}
|
||||||
|
|
||||||
/** A group of monsters that belongs together on the ground. */
|
/** A group of monsters that belongs together on the ground. */
|
||||||
export interface MonsterPack {
|
export interface MonsterPack {
|
||||||
/**
|
/**
|
||||||
|
|
@ -414,6 +427,12 @@ export interface MonsterPack {
|
||||||
* a stronger leader followed by its minions.
|
* a stronger leader followed by its minions.
|
||||||
*/
|
*/
|
||||||
readonly members: readonly MonsterStats[]
|
readonly members: readonly MonsterStats[]
|
||||||
|
/** Fixed world pixel coordinates for landmark-bound SuperUnique packs. */
|
||||||
|
readonly fixedPosition?: { readonly x: number; readonly y: number }
|
||||||
|
/** Landmark tile coordinates (tileX, tileY) if pixel coordinates are resolved at scene load. */
|
||||||
|
readonly landmarkTile?: { readonly tileX: number; readonly tileY: number }
|
||||||
|
/** SuperUnique boss identifier if this pack belongs to a fixed SuperUnique. */
|
||||||
|
readonly superUniqueId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -425,6 +444,14 @@ export interface MonsterPack {
|
||||||
*/
|
*/
|
||||||
const PACK_RADIUS_PX = 96
|
const PACK_RADIUS_PX = 96
|
||||||
|
|
||||||
|
function isInsideSafeZone(x: number, y: number, safeZones: readonly SafeZone[] | undefined): boolean {
|
||||||
|
if (safeZones === undefined || safeZones.length === 0) return false
|
||||||
|
for (const sz of safeZones) {
|
||||||
|
if (Math.hypot(x - sz.x, y - sz.y) < sz.radius) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Place packs, keeping each pack's members together.
|
* Place packs, keeping each pack's members together.
|
||||||
*
|
*
|
||||||
|
|
@ -444,6 +471,7 @@ const PACK_RADIUS_PX = 96
|
||||||
* @param around - the centre to spread camps around.
|
* @param around - the centre to spread camps around.
|
||||||
* @param spread - how far camps may be from that centre, in pixels.
|
* @param spread - how far camps may be from that centre, in pixels.
|
||||||
* @param terrain - collision predicate.
|
* @param terrain - collision predicate.
|
||||||
|
* @param safeZones - optional safe zones (spawn point, town bridge, waypoints) to exclude ordinary packs from.
|
||||||
* @returns how many monsters were actually placed.
|
* @returns how many monsters were actually placed.
|
||||||
*/
|
*/
|
||||||
export function spawnMonsterPacks(
|
export function spawnMonsterPacks(
|
||||||
|
|
@ -452,25 +480,57 @@ export function spawnMonsterPacks(
|
||||||
around: { readonly x: number; readonly y: number },
|
around: { readonly x: number; readonly y: number },
|
||||||
spread: number,
|
spread: number,
|
||||||
terrain: CombatTerrain,
|
terrain: CombatTerrain,
|
||||||
|
safeZones?: readonly SafeZone[],
|
||||||
): number {
|
): number {
|
||||||
if (packs.length === 0) return 0
|
if (packs.length === 0) return 0
|
||||||
let placed = 0
|
let placed = 0
|
||||||
let campIndex = 0
|
let campIndex = 0
|
||||||
let attempt = 0
|
let attempt = 0
|
||||||
const attemptLimit = packs.length * 24
|
const attemptLimit = packs.length * 32
|
||||||
while (campIndex < packs.length && attempt < attemptLimit) {
|
while (campIndex < packs.length && attempt < attemptLimit) {
|
||||||
attempt += 1
|
|
||||||
const angle = attempt * 2.399963
|
|
||||||
const radius = spread * Math.sqrt(attempt / attemptLimit)
|
|
||||||
const campX = around.x + Math.cos(angle) * radius
|
|
||||||
const campY = around.y + Math.sin(angle) * radius
|
|
||||||
if (terrain.overlap(campX, campY) > 0) continue
|
|
||||||
|
|
||||||
const pack = packs[campIndex]!
|
const pack = packs[campIndex]!
|
||||||
|
let campX: number
|
||||||
|
let campY: number
|
||||||
|
let angle: number
|
||||||
|
|
||||||
|
if (pack.fixedPosition !== undefined) {
|
||||||
|
// Fixed landmark SuperUnique pack: anchor camp center to the landmark position.
|
||||||
|
campX = pack.fixedPosition.x
|
||||||
|
campY = pack.fixedPosition.y
|
||||||
|
angle = campIndex * 2.399963
|
||||||
|
if (terrain.overlap(campX, campY) > 0) {
|
||||||
|
// Search outward from landmark if exact center tile is slightly blocked
|
||||||
|
let foundClear = false
|
||||||
|
for (let searchStep = 1; searchStep <= 16; searchStep += 1) {
|
||||||
|
const sAngle = searchStep * 2.399963
|
||||||
|
const sx = campX + Math.cos(sAngle) * (searchStep * 12)
|
||||||
|
const sy = campY + Math.sin(sAngle) * (searchStep * 12)
|
||||||
|
if (terrain.overlap(sx, sy) === 0) {
|
||||||
|
campX = sx
|
||||||
|
campY = sy
|
||||||
|
foundClear = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!foundClear) {
|
||||||
|
campIndex += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
attempt += 1
|
||||||
|
angle = attempt * 2.399963
|
||||||
|
const radius = spread * Math.sqrt(attempt / (packs.length * 24))
|
||||||
|
campX = around.x + Math.cos(angle) * radius
|
||||||
|
campY = around.y + Math.sin(angle) * radius
|
||||||
|
if (isInsideSafeZone(campX, campY, safeZones)) continue
|
||||||
|
if (terrain.overlap(campX, campY) > 0) continue
|
||||||
|
}
|
||||||
|
|
||||||
campIndex += 1
|
campIndex += 1
|
||||||
let memberAttempt = 0
|
let memberAttempt = 0
|
||||||
let memberPlaced = 0
|
let memberPlaced = 0
|
||||||
while (memberPlaced < pack.members.length && memberAttempt < pack.members.length * 12) {
|
while (memberPlaced < pack.members.length && memberAttempt < pack.members.length * 16) {
|
||||||
// The leader stands on the camp centre; the rest ring it. Offsetting the
|
// The leader stands on the camp centre; the rest ring it. Offsetting the
|
||||||
// ring by the camp's own angle stops every camp in the level from having
|
// ring by the camp's own angle stops every camp in the level from having
|
||||||
// an identically oriented formation.
|
// an identically oriented formation.
|
||||||
|
|
@ -481,6 +541,7 @@ export function spawnMonsterPacks(
|
||||||
memberAttempt += 1
|
memberAttempt += 1
|
||||||
const x = campX + Math.cos(memberAngle) * memberRadius
|
const x = campX + Math.cos(memberAngle) * memberRadius
|
||||||
const y = campY + Math.sin(memberAngle) * memberRadius
|
const y = campY + Math.sin(memberAngle) * memberRadius
|
||||||
|
if (pack.fixedPosition === undefined && isInsideSafeZone(x, y, safeZones)) continue
|
||||||
if (terrain.overlap(x, y) > 0) continue
|
if (terrain.overlap(x, y) > 0) continue
|
||||||
const definition = pack.members[memberPlaced]!
|
const definition = pack.members[memberPlaced]!
|
||||||
world.monsters.push({
|
world.monsters.push({
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { tickCombat, createWorld, spawnMonsters, spawnMonsterPacks, damageMonster, rebindPlayer } from './combat.ts'
|
import { tickCombat, createWorld, spawnMonsters, spawnMonsterPacks, damageMonster, rebindPlayer } from './combat.ts'
|
||||||
import type { CombatWorld, CombatOptions, MonsterPack, MonsterStats } from './combat.ts'
|
import type { CombatWorld, CombatOptions, MonsterPack, MonsterStats, SafeZone } 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'
|
||||||
|
|
@ -92,6 +92,11 @@ export interface GameEngineOptions {
|
||||||
* to hit keep passing a count.
|
* to hit keep passing a count.
|
||||||
*/
|
*/
|
||||||
monsterPacks?: readonly MonsterPack[]
|
monsterPacks?: readonly MonsterPack[]
|
||||||
|
/**
|
||||||
|
* Optional safe zones (e.g. around spawn point, town bridges, and waypoints)
|
||||||
|
* where ordinary monster packs must not spawn.
|
||||||
|
*/
|
||||||
|
safeZones?: readonly SafeZone[]
|
||||||
/**
|
/**
|
||||||
* Whether to place NPCs on a synthetic ring around the spawn.
|
* Whether to place NPCs on a synthetic ring around the spawn.
|
||||||
*
|
*
|
||||||
|
|
@ -163,7 +168,7 @@ export class GameEngine {
|
||||||
const spread = opts.monsterSpread ?? 260
|
const spread = opts.monsterSpread ?? 260
|
||||||
const count = opts.monsterCount ?? 8
|
const count = opts.monsterCount ?? 8
|
||||||
if (opts.monsterPacks !== undefined && opts.monsterPacks.length > 0) {
|
if (opts.monsterPacks !== undefined && opts.monsterPacks.length > 0) {
|
||||||
spawnMonsterPacks(this.world, opts.monsterPacks, opts.spawn, spread, this.terrain)
|
spawnMonsterPacks(this.world, opts.monsterPacks, opts.spawn, spread, this.terrain, opts.safeZones)
|
||||||
} else if (count > 0) {
|
} else if (count > 0) {
|
||||||
spawnMonsters(this.world, opts.stats, count, opts.spawn, spread, this.terrain)
|
spawnMonsters(this.world, opts.stats, count, opts.spawn, spread, this.terrain)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -684,14 +684,87 @@ export function readEliteModifiers(table: D2Table): EliteModifier[] {
|
||||||
/** What a monster is: ordinary, or promoted by the level generator. */
|
/** What a monster is: ordinary, or promoted by the level generator. */
|
||||||
export type MonsterRank = 'normal' | 'champion' | 'unique' | 'minion'
|
export type MonsterRank = 'normal' | 'champion' | 'unique' | 'minion'
|
||||||
|
|
||||||
|
/** Canonical elite modifiers from `MonUMod.txt`. */
|
||||||
|
export const CANONICAL_ELITE_MODIFIERS: readonly {
|
||||||
|
readonly id: number
|
||||||
|
readonly name: string
|
||||||
|
readonly label: string
|
||||||
|
readonly champion: boolean
|
||||||
|
}[] = [
|
||||||
|
{ id: 5, name: 'strong', label: 'Extra Strong', champion: true },
|
||||||
|
{ id: 6, name: 'fast', label: 'Extra Fast', champion: true },
|
||||||
|
{ id: 7, name: 'cursed', label: 'Cursed', champion: false },
|
||||||
|
{ id: 8, name: 'magicresistant', label: 'Magic Resistant', champion: false },
|
||||||
|
{ id: 9, name: 'fireenchant', label: 'Fire Enchanted', champion: false },
|
||||||
|
{ id: 17, name: 'lightenchant', label: 'Lightning Enchanted', champion: false },
|
||||||
|
{ id: 18, name: 'coldenchant', label: 'Cold Enchanted', champion: false },
|
||||||
|
{ id: 25, name: 'manahit', label: 'Mana Burn', champion: false },
|
||||||
|
{ id: 26, name: 'teleport', label: 'Teleportation', champion: false },
|
||||||
|
{ id: 27, name: 'spectralhit', label: 'Spectral Hit', champion: false },
|
||||||
|
{ id: 28, name: 'stoneskin', label: 'Stone Skin', champion: false },
|
||||||
|
{ id: 29, name: 'multishot', label: 'Multiple Shots', champion: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roll elite modifiers (`MonUMod.txt`) for a champion or unique pack.
|
||||||
|
*/
|
||||||
|
export function rollEliteModifiers(
|
||||||
|
rank: Exclude<MonsterRank, 'minion'>,
|
||||||
|
rng: Rng,
|
||||||
|
): readonly string[] {
|
||||||
|
if (rank === 'normal') return []
|
||||||
|
if (rank === 'champion') {
|
||||||
|
const champs = CANONICAL_ELITE_MODIFIERS.filter(m => m.champion)
|
||||||
|
return [champs[rng.int(0, champs.length - 1)]!.name]
|
||||||
|
}
|
||||||
|
const count = rng.int(1, 2)
|
||||||
|
const chosen: string[] = []
|
||||||
|
const pool = [...CANONICAL_ELITE_MODIFIERS]
|
||||||
|
while (chosen.length < count && pool.length > 0) {
|
||||||
|
const idx = rng.int(0, pool.length - 1)
|
||||||
|
chosen.push(pool[idx]!.name)
|
||||||
|
pool.splice(idx, 1)
|
||||||
|
}
|
||||||
|
return chosen
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply elite/superunique stat modifiers to base rolled `MonsterStats`.
|
||||||
|
*/
|
||||||
|
export function applyEliteModifiers(
|
||||||
|
base: MonsterStats,
|
||||||
|
rank: MonsterRank,
|
||||||
|
modifiers: readonly string[] = [],
|
||||||
|
superUniqueId?: string,
|
||||||
|
): MonsterStats {
|
||||||
|
let damage = base.damage
|
||||||
|
let speed = base.speed
|
||||||
|
if (modifiers.includes('strong')) {
|
||||||
|
damage = Math.max(1, Math.round(damage * 1.5))
|
||||||
|
}
|
||||||
|
if (modifiers.includes('fast')) {
|
||||||
|
speed = Math.round(speed * 1.4 * 100) / 100
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
damage,
|
||||||
|
speed,
|
||||||
|
rank,
|
||||||
|
...(modifiers.length > 0 ? { modifiers } : {}),
|
||||||
|
...(superUniqueId !== undefined ? { superUniqueId } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** One pack the level populator wants placed. */
|
/** One pack the level populator wants placed. */
|
||||||
export interface MonsterGroup {
|
export interface MonsterGroup {
|
||||||
/** Which monster. */
|
/** Which monster. */
|
||||||
readonly kind: MonsterKind
|
readonly kind: MonsterKind
|
||||||
/** How many, from `MinGrp`…`MaxGrp`. */
|
/** How many, from MinGrp to MaxGrp. */
|
||||||
readonly count: number
|
readonly count: number
|
||||||
/** Whether this pack is an elite one. */
|
/** Whether this pack is an elite one. */
|
||||||
readonly rank: Exclude<MonsterRank, 'minion'>
|
readonly rank: Exclude<MonsterRank, 'minion'>
|
||||||
|
/** Rolled elite modifiers (`MonUMod.txt`). */
|
||||||
|
readonly modifiers?: readonly string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -800,7 +873,10 @@ export function planMonsterGroups(
|
||||||
const count = Math.min(remaining, Math.max(1, rng.int(kind.minGroup, kind.maxGroup)))
|
const count = Math.min(remaining, Math.max(1, rng.int(kind.minGroup, kind.maxGroup)))
|
||||||
// `MonUMod.txt`'s `champion chance` constant is 20, i.e. a fifth of elite
|
// `MonUMod.txt`'s `champion chance` constant is 20, i.e. a fifth of elite
|
||||||
// packs are champions rather than a unique with minions.
|
// packs are champions rather than a unique with minions.
|
||||||
groups.push({ kind, count, rank: rng.next() < 0.2 ? 'champion' : 'unique' })
|
const rank: Exclude<MonsterRank, 'minion'> = rng.next() < 0.2 ? 'champion' : 'unique'
|
||||||
|
const modSeed = ((i + 1) * 0x9e3779b9) ^ (count * 0x85ebca6b) ^ (rank === 'champion' ? 1 : 2)
|
||||||
|
const modifiers = rollEliteModifiers(rank, new Rng(modSeed))
|
||||||
|
groups.push({ kind, count, rank, modifiers })
|
||||||
remaining -= count
|
remaining -= count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -910,6 +986,62 @@ export const ELITE_HEALTH_MULTIPLIER: Readonly<Record<Exclude<MonsterRank, 'mini
|
||||||
unique: 4,
|
unique: 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Specification of a fixed Act 1 outdoor SuperUnique boss tied to a landmark. */
|
||||||
|
export interface SuperUniqueLandmarkSpec {
|
||||||
|
readonly id: string
|
||||||
|
readonly name: string
|
||||||
|
readonly monsterId: string
|
||||||
|
readonly landmark: string
|
||||||
|
readonly modifiers: readonly string[]
|
||||||
|
readonly minMinions: number
|
||||||
|
readonly maxMinions: number
|
||||||
|
readonly minionMonsterId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical Act 1 outdoor SuperUniques tied to level landmarks. */
|
||||||
|
export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly<Record<number, SuperUniqueLandmarkSpec>> = {
|
||||||
|
3: {
|
||||||
|
id: 'Bishibosh',
|
||||||
|
name: 'Bishibosh',
|
||||||
|
monsterId: 'fallenshaman1',
|
||||||
|
landmark: 'Act 1 - Fallen Camp Bishibosh',
|
||||||
|
modifiers: ['magicresistant', 'fireenchant'],
|
||||||
|
minMinions: 5,
|
||||||
|
maxMinions: 7,
|
||||||
|
minionMonsterId: 'fallen1',
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
id: 'Rakanishu',
|
||||||
|
name: 'Rakanishu',
|
||||||
|
monsterId: 'fallen1',
|
||||||
|
landmark: 'Act 1 - Cairn Stones',
|
||||||
|
modifiers: ['lightenchant', 'fast'],
|
||||||
|
minMinions: 6,
|
||||||
|
maxMinions: 8,
|
||||||
|
minionMonsterId: 'fallen1',
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
id: 'Treehead WoodFist',
|
||||||
|
name: 'Treehead WoodFist',
|
||||||
|
monsterId: 'brute1',
|
||||||
|
landmark: 'Act 1 - Inifus',
|
||||||
|
modifiers: ['strong', 'fast'],
|
||||||
|
minMinions: 3,
|
||||||
|
maxMinions: 4,
|
||||||
|
minionMonsterId: 'brute1',
|
||||||
|
},
|
||||||
|
17: {
|
||||||
|
id: 'Blood Raven',
|
||||||
|
name: 'Blood Raven',
|
||||||
|
monsterId: 'corruptrogue1',
|
||||||
|
landmark: 'Act 1 - Graveyard',
|
||||||
|
modifiers: ['fireenchant', 'fast'],
|
||||||
|
minMinions: 6,
|
||||||
|
maxMinions: 8,
|
||||||
|
minionMonsterId: 'zombie1',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
/** What one level's population came out as. */
|
/** What one level's population came out as. */
|
||||||
export interface PlannedLevel {
|
export interface PlannedLevel {
|
||||||
/** `Levels.txt` id. */
|
/** `Levels.txt` id. */
|
||||||
|
|
@ -924,6 +1056,8 @@ export interface PlannedLevel {
|
||||||
readonly packs: readonly MonsterPack[]
|
readonly packs: readonly MonsterPack[]
|
||||||
/** How many packs are champion or unique. */
|
/** How many packs are champion or unique. */
|
||||||
readonly elitePacks: number
|
readonly elitePacks: number
|
||||||
|
/** Fixed SuperUnique bosses planned for this level (e.g. Bishibosh, Rakanishu). */
|
||||||
|
readonly superUniques?: readonly string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -944,6 +1078,7 @@ export interface PlannedLevel {
|
||||||
* @param seed - the level seed.
|
* @param seed - the level seed.
|
||||||
* @param walkSpeedPx - the player's walking speed, the anchor for monster speed.
|
* @param walkSpeedPx - the player's walking speed, the anchor for monster speed.
|
||||||
* @param difficulty - which difficulty's columns to read.
|
* @param difficulty - which difficulty's columns to read.
|
||||||
|
* @param options - optional landmark overrides with exact tile/pixel positions.
|
||||||
* @returns the population, empty for a town or an unknown level.
|
* @returns the population, empty for a town or an unknown level.
|
||||||
*/
|
*/
|
||||||
export function planLevelMonsters(
|
export function planLevelMonsters(
|
||||||
|
|
@ -953,6 +1088,14 @@ export function planLevelMonsters(
|
||||||
seed: number,
|
seed: number,
|
||||||
walkSpeedPx: number,
|
walkSpeedPx: number,
|
||||||
difficulty: Difficulty = 'normal',
|
difficulty: Difficulty = 'normal',
|
||||||
|
options?: {
|
||||||
|
readonly landmarks?: readonly {
|
||||||
|
readonly id: string
|
||||||
|
readonly tileX: number
|
||||||
|
readonly tileY: number
|
||||||
|
readonly fixedPosition?: { readonly x: number; readonly y: number }
|
||||||
|
}[]
|
||||||
|
},
|
||||||
): PlannedLevel {
|
): PlannedLevel {
|
||||||
const empty: PlannedLevel = { levelId, levelName: '', types: [], budget: 0, packs: [], elitePacks: 0 }
|
const empty: PlannedLevel = { levelId, levelName: '', types: [], budget: 0, packs: [], elitePacks: 0 }
|
||||||
const plan = readLevelMonsterPlan(tables.levels, levelId, difficulty)
|
const plan = readLevelMonsterPlan(tables.levels, levelId, difficulty)
|
||||||
|
|
@ -978,18 +1121,62 @@ export function planLevelMonsters(
|
||||||
// rank's full bonus, the minions a smaller one, which is why a champion
|
// 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.
|
// 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)
|
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)) })
|
const scaled = scale === 1 ? base : { ...base, hp: Math.max(1, Math.round(base.hp * scale)) }
|
||||||
|
const memberRank: MonsterRank = group.rank === 'normal' ? 'normal' : (i === 0 || group.rank === 'champion' ? group.rank : 'minion')
|
||||||
|
const memberMods = (i === 0 || group.rank === 'champion') ? (group.modifiers ?? []) : []
|
||||||
|
members.push(applyEliteModifiers(scaled, memberRank, memberMods))
|
||||||
}
|
}
|
||||||
return { members }
|
return { members }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Check if this level has a fixed SuperUnique boss (Bishibosh, Rakanishu, Treehead WoodFist, Blood Raven)
|
||||||
|
const suSpec = CANONICAL_SUPER_UNIQUES_BY_LEVEL[levelId]
|
||||||
|
const superUniques: string[] = []
|
||||||
|
if (suSpec !== undefined && kinds.size > 0) {
|
||||||
|
const suRng = new Rng(seed ^ 0x55aa)
|
||||||
|
const fallbackKind = kinds.get('fallen1') ?? kinds.values().next().value!
|
||||||
|
const bossKind = kinds.get(suSpec.monsterId) ?? fallbackKind
|
||||||
|
const minionKind = kinds.get(suSpec.minionMonsterId) ?? bossKind
|
||||||
|
const bossLevel = monsterLevelFor(bossKind, difficulty, plan.monsterLevel + 3)
|
||||||
|
const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, monsterScaleFor(scaling, bossLevel))
|
||||||
|
const bossScaled: MonsterStats = {
|
||||||
|
...bossBase,
|
||||||
|
id: suSpec.monsterId,
|
||||||
|
name: suSpec.name,
|
||||||
|
hp: Math.max(1, Math.round(bossBase.hp * ELITE_HEALTH_MULTIPLIER.unique)),
|
||||||
|
}
|
||||||
|
const bossStats = applyEliteModifiers(bossScaled, 'unique', suSpec.modifiers, suSpec.id)
|
||||||
|
const minionCount = suRng.int(suSpec.minMinions, suSpec.maxMinions)
|
||||||
|
const suMembers: MonsterStats[] = [bossStats]
|
||||||
|
for (let m = 0; m < minionCount; m += 1) {
|
||||||
|
const mLevel = monsterLevelFor(minionKind, difficulty, plan.monsterLevel)
|
||||||
|
const mBase = monsterStatsOf(minionKind, suRng, walkSpeedPx, monsterScaleFor(scaling, mLevel))
|
||||||
|
const mScaled: MonsterStats = {
|
||||||
|
...mBase,
|
||||||
|
id: suSpec.minionMonsterId,
|
||||||
|
hp: Math.max(1, Math.round(mBase.hp * 2)),
|
||||||
|
}
|
||||||
|
suMembers.push(applyEliteModifiers(mScaled, 'minion', []))
|
||||||
|
}
|
||||||
|
const matchedLandmark = options?.landmarks?.find(l => l.id === suSpec.id)
|
||||||
|
const suPack: MonsterPack = {
|
||||||
|
members: suMembers,
|
||||||
|
superUniqueId: suSpec.id,
|
||||||
|
...(matchedLandmark?.fixedPosition !== undefined ? { fixedPosition: matchedLandmark.fixedPosition } : {}),
|
||||||
|
...(matchedLandmark !== undefined ? { landmarkTile: { tileX: matchedLandmark.tileX, tileY: matchedLandmark.tileY } } : {}),
|
||||||
|
}
|
||||||
|
packs.unshift(suPack)
|
||||||
|
superUniques.push(suSpec.id)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
levelId,
|
levelId,
|
||||||
levelName: plan.levelName,
|
levelName: plan.levelName,
|
||||||
types: types.map(kind => kind.id),
|
types: types.map(kind => kind.id),
|
||||||
budget,
|
budget,
|
||||||
packs,
|
packs,
|
||||||
elitePacks: groups.filter(group => group.rank !== 'normal').length,
|
elitePacks: groups.filter(group => group.rank !== 'normal').length + superUniques.length,
|
||||||
|
...(superUniques.length > 0 ? { superUniques } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -593,6 +593,20 @@ interface SubstitutionReport {
|
||||||
readonly clusters: number
|
readonly clusters: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A fixed SuperUnique boss or guard NPC anchored to a wilderness landmark or border gate. */
|
||||||
|
export interface LandmarkEntity {
|
||||||
|
readonly role: 'superunique' | 'npc_guard'
|
||||||
|
readonly id: string
|
||||||
|
readonly monsterId: string
|
||||||
|
readonly name: string
|
||||||
|
readonly landmark: string
|
||||||
|
readonly tileX: number
|
||||||
|
readonly tileY: number
|
||||||
|
readonly modifiers: readonly string[]
|
||||||
|
readonly minionCount: readonly [number, number]
|
||||||
|
readonly minionMonsterId: string
|
||||||
|
}
|
||||||
|
|
||||||
/** The report collector. */
|
/** The report collector. */
|
||||||
interface WildernessStats {
|
interface WildernessStats {
|
||||||
readonly substitutions: SubstitutionReport[]
|
readonly substitutions: SubstitutionReport[]
|
||||||
|
|
@ -609,6 +623,11 @@ interface WildernessStats {
|
||||||
specialPresets: string[]
|
specialPresets: string[]
|
||||||
/** Every way into or out of the level, in cells. See `WildernessEntrance`. */
|
/** Every way into or out of the level, in cells. See `WildernessEntrance`. */
|
||||||
entrances: WildernessEntrance[]
|
entrances: WildernessEntrance[]
|
||||||
|
waypointsSpawned?: number
|
||||||
|
waypointTile?: { readonly x: number; readonly y: number }
|
||||||
|
shrinesSpawned?: number
|
||||||
|
interactivesSpawned?: number
|
||||||
|
landmarkEntities?: LandmarkEntity[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -652,7 +671,7 @@ export const DIRT_PATH_TILE_LUT: Readonly<Uint8Array> = new Uint8Array([
|
||||||
* reproduce. Reported verbatim in every result's `stats.unimplementedPasses` so
|
* reproduce. Reported verbatim in every result's `stats.unimplementedPasses` so
|
||||||
* the gap is visible rather than implied.
|
* the gap is visible rather than implied.
|
||||||
*/
|
*/
|
||||||
const UNIMPLEMENTED_PASSES: readonly string[] = [
|
export const UNIMPLEMENTED_PASSES: readonly string[] = [
|
||||||
'DRLGVER_CreateVertices',
|
'DRLGVER_CreateVertices',
|
||||||
'DRLGOUTPLACE_CreateLevelConnections',
|
'DRLGOUTPLACE_CreateLevelConnections',
|
||||||
'DRLGOUTDESR_InitAct2OutdoorLevel',
|
'DRLGOUTDESR_InitAct2OutdoorLevel',
|
||||||
|
|
@ -660,8 +679,6 @@ const UNIMPLEMENTED_PASSES: readonly string[] = [
|
||||||
'DRLGOUTDOORS_InitAct4OutdoorLevel',
|
'DRLGOUTDOORS_InitAct4OutdoorLevel',
|
||||||
'DRLGOUTSIEGE_InitAct5OutdoorLevel',
|
'DRLGOUTSIEGE_InitAct5OutdoorLevel',
|
||||||
'DRLG_GenerateJungles',
|
'DRLG_GenerateJungles',
|
||||||
'DRLGOUTDOORS_SpawnAct12Waypoint',
|
|
||||||
'DRLGOUTDOORS_SpawnAct12Shrines',
|
|
||||||
'DRLGOUTDOORS_SpawnAct3Mephisto',
|
'DRLGOUTDOORS_SpawnAct3Mephisto',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -2906,6 +2923,437 @@ function stampAct1InteriorStoneWalls(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------- *
|
||||||
|
* Outdoor Population Phase: Waypoints, Roadside Shrines/Chests, SuperUniques & Flavie
|
||||||
|
* (`DRLGOUTDOORS_SpawnAct12Waypoint`, `DRLGOUTDOORS_SpawnAct12Shrines`)
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/** Outdoor levels in Act 1 and Act 2 that carry a Waypoint (`Levels.txt` `Waypoint != 255`). */
|
||||||
|
const OUTDOOR_WAYPOINT_LEVELS = new Set([3, 4, 5, 6, 42, 43, 44])
|
||||||
|
|
||||||
|
function isTileClear(
|
||||||
|
canvas: Canvas,
|
||||||
|
tx: number,
|
||||||
|
ty: number,
|
||||||
|
placedRects: readonly PlacedPresetRect[],
|
||||||
|
padding = 1,
|
||||||
|
): boolean {
|
||||||
|
if (tx < padding || ty < padding || tx >= canvas.width - padding || ty >= canvas.height - padding) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for (const r of placedRects) {
|
||||||
|
if (tx >= r.x - padding && tx < r.x + r.w + padding && ty >= r.y - padding && ty < r.y + r.h + padding) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let dy = -padding; dy <= padding; dy += 1) {
|
||||||
|
const row = canvas.cells[ty + dy]
|
||||||
|
if (!row) return false
|
||||||
|
for (let dx = -padding; dx <= padding; dx += 1) {
|
||||||
|
const cell = row[tx + dx]
|
||||||
|
if (!cell) return false
|
||||||
|
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNearDirtRoad(
|
||||||
|
dirtPathGrid: Uint8Array | undefined,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
tx: number,
|
||||||
|
ty: number,
|
||||||
|
minDist: number,
|
||||||
|
maxDist: number,
|
||||||
|
): boolean {
|
||||||
|
if (!dirtPathGrid) return false
|
||||||
|
let minDistFound = 999
|
||||||
|
for (let dy = -maxDist; dy <= maxDist; dy += 1) {
|
||||||
|
const ny = ty + dy
|
||||||
|
if (ny < 0 || ny >= height) continue
|
||||||
|
for (let dx = -maxDist; dx <= maxDist; dx += 1) {
|
||||||
|
const nx = tx + dx
|
||||||
|
if (nx < 0 || nx >= width) continue
|
||||||
|
if (dirtPathGrid[ny * width + nx] !== 0) {
|
||||||
|
const d = Math.max(Math.abs(dx), Math.abs(dy))
|
||||||
|
if (d < minDistFound) minDistFound = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return minDistFound >= minDist && minDistFound <= maxDist
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatically locate and place an outdoor Waypoint (`DRLGOUTDOORS_SpawnAct12Waypoint`)
|
||||||
|
* on a flat, open area near the main dirt road when not already provided by a preset.
|
||||||
|
*/
|
||||||
|
function spawnAct12Waypoint(
|
||||||
|
canvas: Canvas,
|
||||||
|
levelId: number,
|
||||||
|
act: number,
|
||||||
|
dirtPathGrid: Uint8Array | undefined,
|
||||||
|
placedRects: readonly PlacedPresetRect[],
|
||||||
|
rng: Rng,
|
||||||
|
stats: WildernessStats,
|
||||||
|
): void {
|
||||||
|
if (!OUTDOOR_WAYPOINT_LEVELS.has(levelId)) return
|
||||||
|
|
||||||
|
// Check if a preset already placed a Waypoint object (Act 1 WP id 37/52, Act 2 WM id 10/306)
|
||||||
|
const existingWp = canvas.objects.find(
|
||||||
|
o => o.type === 2 && (o.id === 37 || o.id === 52 || o.id === 10 || o.id === 306),
|
||||||
|
)
|
||||||
|
if (existingWp !== undefined) {
|
||||||
|
stats.waypointsSpawned = 1
|
||||||
|
stats.waypointTile = {
|
||||||
|
x: Math.floor(existingWp.x / SUB_TILES_PER_TILE),
|
||||||
|
y: Math.floor(existingWp.y / SUB_TILES_PER_TILE),
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const roadCandidates: { x: number; y: number }[] = []
|
||||||
|
const openCandidates: { x: number; y: number }[] = []
|
||||||
|
const margin = Math.min(16, Math.floor(Math.min(canvas.width, canvas.height) / 4))
|
||||||
|
|
||||||
|
for (let y = margin; y < canvas.height - margin; y += 2) {
|
||||||
|
for (let x = margin; x < canvas.width - margin; x += 2) {
|
||||||
|
if (!isTileClear(canvas, x, y, placedRects, 2)) continue
|
||||||
|
if (isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, x, y, 3, 6)) {
|
||||||
|
roadCandidates.push({ x, y })
|
||||||
|
} else {
|
||||||
|
openCandidates.push({ x, y })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = roadCandidates.length > 0 ? roadCandidates : openCandidates
|
||||||
|
const chosen = pool.length > 0
|
||||||
|
? pool[rng.int(0, pool.length - 1)]!
|
||||||
|
: { x: Math.floor(canvas.width / 2), y: Math.floor(canvas.height / 2) }
|
||||||
|
|
||||||
|
const wpId = act === 2 ? 10 : 37
|
||||||
|
canvas.objects.push({
|
||||||
|
type: 2,
|
||||||
|
id: wpId,
|
||||||
|
x: chosen.x * SUB_TILES_PER_TILE + 2,
|
||||||
|
y: chosen.y * SUB_TILES_PER_TILE + 2,
|
||||||
|
flags: 0,
|
||||||
|
})
|
||||||
|
stats.waypointsSpawned = 1
|
||||||
|
stats.waypointTile = { x: chosen.x, y: chosen.y }
|
||||||
|
stats.notes.push(`spawned outdoor waypoint (id=${String(wpId)}) at tile (${String(chosen.x)}, ${String(chosen.y)})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scatter roadside shrines, healing wells, chests, and barrels/rocks (`DRLGOUTDOORS_SpawnAct12Shrines`)
|
||||||
|
* along dirt road edges and near ruins/cottages.
|
||||||
|
*/
|
||||||
|
function spawnAct12Shrines(
|
||||||
|
canvas: Canvas,
|
||||||
|
levelId: number,
|
||||||
|
act: number,
|
||||||
|
dirtPathGrid: Uint8Array | undefined,
|
||||||
|
placedRects: readonly PlacedPresetRect[],
|
||||||
|
rng: Rng,
|
||||||
|
stats: WildernessStats,
|
||||||
|
): void {
|
||||||
|
const isAct1Wild = levelId >= 2 && levelId <= 7
|
||||||
|
const isAct2Wild = levelId >= 41 && levelId <= 46
|
||||||
|
if (!isAct1Wild && !isAct2Wild) return
|
||||||
|
|
||||||
|
const margin = Math.min(10, Math.floor(Math.min(canvas.width, canvas.height) / 5))
|
||||||
|
const candidates: { x: number; y: number }[] = []
|
||||||
|
for (let y = margin; y < canvas.height - margin; y += 2) {
|
||||||
|
for (let x = margin; x < canvas.width - margin; x += 2) {
|
||||||
|
if (!isTileClear(canvas, x, y, placedRects, 1)) continue
|
||||||
|
// Prefer roadside (2..5 tiles from road) or near preset ruins
|
||||||
|
const nearRoad = isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, x, y, 2, 5)
|
||||||
|
if (nearRoad || dirtPathGrid === undefined) {
|
||||||
|
candidates.push({ x, y })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
for (let y = margin; y < canvas.height - margin; y += 4) {
|
||||||
|
for (let x = margin; x < canvas.width - margin; x += 4) {
|
||||||
|
if (isTileClear(canvas, x, y, placedRects, 1)) candidates.push({ x, y })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidates.length === 0) return
|
||||||
|
|
||||||
|
const placedCoords: { x: number; y: number }[] = canvas.objects.map(o => ({
|
||||||
|
x: Math.floor(o.x / SUB_TILES_PER_TILE),
|
||||||
|
y: Math.floor(o.y / SUB_TILES_PER_TILE),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const pickSpot = (): { x: number; y: number } | null => {
|
||||||
|
for (let attempt = 0; attempt < 24; attempt += 1) {
|
||||||
|
const spot = candidates[rng.int(0, candidates.length - 1)]!
|
||||||
|
const tooClose = placedCoords.some(p => Math.hypot(p.x - spot.x, p.y - spot.y) < 4)
|
||||||
|
if (!tooClose) {
|
||||||
|
placedCoords.push(spot)
|
||||||
|
return spot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const shrinePool = act === 2 ? [54, 55, 152, 227] : [29, 82, 83, 84]
|
||||||
|
const wellId = act === 2 ? 234 : 31
|
||||||
|
const chestPool = act === 2 ? [32, 33, 44] : [5, 89, 90, 93]
|
||||||
|
const scatterPool = act === 2 ? [48] : [17, 18, 25]
|
||||||
|
|
||||||
|
let shrinesSpawned = 0
|
||||||
|
let interactivesSpawned = 0
|
||||||
|
|
||||||
|
// 1..2 Shrines
|
||||||
|
const shrineCount = rng.int(1, 2)
|
||||||
|
for (let i = 0; i < shrineCount; i += 1) {
|
||||||
|
const spot = pickSpot()
|
||||||
|
if (!spot) break
|
||||||
|
const id = shrinePool[rng.int(0, shrinePool.length - 1)]!
|
||||||
|
canvas.objects.push({
|
||||||
|
type: 2,
|
||||||
|
id,
|
||||||
|
x: spot.x * SUB_TILES_PER_TILE + 2,
|
||||||
|
y: spot.y * SUB_TILES_PER_TILE + 2,
|
||||||
|
flags: 0,
|
||||||
|
})
|
||||||
|
shrinesSpawned += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 Healing Well
|
||||||
|
const wellSpot = pickSpot()
|
||||||
|
if (wellSpot) {
|
||||||
|
canvas.objects.push({
|
||||||
|
type: 2,
|
||||||
|
id: wellId,
|
||||||
|
x: wellSpot.x * SUB_TILES_PER_TILE + 2,
|
||||||
|
y: wellSpot.y * SUB_TILES_PER_TILE + 2,
|
||||||
|
flags: 0,
|
||||||
|
})
|
||||||
|
shrinesSpawned += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1..3 Chests
|
||||||
|
const chestCount = rng.int(1, 3)
|
||||||
|
for (let i = 0; i < chestCount; i += 1) {
|
||||||
|
const spot = pickSpot()
|
||||||
|
if (!spot) break
|
||||||
|
const id = chestPool[rng.int(0, chestPool.length - 1)]!
|
||||||
|
canvas.objects.push({
|
||||||
|
type: 2,
|
||||||
|
id,
|
||||||
|
x: spot.x * SUB_TILES_PER_TILE + 2,
|
||||||
|
y: spot.y * SUB_TILES_PER_TILE + 2,
|
||||||
|
flags: 0,
|
||||||
|
})
|
||||||
|
interactivesSpawned += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2..4 Barrels / Campfires / Stash Rocks
|
||||||
|
const scatterCount = rng.int(2, 4)
|
||||||
|
for (let i = 0; i < scatterCount; i += 1) {
|
||||||
|
const spot = pickSpot()
|
||||||
|
if (!spot) break
|
||||||
|
const id = scatterPool[rng.int(0, scatterPool.length - 1)]!
|
||||||
|
canvas.objects.push({
|
||||||
|
type: 2,
|
||||||
|
id,
|
||||||
|
x: spot.x * SUB_TILES_PER_TILE + 2,
|
||||||
|
y: spot.y * SUB_TILES_PER_TILE + 2,
|
||||||
|
flags: 0,
|
||||||
|
})
|
||||||
|
interactivesSpawned += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.shrinesSpawned = shrinesSpawned
|
||||||
|
stats.interactivesSpawned = interactivesSpawned
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anchor fixed Act 1 SuperUniques (Bishibosh, Rakanishu, Treehead WoodFist, Blood Raven)
|
||||||
|
* to their landmark presets and place Guard NPC Flavie (`rogue2`) at the Blood Moor <-> Cold Plains border gate.
|
||||||
|
*/
|
||||||
|
function spawnLandmarkEntities(
|
||||||
|
canvas: Canvas,
|
||||||
|
levelId: number,
|
||||||
|
act1Plan: Act1OutdoorPlan | undefined,
|
||||||
|
placedRects: readonly PlacedPresetRect[],
|
||||||
|
gridWidth: number,
|
||||||
|
gridHeight: number,
|
||||||
|
stats: WildernessStats,
|
||||||
|
): void {
|
||||||
|
const entities: LandmarkEntity[] = []
|
||||||
|
const cx = Math.floor(canvas.width / 2)
|
||||||
|
const cy = Math.floor(canvas.height / 2)
|
||||||
|
|
||||||
|
// 1. Guard Flavie (rogue2) at Blood Moor (levelId 2) <-> Cold Plains (levelId 3) open border gate
|
||||||
|
if (levelId === 2 || levelId === 3) {
|
||||||
|
const targetGateLevel = levelId === 2 ? 3 : 2
|
||||||
|
const anchor = act1Plan?.anchors.find(
|
||||||
|
a => a.toLevelId === targetGateLevel || /Cold Plains|Blood Moor/i.test(a.label),
|
||||||
|
)
|
||||||
|
let fx: number
|
||||||
|
let fy: number
|
||||||
|
if (anchor !== undefined) {
|
||||||
|
const dx = Math.sign(cx - anchor.interiorCell.x) * 2
|
||||||
|
const dy = Math.sign(cy - anchor.interiorCell.y) * 2
|
||||||
|
fx = Math.max(4, Math.min(canvas.width - 5, anchor.interiorCell.x + dx))
|
||||||
|
fy = Math.max(4, Math.min(canvas.height - 5, anchor.interiorCell.y + dy))
|
||||||
|
} else {
|
||||||
|
const openings = getPerimeterOpenings(levelId, gridWidth, gridHeight)
|
||||||
|
// Level 2 has exit at (9,5) or east/north border; Level 3 has entrance at south/west border
|
||||||
|
let chosenKey: string | undefined
|
||||||
|
for (const k of openings) {
|
||||||
|
const [bx, by] = k.split(',').map(Number)
|
||||||
|
if (levelId === 2 && bx === gridWidth - 1) chosenKey = k
|
||||||
|
if (levelId === 3 && by === gridHeight - 1) chosenKey = k
|
||||||
|
}
|
||||||
|
if (!chosenKey) chosenKey = openings.values().next().value
|
||||||
|
if (chosenKey) {
|
||||||
|
const [bx, by] = chosenKey.split(',').map(Number)
|
||||||
|
const rawX = (bx ?? 0) * TILES_PER_BLOCK + 4
|
||||||
|
const rawY = (by ?? 0) * TILES_PER_BLOCK + 4
|
||||||
|
const dx = Math.sign(cx - rawX) * 3
|
||||||
|
const dy = Math.sign(cy - rawY) * 3
|
||||||
|
fx = Math.max(4, Math.min(canvas.width - 5, rawX + dx))
|
||||||
|
fy = Math.max(4, Math.min(canvas.height - 5, rawY + dy))
|
||||||
|
} else {
|
||||||
|
fx = cx
|
||||||
|
fy = cy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entities.push({
|
||||||
|
role: 'npc_guard',
|
||||||
|
id: 'Flavie',
|
||||||
|
monsterId: 'rogue2',
|
||||||
|
name: 'Flavie',
|
||||||
|
landmark: 'Border Gate (Blood Moor <-> Cold Plains)',
|
||||||
|
tileX: fx,
|
||||||
|
tileY: fy,
|
||||||
|
modifiers: [],
|
||||||
|
minionCount: [0, 0],
|
||||||
|
minionMonsterId: '',
|
||||||
|
})
|
||||||
|
canvas.objects.push({
|
||||||
|
type: 1,
|
||||||
|
id: 52,
|
||||||
|
x: fx * SUB_TILES_PER_TILE + 2,
|
||||||
|
y: fy * SUB_TILES_PER_TILE + 2,
|
||||||
|
flags: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Bishibosh (Cold Plains - Level 3 - Act 1 - Fallen Camp Bishibosh)
|
||||||
|
if (levelId === 3) {
|
||||||
|
const rect = placedRects.find(r => /Fallen Camp Bishibosh|Bivouac/i.test(r.name))
|
||||||
|
const tx = rect ? Math.floor(rect.x + rect.w / 2) : Math.floor(canvas.width * 0.45)
|
||||||
|
const ty = rect ? Math.floor(rect.y + rect.h / 2) : Math.floor(canvas.height * 0.45)
|
||||||
|
entities.push({
|
||||||
|
role: 'superunique',
|
||||||
|
id: 'Bishibosh',
|
||||||
|
monsterId: 'fallenshaman1',
|
||||||
|
name: 'Bishibosh',
|
||||||
|
landmark: rect?.name ?? 'Act 1 - Fallen Camp Bishibosh',
|
||||||
|
tileX: tx,
|
||||||
|
tileY: ty,
|
||||||
|
modifiers: ['magicresistant', 'fireenchant'],
|
||||||
|
minionCount: [5, 7],
|
||||||
|
minionMonsterId: 'fallen1',
|
||||||
|
})
|
||||||
|
canvas.objects.push({
|
||||||
|
type: 1,
|
||||||
|
id: 70,
|
||||||
|
x: tx * SUB_TILES_PER_TILE + 2,
|
||||||
|
y: ty * SUB_TILES_PER_TILE + 2,
|
||||||
|
flags: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Rakanishu (Stony Field - Level 4 - Act 1 - Cairn Stones)
|
||||||
|
if (levelId === 4) {
|
||||||
|
const rect = placedRects.find(r => /Cairn Stones/i.test(r.name))
|
||||||
|
const tx = rect ? Math.floor(rect.x + rect.w / 2) : Math.floor(canvas.width * 0.52)
|
||||||
|
const ty = rect ? Math.floor(rect.y + rect.h / 2) : Math.floor(canvas.height * 0.48)
|
||||||
|
entities.push({
|
||||||
|
role: 'superunique',
|
||||||
|
id: 'Rakanishu',
|
||||||
|
monsterId: 'fallen1',
|
||||||
|
name: 'Rakanishu',
|
||||||
|
landmark: rect?.name ?? 'Act 1 - Cairn Stones',
|
||||||
|
tileX: tx,
|
||||||
|
tileY: ty,
|
||||||
|
modifiers: ['lightenchant', 'fast'],
|
||||||
|
minionCount: [6, 8],
|
||||||
|
minionMonsterId: 'fallen1',
|
||||||
|
})
|
||||||
|
canvas.objects.push({
|
||||||
|
type: 1,
|
||||||
|
id: 71,
|
||||||
|
x: tx * SUB_TILES_PER_TILE + 2,
|
||||||
|
y: ty * SUB_TILES_PER_TILE + 2,
|
||||||
|
flags: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Treehead WoodFist (Dark Wood - Level 5 - Act 1 - Inifus)
|
||||||
|
if (levelId === 5) {
|
||||||
|
const rect = placedRects.find(r => /Inifus/i.test(r.name))
|
||||||
|
const tx = rect ? Math.floor(rect.x + rect.w / 2) : Math.floor(canvas.width * 0.48)
|
||||||
|
const ty = rect ? Math.floor(rect.y + rect.h / 2) : Math.floor(canvas.height * 0.52)
|
||||||
|
entities.push({
|
||||||
|
role: 'superunique',
|
||||||
|
id: 'Treehead WoodFist',
|
||||||
|
monsterId: 'brute1',
|
||||||
|
name: 'Treehead WoodFist',
|
||||||
|
landmark: rect?.name ?? 'Act 1 - Inifus',
|
||||||
|
tileX: tx,
|
||||||
|
tileY: ty,
|
||||||
|
modifiers: ['strong', 'fast'],
|
||||||
|
minionCount: [3, 4],
|
||||||
|
minionMonsterId: 'brute1',
|
||||||
|
})
|
||||||
|
canvas.objects.push({
|
||||||
|
type: 1,
|
||||||
|
id: 72,
|
||||||
|
x: tx * SUB_TILES_PER_TILE + 2,
|
||||||
|
y: ty * SUB_TILES_PER_TILE + 2,
|
||||||
|
flags: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Blood Raven (Burial Grounds - Level 17 or Graveyard preset)
|
||||||
|
const graveyardRect = placedRects.find(r => /Graveyard/i.test(r.name))
|
||||||
|
if (levelId === 17 || graveyardRect !== undefined) {
|
||||||
|
const tx = graveyardRect ? Math.floor(graveyardRect.x + graveyardRect.w / 2) : cx
|
||||||
|
const ty = graveyardRect ? Math.floor(graveyardRect.y + graveyardRect.h / 2) : cy
|
||||||
|
entities.push({
|
||||||
|
role: 'superunique',
|
||||||
|
id: 'Blood Raven',
|
||||||
|
monsterId: 'corruptrogue1',
|
||||||
|
name: 'Blood Raven',
|
||||||
|
landmark: graveyardRect?.name ?? 'Act 1 - Graveyard',
|
||||||
|
tileX: tx,
|
||||||
|
tileY: ty,
|
||||||
|
modifiers: ['fireenchant', 'fast'],
|
||||||
|
minionCount: [6, 8],
|
||||||
|
minionMonsterId: 'zombie1',
|
||||||
|
})
|
||||||
|
canvas.objects.push({
|
||||||
|
type: 1,
|
||||||
|
id: 73,
|
||||||
|
x: tx * SUB_TILES_PER_TILE + 2,
|
||||||
|
y: ty * SUB_TILES_PER_TILE + 2,
|
||||||
|
flags: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.landmarkEntities = entities
|
||||||
|
}
|
||||||
|
|
||||||
export function generateWilderness(request: WildernessRequest): WildernessResult {
|
export function generateWilderness(request: WildernessRequest): WildernessResult {
|
||||||
const where = `level ${String(request.levelId)} (${request.levelName})`
|
const where = `level ${String(request.levelId)} (${request.levelName})`
|
||||||
if (request.pieces.length === 0) throw new Error(`${where}: no LvlPrest pieces for this level type`)
|
if (request.pieces.length === 0) throw new Error(`${where}: no LvlPrest pieces for this level type`)
|
||||||
|
|
@ -3149,6 +3597,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
)
|
)
|
||||||
|
|
||||||
// Step 4B: Stamp anchored special presets (Town Transition, Den of Evil, Cave Entrance, Cairn Stones, etc.)
|
// Step 4B: Stamp anchored special presets (Town Transition, Den of Evil, Cave Entrance, Cairn Stones, etc.)
|
||||||
|
const allPlacedRects: PlacedPresetRect[] = []
|
||||||
if (act1Plan !== undefined) {
|
if (act1Plan !== undefined) {
|
||||||
for (const preset of act1Plan.specialPresets) {
|
for (const preset of act1Plan.specialPresets) {
|
||||||
const originX = Math.min(preset.bx * TILES_PER_BLOCK, Math.max(canvas.width - preset.ds1.width, 0))
|
const originX = Math.min(preset.bx * TILES_PER_BLOCK, Math.max(canvas.width - preset.ds1.width, 0))
|
||||||
|
|
@ -3156,6 +3605,13 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
stampDs1(canvas, preset.ds1, originX, originY)
|
stampDs1(canvas, preset.ds1, originX, originY)
|
||||||
stats.specialPresets.push(preset.name)
|
stats.specialPresets.push(preset.name)
|
||||||
stats.substitutions.push({ name: preset.name, role: 'object', enabled: true, clusters: 1 })
|
stats.substitutions.push({ name: preset.name, role: 'object', enabled: true, clusters: 1 })
|
||||||
|
allPlacedRects.push({
|
||||||
|
name: preset.name,
|
||||||
|
x: originX,
|
||||||
|
y: originY,
|
||||||
|
w: preset.ds1.width,
|
||||||
|
h: preset.ds1.height,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3228,6 +3684,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
stats,
|
stats,
|
||||||
reservedRoadBlocks,
|
reservedRoadBlocks,
|
||||||
)
|
)
|
||||||
|
allPlacedRects.push(...placedRects)
|
||||||
|
|
||||||
// Step 4C: Run LvlSub substitutions with strict road and preset obstacle avoidance
|
// Step 4C: Run LvlSub substitutions with strict road and preset obstacle avoidance
|
||||||
const themeIndex = Math.max(0, Math.min(4, Math.floor(request.subTheme)))
|
const themeIndex = Math.max(0, Math.min(4, Math.floor(request.subTheme)))
|
||||||
|
|
@ -3249,10 +3706,16 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
// For Act 1 outdoor wilderness maps (LevelId 2..7), lay dirt road networks and
|
// For Act 1 outdoor wilderness maps (LevelId 2..7), lay dirt road networks and
|
||||||
// interior stone wall divider lines across the meadow after random clutter substitutions.
|
// interior stone wall divider lines across the meadow after random clutter substitutions.
|
||||||
if (request.levelId >= 2 && request.levelId <= 7) {
|
if (request.levelId >= 2 && request.levelId <= 7) {
|
||||||
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, allPlacedRects, rng)
|
||||||
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, allPlacedRects, rng)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 5: Outdoor Population Phase (Waypoints, Roadside Shrines/Chests, Landmark SuperUniques & Flavie)
|
||||||
|
const populationRng = new Rng(request.seed ^ 0x6c07)
|
||||||
|
spawnAct12Waypoint(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats)
|
||||||
|
spawnAct12Shrines(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats)
|
||||||
|
spawnLandmarkEntities(canvas, request.levelId, act1Plan, allPlacedRects, gridWidth, gridHeight, stats)
|
||||||
|
|
||||||
for (const piece of fillPieces) {
|
for (const piece of fillPieces) {
|
||||||
if (!placedSpecialPieces.has(piece.name)) {
|
if (!placedSpecialPieces.has(piece.name)) {
|
||||||
stats.notes.push(`fill piece "${piece.name}" is not placed by this port`)
|
stats.notes.push(`fill piece "${piece.name}" is not placed by this port`)
|
||||||
|
|
@ -3313,6 +3776,11 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
substitutions: stats.substitutions,
|
substitutions: stats.substitutions,
|
||||||
substitutedClusters: substituted + placedSpecialPieces.size,
|
substitutedClusters: substituted + placedSpecialPieces.size,
|
||||||
objects: canvas.objects.length,
|
objects: canvas.objects.length,
|
||||||
|
waypointsSpawned: stats.waypointsSpawned ?? 0,
|
||||||
|
waypointTile: stats.waypointTile ?? null,
|
||||||
|
shrinesSpawned: stats.shrinesSpawned ?? 0,
|
||||||
|
interactivesSpawned: stats.interactivesSpawned ?? 0,
|
||||||
|
landmarkEntities: stats.landmarkEntities ?? [],
|
||||||
cells: canvas.width * canvas.height,
|
cells: canvas.width * canvas.height,
|
||||||
unresolved: stats.unresolved,
|
unresolved: stats.unresolved,
|
||||||
unimplementedPasses: UNIMPLEMENTED_PASSES,
|
unimplementedPasses: UNIMPLEMENTED_PASSES,
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,11 @@ import type { Palette } from '../formats/pal.ts'
|
||||||
import type { SpriteSheet } from '../formats/sprite.ts'
|
import type { SpriteSheet } from '../formats/sprite.ts'
|
||||||
import {
|
import {
|
||||||
ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH,
|
ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH,
|
||||||
buildIsoMapScene, findIsoSpawn, isBlockedAt, levelSeed,
|
buildIsoMapScene, cellCentre, findIsoSpawn, isBlockedAt, levelSeed,
|
||||||
} 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 { MonsterPack, MonsterStats } from '../game/combat.ts'
|
import type { MonsterPack, MonsterStats, SafeZone } from '../game/combat.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"
|
||||||
|
|
@ -1280,6 +1280,26 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
||||||
let playerMoving = false
|
let playerMoving = false
|
||||||
let playerWalkFrame = 0
|
let playerWalkFrame = 0
|
||||||
const npcDefs = runtime.npcs.map(n => buildNpcDef(n.token ?? '', n.id, n.name ?? ''))
|
const npcDefs = runtime.npcs.map(n => buildNpcDef(n.token ?? '', n.id, n.name ?? ''))
|
||||||
|
const safeZones: SafeZone[] = [
|
||||||
|
{ x: runtime.spawn.x, y: runtime.spawn.y, radius: 180 },
|
||||||
|
]
|
||||||
|
for (const wp of runtime.waypoints) {
|
||||||
|
const pos = subTileToScene(wp.x, wp.y, runtime.grid)
|
||||||
|
safeZones.push({ x: pos.x, y: pos.y, radius: 160 })
|
||||||
|
}
|
||||||
|
for (const ent of runtime.entrances) {
|
||||||
|
const pos = subTileToScene(ent.x, ent.y, runtime.grid)
|
||||||
|
safeZones.push({ x: pos.x, y: pos.y, radius: 160 })
|
||||||
|
}
|
||||||
|
const resolvedPacks: MonsterPack[] = runtime.monsterPacks.map(pack => {
|
||||||
|
if (pack.landmarkTile !== undefined && pack.fixedPosition === undefined) {
|
||||||
|
return {
|
||||||
|
...pack,
|
||||||
|
fixedPosition: cellCentre(runtime.grid, pack.landmarkTile.tileX, pack.landmarkTile.tileY),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pack
|
||||||
|
})
|
||||||
const engine = new GameEngine(
|
const engine = new GameEngine(
|
||||||
createIsoTerrain(runtime.grid, runtime.widthPx, runtime.heightPx, {
|
createIsoTerrain(runtime.grid, runtime.widthPx, runtime.heightPx, {
|
||||||
width: FEET_WIDTH,
|
width: FEET_WIDTH,
|
||||||
|
|
@ -1310,8 +1330,9 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
||||||
inventoryCols: 10,
|
inventoryCols: 10,
|
||||||
inventoryRows: 4,
|
inventoryRows: 4,
|
||||||
monsterCount: runtime.monsterCount,
|
monsterCount: runtime.monsterCount,
|
||||||
monsterPacks: runtime.monsterPacks,
|
monsterPacks: resolvedPacks,
|
||||||
monsterSpread: Math.max(runtime.widthPx, runtime.heightPx) / 4,
|
monsterSpread: Math.max(runtime.widthPx, runtime.heightPx) / 4,
|
||||||
|
safeZones,
|
||||||
// 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.
|
||||||
npcRingFallback: false,
|
npcRingFallback: false,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,350 @@
|
||||||
|
import { describe, expect, test } from "vitest"
|
||||||
|
import {
|
||||||
|
generateWilderness,
|
||||||
|
UNIMPLEMENTED_PASSES,
|
||||||
|
type WildernessPiece,
|
||||||
|
} from "../src/game/wilderness.ts"
|
||||||
|
import {
|
||||||
|
rollEliteModifiers,
|
||||||
|
applyEliteModifiers,
|
||||||
|
CANONICAL_ELITE_MODIFIERS,
|
||||||
|
planLevelMonsters,
|
||||||
|
} from "../src/game/monsters.ts"
|
||||||
|
import { spawnMonsterPacks, createWorld, type MonsterPack, type SafeZone } from "../src/game/combat.ts"
|
||||||
|
import { parseTable } from "../src/game/acts.ts"
|
||||||
|
import { Rng } from "../src/game/rng.ts"
|
||||||
|
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
|
||||||
|
|
||||||
|
function makeMockDs1(width: number, height: number, hasGapInWalls = false): Ds1 {
|
||||||
|
const cells: Ds1Cell[][] = []
|
||||||
|
for (let y = 0; y < height; y += 1) {
|
||||||
|
const row: Ds1Cell[] = []
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
const isGap = hasGapInWalls && x === Math.floor(width / 2)
|
||||||
|
row.push({
|
||||||
|
walls: isGap ? [] : [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
|
||||||
|
floors: [{ prop1: 2, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }],
|
||||||
|
shadows: [],
|
||||||
|
substitutions: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
cells.push(row)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: 18,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
act: 1,
|
||||||
|
substitutionType: 0,
|
||||||
|
wallLayers: 1,
|
||||||
|
floorLayers: 1,
|
||||||
|
cells,
|
||||||
|
objects: [],
|
||||||
|
npcPathOffset: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTestPieces(): WildernessPiece[] {
|
||||||
|
return [
|
||||||
|
{ name: "Act 1 - Wild Border 1", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
|
||||||
|
{ name: "Act 1 - Wild Border 2", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
|
||||||
|
{ name: "Act 1 - Wild Border 3", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
|
||||||
|
{ name: "Act 1 - Wild Border 4", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
|
||||||
|
{ name: "Act 1 - Wild Border 5", border: true, levels: [makeMockDs1(8, 8, false)] },
|
||||||
|
{ name: "Act 1 - Wild Border 6", border: true, levels: [makeMockDs1(8, 8, false)] },
|
||||||
|
{ name: "Act 1 - Wild Border 7", border: true, levels: [makeMockDs1(8, 8, false)] },
|
||||||
|
{ name: "Act 1 - Wild Border 8", border: true, levels: [makeMockDs1(8, 8, false)] },
|
||||||
|
{ name: "Act 1 - Wild Border 9", border: true, levels: [makeMockDs1(8, 8, false)] },
|
||||||
|
{ name: "Act 1 - Wild Border 10", border: true, levels: [makeMockDs1(8, 8, false)] },
|
||||||
|
{ name: "Act 1 - Wild Border 11", border: true, levels: [makeMockDs1(8, 8, false)] },
|
||||||
|
{ name: "Act 1 - Wild Border 12", border: true, levels: [makeMockDs1(8, 8, false)] },
|
||||||
|
{ name: "Act 1 - Town Transition East", border: true, levels: [makeMockDs1(8, 8, true)] },
|
||||||
|
{ name: "Act 1 - Town Transition South", border: true, levels: [makeMockDs1(8, 8, true)] },
|
||||||
|
{ name: "Act 1 - Fallen Camp Bishibosh", border: false, levels: [makeMockDs1(8, 8, true)] },
|
||||||
|
{ name: "Act 1 - Cairn Stones", border: false, levels: [makeMockDs1(8, 8, true)] },
|
||||||
|
{ name: "Act 1 - Inifus", border: false, levels: [makeMockDs1(8, 8, true)] },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Wilderness Population Phase (Issue #36)", () => {
|
||||||
|
test("DRLGOUTDOORS_SpawnAct12Waypoint and DRLGOUTDOORS_SpawnAct12Shrines are removed from UNIMPLEMENTED_PASSES", () => {
|
||||||
|
expect(UNIMPLEMENTED_PASSES).not.toContain("DRLGOUTDOORS_SpawnAct12Waypoint")
|
||||||
|
expect(UNIMPLEMENTED_PASSES).not.toContain("DRLGOUTDOORS_SpawnAct12Shrines")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("generateWilderness spawns outdoor waypoints on waypoint levels (Cold Plains, Stony Field, Dark Wood, Black Marsh)", () => {
|
||||||
|
const pieces = makeTestPieces()
|
||||||
|
for (const levelId of [3, 4, 5, 6]) {
|
||||||
|
const result = generateWilderness({
|
||||||
|
levelId,
|
||||||
|
levelName: `Act 1 Outdoor ${levelId}`,
|
||||||
|
levelTypeName: "Act 1 - Wilderness",
|
||||||
|
sizeX: 80,
|
||||||
|
sizeY: 80,
|
||||||
|
subType: 6,
|
||||||
|
subTheme: 0,
|
||||||
|
seed: 0x123456 + levelId,
|
||||||
|
pieces,
|
||||||
|
substitutions: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.stats.waypointsSpawned).toBe(1)
|
||||||
|
expect(result.stats.waypointTile).toBeDefined()
|
||||||
|
expect(result.stats.waypointTile).not.toBeNull()
|
||||||
|
|
||||||
|
const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 37)
|
||||||
|
expect(wpObj).toBeDefined()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("generateWilderness does not spawn outdoor waypoint on Blood Moor (levelId 2)", () => {
|
||||||
|
const pieces = makeTestPieces()
|
||||||
|
const result = generateWilderness({
|
||||||
|
levelId: 2,
|
||||||
|
levelName: "Blood Moor",
|
||||||
|
levelTypeName: "Act 1 - Wilderness",
|
||||||
|
sizeX: 80,
|
||||||
|
sizeY: 80,
|
||||||
|
subType: 6,
|
||||||
|
subTheme: 0,
|
||||||
|
seed: 0x222222,
|
||||||
|
pieces,
|
||||||
|
substitutions: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.stats.waypointsSpawned).toBe(0)
|
||||||
|
expect(result.stats.waypointTile).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("generateWilderness scatters roadside shrines and interactive objects along dirt paths", () => {
|
||||||
|
const pieces = makeTestPieces()
|
||||||
|
const result = generateWilderness({
|
||||||
|
levelId: 3,
|
||||||
|
levelName: "Cold Plains",
|
||||||
|
levelTypeName: "Act 1 - Wilderness",
|
||||||
|
sizeX: 80,
|
||||||
|
sizeY: 80,
|
||||||
|
subType: 6,
|
||||||
|
subTheme: 0,
|
||||||
|
seed: 0x998877,
|
||||||
|
pieces,
|
||||||
|
substitutions: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(Number(result.stats.shrinesSpawned)).toBeGreaterThan(0)
|
||||||
|
expect(Number(result.stats.interactivesSpawned)).toBeGreaterThan(0)
|
||||||
|
expect(result.level.objects.length).toBeGreaterThan(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("generateWilderness binds Guard Flavie (rogue2) on Blood Moor (2) and Cold Plains (3)", () => {
|
||||||
|
const pieces = makeTestPieces()
|
||||||
|
const bloodMoor = generateWilderness({
|
||||||
|
levelId: 2,
|
||||||
|
levelName: "Blood Moor",
|
||||||
|
levelTypeName: "Act 1 - Wilderness",
|
||||||
|
sizeX: 80,
|
||||||
|
sizeY: 80,
|
||||||
|
subType: 6,
|
||||||
|
subTheme: 0,
|
||||||
|
seed: 0x1111,
|
||||||
|
pieces,
|
||||||
|
substitutions: [],
|
||||||
|
})
|
||||||
|
const coldPlains = generateWilderness({
|
||||||
|
levelId: 3,
|
||||||
|
levelName: "Cold Plains",
|
||||||
|
levelTypeName: "Act 1 - Wilderness",
|
||||||
|
sizeX: 80,
|
||||||
|
sizeY: 80,
|
||||||
|
subType: 6,
|
||||||
|
subTheme: 0,
|
||||||
|
seed: 0x2222,
|
||||||
|
pieces,
|
||||||
|
substitutions: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const bmLandmarks = bloodMoor.stats.landmarkEntities as Array<{ id: string; monsterId: string; role: string }>
|
||||||
|
const cpLandmarks = coldPlains.stats.landmarkEntities as Array<{ id: string; monsterId: string; role: string }>
|
||||||
|
|
||||||
|
expect(bmLandmarks.some(e => e.monsterId === "rogue2" && e.role === "npc_guard")).toBe(true)
|
||||||
|
expect(cpLandmarks.some(e => e.monsterId === "rogue2" && e.role === "npc_guard")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("generateWilderness binds canonical SuperUniques to their special presets (Bishibosh, Rakanishu, Treehead WoodFist)", () => {
|
||||||
|
const pieces = makeTestPieces()
|
||||||
|
const coldPlains = generateWilderness({
|
||||||
|
levelId: 3,
|
||||||
|
levelName: "Cold Plains",
|
||||||
|
levelTypeName: "Act 1 - Wilderness",
|
||||||
|
sizeX: 80,
|
||||||
|
sizeY: 80,
|
||||||
|
subType: 6,
|
||||||
|
subTheme: 0,
|
||||||
|
seed: 0x3333,
|
||||||
|
pieces,
|
||||||
|
substitutions: [],
|
||||||
|
})
|
||||||
|
const stonyField = generateWilderness({
|
||||||
|
levelId: 4,
|
||||||
|
levelName: "Stony Field",
|
||||||
|
levelTypeName: "Act 1 - Wilderness",
|
||||||
|
sizeX: 80,
|
||||||
|
sizeY: 80,
|
||||||
|
subType: 6,
|
||||||
|
subTheme: 0,
|
||||||
|
seed: 0x4444,
|
||||||
|
pieces,
|
||||||
|
substitutions: [],
|
||||||
|
})
|
||||||
|
const darkWood = generateWilderness({
|
||||||
|
levelId: 5,
|
||||||
|
levelName: "Dark Wood",
|
||||||
|
levelTypeName: "Act 1 - Wilderness",
|
||||||
|
sizeX: 80,
|
||||||
|
sizeY: 80,
|
||||||
|
subType: 6,
|
||||||
|
subTheme: 0,
|
||||||
|
seed: 0x5555,
|
||||||
|
pieces,
|
||||||
|
substitutions: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const cpLandmarks = coldPlains.stats.landmarkEntities as Array<{ id: string; role: string }>
|
||||||
|
const sfLandmarks = stonyField.stats.landmarkEntities as Array<{ id: string; role: string }>
|
||||||
|
const dwLandmarks = darkWood.stats.landmarkEntities as Array<{ id: string; role: string }>
|
||||||
|
|
||||||
|
expect(cpLandmarks.some(e => e.id === "Bishibosh" && e.role === "superunique")).toBe(true)
|
||||||
|
expect(sfLandmarks.some(e => e.id === "Rakanishu" && e.role === "superunique")).toBe(true)
|
||||||
|
expect(dwLandmarks.some(e => e.id === "Treehead WoodFist" && e.role === "superunique")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rollEliteModifiers and applyEliteModifiers roll and apply MonUMod bonuses", () => {
|
||||||
|
const rng = new Rng(0xabcdef)
|
||||||
|
const mods = rollEliteModifiers("unique", rng)
|
||||||
|
expect(mods.length).toBeGreaterThanOrEqual(1)
|
||||||
|
const allowedNames = CANONICAL_ELITE_MODIFIERS.map(m => m.name)
|
||||||
|
for (const m of mods) {
|
||||||
|
expect(allowedNames).toContain(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseStats = {
|
||||||
|
id: "fallen1",
|
||||||
|
name: "Fallen",
|
||||||
|
hp: 20,
|
||||||
|
damage: 4,
|
||||||
|
speed: 2,
|
||||||
|
reach: 32,
|
||||||
|
aggroRadius: 220,
|
||||||
|
cooldownTicks: 25,
|
||||||
|
xp: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
const strongFast = applyEliteModifiers(baseStats, "unique", ["strong", "fast"], "Rakanishu")
|
||||||
|
expect(strongFast.rank).toBe("unique")
|
||||||
|
expect(strongFast.superUniqueId).toBe("Rakanishu")
|
||||||
|
expect(strongFast.modifiers).toEqual(["strong", "fast"])
|
||||||
|
expect(strongFast.damage).toBe(6) // 4 * 1.5
|
||||||
|
expect(strongFast.speed).toBeCloseTo(2.8) // 2 * 1.4
|
||||||
|
})
|
||||||
|
|
||||||
|
test("spawnMonsterPacks enforces safeZones exclusion radius and anchors fixedPosition packs", () => {
|
||||||
|
const world = createWorld(500, 500)
|
||||||
|
const safeZones: SafeZone[] = [
|
||||||
|
{ x: 500, y: 500, radius: 200 },
|
||||||
|
]
|
||||||
|
const packs: MonsterPack[] = [
|
||||||
|
{
|
||||||
|
members: [{ id: "fallen1", name: "Fallen", hp: 10, damage: 2, speed: 2, reach: 30, aggroRadius: 200, cooldownTicks: 20, xp: 5 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
members: [{ id: "fallen1", name: "Fallen", hp: 10, damage: 2, speed: 2, reach: 30, aggroRadius: 200, cooldownTicks: 20, xp: 5 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
superUniqueId: "Rakanishu",
|
||||||
|
fixedPosition: { x: 1200, y: 1200 },
|
||||||
|
members: [{ id: "fallen1", name: "Rakanishu", hp: 80, damage: 10, speed: 3, reach: 30, aggroRadius: 250, cooldownTicks: 20, xp: 50, rank: "unique", superUniqueId: "Rakanishu" }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const placed = spawnMonsterPacks(world, packs, { x: 500, y: 500 }, 800, { overlap: () => 0 }, safeZones)
|
||||||
|
expect(placed).toBe(3)
|
||||||
|
expect(world.monsters.length).toBe(3)
|
||||||
|
|
||||||
|
for (const monster of world.monsters) {
|
||||||
|
if (monster.stats.superUniqueId === "Rakanishu") {
|
||||||
|
// Anchored to fixedPosition (1200, 1200)
|
||||||
|
expect(Math.hypot(monster.x - 1200, monster.y - 1200)).toBeLessThan(80)
|
||||||
|
} else {
|
||||||
|
// Must respect safeZone around (500, 500) with radius 200
|
||||||
|
const dist = Math.hypot(monster.x - 500, monster.y - 500)
|
||||||
|
expect(dist).toBeGreaterThanOrEqual(200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("planLevelMonsters plans fixed SuperUniques for Cold Plains (3), Stony Field (4), Dark Wood (5), and Burial Grounds (17)", () => {
|
||||||
|
const enc = new TextEncoder()
|
||||||
|
const levelsTxt = parseTable(
|
||||||
|
enc.encode(
|
||||||
|
[
|
||||||
|
"Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3",
|
||||||
|
"3\tCold Plains\t600\t2\t4\t2\tfallen1\tfallen2\tzombie1",
|
||||||
|
"4\tStony Field\t600\t2\t4\t4\tfallen1\tfallen2\tskeleton1",
|
||||||
|
"5\tDark Wood\t600\t2\t4\t5\tbrute1\tfallen1\tskeleton1",
|
||||||
|
"17\tBurial Grounds\t500\t1\t2\t3\tzombie1\tskeleton1\tcorruptrogue1",
|
||||||
|
].join("\n"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const monstatsTxt = parseTable(
|
||||||
|
enc.encode(
|
||||||
|
[
|
||||||
|
"Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee",
|
||||||
|
"fallen1\tfallen1\tFallen\t1\t3\t6\t8\t12\t5\t15\t8\t14\t1\t3\t20\t1\t1\t1\t1",
|
||||||
|
"fallen2\tfallen1\tCarver\t2\t3\t6\t8\t12\t8\t25\t12\t20\t2\t4\t28\t1\t1\t1\t1",
|
||||||
|
"fallenshaman1\tfallenshaman1\tFallen Shaman\t2\t1\t2\t7\t10\t10\t35\t16\t26\t3\t6\t30\t1\t1\t1\t1",
|
||||||
|
"zombie1\tzombie1\tZombie\t1\t2\t4\t5\t8\t6\t18\t14\t22\t2\t5\t22\t1\t1\t1\t1",
|
||||||
|
"skeleton1\tskeleton1\tSkeleton\t2\t3\t5\t7\t10\t8\t22\t12\t18\t2\t5\t26\t1\t1\t1\t1",
|
||||||
|
"brute1\tbrute1\tGargantuan Beast\t5\t2\t4\t7\t10\t18\t65\t30\t45\t5\t10\t45\t1\t1\t1\t1",
|
||||||
|
"corruptrogue1\tcorruptrogue1\tCorrupt Rogue\t3\t3\t5\t8\t12\t12\t30\t15\t24\t3\t6\t35\t1\t1\t1\t1",
|
||||||
|
].join("\n"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const monlvlTxt = parseTable(
|
||||||
|
enc.encode(
|
||||||
|
[
|
||||||
|
"Level\tAC\tTH\tHP\tDM\tXP",
|
||||||
|
"1\t100\t100\t100\t100\t100",
|
||||||
|
"2\t100\t100\t100\t100\t100",
|
||||||
|
"3\t100\t100\t100\t100\t100",
|
||||||
|
"4\t100\t100\t100\t100\t100",
|
||||||
|
"5\t100\t100\t100\t100\t100",
|
||||||
|
"6\t100\t100\t100\t100\t100",
|
||||||
|
"7\t100\t100\t100\t100\t100",
|
||||||
|
"8\t100\t100\t100\t100\t100",
|
||||||
|
].join("\n"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt }
|
||||||
|
|
||||||
|
for (const [lvlId, suName] of [
|
||||||
|
[3, "Bishibosh"],
|
||||||
|
[4, "Rakanishu"],
|
||||||
|
[5, "Treehead WoodFist"],
|
||||||
|
[17, "Blood Raven"],
|
||||||
|
] as const) {
|
||||||
|
const planned = planLevelMonsters(tables, lvlId, 6400, 0x12345 + lvlId, 3.5, "normal", {
|
||||||
|
landmarks: [{ id: suName, tileX: 40, tileY: 40, fixedPosition: { x: 1000, y: 1000 } }],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(planned.superUniques).toContain(suName)
|
||||||
|
const suPack = planned.packs.find(p => p.superUniqueId === suName)
|
||||||
|
expect(suPack).toBeDefined()
|
||||||
|
expect(suPack?.fixedPosition).toEqual({ x: 1000, y: 1000 })
|
||||||
|
expect(suPack?.landmarkTile).toEqual({ tileX: 40, tileY: 40 })
|
||||||
|
expect(suPack?.members[0]?.rank).toBe("unique")
|
||||||
|
expect(suPack?.members[0]?.superUniqueId).toBe(suName)
|
||||||
|
expect(suPack?.members[0]?.modifiers?.length).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue