From bbc1af7a7290fbc023470b6db9d22dd4d3940754 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Tue, 15 Sep 2026 00:23:20 +0000 Subject: [PATCH] =?UTF-8?q?fix(wilderness):=20=E8=A1=A5=E5=85=A8=E5=A5=B6?= =?UTF-8?q?=E7=89=9B=E5=85=B3=20Moo=20Moo=20Farm=20=E4=B8=93=E5=B1=9E?= =?UTF-8?q?=E9=A2=84=E8=AE=BE=E6=99=AF=E8=A7=82=E7=94=9F=E6=88=90=20(fixes?= =?UTF-8?q?=20#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 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 校验 --- scripts/pack-act-assets.ts | 9 ++- scripts/verify-generators.ts | 16 +++- src/game/wilderness.ts | 153 ++++++++++++++++++++++++++++++++++- 3 files changed, 174 insertions(+), 4 deletions(-) diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index dbf8399..7d517e2 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -517,7 +517,14 @@ function themeValues(table: D2Table, row: readonly string[], prefix: string): nu } const WILDERNESS_PIECE_FAMILIES: Readonly> = { - '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'], diff --git a/scripts/verify-generators.ts b/scripts/verify-generators.ts index a4899c1..39d4b78 100644 --- a/scripts/verify-generators.ts +++ b/scripts/verify-generators.ts @@ -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> = { - '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)}%`) diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 937a365..493227a 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -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> = { + 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 { + const innerWidth = gridWidth - 2 + const innerHeight = gridHeight - 2 + const totalSlots = innerWidth * innerHeight + const placedPieces = new Set() + 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,