fix(wilderness): 补全奶牛关 Moo Moo Farm 专属预设景观生成 (fixes #15)

- 在 src/game/wilderness.ts 中实现户外特殊预设生成 spawnSpecialPresets,针对奶牛关(Level 39)按 D2MOO 规范放置牛圈 Corral、营地 Bivouac、水塘 Pond 与沼泽/石圈等专属景观
- 保持内部网格碰撞检测与确定性洗牌放置,防止预设重叠与边界穿越
- 扩充 scripts/verify-generators.ts 与 scripts/pack-act-assets.ts 中 Act 1 - Wilderness 预设家族
- 增加 Level 39 的专用断言,验证连通性 100% 与图块 0 缺失
- 重新打包地图数据并通过全量 verify:generators 与 verify:packs 校验
This commit is contained in:
troytt 2026-09-15 00:23:20 +00:00
parent 29b3a4aebd
commit bbc1af7a72
3 changed files with 174 additions and 4 deletions

View File

@ -517,7 +517,14 @@ function themeValues(table: D2Table, row: readonly string[], prefix: string): nu
}
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 1 - Wilderness': ['Act 1 - Wild'],
'Act 1 - Wilderness': [
'Act 1 - Wild',
'Act 1 - Corral Fill',
'Act 1 - Bivouac',
'Act 1 - Pond',
'Act 1 - Swamp Fill',
'Act 1 - Stone Fill',
],
'Act 2 - Desert': ['Act 2 - Desert'],
'Act 3 - Jungle': ['Act 3 - Jungle'],
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],

View File

