From 25ae7289eebdee0dead0d0ec7fc036013d3897a0 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Wed, 16 Sep 2026 03:06:48 +0000 Subject: [PATCH] fix(map-gen): assemble Act 5 Bloody Foothills 15 siege strips, Barricade fortifications & Act 1 stone walls/dirt roads (Issue #35) --- scripts/pack-act-assets.ts | 12 +- src/game/wilderness.ts | 365 +++++++++++++++++++++++++++++- tests/wilderness-features.test.ts | 116 ++++++++++ 3 files changed, 484 insertions(+), 9 deletions(-) diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index e9848eb..282547c 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -589,10 +589,9 @@ function getWildernessPieces(levelTypeName: string): Promise for (const row of tables.lvlprest.rows) { const name = cell(tables.lvlprest, row, 'Name') if (!families.some(family => name.startsWith(family))) continue - if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue const levels = await rowDs1s(tables.lvlprest, row) if (levels.length === 0) continue - pieces.push({ name, levels, border: /border|cliff/i.test(name) }) + pieces.push({ name, levels, border: /border|cliff|ravine/i.test(name) }) } return pieces })() @@ -975,9 +974,10 @@ async function bakeDs1Variant( } const filterLevel = process.env.FILTER_LEVEL +const filterLevels = filterLevel !== undefined ? new Set(filterLevel.split(',').map(s => s.trim())) : null for (const entry of LEVELS) { - if (filterLevel !== undefined && String(entry.levelId) !== filterLevel) continue + if (filterLevels !== null && !filterLevels.has(String(entry.levelId))) continue const levelRow = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === entry.levelId)! const paletteIndex = Number(cell(tables.levels, levelRow, 'Pal')) @@ -1109,7 +1109,9 @@ if (filterLevel !== undefined) { } } await writeFile(indexPath, JSON.stringify(index, null, 1)) -const { bakeEntities } = await import('./pack-entity-assets.ts') -await bakeEntities(archiveDir, outDir) +if (!process.env.SKIP_ENTITIES) { + const { bakeEntities } = await import('./pack-entity-assets.ts') + await bakeEntities(archiveDir, outDir) +} console.log(`\n打包完成:${String(totalLevels)} 张地图,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${outDir}`) console.log(`Skipped artless spawns: ${skippedArtlessSpawns}, Skipped no-art NPCs: ${skippedMissingArtSpawns}`) diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 72450f2..0bff905 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -1087,65 +1087,98 @@ function applySubstitutions( */ export const SPECIAL_PRESETS_BY_LEVEL: Readonly> = { 2: [ + 'Act 1 - DOE Entrance', 'Act 1 - River Upper', 'Act 1 - River Lower', 'Act 1 - Bridge', + 'Act 1 - Fence Fill 1', + 'Act 1 - Fence Fill 3', + 'Act 1 - Ruin', 'Act 1 - Cottages 1', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill 4', + 'Act 1 - Fence Fill 5', 'Act 1 - Stone Fill 1', 'Act 1 - Stone Fill 2', ], 3: [ + 'Act 1 - Cave Entrance', + 'Act 1 - Fallen Camp Bishibosh', + 'Act 1 - Fence Fill 1', + 'Act 1 - Fence Fill 2', + 'Act 1 - Fence Fill 3', + 'Act 1 - Ruin', 'Act 1 - Cottages 2', 'Act 1 - Cottages 3', - 'Act 1 - Fallen Camp Bishibosh', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill 4', + 'Act 1 - Fence Fill 6', 'Act 1 - Stone Fill 1', 'Act 1 - Stone Fill 2', ], 4: [ 'Act 1 - Cairn Stones', - 'Act 1 - Camp', + 'Act 1 - Cave Entrance', 'Act 1 - Tower Tome', + 'Act 1 - Fence Fill 1', + 'Act 1 - Fence Fill 3', + 'Act 1 - Ruin', + 'Act 1 - Camp', 'Act 1 - Cottages 1', 'Act 1 - Cottages 3', 'Act 1 - Fallen Camp 1', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill 4', + 'Act 1 - Fence Fill 5', ], 5: [ 'Act 1 - Inifus', + 'Act 1 - Cave Entrance', 'Act 1 - Ruin', + 'Act 1 - Fence Fill 1', + 'Act 1 - Fence Fill 2', + 'Act 1 - Fence Fill 3', 'Act 1 - Tree Fill', 'Act 1 - Cottages 2', 'Act 1 - Cottages 3', 'Act 1 - Fallen Camp 2', + 'Act 1 - Fence Fill 5', 'Act 1 - Stone Fill 1', 'Act 1 - Stone Fill 2', ], 6: [ 'Act 1 - Tower 1', + 'Act 1 - Cave Entrance', + 'Act 1 - Fence Fill 1', + 'Act 1 - Fence Fill 3', + 'Act 1 - Ruin', 'Act 1 - Swamp Fill 1', 'Act 1 - Swamp Fill 2', 'Act 1 - Cottages 1', 'Act 1 - Cottages 3', 'Act 1 - Fallen Camp 1', + 'Act 1 - Fence Fill 6', 'Act 1 - Stone Fill 1', 'Act 1 - Stone Fill 2', ], 7: [ + 'Act 1 - Fence Fill 1', + 'Act 1 - Fence Fill 3', + 'Act 1 - Ruin', 'Act 1 - Cottages 2', 'Act 1 - Fallen Camp 2', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill 4', + 'Act 1 - Fence Fill 5', ], 17: [ 'Act 1 - Graveyard', ], 39: [ 'Act 1 - Bivouac', + 'Act 1 - Fence Fill 1', + 'Act 1 - Fence Fill 3', + 'Act 1 - Ruin', 'Act 1 - Corral Fill', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill 4', @@ -1156,6 +1189,34 @@ export const SPECIAL_PRESETS_BY_LEVEL: Readonly { + const stripOrder = [ + 'Act 5 - Siege To Barricade', + 'Act 5 - Siege Strip 13', + 'Act 5 - Siege Strip 12', + 'Act 5 - Siege Strip 11', + 'Act 5 - Siege Strip 10', + 'Act 5 - Siege Strip 9', + 'Act 5 - Siege Strip 8', + 'Act 5 - Siege Strip 7', + 'Act 5 - Siege Strip 6', + 'Act 5 - Siege Strip 5', + 'Act 5 - Siege Strip 4', + 'Act 5 - Siege Strip 3', + 'Act 5 - Siege Strip 2', + 'Act 5 - Siege Strip 1', + 'Act 5 - Siege To Town', + ] + const placed = new Set() + for (let i = 0; i < stripOrder.length; i += 1) { + const name = stripOrder[i]! + const piece = pieces.find(p => p.name === name) + const ds1 = piece?.levels[0] + if (!ds1) continue + stampDs1(canvas, ds1, i * 16, 0) + placed.add(name) + stats.substitutions.push({ name, role: 'object', enabled: true, clusters: 1 }) + } + return placed +} + +/** + * Lay a 2-tile-wide meandering dirt road network connecting the South entrance, East exit, + * and interior landmarks in Act 1 wilderness levels (LevelId 2..7). + */ +function stampAct1DirtRoads(canvas: Canvas, rng: Rng): void { + const roadFloor: Ds1Floor = { + prop1: 194, + sequence: 0, + style: 5, + unknown1: 0, + unknown2: 0, + hidden: false, + } + + const paintRoadCell = (cx: number, cy: number): void => { + for (let dy = 0; dy <= 1; dy += 1) { + for (let dx = 0; dx <= 1; dx += 1) { + const x = cx + dx + const y = cy + dy + if (x < 8 || x >= canvas.width - 8 || y < 8 || y >= canvas.height - 8) continue + const cell = canvas.cells[y]?.[x] + if (!cell) continue + // Do not overwrite cells that already carry walls or water/bridge floors + if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) continue + const existingFloor = cell.floors[0] + if (existingFloor && existingFloor.style !== 0) continue + ;(cell.floors as Ds1Floor[])[0] = roadFloor + } + } + } + + const connectPoints = (x0: number, y0: number, x1: number, y1: number): void => { + let curX = x0 + let curY = y0 + let steps = 0 + const maxSteps = canvas.width + canvas.height + 40 + while ((Math.abs(curX - x1) > 1 || Math.abs(curY - y1) > 1) && steps < maxSteps) { + paintRoadCell(curX, curY) + const dx = x1 - curX + const dy = y1 - curY + if (Math.abs(dx) > Math.abs(dy)) { + curX += dx > 0 ? 1 : -1 + if (rng.int(0, 3) === 0 && dy !== 0) curY += dy > 0 ? 1 : -1 + } else { + curY += dy > 0 ? 1 : -1 + if (rng.int(0, 3) === 0 && dx !== 0) curX += dx > 0 ? 1 : -1 + } + steps += 1 + } + paintRoadCell(x1, y1) + } + + const hubX = Math.floor(canvas.width / 2) + rng.int(-5, 5) + const hubY = Math.floor(canvas.height / 2) + rng.int(-5, 5) + const southX = Math.floor(canvas.width / 2) + const southY = canvas.height - 9 + const eastX = canvas.width - 9 + const eastY = Math.floor(canvas.height / 2) + const nwX = Math.floor(canvas.width * 0.28) + const nwY = Math.floor(canvas.height * 0.28) + const swX = Math.floor(canvas.width * 0.28) + const swY = Math.floor(canvas.height * 0.72) + + connectPoints(southX, southY, hubX, hubY) + connectPoints(hubX, hubY, eastX, eastY) + connectPoints(hubX, hubY, nwX, nwY) + connectPoints(hubX, hubY, swX, swY) +} + +/** + * Lay connected stone wall divider lines (`style=2, prop1=129`) across the interior of Act 1 + * wilderness levels (LevelId 2..7), creating classic stone-walled courtyards and meadows with + * archway gate openings where roads pass through. + */ +function stampAct1InteriorStoneWalls(canvas: Canvas, gridWidth: number, gridHeight: number, rng: Rng): void { + const ewWall = (seq: number): Ds1Wall => ({ + prop1: 129, + sequence: seq % 3, + style: 2, + type: 2, + unknown1: 0, + unknown2: 0, + hidden: false, + }) + const nsWall = (seq: number): Ds1Wall => ({ + prop1: 129, + sequence: seq % 3, + style: 2, + type: 1, + unknown1: 0, + unknown2: 0, + hidden: false, + }) + const cornerPillar: Ds1Wall = { + prop1: 129, + sequence: 0, + style: 2, + type: 3, + unknown1: 0, + unknown2: 0, + hidden: false, + } + + const canPlaceWall = (x: number, y: number): boolean => { + if (x < 10 || x >= canvas.width - 10 || y < 10 || y >= canvas.height - 10) return false + const cell = canvas.cells[y]?.[x] + if (!cell) return false + if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) return false + // Leave gate openings wherever a dirt road (`style === 5`) or water crosses + if (cell.floors.some(f => !f.hidden && f.style !== 0)) return false + return true + } + + const placeWall = (x: number, y: number, wall: Ds1Wall): void => { + const cell = canvas.cells[y]?.[x] + if (!cell) return + const targetWalls = cell.walls as Ds1Wall[] + if (targetWalls.length > 0) { + targetWalls[0] = wall + } else { + targetWalls.push(wall) + } + } + + // Primary horizontal (East-West) stone wall divider near the middle of the map + const midBlockY = Math.floor(gridHeight / 2) + (rng.int(0, 1) === 0 ? -1 : 1) + const wallY = Math.max(16, Math.min(canvas.height - 16, midBlockY * TILES_PER_BLOCK)) + + // Primary vertical (North-South) stone wall divider + const midBlockX = Math.floor(gridWidth / 2) + (rng.int(0, 1) === 0 ? -1 : 1) + const wallX = Math.max(16, Math.min(canvas.width - 16, midBlockX * TILES_PER_BLOCK)) + + // Draw East-West stone wall line with periodic gate gaps + let prevPlacedEW = false + for (let x = 11; x < canvas.width - 11; x += 1) { + const isGate = (x % 18) >= 7 && (x % 18) <= 10 + if (isGate || Math.abs(x - wallX) <= 1 || !canPlaceWall(x, wallY)) { + if (prevPlacedEW && canPlaceWall(x - 1, wallY)) { + placeWall(x - 1, wallY, cornerPillar) + } + prevPlacedEW = false + continue + } + if (!prevPlacedEW) { + placeWall(x, wallY, cornerPillar) + } else { + placeWall(x, wallY, ewWall(x)) + } + prevPlacedEW = true + } + + // Draw North-South stone wall line with periodic gate gaps + let prevPlacedNS = false + for (let y = 11; y < canvas.height - 11; y += 1) { + const isGate = (y % 18) >= 7 && (y % 18) <= 10 + if (isGate || Math.abs(y - wallY) <= 1 || !canPlaceWall(wallX, y)) { + if (prevPlacedNS && canPlaceWall(wallX, y - 1)) { + placeWall(wallX, y - 1, cornerPillar) + } + prevPlacedNS = false + continue + } + if (!prevPlacedNS) { + placeWall(wallX, y, cornerPillar) + } else { + placeWall(wallX, y, nsWall(y)) + } + prevPlacedNS = true + } +} + export function generateWilderness(request: WildernessRequest): WildernessResult { const where = `level ${String(request.levelId)} (${request.levelName})` if (request.pieces.length === 0) throw new Error(`${where}: no LvlPrest pieces for this level type`) @@ -1421,8 +1696,73 @@ export function generateWilderness(request: WildernessRequest): WildernessResult } stats.groundCells = fillGround(canvas, groundTile, 0, 0, canvas.width, canvas.height) - const borderPieces = request.pieces.filter(piece => piece.border) - const fillPieces = request.pieces.filter(piece => !piece.border) + // Special case: Act 5 Bloody Foothills (LevelId 110 / Act 5 - Siege 1) is assembled + // from 15 sequential 16x48 siege strips rather than perimeter borders + random scatter. + if (request.levelId === 110 || request.levelTypeName === 'Act 5 - Siege') { + const placedSiege = stampAct5SiegeStrips(canvas, request.pieces, stats) + let finalGroundCells = 0 + for (let y = 0; y < canvas.height; y += 1) { + const row = canvas.cells[y] + if (row === undefined) continue + for (let x = 0; x < canvas.width; x += 1) { + const c = row[x] + if (c !== undefined && c.floors.some(f => !f.hidden && f.prop1 !== 0)) finalGroundCells += 1 + } + } + stats.groundCells = finalGroundCells + return { + level: { + version: version === 0 ? 18 : version, + width: canvas.width, + height: canvas.height, + act, + substitutionType, + wallLayers: canvas.wallLayers, + floorLayers: canvas.floorLayers, + cells: canvas.cells, + objects: canvas.objects, + npcPathOffset: null, + }, + stats: { + levelId: request.levelId, + levelName: request.levelName, + levelTypeName: request.levelTypeName, + seed: request.seed, + sizeX, + sizeY, + sizeSource, + tilesPerBlock: TILES_PER_BLOCK, + blockGrid: { width: gridWidth, height: gridHeight }, + dt1Mask: wildernessDt1Mask(request.levelTypeName), + subType: request.subType, + subTheme: 0, + groundTile, + groundCells: stats.groundCells, + borderPiecesAvailable: 0, + borderBlocks: 0, + borderUsage: {}, + substitutions: stats.substitutions, + substitutedClusters: placedSiege.size, + objects: canvas.objects.length, + cells: canvas.width * canvas.height, + unresolved: stats.unresolved, + unimplementedPasses: UNIMPLEMENTED_PASSES, + notes: stats.notes, + }, + } + } + + // Filter pieces for Act 5 Barricade dirt (111) vs snow (112) + let levelPieces = request.pieces + if (request.levelId === 111) { + levelPieces = request.pieces.filter(p => !/snow/i.test(p.name)) + } else if (request.levelId === 112) { + const snowOnly = request.pieces.filter(p => /snow/i.test(p.name) || /ruins/i.test(p.name)) + if (snowOnly.length > 0) levelPieces = snowOnly + } + + const borderPieces = levelPieces.filter(piece => piece.border) + const fillPieces = levelPieces.filter(piece => !piece.border) layBorder(canvas, borderPieces, gridWidth, gridHeight, rng, stats, request.levelId) let specialPresetNames = request.specialPresets @@ -1432,20 +1772,30 @@ export function generateWilderness(request: WildernessRequest): WildernessResult const hasRiver = rng.int(0, 1) === 0 specialPresetNames = hasRiver ? [ + 'Act 1 - DOE Entrance', 'Act 1 - River Upper', 'Act 1 - River Lower', 'Act 1 - Bridge', + 'Act 1 - Fence Fill 1', + 'Act 1 - Fence Fill 3', + 'Act 1 - Ruin', 'Act 1 - Cottages 1', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill 4', + 'Act 1 - Fence Fill 5', 'Act 1 - Stone Fill 1', 'Act 1 - Stone Fill 2', ] : [ + 'Act 1 - DOE Entrance', 'Act 1 - Pond', + 'Act 1 - Fence Fill 1', + 'Act 1 - Fence Fill 3', + 'Act 1 - Ruin', 'Act 1 - Cottages 1', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill 4', + 'Act 1 - Fence Fill 5', 'Act 1 - Stone Fill 1', 'Act 1 - Stone Fill 2', ] @@ -1455,6 +1805,13 @@ export function generateWilderness(request: WildernessRequest): WildernessResult } const placedSpecialPieces = spawnSpecialPresets(canvas, fillPieces, specialPresetNames, gridWidth, gridHeight, rng, stats) + // For Act 1 outdoor wilderness maps (LevelId 2..7), lay dirt road networks and + // interior stone wall divider lines across the meadow. + if (request.levelId >= 2 && request.levelId <= 7) { + stampAct1DirtRoads(canvas, rng) + stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, rng) + } + 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 ?? [])] diff --git a/tests/wilderness-features.test.ts b/tests/wilderness-features.test.ts index 45b2f10..93ba3ee 100644 --- a/tests/wilderness-features.test.ts +++ b/tests/wilderness-features.test.ts @@ -134,4 +134,120 @@ describe("Wilderness Features & Border Rules", () => { expect(result.level.width).toBe(80) expect(result.level.height).toBe(80) }) + + test("Act 1 Wilderness generates interior stone walls and dirt road network", () => { + const closedDs1 = makeMockDs1(8, 8, false) + const piece: WildernessPiece = { + name: "Act 1 - Wild Border 1", + border: true, + levels: [closedDs1], + } + + const result = generateWilderness({ + levelId: 3, + levelName: "Cold Plains", + levelTypeName: "Act 1 - Wilderness", + sizeX: 80, + sizeY: 80, + subType: 6, + subTheme: 0, + seed: 99999, + pieces: [piece], + substitutions: [], + }) + + let stoneWallCount = 0 + let dirtRoadCount = 0 + for (let y = 8; y < 72; y += 1) { + for (let x = 8; x < 72; x += 1) { + const cell = result.level.cells[y]?.[x] + if (!cell) continue + if (cell.walls.some(w => w.style === 2 && w.prop1 === 129)) { + stoneWallCount += 1 + } + if (cell.floors.some(f => f.style === 5 && f.prop1 === 194)) { + dirtRoadCount += 1 + } + } + } + + expect(stoneWallCount).toBeGreaterThan(20) + expect(dirtRoadCount).toBeGreaterThan(30) + }) + + test("Act 5 Bloody Foothills (Level 110) stamps 15 canonical siege strips in West-to-East order", () => { + const stripNames = [ + "Act 5 - Siege To Barricade", + "Act 5 - Siege Strip 13", + "Act 5 - Siege Strip 12", + "Act 5 - Siege Strip 11", + "Act 5 - Siege Strip 10", + "Act 5 - Siege Strip 9", + "Act 5 - Siege Strip 8", + "Act 5 - Siege Strip 7", + "Act 5 - Siege Strip 6", + "Act 5 - Siege Strip 5", + "Act 5 - Siege Strip 4", + "Act 5 - Siege Strip 3", + "Act 5 - Siege Strip 2", + "Act 5 - Siege Strip 1", + "Act 5 - Siege To Town", + ] + + const pieces: WildernessPiece[] = stripNames.map((name, idx) => { + const baseDs1 = makeMockDs1(17, 49, false) + // Create a copy with tagged floor sequence at (0,0) and one object + const cells = baseDs1.cells.map((row, y) => + row.map((cell, x) => + x === 0 && y === 0 + ? { + ...cell, + floors: [{ ...cell.floors[0], sequence: idx + 10 }], + } + : cell, + ), + ) + const ds1: Ds1 = { + ...baseDs1, + cells, + objects: [ + { + type: 2, + id: 100 + idx, + x: 5, + y: 5, + flags: 0, + }, + ], + } + return { + name, + border: false, + levels: [ds1], + } + }) + + const result = generateWilderness({ + levelId: 110, + levelName: "Bloody Foothills", + levelTypeName: "Act 5 - Siege", + sizeX: 240, + sizeY: 48, + subType: -1, + subTheme: -1, + seed: 42, + pieces, + substitutions: [], + }) + + expect(result.stats.blockGrid).toEqual({ width: 30, height: 6 }) + expect(result.stats.substitutedClusters).toBe(15) + expect(result.level.width).toBe(240) + expect(result.level.height).toBe(48) + expect(result.level.objects.length).toBe(15) + + // Verify strip 0 (Siege To Barricade) is at x=0 and strip 14 (Siege To Town) is at x=224 + expect(result.level.cells[0][0].floors[0].sequence).toBe(10) + expect(result.level.cells[0][224].floors[0].sequence).toBe(24) + }) })