diff --git a/src/game/acts.ts b/src/game/acts.ts index 25ada82..6d10208 100644 --- a/src/game/acts.ts +++ b/src/game/acts.ts @@ -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 } diff --git a/src/game/level-names-zh.ts b/src/game/level-names-zh.ts index 4bc7544..bd75d64 100644 --- a/src/game/level-names-zh.ts +++ b/src/game/level-names-zh.ts @@ -178,15 +178,15 @@ export const LEVEL_NAMES_ZH: Readonly> = { * `CryptCountess1` 末尾也是 `s`,按猜测会错成"南"。 */ const DIRECTION_SUFFIXES: Readonly> = { - townn1: '北', - towne1: '东', - towns1: '南', - townw1: '西', - courtn: '北', - courte: '东', - courtw: '西', - lutn: '北', - lutw: '西', + townn1: '北门', + towne1: '东门', + towns1: '南门', + townw1: '西门', + courtn: '北向', + courte: '东向', + courtw: '西向', + lutn: '北门', + lutw: '西门', } /** diff --git a/src/game/variants.ts b/src/game/variants.ts new file mode 100644 index 0000000..542d188 --- /dev/null +++ b/src/game/variants.ts @@ -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( + 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( + 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] +} diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 3e71dac..528457e 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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 { 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 => { 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 { 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 { 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 diff --git a/tests/variants.test.ts b/tests/variants.test.ts new file mode 100644 index 0000000..8d835cb --- /dev/null +++ b/tests/variants.test.ts @@ -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') + }) +})