refactor(map-gen/preset): fix variant selection and seam orientation alignment (fixes #42)
This commit is contained in:
parent
1fa3f8b7a2
commit
a01cffa0b5
|
|
@ -8,7 +8,7 @@
|
|||
*
|
||||
* `Levels.txt` `Act N - Town` → level id, act, palette index, level type
|
||||
* `LvlTypes.txt` level type id → `File 1..File 32` (the DT1 libraries)
|
||||
* `LvlPrest.txt` level id (`LevelId`) → `File1..File6` (the DS1 quadrants), Dt1Mask
|
||||
* `LvlPrest.txt` level id (`LevelId`) → `File1..File6` (the DS1 variants / versions), Dt1Mask
|
||||
* `data\\global\\palette\\actN\\pal.pl2` → the act's palette
|
||||
*
|
||||
* Two details cost real time to find and are encoded here:
|
||||
|
|
@ -318,13 +318,13 @@ export function resolveActTown(tables: ActTables, act: number): LevelInfo {
|
|||
export interface LoadedActTown {
|
||||
/** The resolution result. */
|
||||
readonly town: LevelInfo
|
||||
/** The decoded DS1 quadrants. */
|
||||
/** The decoded DS1 variants / versions. */
|
||||
readonly levels: readonly Ds1[]
|
||||
/** The decoded DT1 libraries, in file-list order. */
|
||||
readonly libraries: readonly Dt1[]
|
||||
/** The act palette. */
|
||||
readonly palette: Pl2
|
||||
/** Total cells across the quadrants. */
|
||||
/** Total cells across the variants. */
|
||||
readonly cells: number
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -178,15 +178,15 @@ export const LEVEL_NAMES_ZH: Readonly<Record<number, string>> = {
|
|||
* `CryptCountess1` 末尾也是 `s`,按猜测会错成"南"。
|
||||
*/
|
||||
const DIRECTION_SUFFIXES: Readonly<Record<string, string>> = {
|
||||
townn1: '北',
|
||||
towne1: '东',
|
||||
towns1: '南',
|
||||
townw1: '西',
|
||||
courtn: '北',
|
||||
courte: '东',
|
||||
courtw: '西',
|
||||
lutn: '北',
|
||||
lutw: '西',
|
||||
townn1: '北门',
|
||||
towne1: '东门',
|
||||
towns1: '南门',
|
||||
townw1: '西门',
|
||||
courtn: '北向',
|
||||
courte: '东向',
|
||||
courtw: '西向',
|
||||
lutn: '北门',
|
||||
lutw: '西门',
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
import type { Side } from './world-graph.ts'
|
||||
import { oppositeSide } from './world-graph.ts'
|
||||
|
||||
export { oppositeSide }
|
||||
export type { Side }
|
||||
|
||||
export interface VariantCandidate {
|
||||
readonly label?: string
|
||||
readonly ds1?: string
|
||||
readonly slug?: string
|
||||
readonly act?: number
|
||||
readonly levelId?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the compass direction / orientation from a variant's label or DS1 filename.
|
||||
*
|
||||
* Each variant (e.g. TownN1, TownE1, TownS1, TownW1) represents an independent town
|
||||
* structure with an exit gate in that specific compass direction, not a quadrant.
|
||||
*
|
||||
* - `TownN1` / `townn1` / `LutN` -> `north`
|
||||
* - `TownS1` / `towns1` -> `south`
|
||||
* - `TownE1` / `towne1` / `CourtE` -> `east`
|
||||
* - `TownW1` / `townw1` / `LutW` / `CourtW` -> `west`
|
||||
*/
|
||||
export function variantOrientation(candidate: VariantCandidate): Side | undefined {
|
||||
const key = `${candidate.label ?? ''} ${candidate.ds1 ?? ''}`.toLowerCase()
|
||||
if (key.includes('townn') || key.includes('lutn') || key.includes('courtn') || key.includes('-north')) {
|
||||
return 'north'
|
||||
}
|
||||
if (key.includes('towns') || key.includes('luts') || key.includes('courts') || key.includes('-south')) {
|
||||
return 'south'
|
||||
}
|
||||
if (key.includes('towne') || key.includes('lute') || key.includes('courte') || key.includes('-east')) {
|
||||
return 'east'
|
||||
}
|
||||
if (key.includes('townw') || key.includes('lutw') || key.includes('courtw') || key.includes('-west')) {
|
||||
return 'west'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the candidate variant whose gate / exit orientation matches the required compass side.
|
||||
*
|
||||
* When travelling between abutting levels across a seam, if the player exits level A heading
|
||||
* `fromSide`, the arrival level B must have its corresponding entrance on `oppositeSide(fromSide)`.
|
||||
*/
|
||||
export function findMatchingVariant<T extends VariantCandidate>(
|
||||
candidates: readonly T[],
|
||||
requiredSide?: Side | string,
|
||||
variantSeed = 0,
|
||||
): T | undefined {
|
||||
if (candidates.length === 0) return undefined
|
||||
if (candidates.length === 1 || requiredSide === undefined || requiredSide === '') {
|
||||
return candidates[Math.abs(variantSeed) % candidates.length]
|
||||
}
|
||||
|
||||
const side = requiredSide.toLowerCase() as Side
|
||||
const matched = candidates.find(c => variantOrientation(c) === side)
|
||||
return matched ?? candidates[Math.abs(variantSeed) % candidates.length]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default town variant for a given act when starting fresh in town.
|
||||
*
|
||||
* - In Act 1: The Blood Moor (Level 2) has its palisade gate on the North (`Town S Transition`),
|
||||
* so Rogue Encampment (Level 1) must exit to the South (`TownS1.ds1` / `towns1`).
|
||||
* - In Act 2: Rocky Waste (Level 41) has its town seam on the South (`Act 2 - Town Seam`),
|
||||
* so Lut Gholein (Level 40) must exit to the North (`LutN.ds1` / `lutn`).
|
||||
* - Other acts default to their single town entry.
|
||||
*/
|
||||
export function getDefaultTownVariant<T extends VariantCandidate>(
|
||||
forAct: readonly T[],
|
||||
act: number,
|
||||
): T | undefined {
|
||||
if (act === 1) {
|
||||
const towns = forAct.find(c => variantOrientation(c) === 'south')
|
||||
if (towns !== undefined) return towns
|
||||
} else if (act === 2) {
|
||||
const lutn = forAct.find(c => variantOrientation(c) === 'north')
|
||||
if (lutn !== undefined) return lutn
|
||||
}
|
||||
return forAct.find(c => (c.slug ?? '').includes('town')) ?? forAct[0]
|
||||
}
|
||||
|
|
@ -56,6 +56,7 @@ import type { SceneEntrance, SceneWarp, SceneWaypoint } from '../game/level-link
|
|||
import { SEAM_TRIGGER_SUBTILES, WARP_TRIGGER_SUBTILES } from '../game/level-links.ts'
|
||||
import { FadeOverlay, SEAM_FADE_MS, WARP_FADE_MS, subTileToScene } from './transition.ts'
|
||||
import { TownPortalSlot, WaypointNetwork, townLevelForAct } from '../game/portal.ts'
|
||||
import { findMatchingVariant, getDefaultTownVariant, oppositeSide, type Side } from '../game/variants.ts'
|
||||
import { Minimap } from '../ui/minimap.ts'
|
||||
import type { MinimapLevel, MinimapMarker } from '../ui/minimap.ts'
|
||||
|
||||
|
|
@ -128,7 +129,11 @@ export interface ActSceneState {
|
|||
source: 'pack' | 'live' | ''
|
||||
act: number
|
||||
level: string
|
||||
variant: string
|
||||
variants: string[]
|
||||
/** @deprecated Use `variant` instead. */
|
||||
quadrant: string
|
||||
/** @deprecated Use `variants` instead. */
|
||||
quadrants: string[]
|
||||
/** The archive/pack location actually used. */
|
||||
base: string
|
||||
|
|
@ -203,7 +208,7 @@ declare global {
|
|||
}
|
||||
|
||||
const state: ActSceneState = {
|
||||
ready: false, source: '', act: 1, level: '', quadrant: '', quadrants: [], base: '', roofs: 0,
|
||||
ready: false, source: '', act: 1, level: '', variant: '', variants: [], quadrant: '', quadrants: [], base: '', roofs: 0,
|
||||
cellsX: 0, cellsY: 0, widthPx: 0, heightPx: 0, floors: 0, walls: 0, frames: 0,
|
||||
pagesLoaded: 0, pagesTotal: 0, pagesAtFirstFrame: -1, firstFrameMs: 0, priorityPages: [],
|
||||
character: false, characterMembers: 0, characterFrames: 0, characterGroup: -1,
|
||||
|
|
@ -257,15 +262,14 @@ interface MapRuntime {
|
|||
readonly warps: readonly SceneWarp[]
|
||||
/** The waypoint pedestal, when this level has one. */
|
||||
readonly waypoints: readonly SceneWaypoint[]
|
||||
readonly variants: readonly string[]
|
||||
readonly variantParam: 'level' | 'variant'
|
||||
readonly variant: string
|
||||
/** @deprecated Kept for backward compatibility. Use `variants` instead. */
|
||||
readonly quadrants: readonly string[]
|
||||
/**
|
||||
* Which query parameter names the currently shown map block.
|
||||
*
|
||||
* The pack path selects a prebaked map by its entry label (`?level=`), the live
|
||||
* path selects a DS1 file inside one level (`?quadrant=`). Crossing the two
|
||||
* silently falls back to the act's town, so each runtime declares its own.
|
||||
*/
|
||||
/** @deprecated Kept for backward compatibility. Use `variantParam` instead. */
|
||||
readonly quadrantParam: 'level' | 'quadrant'
|
||||
/** @deprecated Kept for backward compatibility. Use `variant` instead. */
|
||||
readonly quadrant: string
|
||||
readonly cellsX: number
|
||||
readonly cellsY: number
|
||||
|
|
@ -501,7 +505,8 @@ async function loadPackRuntime(
|
|||
? undefined
|
||||
: forAct.find(candidate => candidate.label === wantedLevel)
|
||||
?? forAct.find(candidate => candidate.slug === wantedLevel))
|
||||
?? forAct.find(candidate => candidate.slug === 'town')
|
||||
?? getDefaultTownVariant(forAct, act)
|
||||
?? forAct.find(candidate => candidate.slug.includes('town'))
|
||||
?? forAct[0]!
|
||||
return await buildPackRuntime(packBase, index, entry, viewport, charBases)
|
||||
}
|
||||
|
|
@ -509,16 +514,18 @@ async function loadPackRuntime(
|
|||
/**
|
||||
* Build the runtime for one level, named by id rather than by label.
|
||||
*
|
||||
* Generated levels have three baked variants and the world graph names none of
|
||||
* them, so one is picked here. Which one does not matter for connectivity: the
|
||||
* act layout solve gives all three the same seams on the same edges, so any
|
||||
* variant docks with any neighbour. What does matter is that the choice depends
|
||||
* only on the level, so that the same level is the same map every time it is
|
||||
* entered — see {@link WORLD_VARIANT_SEED}.
|
||||
* When a level has multiple baked variants (such as Rogue Encampment TownN1/E1/S1/W1,
|
||||
* Lut Gholein LutN/LutW, or Cloister Courtyard N/E/W), the variant is selected based
|
||||
* on the connecting border orientation (`fromSide`) so that the gate and road
|
||||
* seamlessly align across the level boundary. If no directional requirement exists,
|
||||
* it picks deterministically via `variantSeed`.
|
||||
*
|
||||
* @param packBase - directory holding `index.json`.
|
||||
* @param levelId - `Levels.txt` `Id` of the wanted level.
|
||||
* @param variantSeed - picks between the variants deterministically.
|
||||
* @param viewport - current viewport dimensions.
|
||||
* @param charBases - character assets bases.
|
||||
* @param fromSide - the compass edge of the source level the player walked off of.
|
||||
* @returns the runtime, or null when the pack has no such level.
|
||||
*/
|
||||
async function loadRuntimeForLevel(
|
||||
|
|
@ -527,11 +534,14 @@ async function loadRuntimeForLevel(
|
|||
variantSeed: number,
|
||||
viewport: { width: number; height: number },
|
||||
charBases: readonly string[],
|
||||
fromSide?: Side,
|
||||
): Promise<MapRuntime | null> {
|
||||
const index = await getPackIndex(packBase)
|
||||
if (index === null) return null
|
||||
const candidates = index.levels.filter(entry => entry.levelId === levelId)
|
||||
const entry = candidates[Math.abs(variantSeed) % Math.max(1, candidates.length)]
|
||||
if (candidates.length === 0) return null
|
||||
const requiredSide = fromSide ? oppositeSide(fromSide) : undefined
|
||||
const entry = findMatchingVariant(candidates, requiredSide, variantSeed)
|
||||
if (entry === undefined) return null
|
||||
return await buildPackRuntime(packBase, index, entry, viewport, charBases)
|
||||
}
|
||||
|
|
@ -677,6 +687,9 @@ async function buildPackRuntime(
|
|||
entrances: scene.entrances ?? [],
|
||||
warps: scene.warps ?? [],
|
||||
waypoints: scene.waypoints ?? [],
|
||||
variants: forAct.map(candidate => candidate.label),
|
||||
variantParam: 'level',
|
||||
variant: entry.label,
|
||||
quadrants: forAct.map(candidate => candidate.label),
|
||||
quadrantParam: 'level',
|
||||
quadrant: entry.label,
|
||||
|
|
@ -818,9 +831,19 @@ async function loadLiveRuntime(
|
|||
const town: LevelInfo = wantedLevel === ''
|
||||
? resolveActTown(tables, act)
|
||||
: resolveLevel(tables, Number(wantedLevel), act)
|
||||
const quadrants = town.ds1Names.map(name => name.split('\\').pop() ?? name)
|
||||
const wanted = wantedQuadrant === '' ? (quadrants[0] ?? '') : wantedQuadrant
|
||||
const index = Math.max(0, quadrants.indexOf(wanted))
|
||||
const wantedVariant = wantedQuadrant || param('variant', '')
|
||||
const variants = town.ds1Names.map(name => name.split('\\').pop() ?? name)
|
||||
let index = 0
|
||||
if (wantedVariant !== '') {
|
||||
const found = variants.indexOf(wantedVariant)
|
||||
if (found !== -1) index = found
|
||||
} else if (act === 1) {
|
||||
const s1 = variants.findIndex(v => v.toLowerCase().includes('towns1'))
|
||||
if (s1 !== -1) index = s1
|
||||
} else if (act === 2) {
|
||||
const n = variants.findIndex(v => v.toLowerCase().includes('lutn'))
|
||||
if (n !== -1) index = n
|
||||
}
|
||||
|
||||
const level = decodeDs1(await archives.read(town.ds1Names[index]!))
|
||||
const libraries = []
|
||||
|
|
@ -867,9 +890,12 @@ async function loadLiveRuntime(
|
|||
entrances: [],
|
||||
warps: [],
|
||||
waypoints: [],
|
||||
quadrants,
|
||||
variants,
|
||||
variantParam: 'variant',
|
||||
variant: variants[index] ?? '',
|
||||
quadrants: variants,
|
||||
quadrantParam: 'quadrant',
|
||||
quadrant: quadrants[index] ?? '',
|
||||
quadrant: variants[index] ?? '',
|
||||
cellsX: scene.cellsX,
|
||||
cellsY: scene.cellsY,
|
||||
widthPx: scene.widthPx,
|
||||
|
|
@ -1535,13 +1561,17 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
|||
}
|
||||
|
||||
if (next.source === 'pack') {
|
||||
history.replaceState(null, '', `?act=${String(next.act)}&level=${encodeURIComponent(next.quadrant)}`)
|
||||
void populatePackSelectors(next.base, { act: next.act, label: next.quadrant })
|
||||
history.replaceState(null, '', `?act=${String(next.act)}&level=${encodeURIComponent(next.variant)}`)
|
||||
void populatePackSelectors(next.base, { act: next.act, label: next.variant })
|
||||
}
|
||||
|
||||
registerWaypoints(next)
|
||||
state.level = next.level
|
||||
state.act = next.act
|
||||
state.variant = next.variant
|
||||
state.variants = [...next.variants]
|
||||
state.quadrant = next.variant
|
||||
state.quadrants = [...next.variants]
|
||||
state.cellsX = next.cellsX
|
||||
state.cellsY = next.cellsY
|
||||
state.pagesTotal = next.pages.length
|
||||
|
|
@ -1558,11 +1588,13 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
|||
* @param toLevelId - the level to move to.
|
||||
* @param fadeMs - how long to black out for.
|
||||
* @param override - an explicit landing sub-tile, for waypoints and portals.
|
||||
* @param fromSide - the compass edge left by the player on the source level.
|
||||
*/
|
||||
const travel = async (
|
||||
toLevelId: number,
|
||||
fadeMs: number,
|
||||
override?: { x: number; y: number },
|
||||
fromSide?: Side,
|
||||
): Promise<void> => {
|
||||
if (travelling || runtime.source !== 'pack') return
|
||||
travelling = true
|
||||
|
|
@ -1576,6 +1608,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
|||
WORLD_VARIANT_SEED + toLevelId,
|
||||
{ width: canvas.width, height: canvas.height },
|
||||
runtime.charBases,
|
||||
fromSide,
|
||||
)
|
||||
if (next === null) {
|
||||
status.textContent = `资源包里没有关卡 ${String(toLevelId)}。`
|
||||
|
|
@ -1622,7 +1655,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
|||
|
||||
for (const entrance of runtime.entrances) {
|
||||
if (!near(entrance.x, entrance.y, SEAM_TRIGGER_SUBTILES)) continue
|
||||
void travel(entrance.toLevelId, SEAM_FADE_MS)
|
||||
void travel(entrance.toLevelId, SEAM_FADE_MS, undefined, entrance.side)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1973,7 +2006,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
|||
// reader and stops the overlay from competing with the frame it describes.
|
||||
if (renderStarted >= hudNextMs) {
|
||||
hudNextMs = renderStarted + HUD_INTERVAL_MS
|
||||
hud.textContent = `${runtime.level} · ${runtime.quadrant} · ${runtime.source === 'pack' ? '资源包' : '读归档'}`
|
||||
hud.textContent = `${runtime.level} · ${runtime.variant} · ${runtime.source === 'pack' ? '资源包' : '读归档'}`
|
||||
+ ` · ${String(runtime.cellsX)}×${String(runtime.cellsY)} 格 · ${String(runtime.floors.length)} 地面 / ${String(runtime.walls.length)} 墙`
|
||||
+ ` · 页 ${String(state.pagesLoaded)}/${String(state.pagesTotal)}`
|
||||
+ ` · 对象 ${String(runtime.objects)} · ${loop.tickRate.toFixed(1)} tps · pos ${engine.world.player.x.toFixed(0)},${engine.world.player.y.toFixed(0)}`
|
||||
|
|
@ -2107,6 +2140,8 @@ function publish(runtime: MapRuntime, base: string): void {
|
|||
state.base = base
|
||||
state.act = runtime.act
|
||||
state.level = runtime.level
|
||||
state.variant = runtime.variant
|
||||
state.variants = [...runtime.variants]
|
||||
state.quadrant = runtime.quadrant
|
||||
state.quadrants = [...runtime.quadrants]
|
||||
state.cellsX = runtime.cellsX
|
||||
|
|
@ -2260,18 +2295,19 @@ function populateLiveSelectors(runtime: MapRuntime): void {
|
|||
option.selected = true
|
||||
sceneSelect.replaceChildren(option)
|
||||
sceneSelect.disabled = false
|
||||
variantSelect.replaceChildren(...runtime.quadrants.map(name => {
|
||||
variantSelect.replaceChildren(...runtime.variants.map(name => {
|
||||
const item = document.createElement('option')
|
||||
item.value = name
|
||||
item.textContent = name
|
||||
item.selected = name === runtime.quadrant
|
||||
item.selected = name === runtime.variant
|
||||
return item
|
||||
}))
|
||||
variantSelect.disabled = runtime.quadrants.length === 0
|
||||
variantSelect.disabled = runtime.variants.length === 0
|
||||
actSelect.onchange = () => {
|
||||
const params = new URLSearchParams(location.search)
|
||||
params.set('act', actSelect.value)
|
||||
params.delete('level')
|
||||
params.delete('variant')
|
||||
params.delete('quadrant')
|
||||
location.search = params.toString()
|
||||
}
|
||||
|
|
@ -2279,8 +2315,9 @@ function populateLiveSelectors(runtime: MapRuntime): void {
|
|||
variantSelect.onchange = () => {
|
||||
const params = new URLSearchParams(location.search)
|
||||
params.set('act', String(runtime.act))
|
||||
params.set(runtime.quadrantParam, variantSelect.value)
|
||||
params.delete(runtime.quadrantParam === 'level' ? 'quadrant' : 'level')
|
||||
params.set(runtime.variantParam, variantSelect.value)
|
||||
params.delete(runtime.variantParam === 'level' ? 'variant' : 'level')
|
||||
params.delete('quadrant')
|
||||
location.search = params.toString()
|
||||
}
|
||||
}
|
||||
|
|
@ -2323,7 +2360,7 @@ async function boot(): Promise<void> {
|
|||
const runtime = await loadPackRuntime(packBase, act, param('level', ''), viewport, charBases)
|
||||
if (runtime === null) continue
|
||||
publish(runtime, packBase)
|
||||
void populatePackSelectors(packBase, { act: runtime.act, label: runtime.quadrant })
|
||||
void populatePackSelectors(packBase, { act: runtime.act, label: runtime.variant })
|
||||
const renderer = new SpriteRenderer(canvas, {
|
||||
onContextLost: () => {
|
||||
status.textContent = 'WebGL 上下文丢失,正在尝试恢复…'
|
||||
|
|
@ -2357,7 +2394,8 @@ async function boot(): Promise<void> {
|
|||
location.reload()
|
||||
},
|
||||
})
|
||||
const runtime = await loadLiveRuntime(base, act, renderer, param('quadrant', ''))
|
||||
const requestedVariant = param('variant', '') || param('quadrant', '')
|
||||
const runtime = await loadLiveRuntime(base, act, renderer, requestedVariant)
|
||||
publish(runtime, base)
|
||||
runScene(runtime, renderer, started)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import { describe, expect, test } from 'vitest'
|
||||
import {
|
||||
findMatchingVariant,
|
||||
getDefaultTownVariant,
|
||||
oppositeSide,
|
||||
variantOrientation,
|
||||
} from '../src/game/variants.ts'
|
||||
|
||||
describe('固定地图变体与朝向匹配 (Issue #42)', () => {
|
||||
test('解析变体方向标识符', () => {
|
||||
expect(variantOrientation({ label: '1-act-1-town-townn1' })).toBe('north')
|
||||
expect(variantOrientation({ label: '1-act-1-town-towne1' })).toBe('east')
|
||||
expect(variantOrientation({ label: '1-act-1-town-towns1' })).toBe('south')
|
||||
expect(variantOrientation({ label: '1-act-1-town-townw1' })).toBe('west')
|
||||
|
||||
expect(variantOrientation({ ds1: 'data\\global\\tiles\\Act2\\Town\\LutN.ds1' })).toBe('north')
|
||||
expect(variantOrientation({ ds1: 'data\\global\\tiles\\Act2\\Town\\LutW.ds1' })).toBe('west')
|
||||
|
||||
expect(variantOrientation({ label: '27-act-1-courtyard-1-courte' })).toBe('east')
|
||||
expect(variantOrientation({ label: '27-act-1-courtyard-1-courtn' })).toBe('north')
|
||||
expect(variantOrientation({ label: '27-act-1-courtyard-1-courtw' })).toBe('west')
|
||||
|
||||
expect(variantOrientation({ label: '8-act-1-cave-1-var1' })).toBeUndefined()
|
||||
})
|
||||
|
||||
test('朝向互逆函数计算', () => {
|
||||
expect(oppositeSide('north')).toBe('south')
|
||||
expect(oppositeSide('south')).toBe('north')
|
||||
expect(oppositeSide('east')).toBe('west')
|
||||
expect(oppositeSide('west')).toBe('east')
|
||||
})
|
||||
|
||||
test('依据相邻区域出口朝向自动匹配互逆的城镇变体', () => {
|
||||
const act1TownVariants = [
|
||||
{ label: '1-act-1-town-townn1', levelId: 1 },
|
||||
{ label: '1-act-1-town-towne1', levelId: 1 },
|
||||
{ label: '1-act-1-town-towns1', levelId: 1 },
|
||||
{ label: '1-act-1-town-townw1', levelId: 1 },
|
||||
]
|
||||
|
||||
// 鲜血荒地(Level 2)在北侧大门离开(fromSide: 'north'),进入城镇要求城镇出口在南侧('south')
|
||||
const matchedSouth = findMatchingVariant(act1TownVariants, oppositeSide('north'))
|
||||
expect(matchedSouth?.label).toBe('1-act-1-town-towns1')
|
||||
|
||||
// 若鲜血荒地在西侧大门离开(fromSide: 'west'),进入城镇要求城镇出口在东侧('east')
|
||||
const matchedEast = findMatchingVariant(act1TownVariants, oppositeSide('west'))
|
||||
expect(matchedEast?.label).toBe('1-act-1-town-towne1')
|
||||
|
||||
// 鲁高因(Level 40)变体匹配
|
||||
const act2TownVariants = [
|
||||
{ label: '40-act-2-town-lutw', ds1: 'LutW.ds1', levelId: 40 },
|
||||
{ label: '40-act-2-town-lutn', ds1: 'LutN.ds1', levelId: 40 },
|
||||
]
|
||||
|
||||
// 碎石荒地在南侧离开(fromSide: 'south'),进入鲁高因要求北门('north')
|
||||
const matchedLutN = findMatchingVariant(act2TownVariants, oppositeSide('south'))
|
||||
expect(matchedLutN?.label).toBe('40-act-2-town-lutn')
|
||||
})
|
||||
|
||||
test('默认城镇变体与生成预置缝合对齐', () => {
|
||||
const act1TownVariants = [
|
||||
{ label: '1-act-1-town-townn1', slug: '1-act-1-town', act: 1 },
|
||||
{ label: '1-act-1-town-towne1', slug: '1-act-1-town', act: 1 },
|
||||
{ label: '1-act-1-town-towns1', slug: '1-act-1-town', act: 1 },
|
||||
{ label: '1-act-1-town-townw1', slug: '1-act-1-town', act: 1 },
|
||||
]
|
||||
|
||||
// Act 1 鲜血荒地默认采用 Town S Transition(在荒地北边界),因此默认城镇应选择 TownS1
|
||||
const defaultAct1 = getDefaultTownVariant(act1TownVariants, 1)
|
||||
expect(defaultAct1?.label).toBe('1-act-1-town-towns1')
|
||||
|
||||
const act2TownVariants = [
|
||||
{ label: '40-act-2-town-lutw', slug: '40-act-2-town', act: 2 },
|
||||
{ label: '40-act-2-town-lutn', slug: '40-act-2-town', act: 2 },
|
||||
]
|
||||
// Act 2 碎石荒地默认在南边界衔接城镇,因此默认鲁高因应选择 LutN
|
||||
const defaultAct2 = getDefaultTownVariant(act2TownVariants, 2)
|
||||
expect(defaultAct2?.label).toBe('40-act-2-town-lutn')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue