feat(wilderness): 实现相邻野外关卡接缝空间对齐与出入口坐标咬合 (Issue #69)
- 支持在 PlannedGate 中指定精确边界块坐标 blockCoord 与瓦片坐标 tileCoord - 增加并导出 SeamConstraint 与 GateOpening 接口 - 在 WildernessRequest 中添加可选的 seamConstraints 配置 - 增强 getPerimeterOpenings 与 DRLGOUTPLACE_PlaceAct1245OutdoorBorders,支持 seamConstraints 及 blockCoord/tileCoord 坐标咬合与平滑边界夹紧 - 增强 planAct1OutdoorLevel 中的边界出入口与道路锚点计算,根据前驱/相邻关卡的接缝约束锁定 anchorCell 与 interiorCell - 在 WildernessStats 与 WildernessResult.stats 中记录并返回 gateOpenings - 新增 tests/wilderness-seams.test.ts 全套单元测试与集成测试 TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
parent
d7defd8da3
commit
2274e2fe0b
|
|
@ -298,6 +298,8 @@ export interface WildernessRequest {
|
||||||
* the tests and `verify-generators` use.
|
* the tests and `verify-generators` use.
|
||||||
*/
|
*/
|
||||||
readonly gates?: readonly PlannedGate[]
|
readonly gates?: readonly PlannedGate[]
|
||||||
|
/** Optional seam constraints from predecessor/connected levels for 1:1 spatial alignment. */
|
||||||
|
readonly seamConstraints?: readonly SeamConstraint[]
|
||||||
/** Optional forced gate variant indices per block key (`${x},${y}`). */
|
/** Optional forced gate variant indices per block key (`${x},${y}`). */
|
||||||
readonly openGates?: ReadonlyMap<string, number>
|
readonly openGates?: ReadonlyMap<string, number>
|
||||||
/** Optional set of block keys (`${x},${y}`) claimed by transition/facade presets to skip during border placement. */
|
/** Optional set of block keys (`${x},${y}`) claimed by transition/facade presets to skip during border placement. */
|
||||||
|
|
@ -324,7 +326,28 @@ export interface PlannedGate {
|
||||||
* Three is the ordinary open gate; four is the variant the Burial Grounds and
|
* Three is the ordinary open gate; four is the variant the Burial Grounds and
|
||||||
* the Cold Plains use for the graveyard seam.
|
* the Cold Plains use for the graveyard seam.
|
||||||
*/
|
*/
|
||||||
readonly variant?: number
|
readonly variant?: number | undefined
|
||||||
|
/** Exact block coordinate along the perimeter edge (0..gridWidth-1 or 0..gridHeight-1) */
|
||||||
|
readonly blockCoord?: number | undefined
|
||||||
|
/** Optional exact tile coordinate within the level */
|
||||||
|
readonly tileCoord?: { readonly x: number; readonly y: number } | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Explicit spatial alignment constraint across a level seam. */
|
||||||
|
export interface SeamConstraint {
|
||||||
|
readonly fromLevelId?: number | undefined
|
||||||
|
readonly toLevelId?: number | undefined
|
||||||
|
readonly side: Side
|
||||||
|
readonly blockCoord: number
|
||||||
|
readonly tileCoord?: { readonly x: number; readonly y: number } | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Placed gate opening along the level's perimeter. */
|
||||||
|
export interface GateOpening {
|
||||||
|
readonly side: Side
|
||||||
|
readonly toLevelId: number
|
||||||
|
readonly blockCoord: number
|
||||||
|
readonly tileCoord: { readonly x: number; readonly y: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The generated level. */
|
/** The generated level. */
|
||||||
|
|
@ -336,6 +359,7 @@ export interface WildernessResult {
|
||||||
readonly landmarkEntities?: LandmarkEntity[]
|
readonly landmarkEntities?: LandmarkEntity[]
|
||||||
readonly notes?: string[]
|
readonly notes?: string[]
|
||||||
readonly unresolved?: string[]
|
readonly unresolved?: string[]
|
||||||
|
readonly gateOpenings?: readonly GateOpening[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1122,6 +1146,7 @@ export interface WildernessStats {
|
||||||
proceduralWallCells?: number
|
proceduralWallCells?: number
|
||||||
drlgVertices?: readonly DrlgVertex[] | undefined
|
drlgVertices?: readonly DrlgVertex[] | undefined
|
||||||
roadPolylines?: readonly (readonly { readonly x: number; readonly y: number }[])[] | undefined
|
roadPolylines?: readonly (readonly { readonly x: number; readonly y: number }[])[] | undefined
|
||||||
|
gateOpenings?: GateOpening[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1469,6 +1494,7 @@ export function getPerimeterOpenings(
|
||||||
gridWidth: number,
|
gridWidth: number,
|
||||||
gridHeight: number,
|
gridHeight: number,
|
||||||
gates?: readonly PlannedGate[],
|
gates?: readonly PlannedGate[],
|
||||||
|
seamConstraints?: readonly SeamConstraint[],
|
||||||
): Set<string> {
|
): Set<string> {
|
||||||
const openings = new Set<string>()
|
const openings = new Set<string>()
|
||||||
const key = (x: number, y: number) => `${String(x)},${String(y)}`
|
const key = (x: number, y: number) => `${String(x)},${String(y)}`
|
||||||
|
|
@ -1478,21 +1504,90 @@ export function getPerimeterOpenings(
|
||||||
return openings
|
return openings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const minX = gridWidth > 2 ? 1 : 0
|
||||||
|
const maxX = gridWidth > 2 ? gridWidth - 2 : gridWidth - 1
|
||||||
|
const minY = gridHeight > 2 ? 1 : 0
|
||||||
|
const maxY = gridHeight > 2 ? gridHeight - 2 : gridHeight - 1
|
||||||
|
|
||||||
if (gates !== undefined && gates.length > 0) {
|
if (gates !== undefined && gates.length > 0) {
|
||||||
for (const gate of gates) {
|
for (const gate of gates) {
|
||||||
|
let coord = gate.blockCoord
|
||||||
|
if (coord === undefined && seamConstraints !== undefined) {
|
||||||
|
const match = seamConstraints.find(
|
||||||
|
c =>
|
||||||
|
c.side === gate.side &&
|
||||||
|
(c.toLevelId === gate.toLevelId ||
|
||||||
|
c.fromLevelId === gate.toLevelId ||
|
||||||
|
c.toLevelId === levelId ||
|
||||||
|
c.fromLevelId === levelId),
|
||||||
|
) ?? seamConstraints.find(c => c.side === gate.side)
|
||||||
|
if (match !== undefined) {
|
||||||
|
coord = match.blockCoord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (coord === undefined && gate.tileCoord !== undefined) {
|
||||||
|
coord = (gate.side === 'north' || gate.side === 'south')
|
||||||
|
? Math.floor(gate.tileCoord.x / TILES_PER_BLOCK)
|
||||||
|
: Math.floor(gate.tileCoord.y / TILES_PER_BLOCK)
|
||||||
|
}
|
||||||
|
|
||||||
switch (gate.side) {
|
switch (gate.side) {
|
||||||
case 'north':
|
case 'north': {
|
||||||
openings.add(key(Math.floor(gridWidth / 2), 0))
|
const bx = coord !== undefined
|
||||||
|
? Math.max(minX, Math.min(maxX, coord))
|
||||||
|
: Math.floor(gridWidth / 2)
|
||||||
|
openings.add(key(bx, 0))
|
||||||
break
|
break
|
||||||
case 'south':
|
}
|
||||||
openings.add(key(Math.floor(gridWidth / 2), gridHeight - 1))
|
case 'south': {
|
||||||
|
const bx = coord !== undefined
|
||||||
|
? Math.max(minX, Math.min(maxX, coord))
|
||||||
|
: Math.floor(gridWidth / 2)
|
||||||
|
openings.add(key(bx, gridHeight - 1))
|
||||||
break
|
break
|
||||||
case 'west':
|
}
|
||||||
openings.add(key(0, Math.floor(gridHeight / 2)))
|
case 'west': {
|
||||||
|
const by = coord !== undefined
|
||||||
|
? Math.max(minY, Math.min(maxY, coord))
|
||||||
|
: Math.floor(gridHeight / 2)
|
||||||
|
openings.add(key(0, by))
|
||||||
break
|
break
|
||||||
case 'east':
|
}
|
||||||
openings.add(key(gridWidth - 1, Math.floor(gridHeight / 2)))
|
case 'east': {
|
||||||
|
const by = coord !== undefined
|
||||||
|
? Math.max(minY, Math.min(maxY, coord))
|
||||||
|
: Math.floor(gridHeight / 2)
|
||||||
|
openings.add(key(gridWidth - 1, by))
|
||||||
break
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return openings
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seamConstraints !== undefined && seamConstraints.length > 0) {
|
||||||
|
for (const c of seamConstraints) {
|
||||||
|
switch (c.side) {
|
||||||
|
case 'north': {
|
||||||
|
const bx = Math.max(minX, Math.min(maxX, c.blockCoord))
|
||||||
|
openings.add(key(bx, 0))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'south': {
|
||||||
|
const bx = Math.max(minX, Math.min(maxX, c.blockCoord))
|
||||||
|
openings.add(key(bx, gridHeight - 1))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'west': {
|
||||||
|
const by = Math.max(minY, Math.min(maxY, c.blockCoord))
|
||||||
|
openings.add(key(0, by))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'east': {
|
||||||
|
const by = Math.max(minY, Math.min(maxY, c.blockCoord))
|
||||||
|
openings.add(key(gridWidth - 1, by))
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return openings
|
return openings
|
||||||
|
|
@ -1530,6 +1625,7 @@ export function getPerimeterOpenings(
|
||||||
* @param openGates - optional map from `${x},${y}` block key to forced variant index.
|
* @param openGates - optional map from `${x},${y}` block key to forced variant index.
|
||||||
* @param claimedBorderBlocks - optional set of `${x},${y}` keys to skip (e.g. Town Transitions, Facades).
|
* @param claimedBorderBlocks - optional set of `${x},${y}` keys to skip (e.g. Town Transitions, Facades).
|
||||||
* @param gates - optional planned gates from the world graph.
|
* @param gates - optional planned gates from the world graph.
|
||||||
|
* @param seamConstraints - optional seam constraints for spatial alignment.
|
||||||
* @returns the number of pieces stamped.
|
* @returns the number of pieces stamped.
|
||||||
*/
|
*/
|
||||||
export function DRLGOUTPLACE_PlaceAct1245OutdoorBorders(
|
export function DRLGOUTPLACE_PlaceAct1245OutdoorBorders(
|
||||||
|
|
@ -1543,6 +1639,7 @@ export function DRLGOUTPLACE_PlaceAct1245OutdoorBorders(
|
||||||
openGates?: ReadonlyMap<string, number>,
|
openGates?: ReadonlyMap<string, number>,
|
||||||
claimedBorderBlocks?: ReadonlySet<string>,
|
claimedBorderBlocks?: ReadonlySet<string>,
|
||||||
gates?: readonly PlannedGate[],
|
gates?: readonly PlannedGate[],
|
||||||
|
seamConstraints?: readonly SeamConstraint[],
|
||||||
): number {
|
): number {
|
||||||
if (stats.proceduralWallCells === undefined) {
|
if (stats.proceduralWallCells === undefined) {
|
||||||
stats.proceduralWallCells = 0
|
stats.proceduralWallCells = 0
|
||||||
|
|
@ -1591,7 +1688,7 @@ export function DRLGOUTPLACE_PlaceAct1245OutdoorBorders(
|
||||||
{ x: 0, y: gridHeight - 1 }, // SW
|
{ x: 0, y: gridHeight - 1 }, // SW
|
||||||
]
|
]
|
||||||
|
|
||||||
const openings = levelId !== undefined ? getPerimeterOpenings(levelId, gridWidth, gridHeight, gates) : new Set<string>()
|
const openings = levelId !== undefined ? getPerimeterOpenings(levelId, gridWidth, gridHeight, gates, seamConstraints) : new Set<string>()
|
||||||
if (openGates !== undefined) {
|
if (openGates !== undefined) {
|
||||||
for (const gateKey of openGates.keys()) {
|
for (const gateKey of openGates.keys()) {
|
||||||
openings.add(gateKey)
|
openings.add(gateKey)
|
||||||
|
|
@ -1724,6 +1821,7 @@ export interface Act1OutdoorPlan {
|
||||||
readonly roadObstacleBlocks: boolean[][]
|
readonly roadObstacleBlocks: boolean[][]
|
||||||
readonly specialPresets: SpecialPresetPlacement[]
|
readonly specialPresets: SpecialPresetPlacement[]
|
||||||
readonly anchors: RoadAnchor[]
|
readonly anchors: RoadAnchor[]
|
||||||
|
readonly gateOpenings?: GateOpening[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map a compass side onto the generator's own edge names. */
|
/** Map a compass side onto the generator's own edge names. */
|
||||||
|
|
@ -1774,6 +1872,8 @@ export function planAct1OutdoorLevel(
|
||||||
const specialPresets: SpecialPresetPlacement[] = []
|
const specialPresets: SpecialPresetPlacement[] = []
|
||||||
const anchors: RoadAnchor[] = []
|
const anchors: RoadAnchor[] = []
|
||||||
|
|
||||||
|
const gateOpenings: GateOpening[] = []
|
||||||
|
|
||||||
const claimBox = (bx: number, by: number, w: number, h: number, isObstacle = true): void => {
|
const claimBox = (bx: number, by: number, w: number, h: number, isObstacle = true): void => {
|
||||||
for (let dy = 0; dy < h; dy += 1) {
|
for (let dy = 0; dy < h; dy += 1) {
|
||||||
for (let dx = 0; dx < w; dx += 1) {
|
for (let dx = 0; dx < w; dx += 1) {
|
||||||
|
|
@ -1795,71 +1895,183 @@ export function planAct1OutdoorLevel(
|
||||||
variant: number,
|
variant: number,
|
||||||
label: string,
|
label: string,
|
||||||
toLevelId?: number,
|
toLevelId?: number,
|
||||||
|
forcedBlockCoord?: number,
|
||||||
|
forcedTileCoord?: { readonly x: number; readonly y: number },
|
||||||
): void => {
|
): void => {
|
||||||
|
const side: Side = edge === 'top' ? 'north' : edge === 'bottom' ? 'south' : edge === 'left' ? 'west' : 'east'
|
||||||
|
|
||||||
|
let blockCoord = forcedBlockCoord
|
||||||
|
let tileCoord = forcedTileCoord
|
||||||
|
|
||||||
|
if (blockCoord === undefined && request.seamConstraints !== undefined) {
|
||||||
|
const match = request.seamConstraints.find(c => {
|
||||||
|
if (c.side !== side) return false
|
||||||
|
if (toLevelId !== undefined && toLevelId !== -1) {
|
||||||
|
return c.toLevelId === toLevelId || c.fromLevelId === toLevelId
|
||||||
|
}
|
||||||
|
return c.toLevelId === request.levelId || c.fromLevelId === request.levelId
|
||||||
|
}) ?? request.seamConstraints.find(c => c.side === side)
|
||||||
|
|
||||||
|
if (match !== undefined) {
|
||||||
|
blockCoord = match.blockCoord
|
||||||
|
tileCoord = tileCoord ?? match.tileCoord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blockCoord === undefined && tileCoord !== undefined) {
|
||||||
|
blockCoord = (side === 'north' || side === 'south')
|
||||||
|
? Math.floor(tileCoord.x / TILES_PER_BLOCK)
|
||||||
|
: Math.floor(tileCoord.y / TILES_PER_BLOCK)
|
||||||
|
}
|
||||||
|
|
||||||
if (edge === 'left') {
|
if (edge === 'left') {
|
||||||
let by = rng.int(2, Math.max(2, gridHeight - 3))
|
const minY = gridHeight > 2 ? 1 : 0
|
||||||
for (let tries = 0; tries < gridHeight; tries += 1) {
|
const maxY = gridHeight > 2 ? gridHeight - 2 : gridHeight - 1
|
||||||
if (!claimedBorderBlocks.has(`0,${String(by)}`) && !openGates.has(`0,${String(by)}`)) break
|
let by: number
|
||||||
by = 2 + ((by - 1) % Math.max(1, gridHeight - 4))
|
if (blockCoord !== undefined) {
|
||||||
|
by = Math.max(minY, Math.min(maxY, blockCoord))
|
||||||
|
if (claimedBorderBlocks.has(`0,${String(by)}`)) {
|
||||||
|
for (let tries = 0; tries < gridHeight; tries += 1) {
|
||||||
|
if (!claimedBorderBlocks.has(`0,${String(by)}`) && !openGates.has(`0,${String(by)}`)) break
|
||||||
|
by = minY + ((by - minY + 1) % (maxY - minY + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
by = rng.int(2, Math.max(2, gridHeight - 3))
|
||||||
|
for (let tries = 0; tries < gridHeight; tries += 1) {
|
||||||
|
if (!claimedBorderBlocks.has(`0,${String(by)}`) && !openGates.has(`0,${String(by)}`)) break
|
||||||
|
by = 2 + ((by - 1) % Math.max(1, gridHeight - 4))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
openGates.set(`0,${String(by)}`, variant)
|
openGates.set(`0,${String(by)}`, variant)
|
||||||
claimedBlocks[by]![0] = true
|
claimedBlocks[by]![0] = true
|
||||||
|
const anchorCell = tileCoord ?? { x: 3, y: 8 * by + 3 }
|
||||||
|
const interiorCell = tileCoord ? { x: tileCoord.x + 8, y: tileCoord.y } : { x: 11, y: 8 * by + 3 }
|
||||||
anchors.push({
|
anchors.push({
|
||||||
label,
|
label,
|
||||||
anchorCell: { x: 3, y: 8 * by + 3 },
|
anchorCell,
|
||||||
interiorCell: { x: 11, y: 8 * by + 3 },
|
interiorCell,
|
||||||
toLevelId,
|
toLevelId,
|
||||||
side: 'west',
|
side: 'west',
|
||||||
kind: 'gate',
|
kind: 'gate',
|
||||||
})
|
})
|
||||||
|
gateOpenings.push({
|
||||||
|
side: 'west',
|
||||||
|
toLevelId: toLevelId ?? -1,
|
||||||
|
blockCoord: by,
|
||||||
|
tileCoord: anchorCell,
|
||||||
|
})
|
||||||
} else if (edge === 'right') {
|
} else if (edge === 'right') {
|
||||||
let by = rng.int(2, Math.max(2, gridHeight - 3))
|
const minY = gridHeight > 2 ? 1 : 0
|
||||||
for (let tries = 0; tries < gridHeight; tries += 1) {
|
const maxY = gridHeight > 2 ? gridHeight - 2 : gridHeight - 1
|
||||||
if (!claimedBorderBlocks.has(`${String(gridWidth - 1)},${String(by)}`) && !openGates.has(`${String(gridWidth - 1)},${String(by)}`)) break
|
let by: number
|
||||||
by = 2 + ((by - 1) % Math.max(1, gridHeight - 4))
|
if (blockCoord !== undefined) {
|
||||||
|
by = Math.max(minY, Math.min(maxY, blockCoord))
|
||||||
|
if (claimedBorderBlocks.has(`${String(gridWidth - 1)},${String(by)}`)) {
|
||||||
|
for (let tries = 0; tries < gridHeight; tries += 1) {
|
||||||
|
if (!claimedBorderBlocks.has(`${String(gridWidth - 1)},${String(by)}`) && !openGates.has(`${String(gridWidth - 1)},${String(by)}`)) break
|
||||||
|
by = minY + ((by - minY + 1) % (maxY - minY + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
by = rng.int(2, Math.max(2, gridHeight - 3))
|
||||||
|
for (let tries = 0; tries < gridHeight; tries += 1) {
|
||||||
|
if (!claimedBorderBlocks.has(`${String(gridWidth - 1)},${String(by)}`) && !openGates.has(`${String(gridWidth - 1)},${String(by)}`)) break
|
||||||
|
by = 2 + ((by - 1) % Math.max(1, gridHeight - 4))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
openGates.set(`${String(gridWidth - 1)},${String(by)}`, variant)
|
openGates.set(`${String(gridWidth - 1)},${String(by)}`, variant)
|
||||||
claimedBlocks[by]![gridWidth - 1] = true
|
claimedBlocks[by]![gridWidth - 1] = true
|
||||||
|
const anchorCell = tileCoord ?? { x: 8 * (gridWidth - 1) + 3, y: 8 * by + 3 }
|
||||||
|
const interiorCell = tileCoord ? { x: tileCoord.x - 8, y: tileCoord.y } : { x: 8 * (gridWidth - 2) + 3, y: 8 * by + 3 }
|
||||||
anchors.push({
|
anchors.push({
|
||||||
label,
|
label,
|
||||||
anchorCell: { x: 8 * (gridWidth - 1) + 3, y: 8 * by + 3 },
|
anchorCell,
|
||||||
interiorCell: { x: 8 * (gridWidth - 2) + 3, y: 8 * by + 3 },
|
interiorCell,
|
||||||
toLevelId,
|
toLevelId,
|
||||||
side: 'east',
|
side: 'east',
|
||||||
kind: 'gate',
|
kind: 'gate',
|
||||||
})
|
})
|
||||||
|
gateOpenings.push({
|
||||||
|
side: 'east',
|
||||||
|
toLevelId: toLevelId ?? -1,
|
||||||
|
blockCoord: by,
|
||||||
|
tileCoord: anchorCell,
|
||||||
|
})
|
||||||
} else if (edge === 'top') {
|
} else if (edge === 'top') {
|
||||||
let bx = rng.int(2, Math.max(2, gridWidth - 3))
|
const minX = gridWidth > 2 ? 1 : 0
|
||||||
for (let tries = 0; tries < gridWidth; tries += 1) {
|
const maxX = gridWidth > 2 ? gridWidth - 2 : gridWidth - 1
|
||||||
if (!claimedBorderBlocks.has(`${String(bx)},0`) && !openGates.has(`${String(bx)},0`)) break
|
let bx: number
|
||||||
bx = 2 + ((bx - 1) % Math.max(1, gridWidth - 4))
|
if (blockCoord !== undefined) {
|
||||||
|
bx = Math.max(minX, Math.min(maxX, blockCoord))
|
||||||
|
if (claimedBorderBlocks.has(`${String(bx)},0`)) {
|
||||||
|
for (let tries = 0; tries < gridWidth; tries += 1) {
|
||||||
|
if (!claimedBorderBlocks.has(`${String(bx)},0`) && !openGates.has(`${String(bx)},0`)) break
|
||||||
|
bx = minX + ((bx - minX + 1) % (maxX - minX + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bx = rng.int(2, Math.max(2, gridWidth - 3))
|
||||||
|
for (let tries = 0; tries < gridWidth; tries += 1) {
|
||||||
|
if (!claimedBorderBlocks.has(`${String(bx)},0`) && !openGates.has(`${String(bx)},0`)) break
|
||||||
|
bx = 2 + ((bx - 1) % Math.max(1, gridWidth - 4))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
openGates.set(`${String(bx)},0`, variant)
|
openGates.set(`${String(bx)},0`, variant)
|
||||||
claimedBlocks[0]![bx] = true
|
claimedBlocks[0]![bx] = true
|
||||||
|
const anchorCell = tileCoord ?? { x: 8 * bx + 3, y: 3 }
|
||||||
|
const interiorCell = tileCoord ? { x: tileCoord.x, y: tileCoord.y + 8 } : { x: 8 * bx + 3, y: 11 }
|
||||||
anchors.push({
|
anchors.push({
|
||||||
label,
|
label,
|
||||||
anchorCell: { x: 8 * bx + 3, y: 3 },
|
anchorCell,
|
||||||
interiorCell: { x: 8 * bx + 3, y: 11 },
|
interiorCell,
|
||||||
toLevelId,
|
toLevelId,
|
||||||
side: 'north',
|
side: 'north',
|
||||||
kind: 'gate',
|
kind: 'gate',
|
||||||
})
|
})
|
||||||
|
gateOpenings.push({
|
||||||
|
side: 'north',
|
||||||
|
toLevelId: toLevelId ?? -1,
|
||||||
|
blockCoord: bx,
|
||||||
|
tileCoord: anchorCell,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
let bx = rng.int(2, Math.max(2, gridWidth - 3))
|
const minX = gridWidth > 2 ? 1 : 0
|
||||||
for (let tries = 0; tries < gridWidth; tries += 1) {
|
const maxX = gridWidth > 2 ? gridWidth - 2 : gridWidth - 1
|
||||||
if (!claimedBorderBlocks.has(`${String(bx)},${String(gridHeight - 1)}`) && !openGates.has(`${String(bx)},${String(gridHeight - 1)}`)) break
|
let bx: number
|
||||||
bx = 2 + ((bx - 1) % Math.max(1, gridWidth - 4))
|
if (blockCoord !== undefined) {
|
||||||
|
bx = Math.max(minX, Math.min(maxX, blockCoord))
|
||||||
|
if (claimedBorderBlocks.has(`${String(bx)},${String(gridHeight - 1)}`)) {
|
||||||
|
for (let tries = 0; tries < gridWidth; tries += 1) {
|
||||||
|
if (!claimedBorderBlocks.has(`${String(bx)},${String(gridHeight - 1)}`) && !openGates.has(`${String(bx)},${String(gridHeight - 1)}`)) break
|
||||||
|
bx = minX + ((bx - minX + 1) % (maxX - minX + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bx = rng.int(2, Math.max(2, gridWidth - 3))
|
||||||
|
for (let tries = 0; tries < gridWidth; tries += 1) {
|
||||||
|
if (!claimedBorderBlocks.has(`${String(bx)},${String(gridHeight - 1)}`) && !openGates.has(`${String(bx)},${String(gridHeight - 1)}`)) break
|
||||||
|
bx = 2 + ((bx - 1) % Math.max(1, gridWidth - 4))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
openGates.set(`${String(bx)},${String(gridHeight - 1)}`, variant)
|
openGates.set(`${String(bx)},${String(gridHeight - 1)}`, variant)
|
||||||
claimedBlocks[gridHeight - 1]![bx] = true
|
claimedBlocks[gridHeight - 1]![bx] = true
|
||||||
|
const anchorCell = tileCoord ?? { x: 8 * bx + 3, y: 8 * (gridHeight - 1) + 3 }
|
||||||
|
const interiorCell = tileCoord ? { x: tileCoord.x, y: tileCoord.y - 8 } : { x: 8 * bx + 3, y: 8 * (gridHeight - 2) + 3 }
|
||||||
anchors.push({
|
anchors.push({
|
||||||
label,
|
label,
|
||||||
anchorCell: { x: 8 * bx + 3, y: 8 * (gridHeight - 1) + 3 },
|
anchorCell,
|
||||||
interiorCell: { x: 8 * bx + 3, y: 8 * (gridHeight - 2) + 3 },
|
interiorCell,
|
||||||
toLevelId,
|
toLevelId,
|
||||||
side: 'south',
|
side: 'south',
|
||||||
kind: 'gate',
|
kind: 'gate',
|
||||||
})
|
})
|
||||||
|
gateOpenings.push({
|
||||||
|
side: 'south',
|
||||||
|
toLevelId: toLevelId ?? -1,
|
||||||
|
blockCoord: bx,
|
||||||
|
tileCoord: anchorCell,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1944,45 +2156,63 @@ export function planAct1OutdoorLevel(
|
||||||
* @param toLevelId - the town's level id.
|
* @param toLevelId - the town's level id.
|
||||||
* @returns whether a preset was placed.
|
* @returns whether a preset was placed.
|
||||||
*/
|
*/
|
||||||
const placeTownTransition = (side: Side, toLevelId?: number): boolean => {
|
const placeTownTransition = (side: Side, toLevelId?: number, forcedBlockCoord?: number): boolean => {
|
||||||
if (side === 'west') {
|
if (side === 'west') {
|
||||||
const townE = findPiece('Act 1 - Town 1 Transition E')
|
const townE = findPiece('Act 1 - Town 1 Transition E')
|
||||||
if (townE?.levels[0] === undefined) return false
|
if (townE?.levels[0] === undefined) return false
|
||||||
const ds1 = townE.levels[0]
|
const ds1 = townE.levels[0]
|
||||||
const by = Math.max(1, Math.floor((gridHeight - 5) / 2))
|
const by = forcedBlockCoord !== undefined
|
||||||
|
? Math.max(1, Math.min(gridHeight - 6, forcedBlockCoord))
|
||||||
|
: Math.max(1, Math.floor((gridHeight - 5) / 2))
|
||||||
for (let dy = 0; dy < 5; dy += 1) {
|
for (let dy = 0; dy < 5; dy += 1) {
|
||||||
claimedBorderBlocks.add(`0,${String(by + dy)}`)
|
claimedBorderBlocks.add(`0,${String(by + dy)}`)
|
||||||
claimedBlocks[by + dy]![0] = true
|
claimedBlocks[by + dy]![0] = true
|
||||||
}
|
}
|
||||||
specialPresets.push({ name: townE.name, ds1, bx: 0, by, blocksX: 1, blocksY: 5 })
|
specialPresets.push({ name: townE.name, ds1, bx: 0, by, blocksX: 1, blocksY: 5 })
|
||||||
|
const anchorCell = { x: 7, y: 8 * by + 19 }
|
||||||
anchors.push({
|
anchors.push({
|
||||||
label: 'Town E Transition',
|
label: 'Town E Transition',
|
||||||
anchorCell: { x: 7, y: 8 * by + 19 },
|
anchorCell,
|
||||||
interiorCell: { x: 11, y: 8 * by + 19 },
|
interiorCell: { x: 11, y: 8 * by + 19 },
|
||||||
toLevelId,
|
toLevelId,
|
||||||
side: 'west',
|
side: 'west',
|
||||||
kind: 'gate',
|
kind: 'gate',
|
||||||
})
|
})
|
||||||
|
gateOpenings.push({
|
||||||
|
side: 'west',
|
||||||
|
toLevelId: toLevelId ?? 1,
|
||||||
|
blockCoord: by,
|
||||||
|
tileCoord: anchorCell,
|
||||||
|
})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (side === 'north') {
|
if (side === 'north') {
|
||||||
const townS = findPiece('Act 1 - Town 1 Transition S')
|
const townS = findPiece('Act 1 - Town 1 Transition S')
|
||||||
if (townS?.levels[0] === undefined) return false
|
if (townS?.levels[0] === undefined) return false
|
||||||
const ds1 = townS.levels[0]
|
const ds1 = townS.levels[0]
|
||||||
const bx = Math.max(1, Math.floor((gridWidth - 7) / 2))
|
const bx = forcedBlockCoord !== undefined
|
||||||
|
? Math.max(1, Math.min(gridWidth - 8, forcedBlockCoord))
|
||||||
|
: Math.max(1, Math.floor((gridWidth - 7) / 2))
|
||||||
for (let dx = 0; dx < 7; dx += 1) {
|
for (let dx = 0; dx < 7; dx += 1) {
|
||||||
claimedBorderBlocks.add(`${String(bx + dx)},0`)
|
claimedBorderBlocks.add(`${String(bx + dx)},0`)
|
||||||
claimedBlocks[0]![bx + dx] = true
|
claimedBlocks[0]![bx + dx] = true
|
||||||
}
|
}
|
||||||
specialPresets.push({ name: townS.name, ds1, bx, by: 0, blocksX: 7, blocksY: 1 })
|
specialPresets.push({ name: townS.name, ds1, bx, by: 0, blocksX: 7, blocksY: 1 })
|
||||||
|
const anchorCell = { x: 8 * bx + 29, y: 7 }
|
||||||
anchors.push({
|
anchors.push({
|
||||||
label: 'Town S Transition',
|
label: 'Town S Transition',
|
||||||
anchorCell: { x: 8 * bx + 29, y: 7 },
|
anchorCell,
|
||||||
interiorCell: { x: 8 * bx + 29, y: 11 },
|
interiorCell: { x: 8 * bx + 29, y: 11 },
|
||||||
toLevelId,
|
toLevelId,
|
||||||
side: 'north',
|
side: 'north',
|
||||||
kind: 'gate',
|
kind: 'gate',
|
||||||
})
|
})
|
||||||
|
gateOpenings.push({
|
||||||
|
side: 'north',
|
||||||
|
toLevelId: toLevelId ?? 1,
|
||||||
|
blockCoord: bx,
|
||||||
|
tileCoord: anchorCell,
|
||||||
|
})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|
@ -2010,14 +2240,21 @@ export function planAct1OutdoorLevel(
|
||||||
if (withGate) {
|
if (withGate) {
|
||||||
openGates.set(`2,${String(gridHeight - 1)}`, 4)
|
openGates.set(`2,${String(gridHeight - 1)}`, 4)
|
||||||
claimedBlocks[gridHeight - 1]![2] = true
|
claimedBlocks[gridHeight - 1]![2] = true
|
||||||
|
const anchorCell = { x: 2 * 8 + 3, y: 8 * (gridHeight - 1) + 3 }
|
||||||
anchors.push({
|
anchors.push({
|
||||||
label: 'Burial Grounds Gate',
|
label: 'Burial Grounds Gate',
|
||||||
anchorCell: { x: 2 * 8 + 3, y: 8 * (gridHeight - 1) + 3 },
|
anchorCell,
|
||||||
interiorCell: { x: 2 * 8 + 3, y: 8 * (gridHeight - 2) + 3 },
|
interiorCell: { x: 2 * 8 + 3, y: 8 * (gridHeight - 2) + 3 },
|
||||||
toLevelId: 3,
|
toLevelId: 3,
|
||||||
side: 'south',
|
side: 'south',
|
||||||
kind: 'gate',
|
kind: 'gate',
|
||||||
})
|
})
|
||||||
|
gateOpenings.push({
|
||||||
|
side: 'south',
|
||||||
|
toLevelId: 3,
|
||||||
|
blockCoord: 2,
|
||||||
|
tileCoord: anchorCell,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
anchors.push({
|
anchors.push({
|
||||||
label: 'Graveyard Entrance',
|
label: 'Graveyard Entrance',
|
||||||
|
|
@ -2111,10 +2348,22 @@ export function planAct1OutdoorLevel(
|
||||||
// it records one legal roll of the outdoor placer as if it were fixed, and
|
// it records one legal roll of the outdoor placer as if it were fixed, and
|
||||||
// it only ever covered Act 1.
|
// it only ever covered Act 1.
|
||||||
const townSeam = id === 2 ? plannedGates.find(gate => gate.toLevelId === 1) : undefined
|
const townSeam = id === 2 ? plannedGates.find(gate => gate.toLevelId === 1) : undefined
|
||||||
const placedTown = townSeam !== undefined && placeTownTransition(townSeam.side, townSeam.toLevelId)
|
const placedTown = townSeam !== undefined && placeTownTransition(townSeam.side, townSeam.toLevelId, townSeam.blockCoord)
|
||||||
for (const gate of plannedGates) {
|
for (const gate of plannedGates) {
|
||||||
if (placedTown && gate === townSeam) continue
|
if (placedTown && gate === townSeam) continue
|
||||||
addBorderGate(SIDE_TO_EDGE[gate.side], gate.variant ?? 3, gate.label, gate.toLevelId)
|
let blockCoord = gate.blockCoord
|
||||||
|
let tileCoord = gate.tileCoord
|
||||||
|
if (blockCoord === undefined && request.seamConstraints !== undefined) {
|
||||||
|
const match = request.seamConstraints.find(c => {
|
||||||
|
if (c.side !== gate.side) return false
|
||||||
|
return c.toLevelId === gate.toLevelId || c.fromLevelId === gate.toLevelId
|
||||||
|
}) ?? request.seamConstraints.find(c => c.side === gate.side)
|
||||||
|
if (match !== undefined) {
|
||||||
|
blockCoord = match.blockCoord
|
||||||
|
tileCoord = tileCoord ?? match.tileCoord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addBorderGate(SIDE_TO_EDGE[gate.side], gate.variant ?? 3, gate.label, gate.toLevelId, blockCoord, tileCoord)
|
||||||
}
|
}
|
||||||
if (id === 17) {
|
if (id === 17) {
|
||||||
if (!placeGraveyardPreset(false)) {
|
if (!placeGraveyardPreset(false)) {
|
||||||
|
|
@ -2127,13 +2376,22 @@ export function planAct1OutdoorLevel(
|
||||||
// Blood Moor: Town Transition + Exit Gate to Cold Plains + Den of Evil Entrance
|
// Blood Moor: Town Transition + Exit Gate to Cold Plains + Den of Evil Entrance
|
||||||
const townE = findPiece('Act 1 - Town 1 Transition E')
|
const townE = findPiece('Act 1 - Town 1 Transition E')
|
||||||
const useWest = rng.int(0, 1) === 0 && townE !== undefined
|
const useWest = rng.int(0, 1) === 0 && townE !== undefined
|
||||||
|
const coldPlainsConstraint = request.seamConstraints?.find(c => c.toLevelId === 3 || c.fromLevelId === 3)
|
||||||
|
let exitEdge: 'right' | 'bottom'
|
||||||
|
if (coldPlainsConstraint?.side === 'east') {
|
||||||
|
exitEdge = 'right'
|
||||||
|
} else if (coldPlainsConstraint?.side === 'south') {
|
||||||
|
exitEdge = 'bottom'
|
||||||
|
} else {
|
||||||
|
exitEdge = rng.int(0, 1) === 0 ? 'right' : 'bottom'
|
||||||
|
}
|
||||||
if (useWest && placeTownTransition('west', 1)) {
|
if (useWest && placeTownTransition('west', 1)) {
|
||||||
addBorderGate(rng.int(0, 1) === 0 ? 'right' : 'bottom', 3, 'Cold Plains Exit', 3)
|
addBorderGate(exitEdge, 3, 'Cold Plains Exit', 3, coldPlainsConstraint?.blockCoord, coldPlainsConstraint?.tileCoord)
|
||||||
} else if (placeTownTransition('north', 1)) {
|
} else if (placeTownTransition('north', 1)) {
|
||||||
addBorderGate(rng.int(0, 1) === 0 ? 'bottom' : 'right', 3, 'Cold Plains Exit', 3)
|
addBorderGate(exitEdge, 3, 'Cold Plains Exit', 3, coldPlainsConstraint?.blockCoord, coldPlainsConstraint?.tileCoord)
|
||||||
} else {
|
} else {
|
||||||
addBorderGate('left', 3, 'Town Entry', 1)
|
addBorderGate('left', 3, 'Town Entry', 1)
|
||||||
addBorderGate('right', 3, 'Cold Plains Exit', 3)
|
addBorderGate(exitEdge, 3, 'Cold Plains Exit', 3, coldPlainsConstraint?.blockCoord, coldPlainsConstraint?.tileCoord)
|
||||||
}
|
}
|
||||||
placeInteriorPresets(2)
|
placeInteriorPresets(2)
|
||||||
} else if (id === 3) {
|
} else if (id === 3) {
|
||||||
|
|
@ -2176,6 +2434,10 @@ export function planAct1OutdoorLevel(
|
||||||
addBorderGate('right', 3, 'Exit Gate')
|
addBorderGate('right', 3, 'Exit Gate')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (stats !== undefined) {
|
||||||
|
stats.gateOpenings = gateOpenings
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
openGates,
|
openGates,
|
||||||
claimedBorderBlocks,
|
claimedBorderBlocks,
|
||||||
|
|
@ -2183,6 +2445,7 @@ export function planAct1OutdoorLevel(
|
||||||
roadObstacleBlocks,
|
roadObstacleBlocks,
|
||||||
specialPresets,
|
specialPresets,
|
||||||
anchors,
|
anchors,
|
||||||
|
gateOpenings,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5548,6 +5811,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
anchors: 0,
|
anchors: 0,
|
||||||
specialPresets: [],
|
specialPresets: [],
|
||||||
entrances: [],
|
entrances: [],
|
||||||
|
gateOpenings: [],
|
||||||
proceduralRoadCells: 0,
|
proceduralRoadCells: 0,
|
||||||
proceduralDividerCells: 0,
|
proceduralDividerCells: 0,
|
||||||
proceduralWaterCells: 0,
|
proceduralWaterCells: 0,
|
||||||
|
|
@ -5570,6 +5834,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
borderStamped: 0, groundCells: 0, groundTile: null, groundTiles: undefined, sizeSource,
|
borderStamped: 0, groundCells: 0, groundTile: null, groundTiles: undefined, sizeSource,
|
||||||
roadCells: 0, roadSegments: 0, anchors: 0, specialPresets: [], entrances: [],
|
roadCells: 0, roadSegments: 0, anchors: 0, specialPresets: [], entrances: [],
|
||||||
proceduralRoadCells: 0, proceduralDividerCells: 0, proceduralWaterCells: 0, proceduralWallCells: 0,
|
proceduralRoadCells: 0, proceduralDividerCells: 0, proceduralWaterCells: 0, proceduralWallCells: 0,
|
||||||
|
gateOpenings: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
let wallLayers = 1
|
let wallLayers = 1
|
||||||
|
|
@ -5755,10 +6020,13 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
// existing packs and the generator verifier reproduce byte for byte.
|
// existing packs and the generator verifier reproduce byte for byte.
|
||||||
let act1Plan: Act1OutdoorPlan | undefined
|
let act1Plan: Act1OutdoorPlan | undefined
|
||||||
let dirtPathGrid: Uint8Array | undefined
|
let dirtPathGrid: Uint8Array | undefined
|
||||||
if (request.levelTypeName === 'Act 1 - Wilderness' || request.gates !== undefined) {
|
if (request.levelTypeName === 'Act 1 - Wilderness' || request.gates !== undefined || request.seamConstraints !== undefined) {
|
||||||
act1Plan = planAct1OutdoorLevel(request, gridWidth, gridHeight, rng, stats)
|
act1Plan = planAct1OutdoorLevel(request, gridWidth, gridHeight, rng, stats)
|
||||||
stats.anchors = act1Plan.anchors.length
|
stats.anchors = act1Plan.anchors.length
|
||||||
stats.entrances = toEntrances(act1Plan.anchors)
|
stats.entrances = toEntrances(act1Plan.anchors)
|
||||||
|
if (act1Plan.gateOpenings !== undefined) {
|
||||||
|
stats.gateOpenings = act1Plan.gateOpenings
|
||||||
|
}
|
||||||
const vertexResult = createDrlgVertices(act1Plan, gridWidth, gridHeight, rng)
|
const vertexResult = createDrlgVertices(act1Plan, gridWidth, gridHeight, rng)
|
||||||
stats.drlgVertices = vertexResult.vertices
|
stats.drlgVertices = vertexResult.vertices
|
||||||
stats.roadPolylines = vertexResult.polylines
|
stats.roadPolylines = vertexResult.polylines
|
||||||
|
|
@ -5784,6 +6052,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
request.openGates ?? act1Plan?.openGates,
|
request.openGates ?? act1Plan?.openGates,
|
||||||
request.claimedBorderBlocks ?? act1Plan?.claimedBorderBlocks,
|
request.claimedBorderBlocks ?? act1Plan?.claimedBorderBlocks,
|
||||||
request.gates,
|
request.gates,
|
||||||
|
request.seamConstraints,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Step 4B: Stamp anchored special presets (Town Transition, Den of Evil, Cave Entrance, Cairn Stones, etc.)
|
// Step 4B: Stamp anchored special presets (Town Transition, Den of Evil, Cave Entrance, Cairn Stones, etc.)
|
||||||
|
|
@ -6007,6 +6276,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
roadSegments: stats.roadSegments,
|
roadSegments: stats.roadSegments,
|
||||||
anchors: stats.anchors,
|
anchors: stats.anchors,
|
||||||
entrances: stats.entrances,
|
entrances: stats.entrances,
|
||||||
|
gateOpenings: stats.gateOpenings ?? act1Plan?.gateOpenings ?? [],
|
||||||
specialPresets: stats.specialPresets,
|
specialPresets: stats.specialPresets,
|
||||||
drlgVertices: stats.drlgVertices,
|
drlgVertices: stats.drlgVertices,
|
||||||
roadPolylines: stats.roadPolylines,
|
roadPolylines: stats.roadPolylines,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,412 @@
|
||||||
|
import { describe, expect, test } from "vitest"
|
||||||
|
import {
|
||||||
|
generateWilderness,
|
||||||
|
getPerimeterOpenings,
|
||||||
|
type WildernessPiece,
|
||||||
|
type SeamConstraint,
|
||||||
|
type GateOpening,
|
||||||
|
type PlannedGate,
|
||||||
|
} from "../src/game/wilderness.ts"
|
||||||
|
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
|
||||||
|
|
||||||
|
function makeMockDs1(width: number, height: number, hasGapInWalls = false): Ds1 {
|
||||||
|
const cells: Ds1Cell[][] = []
|
||||||
|
for (let y = 0; y < height; y += 1) {
|
||||||
|
const row: Ds1Cell[] = []
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
const isGap = hasGapInWalls && x === Math.floor(width / 2)
|
||||||
|
row.push({
|
||||||
|
walls: isGap ? [] : [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
|
||||||
|
floors: [{ prop1: 2, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }],
|
||||||
|
shadows: [],
|
||||||
|
substitutions: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
cells.push(row)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: 18,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
act: 1,
|
||||||
|
substitutionType: 0,
|
||||||
|
wallLayers: 1,
|
||||||
|
floorLayers: 1,
|
||||||
|
cells,
|
||||||
|
objects: [],
|
||||||
|
npcPathOffset: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTestPieces(): WildernessPiece[] {
|
||||||
|
return [
|
||||||
|
{ name: "Act 1 - Wild Border 1", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
|
||||||
|
{ name: "Act 1 - Wild Border 2", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
|
||||||
|
{ name: "Act 1 - Wild Border 3", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
|
||||||
|
{ name: "Act 1 - Wild Border 4", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
|
||||||
|
{ name: "Act 1 - Wild Border 5", border: true, levels: [makeMockDs1(8, 8, false)] },
|
||||||
|
{ name: "Act 1 - Wild Border 6", border: true, levels: [makeMockDs1(8, 8, false)] },
|
||||||
|
{ name: "Act 1 - Wild Border 7", border: true, levels: [makeMockDs1(8, 8, false)] },
|
||||||
|
{ name: "Act 1 - Wild Border 8", border: true, levels: [makeMockDs1(8, 8, false)] },
|
||||||
|
{ name: "Act 1 - Wild Border 9", border: true, levels: [makeMockDs1(8, 8, false)] },
|
||||||
|
{ name: "Act 1 - Wild Border 10", border: true, levels: [makeMockDs1(8, 8, false)] },
|
||||||
|
{ name: "Act 1 - Wild Border 11", border: true, levels: [makeMockDs1(8, 8, false)] },
|
||||||
|
{ name: "Act 1 - Wild Border 12", border: true, levels: [makeMockDs1(8, 8, false)] },
|
||||||
|
{ name: "Act 1 - Town Transition East", border: true, levels: [makeMockDs1(8, 8, true)] },
|
||||||
|
{ name: "Act 1 - Town Transition South", border: true, levels: [makeMockDs1(8, 8, true)] },
|
||||||
|
{ name: "Act 1 - Fallen Camp Bishibosh", border: false, levels: [makeMockDs1(8, 8, true)] },
|
||||||
|
{ name: "Act 1 - Cairn Stones", border: false, levels: [makeMockDs1(8, 8, true)] },
|
||||||
|
{ name: "Act 1 - Inifus", border: false, levels: [makeMockDs1(8, 8, true)] },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", () => {
|
||||||
|
describe("getPerimeterOpenings unit tests", () => {
|
||||||
|
test("uses forced gate.blockCoord directly", () => {
|
||||||
|
const gates: PlannedGate[] = [
|
||||||
|
{ label: "Cold Plains", side: "east", toLevelId: 3, blockCoord: 5 },
|
||||||
|
]
|
||||||
|
const openings = getPerimeterOpenings(2, 10, 10, gates)
|
||||||
|
// East side is at x = 9, y = 5
|
||||||
|
expect(openings.has("9,5")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clamps gate.blockCoord away from corners [1, size - 2]", () => {
|
||||||
|
const gates: PlannedGate[] = [
|
||||||
|
{ label: "Stony Field", side: "north", toLevelId: 4, blockCoord: 0 }, // Out of bounds / corner -> clamp to 1
|
||||||
|
{ label: "Dark Wood", side: "south", toLevelId: 5, blockCoord: 9 }, // Out of bounds / corner -> clamp to 8
|
||||||
|
]
|
||||||
|
const openings = getPerimeterOpenings(3, 10, 10, gates)
|
||||||
|
// North opening at x = 1, y = 0
|
||||||
|
expect(openings.has("1,0")).toBe(true)
|
||||||
|
// South opening at x = 8, y = 9
|
||||||
|
expect(openings.has("8,9")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("matches seamConstraints by side and level ID", () => {
|
||||||
|
const gates: PlannedGate[] = [
|
||||||
|
{ label: "Blood Moor", side: "west", toLevelId: 2 },
|
||||||
|
]
|
||||||
|
const seamConstraints: SeamConstraint[] = [
|
||||||
|
{ side: "west", fromLevelId: 2, toLevelId: 3, blockCoord: 4 },
|
||||||
|
]
|
||||||
|
const openings = getPerimeterOpenings(3, 10, 10, gates, seamConstraints)
|
||||||
|
// West opening at x = 0, y = 4
|
||||||
|
expect(openings.has("0,4")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("converts tileCoord to blockCoord when blockCoord is not specified", () => {
|
||||||
|
const gates: PlannedGate[] = [
|
||||||
|
{ label: "Stony Field", side: "north", toLevelId: 4, tileCoord: { x: 48, y: 0 } },
|
||||||
|
]
|
||||||
|
// tile x: 48 -> block x: 48 / 8 = 6
|
||||||
|
const openings = getPerimeterOpenings(3, 10, 10, gates)
|
||||||
|
expect(openings.has("6,0")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles extreme out-of-bounds coordinates safely", () => {
|
||||||
|
const gates: PlannedGate[] = [
|
||||||
|
{ label: "Blood Moor", side: "west", toLevelId: 2, blockCoord: -100 },
|
||||||
|
{ label: "Cold Plains", side: "east", toLevelId: 3, blockCoord: 1000 },
|
||||||
|
]
|
||||||
|
const openings = getPerimeterOpenings(2, 10, 10, gates)
|
||||||
|
expect(openings.has("0,1")).toBe(true)
|
||||||
|
expect(openings.has("9,8")).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Sequential generation with seam interlocking (Blood Moor -> Cold Plains -> Stony Field)", () => {
|
||||||
|
test("interlocks Blood Moor East exit with Cold Plains West entrance", () => {
|
||||||
|
const pieces = makeTestPieces()
|
||||||
|
|
||||||
|
// Step 1: Generate Blood Moor (level 2)
|
||||||
|
const bloodMoorResult = generateWilderness({
|
||||||
|
levelId: 2,
|
||||||
|
levelName: "Blood Moor",
|
||||||
|
levelTypeName: "Act 1 - Wilderness",
|
||||||
|
sizeX: 80,
|
||||||
|
sizeY: 80,
|
||||||
|
subType: 6,
|
||||||
|
subTheme: 0,
|
||||||
|
seed: 0x123456,
|
||||||
|
pieces,
|
||||||
|
substitutions: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const bloodMoorOpenings = bloodMoorResult.stats.gateOpenings
|
||||||
|
expect(bloodMoorOpenings).toBeDefined()
|
||||||
|
expect(bloodMoorOpenings!.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Find East exit to Cold Plains (level 3)
|
||||||
|
const eastGate = bloodMoorOpenings!.find(g => g.side === "east" && g.toLevelId === 3)
|
||||||
|
expect(eastGate).toBeDefined()
|
||||||
|
const sharedYBlock = eastGate!.blockCoord
|
||||||
|
expect(sharedYBlock).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(sharedYBlock).toBeLessThanOrEqual(8)
|
||||||
|
|
||||||
|
// Step 2: Generate Cold Plains (level 3) with seam constraint matching Blood Moor's East exit
|
||||||
|
const coldPlainsResult = generateWilderness({
|
||||||
|
levelId: 3,
|
||||||
|
levelName: "Cold Plains",
|
||||||
|
levelTypeName: "Act 1 - Wilderness",
|
||||||
|
sizeX: 80,
|
||||||
|
sizeY: 80,
|
||||||
|
subType: 6,
|
||||||
|
subTheme: 0,
|
||||||
|
seed: 0x654321,
|
||||||
|
pieces,
|
||||||
|
substitutions: [],
|
||||||
|
seamConstraints: [
|
||||||
|
{
|
||||||
|
side: "west",
|
||||||
|
fromLevelId: 2,
|
||||||
|
toLevelId: 3,
|
||||||
|
blockCoord: sharedYBlock,
|
||||||
|
...(eastGate!.tileCoord ? { tileCoord: { x: 0, y: eastGate!.tileCoord.y } } : {}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const coldPlainsOpenings = coldPlainsResult.stats.gateOpenings
|
||||||
|
expect(coldPlainsOpenings).toBeDefined()
|
||||||
|
|
||||||
|
// Find West entrance from Blood Moor (level 2)
|
||||||
|
const westGate = coldPlainsOpenings!.find(g => g.side === "west" && g.toLevelId === 2)
|
||||||
|
expect(westGate).toBeDefined()
|
||||||
|
expect(westGate!.blockCoord).toBe(sharedYBlock)
|
||||||
|
|
||||||
|
// If tileCoord was provided, verify along-seam coordinate matches
|
||||||
|
if (eastGate!.tileCoord && westGate!.tileCoord) {
|
||||||
|
expect(westGate!.tileCoord.y).toBe(eastGate!.tileCoord.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Cold Plains has exit to Stony Field (level 4) on North
|
||||||
|
const northGate = coldPlainsOpenings!.find(g => g.side === "north" && g.toLevelId === 4)
|
||||||
|
expect(northGate).toBeDefined()
|
||||||
|
const sharedXBlock = northGate!.blockCoord
|
||||||
|
expect(sharedXBlock).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(sharedXBlock).toBeLessThanOrEqual(8)
|
||||||
|
|
||||||
|
// Step 4: Generate Stony Field (level 4) with seam constraint on South matching Cold Plains North exit
|
||||||
|
const stonyFieldResult = generateWilderness({
|
||||||
|
levelId: 4,
|
||||||
|
levelName: "Stony Field",
|
||||||
|
levelTypeName: "Act 1 - Wilderness",
|
||||||
|
sizeX: 80,
|
||||||
|
sizeY: 80,
|
||||||
|
subType: 6,
|
||||||
|
subTheme: 0,
|
||||||
|
seed: 0xabcdef,
|
||||||
|
pieces,
|
||||||
|
substitutions: [],
|
||||||
|
seamConstraints: [
|
||||||
|
{
|
||||||
|
side: "south",
|
||||||
|
fromLevelId: 3,
|
||||||
|
toLevelId: 4,
|
||||||
|
blockCoord: sharedXBlock,
|
||||||
|
...(northGate!.tileCoord ? { tileCoord: { x: northGate!.tileCoord.x, y: 79 } } : {}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const stonyFieldOpenings = stonyFieldResult.stats.gateOpenings
|
||||||
|
expect(stonyFieldOpenings).toBeDefined()
|
||||||
|
|
||||||
|
const southGate = stonyFieldOpenings!.find(g => g.side === "south" && g.toLevelId === 3)
|
||||||
|
expect(southGate).toBeDefined()
|
||||||
|
expect(southGate!.blockCoord).toBe(sharedXBlock)
|
||||||
|
|
||||||
|
if (northGate!.tileCoord && southGate!.tileCoord) {
|
||||||
|
expect(southGate!.tileCoord.x).toBe(northGate!.tileCoord.x)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("PlannedGate forced blockCoord and tileCoord in generateWilderness", () => {
|
||||||
|
test("honors gates with explicit blockCoord", () => {
|
||||||
|
const pieces = makeTestPieces()
|
||||||
|
const result = generateWilderness({
|
||||||
|
levelId: 3,
|
||||||
|
levelName: "Cold Plains",
|
||||||
|
levelTypeName: "Act 1 - Wilderness",
|
||||||
|
sizeX: 80,
|
||||||
|
sizeY: 80,
|
||||||
|
subType: 6,
|
||||||
|
subTheme: 0,
|
||||||
|
seed: 0x999999,
|
||||||
|
pieces,
|
||||||
|
substitutions: [],
|
||||||
|
gates: [
|
||||||
|
{ label: "Blood Moor", side: "west", toLevelId: 2, blockCoord: 3 },
|
||||||
|
{ label: "Stony Field", side: "north", toLevelId: 4, blockCoord: 6 },
|
||||||
|
{ label: "Underground", side: "east", toLevelId: 10, blockCoord: 7 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const openings = result.stats.gateOpenings
|
||||||
|
expect(openings).toBeDefined()
|
||||||
|
|
||||||
|
const west = openings!.find(g => g.side === "west")
|
||||||
|
expect(west?.blockCoord).toBe(3)
|
||||||
|
|
||||||
|
const north = openings!.find(g => g.side === "north")
|
||||||
|
expect(north?.blockCoord).toBe(6)
|
||||||
|
|
||||||
|
const east = openings!.find(g => g.side === "east")
|
||||||
|
expect(east?.blockCoord).toBe(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("honors gates with explicit tileCoord and positions road anchors", () => {
|
||||||
|
const pieces = makeTestPieces()
|
||||||
|
const targetTileY = 8 * 4 + 3 // block 4, tile 35
|
||||||
|
const result = generateWilderness({
|
||||||
|
levelId: 3,
|
||||||
|
levelName: "Cold Plains",
|
||||||
|
levelTypeName: "Act 1 - Wilderness",
|
||||||
|
sizeX: 80,
|
||||||
|
sizeY: 80,
|
||||||
|
subType: 6,
|
||||||
|
subTheme: 0,
|
||||||
|
seed: 0x777777,
|
||||||
|
pieces,
|
||||||
|
substitutions: [],
|
||||||
|
gates: [
|
||||||
|
{ label: "Blood Moor", side: "west", toLevelId: 2, tileCoord: { x: 3, y: targetTileY } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const openings = result.stats.gateOpenings
|
||||||
|
expect(openings).toBeDefined()
|
||||||
|
const west = openings!.find(g => g.side === "west")
|
||||||
|
expect(west?.blockCoord).toBe(4)
|
||||||
|
expect(west?.tileCoord).toEqual({ x: 3, y: targetTileY })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Bidirectional level links & seam alignment consistency", () => {
|
||||||
|
test("level A (East) and level B (West) have matching seam and opening block", () => {
|
||||||
|
const pieces = makeTestPieces()
|
||||||
|
const targetSeamY = 5
|
||||||
|
|
||||||
|
const levelA = generateWilderness({
|
||||||
|
levelId: 2,
|
||||||
|
levelName: "Level A",
|
||||||
|
levelTypeName: "Act 1 - Wilderness",
|
||||||
|
sizeX: 80,
|
||||||
|
sizeY: 80,
|
||||||
|
subType: 6,
|
||||||
|
subTheme: 0,
|
||||||
|
seed: 0x111,
|
||||||
|
pieces,
|
||||||
|
substitutions: [],
|
||||||
|
gates: [{ label: "Level B", side: "east", toLevelId: 3, blockCoord: targetSeamY }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const levelB = generateWilderness({
|
||||||
|
levelId: 3,
|
||||||
|
levelName: "Level B",
|
||||||
|
levelTypeName: "Act 1 - Wilderness",
|
||||||
|
sizeX: 80,
|
||||||
|
sizeY: 80,
|
||||||
|
subType: 6,
|
||||||
|
subTheme: 0,
|
||||||
|
seed: 0x222,
|
||||||
|
pieces,
|
||||||
|
substitutions: [],
|
||||||
|
seamConstraints: [{ side: "west", fromLevelId: 2, toLevelId: 3, blockCoord: targetSeamY }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const gateA = levelA.stats.gateOpenings!.find(g => g.side === "east")!
|
||||||
|
const gateB = levelB.stats.gateOpenings!.find(g => g.side === "west")!
|
||||||
|
|
||||||
|
expect(gateA.blockCoord).toBe(targetSeamY)
|
||||||
|
expect(gateB.blockCoord).toBe(targetSeamY)
|
||||||
|
// Along-seam tile coordinate: Y coordinate matches
|
||||||
|
expect(gateA.tileCoord!.y).toBe(gateB.tileCoord!.y)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("level B (North) and level C (South) have matching seam and opening block", () => {
|
||||||
|
const pieces = makeTestPieces()
|
||||||
|
const targetSeamX = 6
|
||||||
|
|
||||||
|
const levelB = generateWilderness({
|
||||||
|
levelId: 3,
|
||||||
|
levelName: "Level B",
|
||||||
|
levelTypeName: "Act 1 - Wilderness",
|
||||||
|
sizeX: 80,
|
||||||
|
sizeY: 80,
|
||||||
|
subType: 6,
|
||||||
|
subTheme: 0,
|
||||||
|
seed: 0x333,
|
||||||
|
pieces,
|
||||||
|
substitutions: [],
|
||||||
|
gates: [{ label: "Level C", side: "north", toLevelId: 4, blockCoord: targetSeamX }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const levelC = generateWilderness({
|
||||||
|
levelId: 4,
|
||||||
|
levelName: "Level C",
|
||||||
|
levelTypeName: "Act 1 - Wilderness",
|
||||||
|
sizeX: 80,
|
||||||
|
sizeY: 80,
|
||||||
|
subType: 6,
|
||||||
|
subTheme: 0,
|
||||||
|
seed: 0x444,
|
||||||
|
pieces,
|
||||||
|
substitutions: [],
|
||||||
|
seamConstraints: [{ side: "south", fromLevelId: 3, toLevelId: 4, blockCoord: targetSeamX }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const gateB = levelB.stats.gateOpenings!.find(g => g.side === "north")!
|
||||||
|
const gateC = levelC.stats.gateOpenings!.find(g => g.side === "south")!
|
||||||
|
|
||||||
|
expect(gateB.blockCoord).toBe(targetSeamX)
|
||||||
|
expect(gateC.blockCoord).toBe(targetSeamX)
|
||||||
|
// Along-seam tile coordinate: X coordinate matches
|
||||||
|
expect(gateB.tileCoord!.x).toBe(gateC.tileCoord!.x)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Out of bounds clamping gracefully preserves valid playable borders", () => {
|
||||||
|
test("clamps negative blockCoord to 1 without crashing", () => {
|
||||||
|
const pieces = makeTestPieces()
|
||||||
|
const result = generateWilderness({
|
||||||
|
levelId: 3,
|
||||||
|
levelName: "Cold Plains",
|
||||||
|
levelTypeName: "Act 1 - Wilderness",
|
||||||
|
sizeX: 80,
|
||||||
|
sizeY: 80,
|
||||||
|
subType: 6,
|
||||||
|
subTheme: 0,
|
||||||
|
seed: 0x555,
|
||||||
|
pieces,
|
||||||
|
substitutions: [],
|
||||||
|
seamConstraints: [{ side: "west", fromLevelId: 2, toLevelId: 3, blockCoord: -10 }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const west = result.stats.gateOpenings!.find(g => g.side === "west")
|
||||||
|
expect(west?.blockCoord).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clamps excessive blockCoord to gridWidth - 2 without crashing", () => {
|
||||||
|
const pieces = makeTestPieces()
|
||||||
|
const result = generateWilderness({
|
||||||
|
levelId: 3,
|
||||||
|
levelName: "Cold Plains",
|
||||||
|
levelTypeName: "Act 1 - Wilderness",
|
||||||
|
sizeX: 80,
|
||||||
|
sizeY: 80,
|
||||||
|
subType: 6,
|
||||||
|
subTheme: 0,
|
||||||
|
seed: 0x666,
|
||||||
|
pieces,
|
||||||
|
substitutions: [],
|
||||||
|
seamConstraints: [{ side: "north", fromLevelId: 4, toLevelId: 3, blockCoord: 999 }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const north = result.stats.gateOpenings!.find(g => g.side === "north")
|
||||||
|
expect(north?.blockCoord).toBe(8) // 80 / 8 = 10 blocks -> max is 10 - 2 = 8
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue