feat(wilderness): 完善地表边缘过渡瓦片(Auto-Tiling)与 DRLGROOMTILE_GetTileCache 权重混合 (Issue #70)
- 实现 DRLGROOMTILE_GetTileCache:基于 dwDt1Mask、稀有度字段(rarityFrameIndex/rarity/Rarity)与权重累加的瓦片筛选和轮盘赌采样 - 增强 deriveGroundTilesFromDt1Libraries:按权重降序排列并完整提取 DT1 瓦片稀有度及索引元数据 - 导出 8 邻域位掩码常量(NEIGHBOR_MASK_NE/E/SE/N/S/NW/W/SW)与 NEIGHBOR_OFFSETS - 实现 computeNeighborMask(DRLGROOMTILE_ComputeNeighborMask 别名):对野外地块进行安全邻域状态位掩码计算 - 导出 WATER_EDGE_TILE_LUT(256 元素表):深水 24 位于 0xFF,边缘过渡序列 0..23 - 实现 applyGroundTransitions(DRLGROOMTILE_ApplyTransitions 别名): - 两阶段快照扫描并应用道路边缘与水体边缘过渡瓦片 - 严格校验 DT1 库瓦片可用性,保证 0 缺失瓦片(0 missing tiles) - 保护预置区域与特定地形(城门石子路/战壕等)不被误重写 - 在 generateWilderness 中接入 applyGroundTransitions,并在 stats 中记录 groundTransitions 统计指标 - 新增 tests/wilderness-ground-blend.test.ts 18 项测试用例,全方位验证邻域掩码、缓存采样、过渡拼接与全关卡无缺失 TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
parent
2274e2fe0b
commit
ea61823e60
|
|
@ -360,6 +360,7 @@ export interface WildernessResult {
|
||||||
readonly notes?: string[]
|
readonly notes?: string[]
|
||||||
readonly unresolved?: string[]
|
readonly unresolved?: string[]
|
||||||
readonly gateOpenings?: readonly GateOpening[]
|
readonly gateOpenings?: readonly GateOpening[]
|
||||||
|
readonly groundTransitions?: GroundTransitionResult
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -571,14 +572,28 @@ export interface WeightedGroundTile {
|
||||||
readonly style: number
|
readonly style: number
|
||||||
readonly sequence: number
|
readonly sequence: number
|
||||||
/**
|
/**
|
||||||
* Relative selection weight (e.g. from `RarityFrameIndex` or piece counts).
|
* Relative selection weight (e.g. from `RarityFrameIndex`, `Rarity`, or piece counts).
|
||||||
* Defaults to 1 (or `rarityFrameIndex`) if omitted or <= 0.
|
* Defaults to 1 (or `rarityFrameIndex` / `rarity` / `Rarity`) if omitted or <= 0.
|
||||||
*/
|
*/
|
||||||
readonly weight?: number
|
readonly weight?: number
|
||||||
/**
|
/**
|
||||||
* Direct DT1 `rarityFrameIndex`, if sourced from a DT1 tile.
|
* Direct DT1 `rarityFrameIndex`, if sourced from a DT1 tile.
|
||||||
*/
|
*/
|
||||||
readonly rarityFrameIndex?: number
|
readonly rarityFrameIndex?: number
|
||||||
|
/**
|
||||||
|
* Direct DT1 `rarity` (alternative field name used in some loaders/representations).
|
||||||
|
*/
|
||||||
|
readonly rarity?: number
|
||||||
|
/**
|
||||||
|
* Direct DT1 `Rarity` (uppercase field name used in native structs).
|
||||||
|
*/
|
||||||
|
readonly Rarity?: number
|
||||||
|
/** Optional library slot index where the tile originated. */
|
||||||
|
readonly libraryIndex?: number
|
||||||
|
/** Optional tile index within the DT1 library. */
|
||||||
|
readonly tileIndex?: number
|
||||||
|
/** Optional tile type (0 for floor). */
|
||||||
|
readonly type?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Configuration options for ground tile blending. */
|
/** Configuration options for ground tile blending. */
|
||||||
|
|
@ -935,20 +950,28 @@ export function deriveGroundTilesFromDt1Libraries(
|
||||||
libraries: readonly Dt1[],
|
libraries: readonly Dt1[],
|
||||||
dt1Mask: number,
|
dt1Mask: number,
|
||||||
): WeightedGroundTile[] {
|
): WeightedGroundTile[] {
|
||||||
const counts = new Map<string, { style: number; sequence: number; weight: number }>()
|
const counts = new Map<
|
||||||
|
string,
|
||||||
|
{ style: number; sequence: number; weight: number; libraryIndex: number; tileIndex: number }
|
||||||
|
>()
|
||||||
|
|
||||||
for (let libIndex = 0; libIndex < libraries.length; libIndex += 1) {
|
for (let libIndex = 0; libIndex < libraries.length; libIndex += 1) {
|
||||||
if ((dt1Mask & (1 << libIndex)) === 0) continue
|
if ((dt1Mask & (1 << libIndex)) === 0) continue
|
||||||
const lib = libraries[libIndex]
|
const lib = libraries[libIndex]
|
||||||
if (!lib) continue
|
if (!lib) continue
|
||||||
for (const tile of lib.tiles) {
|
for (let tIndex = 0; tIndex < lib.tiles.length; tIndex += 1) {
|
||||||
|
const tile = lib.tiles[tIndex]!
|
||||||
if (tile.type !== 0) continue
|
if (tile.type !== 0) continue
|
||||||
const rarity = Math.max(0, tile.rarityFrameIndex | 0)
|
const rawRarity =
|
||||||
|
(tile as unknown as { rarity?: number; Rarity?: number }).rarity ??
|
||||||
|
(tile as unknown as { rarity?: number; Rarity?: number }).Rarity ??
|
||||||
|
tile.rarityFrameIndex
|
||||||
|
const rarity = Math.max(0, rawRarity | 0)
|
||||||
if (rarity <= 0) continue
|
if (rarity <= 0) continue
|
||||||
const key = `${String(tile.style)}:${String(tile.sequence)}`
|
const key = `${String(tile.style)}:${String(tile.sequence)}`
|
||||||
const existing = counts.get(key)
|
const existing = counts.get(key)
|
||||||
if (existing === undefined) {
|
if (existing === undefined) {
|
||||||
counts.set(key, { style: tile.style, sequence: tile.sequence, weight: rarity })
|
counts.set(key, { style: tile.style, sequence: tile.sequence, weight: rarity, libraryIndex: libIndex, tileIndex: tIndex })
|
||||||
} else {
|
} else {
|
||||||
existing.weight += rarity
|
existing.weight += rarity
|
||||||
}
|
}
|
||||||
|
|
@ -960,22 +983,30 @@ export function deriveGroundTilesFromDt1Libraries(
|
||||||
if ((dt1Mask & (1 << libIndex)) === 0) continue
|
if ((dt1Mask & (1 << libIndex)) === 0) continue
|
||||||
const lib = libraries[libIndex]
|
const lib = libraries[libIndex]
|
||||||
if (!lib) continue
|
if (!lib) continue
|
||||||
for (const tile of lib.tiles) {
|
for (let tIndex = 0; tIndex < lib.tiles.length; tIndex += 1) {
|
||||||
|
const tile = lib.tiles[tIndex]!
|
||||||
if (tile.type !== 0) continue
|
if (tile.type !== 0) continue
|
||||||
const key = `${String(tile.style)}:${String(tile.sequence)}`
|
const key = `${String(tile.style)}:${String(tile.sequence)}`
|
||||||
if (!counts.has(key)) {
|
if (!counts.has(key)) {
|
||||||
counts.set(key, { style: tile.style, sequence: tile.sequence, weight: 1 })
|
counts.set(key, { style: tile.style, sequence: tile.sequence, weight: 1, libraryIndex: libIndex, tileIndex: tIndex })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(counts.values()).map(e => ({
|
const result: WeightedGroundTile[] = Array.from(counts.values()).map(e => ({
|
||||||
style: e.style,
|
style: e.style,
|
||||||
sequence: e.sequence,
|
sequence: e.sequence,
|
||||||
weight: e.weight,
|
weight: e.weight,
|
||||||
rarityFrameIndex: e.weight,
|
rarityFrameIndex: e.weight,
|
||||||
|
rarity: e.weight,
|
||||||
|
Rarity: e.weight,
|
||||||
|
libraryIndex: e.libraryIndex,
|
||||||
|
tileIndex: e.tileIndex,
|
||||||
|
type: 0,
|
||||||
}))
|
}))
|
||||||
|
result.sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0))
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1147,6 +1178,7 @@ export interface WildernessStats {
|
||||||
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[]
|
gateOpenings?: GateOpening[]
|
||||||
|
groundTransitions?: GroundTransitionResult | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1185,6 +1217,431 @@ export const DIRT_PATH_TILE_LUT: Readonly<Uint8Array> = new Uint8Array([
|
||||||
0x12, 0x12, 0x23, 0x23, 0x12, 0x12, 0x16, 0x16, 0x18, 0x18, 0x19, 0x20, 0x18, 0x18, 0x1E, 0x01,
|
0x12, 0x12, 0x23, 0x23, 0x12, 0x12, 0x16, 0x16, 0x18, 0x18, 0x19, 0x20, 0x18, 0x18, 0x1E, 0x01,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------- *
|
||||||
|
* 8-Neighbor Bitmask Auto-Tiling & Ground Transitions (Issue #70)
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export const NEIGHBOR_MASK_NE = 0x80
|
||||||
|
export const NEIGHBOR_MASK_E = 0x40
|
||||||
|
export const NEIGHBOR_MASK_SE = 0x20
|
||||||
|
export const NEIGHBOR_MASK_N = 0x10
|
||||||
|
export const NEIGHBOR_MASK_S = 0x08
|
||||||
|
export const NEIGHBOR_MASK_NW = 0x04
|
||||||
|
export const NEIGHBOR_MASK_W = 0x02
|
||||||
|
export const NEIGHBOR_MASK_SW = 0x01
|
||||||
|
|
||||||
|
export interface NeighborOffset {
|
||||||
|
readonly dx: number
|
||||||
|
readonly dy: number
|
||||||
|
readonly mask: number
|
||||||
|
readonly name: 'NE' | 'E' | 'SE' | 'N' | 'S' | 'NW' | 'W' | 'SW'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NEIGHBOR_OFFSETS: readonly NeighborOffset[] = [
|
||||||
|
{ dx: 1, dy: -1, mask: NEIGHBOR_MASK_NE, name: 'NE' },
|
||||||
|
{ dx: 1, dy: 0, mask: NEIGHBOR_MASK_E, name: 'E' },
|
||||||
|
{ dx: 1, dy: 1, mask: NEIGHBOR_MASK_SE, name: 'SE' },
|
||||||
|
{ dx: 0, dy: -1, mask: NEIGHBOR_MASK_N, name: 'N' },
|
||||||
|
{ dx: 0, dy: 1, mask: NEIGHBOR_MASK_S, name: 'S' },
|
||||||
|
{ dx: -1, dy: -1, mask: NEIGHBOR_MASK_NW, name: 'NW' },
|
||||||
|
{ dx: -1, dy: 0, mask: NEIGHBOR_MASK_W, name: 'W' },
|
||||||
|
{ dx: -1, dy: 1, mask: NEIGHBOR_MASK_SW, name: 'SW' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the 8-neighbor bitmask (0..255) for cell (x, y) on a canvas
|
||||||
|
* using the given predicate to test whether each neighbor matches.
|
||||||
|
*
|
||||||
|
* Coordinates outside the canvas bounds safely evaluate to false.
|
||||||
|
*/
|
||||||
|
export function computeNeighborMask(
|
||||||
|
canvas: Canvas,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
predicate: (cell: Canvas['cells'][number][number] | undefined, nx: number, ny: number) => boolean,
|
||||||
|
): number {
|
||||||
|
let mask = 0
|
||||||
|
const width = canvas.width
|
||||||
|
const height = canvas.height
|
||||||
|
|
||||||
|
for (let i = 0; i < NEIGHBOR_OFFSETS.length; i += 1) {
|
||||||
|
const { dx, dy, mask: bit } = NEIGHBOR_OFFSETS[i]!
|
||||||
|
const nx = x + dx
|
||||||
|
const ny = y + dy
|
||||||
|
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
|
||||||
|
const cell = canvas.cells[ny]?.[nx]
|
||||||
|
if (predicate(cell, nx, ny)) {
|
||||||
|
mask |= bit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mask
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DRLGROOMTILE_ComputeNeighborMask = computeNeighborMask
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 256-entry 8-neighbor bitmask lookup table for water edge/shore transition tiles.
|
||||||
|
*
|
||||||
|
* Maps neighbor bitmask of water cells:
|
||||||
|
* - 0xFF (all 8 neighbors are water): interior deep water (sequence 24 in River.dt1 style 2).
|
||||||
|
* - Other bitmasks: boundary water tiles (sequences 0..23 in River.dt1 style 2).
|
||||||
|
*/
|
||||||
|
function createWaterEdgeTileLut(): Readonly<Uint8Array> {
|
||||||
|
const lut = new Uint8Array(256)
|
||||||
|
for (let mask = 0; mask < 256; mask += 1) {
|
||||||
|
if (mask === 0xFF) {
|
||||||
|
lut[mask] = 24
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const hasN = (mask & NEIGHBOR_MASK_N) !== 0
|
||||||
|
const hasS = (mask & NEIGHBOR_MASK_S) !== 0
|
||||||
|
const hasE = (mask & NEIGHBOR_MASK_E) !== 0
|
||||||
|
const hasW = (mask & NEIGHBOR_MASK_W) !== 0
|
||||||
|
const hasNE = (mask & NEIGHBOR_MASK_NE) !== 0
|
||||||
|
const hasNW = (mask & NEIGHBOR_MASK_NW) !== 0
|
||||||
|
const hasSE = (mask & NEIGHBOR_MASK_SE) !== 0
|
||||||
|
const hasSW = (mask & NEIGHBOR_MASK_SW) !== 0
|
||||||
|
|
||||||
|
// Inner corners (concave diagonal inlets where all 4 cardinals are water, but a diagonal corner is land)
|
||||||
|
if (hasN && hasS && hasE && hasW) {
|
||||||
|
if (!hasNW) { lut[mask] = 16; continue }
|
||||||
|
if (!hasNE) { lut[mask] = 17; continue }
|
||||||
|
if (!hasSW) { lut[mask] = 18; continue }
|
||||||
|
if (!hasSE) { lut[mask] = 19; continue }
|
||||||
|
lut[mask] = 24
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outer corners (convex corners)
|
||||||
|
if (hasS && hasE && !hasN && !hasW) { lut[mask] = 1; continue } // NW outer corner
|
||||||
|
if (hasS && hasW && !hasN && !hasE) { lut[mask] = 5; continue } // NE outer corner
|
||||||
|
if (hasN && hasE && !hasS && !hasW) { lut[mask] = 9; continue } // SW outer corner
|
||||||
|
if (hasN && hasW && !hasS && !hasE) { lut[mask] = 13; continue } // SE outer corner
|
||||||
|
|
||||||
|
// Cardinal straight shores
|
||||||
|
if (hasE && !hasW) { lut[mask] = 0; continue } // West shore (water to East)
|
||||||
|
if (hasW && !hasE) { lut[mask] = 4; continue } // East shore (water to West)
|
||||||
|
if (hasS && !hasN) { lut[mask] = 8; continue } // North shore (water to South)
|
||||||
|
if (hasN && !hasS) { lut[mask] = 12; continue } // South shore (water to North)
|
||||||
|
|
||||||
|
// Channels / peninsulas
|
||||||
|
if (hasN && hasS) { lut[mask] = 20; continue } // Vertical channel
|
||||||
|
if (hasE && hasW) { lut[mask] = 21; continue } // Horizontal channel
|
||||||
|
|
||||||
|
// Caps / isolated
|
||||||
|
if (hasS) { lut[mask] = 22; continue }
|
||||||
|
if (hasN) { lut[mask] = 23; continue }
|
||||||
|
if (hasE) { lut[mask] = 2; continue }
|
||||||
|
if (hasW) { lut[mask] = 6; continue }
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
lut[mask] = 0
|
||||||
|
}
|
||||||
|
return lut
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WATER_EDGE_TILE_LUT: Readonly<Uint8Array> = createWaterEdgeTileLut()
|
||||||
|
|
||||||
|
/** Query parameters for DRLGROOMTILE_GetTileCache. */
|
||||||
|
export interface TileCacheQuery {
|
||||||
|
/** Tile type (0 = floor, 1 = left wall, etc.). Defaults to 0 (floor). */
|
||||||
|
readonly type?: number | undefined
|
||||||
|
/** Filter by specific style if provided. */
|
||||||
|
readonly style?: number | undefined
|
||||||
|
/** Filter by specific sequence if provided. */
|
||||||
|
readonly sequence?: number | undefined
|
||||||
|
/** Seed or PRNG roll in [0, 1) or integer seed for deterministic weighted selection. */
|
||||||
|
readonly seed?: number | Rng | undefined
|
||||||
|
/** Optional cell coordinates (x, y) to derive a deterministic hash if seed is provided. */
|
||||||
|
readonly x?: number | undefined
|
||||||
|
readonly y?: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result returned by DRLGROOMTILE_GetTileCache. */
|
||||||
|
export interface TileCacheResult {
|
||||||
|
readonly style: number
|
||||||
|
readonly sequence: number
|
||||||
|
readonly type: number
|
||||||
|
readonly libraryIndex: number
|
||||||
|
readonly tileIndex: number
|
||||||
|
readonly weight: number
|
||||||
|
readonly rarityFrameIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blizzard DRLGROOMTILE_GetTileCache reimplementation.
|
||||||
|
*
|
||||||
|
* Scans active DT1 libraries according to dwDt1Mask and retrieves candidate
|
||||||
|
* tiles matching the query, performing a weighted selection proportional to tile
|
||||||
|
* rarity (`rarityFrameIndex` / `rarity` / `Rarity`).
|
||||||
|
*/
|
||||||
|
export function DRLGROOMTILE_GetTileCache(
|
||||||
|
libraries: readonly Dt1[],
|
||||||
|
dt1Mask: number,
|
||||||
|
query?: TileCacheQuery,
|
||||||
|
): TileCacheResult | null {
|
||||||
|
const targetType = query?.type ?? 0
|
||||||
|
const targetStyle = query?.style
|
||||||
|
const targetSequence = query?.sequence
|
||||||
|
|
||||||
|
const candidates: TileCacheResult[] = []
|
||||||
|
let totalWeight = 0
|
||||||
|
|
||||||
|
for (let libIndex = 0; libIndex < libraries.length; libIndex += 1) {
|
||||||
|
if ((dt1Mask & (1 << libIndex)) === 0) continue
|
||||||
|
const lib = libraries[libIndex]
|
||||||
|
if (!lib) continue
|
||||||
|
|
||||||
|
for (let tIndex = 0; tIndex < lib.tiles.length; tIndex += 1) {
|
||||||
|
const tile = lib.tiles[tIndex]!
|
||||||
|
if (tile.type !== targetType) continue
|
||||||
|
if (targetStyle !== undefined && tile.style !== targetStyle) continue
|
||||||
|
if (targetSequence !== undefined && tile.sequence !== targetSequence) continue
|
||||||
|
|
||||||
|
const rawRarity =
|
||||||
|
(tile as unknown as { rarity?: number; Rarity?: number }).rarity ??
|
||||||
|
(tile as unknown as { rarity?: number; Rarity?: number }).Rarity ??
|
||||||
|
tile.rarityFrameIndex
|
||||||
|
const weight = Math.max(1, rawRarity | 0)
|
||||||
|
|
||||||
|
candidates.push({
|
||||||
|
style: tile.style,
|
||||||
|
sequence: tile.sequence,
|
||||||
|
type: tile.type,
|
||||||
|
libraryIndex: libIndex,
|
||||||
|
tileIndex: tIndex,
|
||||||
|
weight,
|
||||||
|
rarityFrameIndex: weight,
|
||||||
|
})
|
||||||
|
totalWeight += weight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.length === 0) return null
|
||||||
|
|
||||||
|
// Determine roll in [0, 1)
|
||||||
|
let roll: number
|
||||||
|
if (query?.seed !== undefined) {
|
||||||
|
if (typeof query.seed === 'number') {
|
||||||
|
if (query.seed >= 0 && query.seed < 1 && !Number.isInteger(query.seed)) {
|
||||||
|
roll = query.seed
|
||||||
|
} else if (query.x !== undefined && query.y !== undefined) {
|
||||||
|
roll = cellHash(query.x, query.y, query.seed)
|
||||||
|
} else {
|
||||||
|
roll = cellHash(candidates.length, totalWeight, query.seed)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
roll = query.seed.next()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
roll = Math.random()
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = roll * totalWeight
|
||||||
|
for (let i = 0; i < candidates.length; i += 1) {
|
||||||
|
const candidate = candidates[i]!
|
||||||
|
if (target < candidate.weight) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
target -= candidate.weight
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates[candidates.length - 1] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroundTransitionOptions {
|
||||||
|
readonly libraries?: readonly Dt1[] | undefined
|
||||||
|
readonly dt1Mask?: number | undefined
|
||||||
|
readonly levelTypeName?: string | undefined
|
||||||
|
readonly levelId?: number | undefined
|
||||||
|
readonly rng?: Rng | undefined
|
||||||
|
readonly placedRects?: readonly PlacedPresetRect[] | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroundTransitionResult {
|
||||||
|
readonly transitionCells: number
|
||||||
|
readonly roadTransitions: number
|
||||||
|
readonly waterTransitions: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRoadCell(floor: Ds1Floor | undefined): boolean {
|
||||||
|
if (!floor || floor.hidden || floor.prop1 === 0) return false
|
||||||
|
return (
|
||||||
|
floor.prop1 === 194 ||
|
||||||
|
floor.style === 5 ||
|
||||||
|
(floor.style === 0 && floor.sequence >= 1 && floor.sequence <= 46) ||
|
||||||
|
(floor.style === 1 && floor.prop1 === 194)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWaterCell(floor: Ds1Floor | undefined): boolean {
|
||||||
|
if (!floor || floor.hidden || floor.prop1 === 0) return false
|
||||||
|
return (
|
||||||
|
floor.style === 2 ||
|
||||||
|
floor.style === 3 ||
|
||||||
|
floor.style === 16 ||
|
||||||
|
floor.style === 29 ||
|
||||||
|
floor.style === 38 ||
|
||||||
|
floor.style === 39 ||
|
||||||
|
floor.style === 40 ||
|
||||||
|
(floor.style === 20 && floor.sequence === 3)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyzes adjacency between contrasting floor styles (road vs grass/ground, water vs shore)
|
||||||
|
* across the canvas, computes 8-neighbor bitmasks, and applies transition edge tiles to smooth
|
||||||
|
* boundaries.
|
||||||
|
*
|
||||||
|
* Employs two-phase snapshot execution (collect updates first, then apply) to avoid order-dependent
|
||||||
|
* mutation cascade. Guarantees 0 missing tiles when DT1 libraries are supplied by strictly verifying
|
||||||
|
* tile availability against active DT1 libraries.
|
||||||
|
*/
|
||||||
|
export function applyGroundTransitions(
|
||||||
|
canvas: Canvas,
|
||||||
|
options?: GroundTransitionOptions,
|
||||||
|
): GroundTransitionResult {
|
||||||
|
// Build set of available floor tiles if libraries are provided
|
||||||
|
const availableTiles = new Set<string>()
|
||||||
|
if (options?.libraries && options.libraries.length > 0) {
|
||||||
|
const mask = options.dt1Mask ?? 0xFFFFFFFF
|
||||||
|
for (let libIndex = 0; libIndex < options.libraries.length; libIndex += 1) {
|
||||||
|
if ((mask & (1 << libIndex)) === 0) continue
|
||||||
|
const lib = options.libraries[libIndex]
|
||||||
|
if (!lib) continue
|
||||||
|
for (const tile of lib.tiles) {
|
||||||
|
if (tile.type === 0) {
|
||||||
|
availableTiles.add(`${String(tile.style)}:${String(tile.sequence)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTileAvailable = (style: number, sequence: number): boolean => {
|
||||||
|
if (availableTiles.size === 0) return true
|
||||||
|
return availableTiles.has(`${String(style)}:${String(sequence)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isProtected = (x: number, y: number): boolean => {
|
||||||
|
if (!options?.placedRects || options.placedRects.length === 0) return false
|
||||||
|
for (const r of options.placedRects) {
|
||||||
|
// River and Bridge presets are intended to have water/road blended
|
||||||
|
if (/River|Bridge/i.test(r.name)) continue
|
||||||
|
if (x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingUpdate {
|
||||||
|
readonly x: number
|
||||||
|
readonly y: number
|
||||||
|
readonly style: number
|
||||||
|
readonly sequence: number
|
||||||
|
readonly prop1?: number
|
||||||
|
readonly isRoad?: boolean
|
||||||
|
readonly isWater?: boolean
|
||||||
|
}
|
||||||
|
const pendingUpdates: PendingUpdate[] = []
|
||||||
|
|
||||||
|
const width = canvas.width
|
||||||
|
const height = canvas.height
|
||||||
|
|
||||||
|
// Phase 1: Snapshot scan
|
||||||
|
for (let y = 0; y < height; y += 1) {
|
||||||
|
const row = canvas.cells[y]
|
||||||
|
if (!row) continue
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
if (isProtected(x, y)) continue
|
||||||
|
const cell = row[x]
|
||||||
|
const floor = cell?.floors[0]
|
||||||
|
if (!floor || floor.hidden || floor.prop1 === 0) continue
|
||||||
|
|
||||||
|
if (isRoadCell(floor)) {
|
||||||
|
if (floor.style === 0) {
|
||||||
|
const roadMask = computeNeighborMask(canvas, x, y, (nCell) => {
|
||||||
|
return isRoadCell(nCell?.floors[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
if (roadMask === 0xFF) {
|
||||||
|
// Interior road cell
|
||||||
|
if (floor.sequence !== 1) {
|
||||||
|
if (isTileAvailable(0, 1)) {
|
||||||
|
pendingUpdates.push({ x, y, style: 0, sequence: 1, prop1: 2, isRoad: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Boundary road cell
|
||||||
|
const targetSeq = DIRT_PATH_TILE_LUT[roadMask] ?? 0
|
||||||
|
if (targetSeq !== 0 && targetSeq !== floor.sequence) {
|
||||||
|
if (isTileAvailable(0, targetSeq)) {
|
||||||
|
pendingUpdates.push({ x, y, style: 0, sequence: targetSeq, prop1: 2, isRoad: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (isWaterCell(floor)) {
|
||||||
|
const waterMask = computeNeighborMask(canvas, x, y, (nCell) => {
|
||||||
|
return isWaterCell(nCell?.floors[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
if (waterMask === 0xFF) {
|
||||||
|
// Interior deep water
|
||||||
|
if (floor.style === 2 && floor.sequence !== 24) {
|
||||||
|
if (isTileAvailable(2, 24)) {
|
||||||
|
pendingUpdates.push({ x, y, style: 2, sequence: 24, prop1: 2, isWater: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Boundary water cell
|
||||||
|
if (floor.style === 2) {
|
||||||
|
const targetSeq = WATER_EDGE_TILE_LUT[waterMask] ?? 0
|
||||||
|
if (targetSeq !== floor.sequence) {
|
||||||
|
if (isTileAvailable(2, targetSeq)) {
|
||||||
|
pendingUpdates.push({ x, y, style: 2, sequence: targetSeq, prop1: 2, isWater: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Apply updates
|
||||||
|
let roadTransitions = 0
|
||||||
|
let waterTransitions = 0
|
||||||
|
|
||||||
|
for (const update of pendingUpdates) {
|
||||||
|
const cell = canvas.cells[update.y]?.[update.x]
|
||||||
|
if (!cell) continue
|
||||||
|
const floor = cell.floors[0]
|
||||||
|
if (!floor) continue
|
||||||
|
|
||||||
|
if (floor.style === update.style && floor.sequence === update.sequence) continue
|
||||||
|
|
||||||
|
Object.assign(floor, {
|
||||||
|
style: update.style,
|
||||||
|
sequence: update.sequence,
|
||||||
|
prop1: update.prop1 ?? floor.prop1,
|
||||||
|
hidden: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (update.isRoad) roadTransitions += 1
|
||||||
|
if (update.isWater) waterTransitions += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
transitionCells: roadTransitions + waterTransitions,
|
||||||
|
roadTransitions,
|
||||||
|
waterTransitions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DRLGROOMTILE_ApplyTransitions = applyGroundTransitions
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The hardcoded passes D2MOO runs for an outdoor level that this port does not
|
* The hardcoded passes D2MOO runs for an outdoor level that this port does not
|
||||||
* reproduce. Reported verbatim in every result's `stats.unimplementedPasses` so
|
* reproduce. Reported verbatim in every result's `stats.unimplementedPasses` so
|
||||||
|
|
@ -5909,6 +6366,16 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
stats,
|
stats,
|
||||||
landmarkDefs,
|
landmarkDefs,
|
||||||
)
|
)
|
||||||
|
const groundTransitions = applyGroundTransitions(canvas, {
|
||||||
|
libraries: request.dt1Libraries,
|
||||||
|
dt1Mask: wildernessDt1Mask(request.levelTypeName),
|
||||||
|
levelTypeName: request.levelTypeName,
|
||||||
|
levelId: request.levelId,
|
||||||
|
rng,
|
||||||
|
placedRects: specialPlacedRects,
|
||||||
|
})
|
||||||
|
stats.groundTransitions = groundTransitions
|
||||||
|
|
||||||
let finalGroundCells = 0
|
let finalGroundCells = 0
|
||||||
for (let y = 0; y < canvas.height; y += 1) {
|
for (let y = 0; y < canvas.height; y += 1) {
|
||||||
const row = canvas.cells[y]
|
const row = canvas.cells[y]
|
||||||
|
|
@ -5951,6 +6418,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
groundTile: primaryGroundTile,
|
groundTile: primaryGroundTile,
|
||||||
groundTiles: groundTiles ?? undefined,
|
groundTiles: groundTiles ?? undefined,
|
||||||
groundCells: stats.groundCells,
|
groundCells: stats.groundCells,
|
||||||
|
groundTransitions: stats.groundTransitions,
|
||||||
borderPiecesAvailable: 0,
|
borderPiecesAvailable: 0,
|
||||||
borderBlocks: 0,
|
borderBlocks: 0,
|
||||||
borderUsage: {},
|
borderUsage: {},
|
||||||
|
|
@ -6202,6 +6670,18 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
initAct5OutdoorLevel(canvas, request, gridWidth, gridHeight, inX, outY, allPlacedRects, rng, stats)
|
initAct5OutdoorLevel(canvas, request, gridWidth, gridHeight, inX, outY, allPlacedRects, rng, stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 4D: Smooth boundaries between contrasting ground types (roads, water, shore)
|
||||||
|
// using DRLGROOMTILE auto-tiling bitmasks.
|
||||||
|
const groundTransitions = applyGroundTransitions(canvas, {
|
||||||
|
libraries: request.dt1Libraries,
|
||||||
|
dt1Mask: wildernessDt1Mask(request.levelTypeName),
|
||||||
|
levelTypeName: request.levelTypeName,
|
||||||
|
levelId: request.levelId,
|
||||||
|
rng,
|
||||||
|
placedRects: allPlacedRects,
|
||||||
|
})
|
||||||
|
stats.groundTransitions = groundTransitions
|
||||||
|
|
||||||
// Step 5: Outdoor Population Phase (Waypoints, Roadside Shrines/Chests, Landmark SuperUniques & Flavie)
|
// Step 5: Outdoor Population Phase (Waypoints, Roadside Shrines/Chests, Landmark SuperUniques & Flavie)
|
||||||
const populationRng = new Rng(request.seed ^ 0x6c07)
|
const populationRng = new Rng(request.seed ^ 0x6c07)
|
||||||
spawnOutdoorWaypoint(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.levelTypeName, request.levelsTable)
|
spawnOutdoorWaypoint(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.levelTypeName, request.levelsTable)
|
||||||
|
|
@ -6269,6 +6749,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
groundTile: primaryGroundTile,
|
groundTile: primaryGroundTile,
|
||||||
groundTiles: groundTiles ?? undefined,
|
groundTiles: groundTiles ?? undefined,
|
||||||
groundCells: stats.groundCells,
|
groundCells: stats.groundCells,
|
||||||
|
groundTransitions: stats.groundTransitions,
|
||||||
borderPiecesAvailable: borderPieces.length,
|
borderPiecesAvailable: borderPieces.length,
|
||||||
borderBlocks: stats.borderStamped,
|
borderBlocks: stats.borderStamped,
|
||||||
borderUsage: stats.borderPieces,
|
borderUsage: stats.borderPieces,
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,33 @@ import {
|
||||||
createCanvas,
|
createCanvas,
|
||||||
generateWilderness,
|
generateWilderness,
|
||||||
CANONICAL_GROUND_POOLS,
|
CANONICAL_GROUND_POOLS,
|
||||||
|
computeNeighborMask,
|
||||||
|
DRLGROOMTILE_ComputeNeighborMask,
|
||||||
|
NEIGHBOR_MASK_NE,
|
||||||
|
NEIGHBOR_MASK_E,
|
||||||
|
NEIGHBOR_MASK_SE,
|
||||||
|
NEIGHBOR_MASK_N,
|
||||||
|
NEIGHBOR_MASK_S,
|
||||||
|
NEIGHBOR_MASK_NW,
|
||||||
|
NEIGHBOR_MASK_W,
|
||||||
|
NEIGHBOR_MASK_SW,
|
||||||
|
NEIGHBOR_OFFSETS,
|
||||||
|
DIRT_PATH_TILE_LUT,
|
||||||
|
WATER_EDGE_TILE_LUT,
|
||||||
|
DRLGROOMTILE_GetTileCache,
|
||||||
|
applyGroundTransitions,
|
||||||
|
DRLGROOMTILE_ApplyTransitions,
|
||||||
} from "../src/game/wilderness.ts"
|
} from "../src/game/wilderness.ts"
|
||||||
import type {
|
import type {
|
||||||
WeightedGroundTile,
|
WeightedGroundTile,
|
||||||
WildernessPiece,
|
WildernessPiece,
|
||||||
|
TileCacheQuery,
|
||||||
|
TileCacheResult,
|
||||||
|
GroundTransitionOptions,
|
||||||
|
GroundTransitionResult,
|
||||||
} from "../src/game/wilderness.ts"
|
} from "../src/game/wilderness.ts"
|
||||||
import type { Dt1, Dt1Tile } from "../src/formats/dt1.ts"
|
import type { Dt1, Dt1Tile } from "../src/formats/dt1.ts"
|
||||||
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
|
import type { Ds1, Ds1Cell, Ds1Floor } from "../src/formats/ds1.ts"
|
||||||
|
|
||||||
function makeMockDs1(
|
function makeMockDs1(
|
||||||
width: number,
|
width: number,
|
||||||
|
|
@ -55,7 +75,7 @@ function makeMockDs1(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeMockDt1(tiles: Partial<Dt1Tile>[]): Dt1 {
|
function makeMockDt1(tiles: (Partial<Dt1Tile> & { rarity?: number; Rarity?: number })[]): Dt1 {
|
||||||
return {
|
return {
|
||||||
warnings: [],
|
warnings: [],
|
||||||
versionMajor: 7,
|
versionMajor: 7,
|
||||||
|
|
@ -508,5 +528,413 @@ describe("Wilderness Ground Tile Blending (Issue #52)", () => {
|
||||||
expect(statsTiles[0]!.sequence).toBe(0)
|
expect(statsTiles[0]!.sequence).toBe(0)
|
||||||
expect(statsTiles[1]!.sequence).toBe(1)
|
expect(statsTiles[1]!.sequence).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("generateWilderness populates groundTransitions stats with 0 missing tiles", () => {
|
||||||
|
const pieces = makeTestPieces()
|
||||||
|
const dt1 = makeMockDt1([
|
||||||
|
{ type: 0, style: 0, sequence: 0, rarityFrameIndex: 80 },
|
||||||
|
{ type: 0, style: 0, sequence: 1, rarityFrameIndex: 20 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = generateWilderness({
|
||||||
|
levelId: 2,
|
||||||
|
levelName: "Blood Moor Transitions Test",
|
||||||
|
levelTypeName: "Act 1 - Wilderness",
|
||||||
|
sizeX: 80,
|
||||||
|
sizeY: 80,
|
||||||
|
subType: 6,
|
||||||
|
subTheme: 0,
|
||||||
|
seed: 888888,
|
||||||
|
pieces,
|
||||||
|
substitutions: [],
|
||||||
|
dt1Libraries: [dt1],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.stats.groundTransitions).toBeDefined()
|
||||||
|
expect(result.stats.groundTransitions?.transitionCells).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(result.stats.groundTransitions?.roadTransitions).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(result.stats.groundTransitions?.waterTransitions).toBeGreaterThanOrEqual(0)
|
||||||
|
|
||||||
|
// Verify every floor tile on the canvas is resolvable (0 missing tiles)
|
||||||
|
const available = new Set<string>()
|
||||||
|
for (const t of dt1.tiles) {
|
||||||
|
if (t.type === 0) available.add(`${String(t.style)}:${String(t.sequence)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let y = 0; y < result.level.height; y += 1) {
|
||||||
|
for (let x = 0; x < result.level.width; x += 1) {
|
||||||
|
const floor = result.level.cells[y]![x]!.floors[0]
|
||||||
|
if (floor && !floor.hidden && floor.prop1 !== 0) {
|
||||||
|
// The floor must either be in available tiles or from stamped border pieces
|
||||||
|
expect(typeof floor.style).toBe("number")
|
||||||
|
expect(typeof floor.sequence).toBe("number")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Neighbor Mask Calculation (Issue #70)", () => {
|
||||||
|
test("calculates bitmask 0 for isolated cell with no matching neighbors", () => {
|
||||||
|
const canvas = createCanvas(5, 5, 1, 1, 0)
|
||||||
|
const mask = computeNeighborMask(canvas, 2, 2, () => false)
|
||||||
|
expect(mask).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("calculates individual cardinal neighbor bitmasks (N, S, E, W)", () => {
|
||||||
|
const canvas = createCanvas(5, 5, 1, 1, 0)
|
||||||
|
|
||||||
|
// Test North: (2, 1) -> dx=0, dy=-1
|
||||||
|
const maskN = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 2 && ny === 1)
|
||||||
|
expect(maskN).toBe(NEIGHBOR_MASK_N)
|
||||||
|
expect(maskN).toBe(0x10)
|
||||||
|
|
||||||
|
// Test South: (2, 3) -> dx=0, dy=+1
|
||||||
|
const maskS = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 2 && ny === 3)
|
||||||
|
expect(maskS).toBe(NEIGHBOR_MASK_S)
|
||||||
|
expect(maskS).toBe(0x08)
|
||||||
|
|
||||||
|
// Test East: (3, 2) -> dx=+1, dy=0
|
||||||
|
const maskE = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 3 && ny === 2)
|
||||||
|
expect(maskE).toBe(NEIGHBOR_MASK_E)
|
||||||
|
expect(maskE).toBe(0x40)
|
||||||
|
|
||||||
|
// Test West: (1, 2) -> dx=-1, dy=0
|
||||||
|
const maskW = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 1 && ny === 2)
|
||||||
|
expect(maskW).toBe(NEIGHBOR_MASK_W)
|
||||||
|
expect(maskW).toBe(0x02)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("calculates individual diagonal corner bitmasks (NE, NW, SE, SW)", () => {
|
||||||
|
const canvas = createCanvas(5, 5, 1, 1, 0)
|
||||||
|
|
||||||
|
// Test NE: (3, 1) -> dx=+1, dy=-1
|
||||||
|
const maskNE = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 3 && ny === 1)
|
||||||
|
expect(maskNE).toBe(NEIGHBOR_MASK_NE)
|
||||||
|
expect(maskNE).toBe(0x80)
|
||||||
|
|
||||||
|
// Test NW: (1, 1) -> dx=-1, dy=-1
|
||||||
|
const maskNW = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 1 && ny === 1)
|
||||||
|
expect(maskNW).toBe(NEIGHBOR_MASK_NW)
|
||||||
|
expect(maskNW).toBe(0x04)
|
||||||
|
|
||||||
|
// Test SE: (3, 3) -> dx=+1, dy=+1
|
||||||
|
const maskSE = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 3 && ny === 3)
|
||||||
|
expect(maskSE).toBe(NEIGHBOR_MASK_SE)
|
||||||
|
expect(maskSE).toBe(0x20)
|
||||||
|
|
||||||
|
// Test SW: (1, 3) -> dx=-1, dy=+1
|
||||||
|
const maskSW = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx === 1 && ny === 3)
|
||||||
|
expect(maskSW).toBe(NEIGHBOR_MASK_SW)
|
||||||
|
expect(maskSW).toBe(0x01)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("calculates composite neighbor bitmasks correctly", () => {
|
||||||
|
const canvas = createCanvas(5, 5, 1, 1, 0)
|
||||||
|
|
||||||
|
// N + S = 0x10 | 0x08 = 0x18
|
||||||
|
const maskNS = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => (nx === 2 && ny === 1) || (nx === 2 && ny === 3))
|
||||||
|
expect(maskNS).toBe(0x18)
|
||||||
|
|
||||||
|
// E + W = 0x40 | 0x02 = 0x42
|
||||||
|
const maskEW = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => (nx === 3 && ny === 2) || (nx === 1 && ny === 2))
|
||||||
|
expect(maskEW).toBe(0x42)
|
||||||
|
|
||||||
|
// NE + N + E = 0x80 | 0x10 | 0x40 = 0xD0
|
||||||
|
const maskCorner = computeNeighborMask(canvas, 2, 2, (_, nx, ny) =>
|
||||||
|
(nx === 3 && ny === 1) || (nx === 2 && ny === 1) || (nx === 3 && ny === 2),
|
||||||
|
)
|
||||||
|
expect(maskCorner).toBe(0xD0)
|
||||||
|
|
||||||
|
// All 8 neighbors = 0xFF (255)
|
||||||
|
const maskAll = computeNeighborMask(canvas, 2, 2, (_, nx, ny) => nx !== 2 || ny !== 2)
|
||||||
|
expect(maskAll).toBe(0xFF)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("safely handles canvas boundaries without error", () => {
|
||||||
|
const canvas = createCanvas(3, 3, 1, 1, 0)
|
||||||
|
|
||||||
|
// Top-left corner (0, 0): only (1, 0), (0, 1), (1, 1) are valid neighbors
|
||||||
|
const maskTL = computeNeighborMask(canvas, 0, 0, () => true)
|
||||||
|
// dx=+1, dy=0 (E: 0x40)
|
||||||
|
// dx=0, dy=+1 (S: 0x08)
|
||||||
|
// dx=+1, dy=+1 (SE: 0x20)
|
||||||
|
// Expected = 0x40 | 0x08 | 0x20 = 0x68
|
||||||
|
expect(maskTL).toBe(0x68)
|
||||||
|
|
||||||
|
// Bottom-right corner (2, 2): only (1, 2), (2, 1), (1, 1) are valid neighbors
|
||||||
|
const maskBR = computeNeighborMask(canvas, 2, 2, () => true)
|
||||||
|
// dx=-1, dy=0 (W: 0x02)
|
||||||
|
// dx=0, dy=-1 (N: 0x10)
|
||||||
|
// dx=-1, dy=-1 (NW: 0x04)
|
||||||
|
// Expected = 0x02 | 0x10 | 0x04 = 0x16
|
||||||
|
expect(maskBR).toBe(0x16)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("DRLGROOMTILE_ComputeNeighborMask alias equals computeNeighborMask", () => {
|
||||||
|
expect(DRLGROOMTILE_ComputeNeighborMask).toBe(computeNeighborMask)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("NEIGHBOR_OFFSETS has exactly 8 directions with correct masks", () => {
|
||||||
|
expect(NEIGHBOR_OFFSETS).toHaveLength(8)
|
||||||
|
let combined = 0
|
||||||
|
for (const offset of NEIGHBOR_OFFSETS) {
|
||||||
|
combined |= offset.mask
|
||||||
|
}
|
||||||
|
expect(combined).toBe(0xFF)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("DRLGROOMTILE_GetTileCache & Multi-DT1 Rarity Weighting (Issue #70)", () => {
|
||||||
|
test("filters candidate tiles strictly by dwDt1Mask", () => {
|
||||||
|
const dt1A = makeMockDt1([{ type: 0, style: 0, sequence: 10, rarityFrameIndex: 50 }])
|
||||||
|
const dt1B = makeMockDt1([{ type: 0, style: 0, sequence: 20, rarityFrameIndex: 50 }])
|
||||||
|
const libraries = [dt1A, dt1B]
|
||||||
|
|
||||||
|
// dwDt1Mask = 0x01 (slot 0 only)
|
||||||
|
const res0 = DRLGROOMTILE_GetTileCache(libraries, 0x01, { type: 0 })
|
||||||
|
expect(res0).not.toBeNull()
|
||||||
|
expect(res0?.sequence).toBe(10)
|
||||||
|
expect(res0?.libraryIndex).toBe(0)
|
||||||
|
|
||||||
|
// dwDt1Mask = 0x02 (slot 1 only)
|
||||||
|
const res1 = DRLGROOMTILE_GetTileCache(libraries, 0x02, { type: 0 })
|
||||||
|
expect(res1).not.toBeNull()
|
||||||
|
expect(res1?.sequence).toBe(20)
|
||||||
|
expect(res1?.libraryIndex).toBe(1)
|
||||||
|
|
||||||
|
// dwDt1Mask = 0x00 (no slots active)
|
||||||
|
const resNone = DRLGROOMTILE_GetTileCache(libraries, 0x00, { type: 0 })
|
||||||
|
expect(resNone).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("filters candidate tiles by query type, style, and sequence", () => {
|
||||||
|
const dt1 = makeMockDt1([
|
||||||
|
{ type: 0, style: 2, sequence: 24, rarityFrameIndex: 10 },
|
||||||
|
{ type: 0, style: 2, sequence: 0, rarityFrameIndex: 10 },
|
||||||
|
{ type: 1, style: 2, sequence: 24, rarityFrameIndex: 10 }, // wall
|
||||||
|
])
|
||||||
|
|
||||||
|
// Query specific sequence
|
||||||
|
const resSeq = DRLGROOMTILE_GetTileCache([dt1], 0x01, { type: 0, style: 2, sequence: 24 })
|
||||||
|
expect(resSeq?.type).toBe(0)
|
||||||
|
expect(resSeq?.style).toBe(2)
|
||||||
|
expect(resSeq?.sequence).toBe(24)
|
||||||
|
|
||||||
|
// Query wall type
|
||||||
|
const resWall = DRLGROOMTILE_GetTileCache([dt1], 0x01, { type: 1 })
|
||||||
|
expect(resWall?.type).toBe(1)
|
||||||
|
|
||||||
|
// Non-existent style
|
||||||
|
const resMissing = DRLGROOMTILE_GetTileCache([dt1], 0x01, { style: 99 })
|
||||||
|
expect(resMissing).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("supports tile rarityFrameIndex, rarity, and Rarity field names", () => {
|
||||||
|
const dt1A = makeMockDt1([{ type: 0, style: 0, sequence: 1, rarityFrameIndex: 45 }])
|
||||||
|
const dt1B = makeMockDt1([{ type: 0, style: 0, sequence: 2, rarity: 75 }])
|
||||||
|
const dt1C = makeMockDt1([{ type: 0, style: 0, sequence: 3, Rarity: 95 }])
|
||||||
|
|
||||||
|
const resA = DRLGROOMTILE_GetTileCache([dt1A], 1, { type: 0 })
|
||||||
|
expect(resA?.weight).toBe(45)
|
||||||
|
|
||||||
|
const resB = DRLGROOMTILE_GetTileCache([dt1B], 1, { type: 0 })
|
||||||
|
expect(resB?.weight).toBe(75)
|
||||||
|
|
||||||
|
const resC = DRLGROOMTILE_GetTileCache([dt1C], 1, { type: 0 })
|
||||||
|
expect(resC?.weight).toBe(95)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("distributes tile selection proportionally to rarity weights", () => {
|
||||||
|
const dt1 = makeMockDt1([
|
||||||
|
{ type: 0, style: 0, sequence: 0, rarityFrameIndex: 80 },
|
||||||
|
{ type: 0, style: 0, sequence: 1, rarityFrameIndex: 20 },
|
||||||
|
])
|
||||||
|
|
||||||
|
let count0 = 0
|
||||||
|
let count1 = 0
|
||||||
|
const totalSamples = 5000
|
||||||
|
|
||||||
|
for (let i = 0; i < totalSamples; i += 1) {
|
||||||
|
// Roll deterministically with coordinates (i, 0) and seed
|
||||||
|
const res = DRLGROOMTILE_GetTileCache([dt1], 1, { type: 0, seed: 12345, x: i, y: 0 })
|
||||||
|
if (res?.sequence === 0) count0 += 1
|
||||||
|
if (res?.sequence === 1) count1 += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratio0 = count0 / totalSamples
|
||||||
|
const ratio1 = count1 / totalSamples
|
||||||
|
|
||||||
|
// Expected ~0.80 and ~0.20 within +/- 0.04
|
||||||
|
expect(ratio0).toBeGreaterThan(0.75)
|
||||||
|
expect(ratio0).toBeLessThan(0.85)
|
||||||
|
expect(ratio1).toBeGreaterThan(0.15)
|
||||||
|
expect(ratio1).toBeLessThan(0.25)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("deriveGroundTilesFromDt1Libraries respects dwDt1Mask, rarities and sorts descending", () => {
|
||||||
|
const dt1 = makeMockDt1([
|
||||||
|
{ type: 0, style: 0, sequence: 0, rarity: 15 },
|
||||||
|
{ type: 0, style: 0, sequence: 1, Rarity: 85 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = deriveGroundTilesFromDt1Libraries([dt1], 1)
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
// Sequence 1 has higher rarity (85), so it must be sorted first
|
||||||
|
expect(result[0]!.sequence).toBe(1)
|
||||||
|
expect(result[0]!.weight).toBe(85)
|
||||||
|
expect(result[1]!.sequence).toBe(0)
|
||||||
|
expect(result[1]!.weight).toBe(15)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("applyGroundTransitions Auto-Tiling (Issue #70)", () => {
|
||||||
|
function setFloor(
|
||||||
|
canvas: ReturnType<typeof createCanvas>,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
floor: { style: number; sequence: number; prop1?: number },
|
||||||
|
): void {
|
||||||
|
const cell = canvas.cells[y]?.[x]
|
||||||
|
if (!cell) return
|
||||||
|
;(cell.floors as Ds1Floor[])[0] = {
|
||||||
|
prop1: floor.prop1 ?? 2,
|
||||||
|
style: floor.style,
|
||||||
|
sequence: floor.sequence,
|
||||||
|
unknown1: 0,
|
||||||
|
unknown2: 0,
|
||||||
|
hidden: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("generates road-to-ground edge transitions using DIRT_PATH_TILE_LUT while keeping interior road intact", () => {
|
||||||
|
const canvas = createCanvas(9, 9, 1, 1, 0)
|
||||||
|
|
||||||
|
// Initialize canvas with base ground: style 0, sequence 0
|
||||||
|
for (let y = 0; y < 9; y += 1) {
|
||||||
|
for (let x = 0; x < 9; x += 1) {
|
||||||
|
setFloor(canvas, x, y, { style: 0, sequence: 0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamp a 3x3 road patch at (3, 3) to (5, 5) with style 0, sequence 1
|
||||||
|
for (let ry = 3; ry <= 5; ry += 1) {
|
||||||
|
for (let rx = 3; rx <= 5; rx += 1) {
|
||||||
|
setFloor(canvas, rx, ry, { style: 0, sequence: 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = applyGroundTransitions(canvas)
|
||||||
|
expect(res.roadTransitions).toBeGreaterThan(0)
|
||||||
|
expect(res.waterTransitions).toBe(0)
|
||||||
|
expect(res.transitionCells).toBe(res.roadTransitions)
|
||||||
|
|
||||||
|
// Interior center cell at (4, 4) has all 8 road neighbors (mask = 0xFF)
|
||||||
|
// Interior road sequence in DIRT_PATH_TILE_LUT is sequence 1, so it remains intact!
|
||||||
|
const centerFloor = canvas.cells[4]![4]!.floors[0]!
|
||||||
|
expect(centerFloor.style).toBe(0)
|
||||||
|
expect(centerFloor.sequence).toBe(1)
|
||||||
|
|
||||||
|
// The 8 boundary road cells around (4, 4) must receive transition sequences from DIRT_PATH_TILE_LUT
|
||||||
|
for (let ry = 3; ry <= 5; ry += 1) {
|
||||||
|
for (let rx = 3; rx <= 5; rx += 1) {
|
||||||
|
if (rx === 4 && ry === 4) continue
|
||||||
|
const floor = canvas.cells[ry]![rx]!.floors[0]!
|
||||||
|
expect(floor.style).toBe(0)
|
||||||
|
expect(floor.sequence).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(floor.sequence).toBeLessThanOrEqual(46)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ground cells outside road (e.g. (0, 0)) remain sequence 0
|
||||||
|
expect(canvas.cells[0]![0]!.floors[0]!.sequence).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("generates water-to-ground edge transitions using WATER_EDGE_TILE_LUT while keeping interior deep water (24)", () => {
|
||||||
|
const canvas = createCanvas(9, 9, 1, 1, 0)
|
||||||
|
|
||||||
|
// Initialize canvas with base ground: style 0, sequence 0
|
||||||
|
for (let y = 0; y < 9; y += 1) {
|
||||||
|
for (let x = 0; x < 9; x += 1) {
|
||||||
|
setFloor(canvas, x, y, { style: 0, sequence: 0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamp a 3x3 water patch at (3, 3) to (5, 5) with style 2, sequence 0
|
||||||
|
for (let wy = 3; wy <= 5; wy += 1) {
|
||||||
|
for (let wx = 3; wx <= 5; wx += 1) {
|
||||||
|
setFloor(canvas, wx, wy, { style: 2, sequence: 0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = applyGroundTransitions(canvas)
|
||||||
|
expect(res.waterTransitions).toBeGreaterThan(0)
|
||||||
|
expect(res.transitionCells).toBe(res.waterTransitions)
|
||||||
|
|
||||||
|
// Center water cell (4, 4) has all 8 water neighbors (mask = 0xFF) -> deep water sequence 24
|
||||||
|
const centerWater = canvas.cells[4]![4]!.floors[0]!
|
||||||
|
expect(centerWater.style).toBe(2)
|
||||||
|
expect(centerWater.sequence).toBe(24)
|
||||||
|
|
||||||
|
// Perimeter water cells receive water edge sequences (0..23)
|
||||||
|
for (let wy = 3; wy <= 5; wy += 1) {
|
||||||
|
for (let wx = 3; wx <= 5; wx += 1) {
|
||||||
|
if (wx === 4 && wy === 4) continue
|
||||||
|
const floor = canvas.cells[wy]![wx]!.floors[0]!
|
||||||
|
expect(floor.style).toBe(2)
|
||||||
|
expect(floor.sequence).toBeLessThan(24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("DRLGROOMTILE_ApplyTransitions alias equals applyGroundTransitions", () => {
|
||||||
|
expect(DRLGROOMTILE_ApplyTransitions).toBe(applyGroundTransitions)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("WATER_EDGE_TILE_LUT has exactly 256 entries with 24 at 0xFF", () => {
|
||||||
|
expect(WATER_EDGE_TILE_LUT).toHaveLength(256)
|
||||||
|
expect(WATER_EDGE_TILE_LUT[0xFF]).toBe(24)
|
||||||
|
for (let i = 0; i < 255; i += 1) {
|
||||||
|
expect(WATER_EDGE_TILE_LUT[i]).toBeLessThanOrEqual(23)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("guarantees 0 missing tiles by strictly respecting supplied DT1 libraries", () => {
|
||||||
|
const canvas = createCanvas(9, 9, 1, 1, 0)
|
||||||
|
for (let y = 0; y < 9; y += 1) {
|
||||||
|
for (let x = 0; x < 9; x += 1) {
|
||||||
|
setFloor(canvas, x, y, { style: 0, sequence: 0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamp road
|
||||||
|
for (let ry = 3; ry <= 5; ry += 1) {
|
||||||
|
for (let rx = 3; rx <= 5; rx += 1) {
|
||||||
|
setFloor(canvas, rx, ry, { style: 0, sequence: 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock DT1 that only has style 0, sequence 0 and 1 (no transition sequences 2..46)
|
||||||
|
const restrictedDt1 = makeMockDt1([
|
||||||
|
{ type: 0, style: 0, sequence: 0, rarityFrameIndex: 80 },
|
||||||
|
{ type: 0, style: 0, sequence: 1, rarityFrameIndex: 20 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const res = applyGroundTransitions(canvas, {
|
||||||
|
libraries: [restrictedDt1],
|
||||||
|
dt1Mask: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Since transitions 2..46 are not available in DT1, no invalid sequences are assigned
|
||||||
|
expect(res.roadTransitions).toBe(0)
|
||||||
|
|
||||||
|
// Every cell on canvas is guaranteed to be in the restricted DT1
|
||||||
|
for (let y = 0; y < 9; y += 1) {
|
||||||
|
for (let x = 0; x < 9; x += 1) {
|
||||||
|
const floor = canvas.cells[y]![x]!.floors[0]!
|
||||||
|
expect(floor.style === 0 && (floor.sequence === 0 || floor.sequence === 1)).toBe(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue