refactor(map-gen/preset): fix variant selection and seam orientation alignment (fixes #42)

This commit is contained in:
troytt 2026-09-17 06:18:42 +00:00
parent 1fa3f8b7a2
commit a01cffa0b5
5 changed files with 247 additions and 44 deletions

View File

@ -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
}

View File

@ -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: '西门',
}
/**

85
src/game/variants.ts Normal file
View File

@ -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]
}

View File

@ -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

80
tests/variants.test.ts Normal file
View File

@ -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')
})
})