@ -397,7 +397,14 @@ function themeValues(table: D2Table, row: readonly string[], prefix: string): nu
/** Which `LvlPrest` name families belong to which wilderness level type. */
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 1 - Wilderness': ['Act 1 - Wild'],
'Act 1 - Wilderness': [
'Act 1 - Wild',
'Act 1 - Corral Fill',
'Act 1 - Bivouac',
'Act 1 - Pond',
'Act 1 - Swamp Fill',
'Act 1 - Stone Fill',
],
'Act 2 - Desert': ['Act 2 - Desert'],
'Act 3 - Jungle': ['Act 3 - Jungle'],
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
@ -667,6 +674,13 @@ for (const { id, name } of wildLevels) {
check(first.level.width === expectedBlocksX * AREA_CELLS && first.level.height === expectedBlocksY * AREA_CELLS, label, 'map extent does not match whole blocks of the declared size')
check(missingShare <= MAX_MISSING_SHARE, label, `${(missingShare * 100).toFixed(2)}% of tile references are missing`)
check(fill.share >= MIN_REACHABLE_SHARE, label, `only ${(fill.share * 100).toFixed(1)}% of walkable areas are reachable (${String(fill.reached)}/${String(fill.total)})`)
if (id === 39) {
check(scene.walls.length > 700, label, `Moo Moo Farm walls (${String(scene.walls.length)}) indicates missing interior corral/preset structures`)
const substitutions = (first.stats.substitutions ?? []) as readonly { readonly name: string }[]
check(substitutions.some(s => s.name.includes('Corral')), label, 'Moo Moo Farm missing Corral Fill special preset')
check(substitutions.some(s => s.name.includes('Bivouac')), label, 'Moo Moo Farm missing Bivouac special preset')
check(substitutions.some(s => s.name.includes('Pond')), label, 'Moo Moo Farm missing Pond special preset')
}
if (hashA === hashB && fill.share >= MIN_REACHABLE_SHARE && missingShare <= MAX_MISSING_SHARE) wildPassed += 1
console.log(` ${String(id).padStart(3)} ${name.padEnd(29)} ${type.name.padEnd(20)} ${`${String(first.stats.sizeX)}x${String(first.stats.sizeY)}`.padEnd(10)} ${`${String(blockGrid.width)}x${String(blockGrid.height)}`.padEnd(8)} ${hashA} ${(fill.share * 100).toFixed(1).padStart(5)}% ${(missingShare * 100).toFixed(2).padStart(6)}%`)

View File

@ -196,6 +196,12 @@ export interface WildernessRequest {
readonly substitutions: readonly WildernessSubstitution[]
/** `LvlSub` rows whose `Type` is the level's `SubShrine`, if any. */
readonly shrineSubstitutions?: readonly WildernessSubstitution[]
/**
* Special preset names to spawn in the level's interior.
*
* When omitted, defaults to {@link SPECIAL_PRESETS_BY_LEVEL} for the level's ID.
*/
readonly specialPresets?: readonly string[]
/**
* The ground tile to lay under everything, as a DT1 `style`/`sequence` pair.
*
@ -747,6 +753,142 @@ function applySubstitutions(
return total
}
/* ------------------------------------------------------------------------- *
* Special presets
* ------------------------------------------------------------------------- */
/**
* Special presets that D2MOO's `DRLGOUTWILD_SpawnSpecialPresets` spawns for
* specific outdoor levels.
*
* For example, Level 39 (Moo Moo Farm) has `SubType = -1` in `Levels.txt` so it
* gets no `LvlSub` substitutions; instead the engine stamps its iconic interior
* landmarks: the fallen bivouac camp, the pond, the cow corral, and swamp/stone
* fills.
*/
export const SPECIAL_PRESETS_BY_LEVEL: Readonly<Record<number, readonly string[]>> = {
39: [
'Act 1 - Bivouac',
'Act 1 - Pond',
'Act 1 - Corral Fill',
'Act 1 - Swamp Fill 1',
'Act 1 - Swamp Fill 2',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
],
}
/**
* Scatter special presets across the level's interior.
*
* Follows D2MOO's `DRLGOUTWILD_SpawnSpecialPresets` and
* `DRLGOUTDOORS_SpawnOutdoorLevelPreset`: candidate interior grid coordinates
* (excluding the outer 1-block border) are shuffled deterministically with the
* level's RNG, and each preset in sequence scans for the first free block
* rectangle where it can fit without overlapping already-occupied blocks or the
* perimeter.
*
* @param canvas - the mutable canvas.
* @param pieces - available fill pieces.
* @param presetNames - preset name prefixes to spawn in priority order.
* @param gridWidth - blocks across.
* @param gridHeight - blocks down.
* @param rng - seeded RNG.
* @param stats - report collector.
* @returns set of piece names that were placed.
*/
function spawnSpecialPresets(
canvas: Canvas,
pieces: readonly WildernessPiece[],
presetNames: readonly string[],
gridWidth: number,
gridHeight: number,
rng: Rng,
stats: WildernessStats,
): Set<string> {
const innerWidth = gridWidth - 2
const innerHeight = gridHeight - 2
const totalSlots = innerWidth * innerHeight
const placedPieces = new Set<string>()
if (totalSlots <= 0 || presetNames.length === 0) return placedPieces
const occupied: boolean[][] = Array.from({ length: gridHeight }, () => Array.from({ length: gridWidth }, () => false))
for (let x = 0; x < gridWidth; x += 1) {
occupied[0]![x] = true
occupied[gridHeight - 1]![x] = true
}
for (let y = 0; y < gridHeight; y += 1) {
occupied[y]![0] = true
occupied[y]![gridWidth - 1] = true
}
for (const presetName of presetNames) {
const piece = pieces.find(p => p.name === presetName || p.name.startsWith(presetName))
if (!piece || piece.levels.length === 0) continue
const variantIndex = piece.levels.length > 1 ? rng.int(0, piece.levels.length - 1) : 0
const ds1 = piece.levels[variantIndex]!
const blocksX = Math.max(1, Math.floor(ds1.width / TILES_PER_BLOCK))
const blocksY = Math.max(1, Math.floor(ds1.height / TILES_PER_BLOCK))
const coords: { x: number; y: number }[] = []
for (let i = 0; i < totalSlots; i += 1) {
coords.push({ x: i % innerWidth, y: Math.floor(i / innerWidth) })
}
for (let i = 0; i < totalSlots; i += 1) {
const r1 = rng.int(0, totalSlots - 1)
const r2 = rng.int(0, totalSlots - 1)
const tmp = coords[r1]!
coords[r1] = coords[r2]!
coords[r2] = tmp
}
let placed = false
for (let i = 0; i < totalSlots; i += 1) {
const bx = coords[i]!.x + 1
const by = coords[i]!.y + 1
if (bx < 1 || by < 1 || bx + blocksX > gridWidth - 1 || by + blocksY > gridHeight - 1) continue
let blocked = false
for (let dy = 0; dy < blocksY; dy += 1) {
for (let dx = 0; dx < blocksX; dx += 1) {
if (occupied[by + dy]![bx + dx]) {
blocked = true
break
}
}
if (blocked) break
}
if (blocked) continue
stampDs1(canvas, ds1, bx * TILES_PER_BLOCK, by * TILES_PER_BLOCK)
for (let dy = 0; dy < blocksY; dy += 1) {
for (let dx = 0; dx < blocksX; dx += 1) {
occupied[by + dy]![bx + dx] = true
}
}
stats.substitutions.push({
name: piece.name,
role: 'object',
enabled: true,
clusters: 1,
})
placedPieces.add(piece.name)
placed = true
break
}
if (!placed) {
stats.notes.push(`special preset "${presetName}" could not be placed (no free interior block)`)
}
}
return placedPieces
}
/* ------------------------------------------------------------------------- *
* Entry point
* ------------------------------------------------------------------------- */
@ -903,6 +1045,9 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
const fillPieces = request.pieces.filter(piece => !piece.border)
layBorder(canvas, borderPieces, gridWidth, gridHeight, rng, stats)
const specialPresetNames = request.specialPresets ?? SPECIAL_PRESETS_BY_LEVEL[request.levelId] ?? []
const placedSpecialPieces = spawnSpecialPresets(canvas, fillPieces, specialPresetNames, gridWidth, gridHeight, rng, stats)
const themeIndex = Math.max(0, Math.min(4, Math.floor(request.subTheme)))
const objectRows = request.substitutions.filter(row => classifySubstitutionRole(row.name, row.type) !== 'border')
const borderRows = [...request.substitutions, ...(request.shrineSubstitutions ?? [])]
@ -918,7 +1063,11 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
rng,
stats,
)
for (const piece of fillPieces) stats.notes.push(`fill piece "${piece.name}" is not placed by this port`)
for (const piece of fillPieces) {
if (!placedSpecialPieces.has(piece.name)) {
stats.notes.push(`fill piece "${piece.name}" is not placed by this port`)
}
}
if (!Number.isFinite(canvas.width * canvas.height) || canvas.width * canvas.height > (1 << 22)) {
throw new Error(`${where}: synthesized map is ${String(canvas.width)}x${String(canvas.height)} cells, outside the supported bound`)
@ -956,7 +1105,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
borderBlocks: stats.borderStamped,
borderUsage: stats.borderPieces,
substitutions: stats.substitutions,
substitutedClusters: substituted,
substitutedClusters: substituted + placedSpecialPieces.size,
objects: canvas.objects.length,
cells: canvas.width * canvas.height,
unresolved: stats.unresolved,