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:
parent
29b3a4aebd
commit
bbc1af7a72
|
|
@ -517,7 +517,14 @@ function themeValues(table: D2Table, row: readonly string[], prefix: string): nu
|
||||||
}
|
}
|
||||||
|
|
||||||
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
|
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 2 - Desert': ['Act 2 - Desert'],
|
||||||
'Act 3 - Jungle': ['Act 3 - Jungle'],
|
'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'],
|
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
|
||||||
|
|
|
||||||
|
|
@ -397,7 +397,14 @@ function themeValues(table: D2Table, row: readonly string[], prefix: string): nu
|
||||||
|
|
||||||
/** Which `LvlPrest` name families belong to which wilderness level type. */
|
/** Which `LvlPrest` name families belong to which wilderness level type. */
|
||||||
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
|
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 2 - Desert': ['Act 2 - Desert'],
|
||||||
'Act 3 - Jungle': ['Act 3 - Jungle'],
|
'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'],
|
'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(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(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)})`)
|
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
|
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)}%`)
|
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)}%`)
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,12 @@ export interface WildernessRequest {
|
||||||
readonly substitutions: readonly WildernessSubstitution[]
|
readonly substitutions: readonly WildernessSubstitution[]
|
||||||
/** `LvlSub` rows whose `Type` is the level's `SubShrine`, if any. */
|
/** `LvlSub` rows whose `Type` is the level's `SubShrine`, if any. */
|
||||||
readonly shrineSubstitutions?: readonly WildernessSubstitution[]
|
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.
|
* The ground tile to lay under everything, as a DT1 `style`/`sequence` pair.
|
||||||
*
|
*
|
||||||
|
|
@ -747,6 +753,142 @@ function applySubstitutions(
|
||||||
return total
|
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
|
* Entry point
|
||||||
* ------------------------------------------------------------------------- */
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
@ -903,6 +1045,9 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
const fillPieces = request.pieces.filter(piece => !piece.border)
|
const fillPieces = request.pieces.filter(piece => !piece.border)
|
||||||
layBorder(canvas, borderPieces, gridWidth, gridHeight, rng, stats)
|
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 themeIndex = Math.max(0, Math.min(4, Math.floor(request.subTheme)))
|
||||||
const objectRows = request.substitutions.filter(row => classifySubstitutionRole(row.name, row.type) !== 'border')
|
const objectRows = request.substitutions.filter(row => classifySubstitutionRole(row.name, row.type) !== 'border')
|
||||||
const borderRows = [...request.substitutions, ...(request.shrineSubstitutions ?? [])]
|
const borderRows = [...request.substitutions, ...(request.shrineSubstitutions ?? [])]
|
||||||
|
|
@ -918,7 +1063,11 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
rng,
|
rng,
|
||||||
stats,
|
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)) {
|
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`)
|
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,
|
borderBlocks: stats.borderStamped,
|
||||||
borderUsage: stats.borderPieces,
|
borderUsage: stats.borderPieces,
|
||||||
substitutions: stats.substitutions,
|
substitutions: stats.substitutions,
|
||||||
substitutedClusters: substituted,
|
substitutedClusters: substituted + placedSpecialPieces.size,
|
||||||
objects: canvas.objects.length,
|
objects: canvas.objects.length,
|
||||||
cells: canvas.width * canvas.height,
|
cells: canvas.width * canvas.height,
|
||||||
unresolved: stats.unresolved,
|
unresolved: stats.unresolved,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue