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[]>> = {
|
||||
'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'],
|
||||
|
|
|
|||
|
|
@ -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)}%`)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue