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
|
* `Levels.txt` `Act N - Town` → level id, act, palette index, level type
|
||||||
* `LvlTypes.txt` level type id → `File 1..File 32` (the DT1 libraries)
|
* `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
|
* `data\\global\\palette\\actN\\pal.pl2` → the act's palette
|
||||||
*
|
*
|
||||||
* Two details cost real time to find and are encoded here:
|
* 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 {
|
export interface LoadedActTown {
|
||||||
/** The resolution result. */
|
/** The resolution result. */
|
||||||
readonly town: LevelInfo
|
readonly town: LevelInfo
|
||||||
/** The decoded DS1 quadrants. */
|
/** The decoded DS1 variants / versions. */
|
||||||
readonly levels: readonly Ds1[]
|
readonly levels: readonly Ds1[]
|
||||||
/** The decoded DT1 libraries, in file-list order. */
|
/** The decoded DT1 libraries, in file-list order. */
|
||||||
readonly libraries: readonly Dt1[]
|
readonly libraries: readonly Dt1[]
|
||||||
/** The act palette. */
|
/** The act palette. */
|
||||||
readonly palette: Pl2
|
readonly palette: Pl2
|
||||||
/** Total cells across the quadrants. */
|
/** Total cells across the variants. */
|
||||||
readonly cells: number
|
readonly cells: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -178,15 +178,15 @@ export const LEVEL_NAMES_ZH: Readonly<Record<number, string>> = {
|
||||||
* `CryptCountess1` 末尾也是 `s`,按猜测会错成"南"。
|
* `CryptCountess1` 末尾也是 `s`,按猜测会错成"南"。
|
||||||
*/
|
*/
|
||||||
const DIRECTION_SUFFIXES: Readonly<Record<string, string>> = {
|
const DIRECTION_SUFFIXES: Readonly<Record<string, string>> = {
|
||||||
townn1: '北',
|
townn1: '北门',
|
||||||
towne1: '东',
|
towne1: '东门',
|
||||||
towns1: '南',
|
towns1: '南门',
|
||||||
townw1: '西',
|
townw1: '西门',
|
||||||
courtn: '北',
|
courtn: '北向',
|
||||||
courte: '东',
|
courte: '东向',
|
||||||
courtw: '西',
|
courtw: '西向',
|
||||||
lutn: '北',
|
lutn: '北门',
|
||||||
lutw: '西',
|
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 { SEAM_TRIGGER_SUBTILES, WARP_TRIGGER_SUBTILES } from '../game/level-links.ts'
|
||||||
import { FadeOverlay, SEAM_FADE_MS, WARP_FADE_MS, subTileToScene } from './transition.ts'
|
import { FadeOverlay, SEAM_FADE_MS, WARP_FADE_MS, subTileToScene } from './transition.ts'
|
||||||
import { TownPortalSlot, WaypointNetwork, townLevelForAct } from '../game/portal.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 { Minimap } from '../ui/minimap.ts'
|
||||||
import type { MinimapLevel, MinimapMarker } from '../ui/minimap.ts'
|
import type { MinimapLevel, MinimapMarker } from '../ui/minimap.ts'
|
||||||
|
|
||||||
|
|
@ -128,7 +129,11 @@ export interface ActSceneState {
|
||||||
source: 'pack' | 'live' | ''
|
source: 'pack' | 'live' | ''
|
||||||
act: number
|
act: number
|
||||||
level: string
|
level: string
|
||||||
|
variant: string
|
||||||
|
variants: string[]
|
||||||
|
/** @deprecated Use `variant` instead. */
|
||||||
quadrant: string
|
quadrant: string
|
||||||
|
/** @deprecated Use `variants` instead. */
|
||||||
quadrants: string[]
|
quadrants: string[]
|
||||||
/** The archive/pack location actually used. */
|
/** The archive/pack location actually used. */
|
||||||
base: string
|
base: string
|
||||||
|
|
@ -203,7 +208,7 @@ declare global {
|
||||||
}
|
}
|
||||||
|
|
||||||
const state: ActSceneState = {
|
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,
|
cellsX: 0, cellsY: 0, widthPx: 0, heightPx: 0, floors: 0, walls: 0, frames: 0,
|
||||||
pagesLoaded: 0, pagesTotal: 0, pagesAtFirstFrame: -1, firstFrameMs: 0, priorityPages: [],
|
pagesLoaded: 0, pagesTotal: 0, pagesAtFirstFrame: -1, firstFrameMs: 0, priorityPages: [],
|
||||||
character: false, characterMembers: 0, characterFrames: 0, characterGroup: -1,
|
character: false, characterMembers: 0, characterFrames: 0, characterGroup: -1,
|
||||||
|
|
@ -257,15 +262,14 @@ interface MapRuntime {
|
||||||
readonly warps: readonly SceneWarp[]
|
readonly warps: readonly SceneWarp[]
|
||||||
/** The waypoint pedestal, when this level has one. */
|
/** The waypoint pedestal, when this level has one. */
|
||||||
readonly waypoints: readonly SceneWaypoint[]
|
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[]
|
readonly quadrants: readonly string[]
|
||||||
/**
|
/** @deprecated Kept for backward compatibility. Use `variantParam` instead. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
readonly quadrantParam: 'level' | 'quadrant'
|
readonly quadrantParam: 'level' | 'quadrant'
|
||||||
|
/** @deprecated Kept for backward compatibility. Use `variant` instead. */
|
||||||
readonly quadrant: string
|
readonly quadrant: string
|
||||||
readonly cellsX: number
|
readonly cellsX: number
|
||||||
readonly cellsY: number
|
readonly cellsY: number
|
||||||
|
|
@ -501,7 +505,8 @@ async function loadPackRuntime(
|
||||||
? undefined
|
? undefined
|
||||||
: forAct.find(candidate => candidate.label === wantedLevel)
|
: forAct.find(candidate => candidate.label === wantedLevel)
|
||||||
?? forAct.find(candidate => candidate.slug === 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]!
|
?? forAct[0]!
|
||||||
return await buildPackRuntime(packBase, index, entry, viewport, charBases)
|
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.
|
* 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
|
* When a level has multiple baked variants (such as Rogue Encampment TownN1/E1/S1/W1,
|
||||||
* them, so one is picked here. Which one does not matter for connectivity: the
|
* Lut Gholein LutN/LutW, or Cloister Courtyard N/E/W), the variant is selected based
|
||||||
* act layout solve gives all three the same seams on the same edges, so any
|
* on the connecting border orientation (`fromSide`) so that the gate and road
|
||||||
* variant docks with any neighbour. What does matter is that the choice depends
|
* seamlessly align across the level boundary. If no directional requirement exists,
|
||||||
* only on the level, so that the same level is the same map every time it is
|
* it picks deterministically via `variantSeed`.
|
||||||
* entered — see {@link WORLD_VARIANT_SEED}.
|
|
||||||
*
|
*
|
||||||
* @param packBase - directory holding `index.json`.
|
* @param packBase - directory holding `index.json`.
|
||||||
* @param levelId - `Levels.txt` `Id` of the wanted level.
|
* @param levelId - `Levels.txt` `Id` of the wanted level.
|
||||||
* @param variantSeed - picks between the variants deterministically.
|
* @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.
|
* @returns the runtime, or null when the pack has no such level.
|
||||||
*/
|
*/
|
||||||
async function loadRuntimeForLevel(
|
async function loadRuntimeForLevel(
|
||||||
|
|
@ -527,11 +534,14 @@ async function loadRuntimeForLevel(
|
||||||
variantSeed: number,
|
variantSeed: number,
|
||||||
viewport: { width: number; height: number },
|
viewport: { width: number; height: number },
|
||||||
charBases: readonly string[],
|
charBases: readonly string[],
|
||||||
|
fromSide?: Side,
|
||||||
): Promise<MapRuntime | null> {
|
): Promise<MapRuntime | null> {
|
||||||
const index = await getPackIndex(packBase)
|
const index = await getPackIndex(packBase)
|
||||||
if (index === null) return null
|
if (index === null) return null
|
||||||
const candidates = index.levels.filter(entry => entry.levelId === levelId)
|
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
|
if (entry === undefined) return null
|
||||||
return await buildPackRuntime(packBase, index, entry, viewport, charBases)
|
return await buildPackRuntime(packBase, index, entry, viewport, charBases)
|
||||||
}
|
}
|
||||||
|
|
@ -677,6 +687,9 @@ async function buildPackRuntime(
|
||||||
entrances: scene.entrances ?? [],
|
entrances: scene.entrances ?? [],
|
||||||
warps: scene.warps ?? [],
|
warps: scene.warps ?? [],
|
||||||
waypoints: scene.waypoints ?? [],
|
waypoints: scene.waypoints ?? [],
|
||||||
|
variants: forAct.map(candidate => candidate.label),
|
||||||
|
variantParam: 'level',
|
||||||
|
variant: entry.label,
|
||||||
quadrants: forAct.map(candidate => candidate.label),
|
quadrants: forAct.map(candidate => candidate.label),
|
||||||
quadrantParam: 'level',
|
quadrantParam: 'level',
|
||||||
quadrant: entry.label,
|
quadrant: entry.label,
|
||||||
|
|
@ -818,9 +831,19 @@ async function loadLiveRuntime(
|
||||||
const town: LevelInfo = wantedLevel === ''
|
const town: LevelInfo = wantedLevel === ''
|
||||||
? resolveActTown(tables, act)
|
? resolveActTown(tables, act)
|
||||||
: resolveLevel(tables, Number(wantedLevel), act)
|
: resolveLevel(tables, Number(wantedLevel), act)
|
||||||
const quadrants = town.ds1Names.map(name => name.split('\\').pop() ?? name)
|
const wantedVariant = wantedQuadrant || param('variant', '')
|
||||||
const wanted = wantedQuadrant === '' ? (quadrants[0] ?? '') : wantedQuadrant
|
const variants = town.ds1Names.map(name => name.split('\\').pop() ?? name)
|
||||||
const index = Math.max(0, quadrants.indexOf(wanted))
|
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 level = decodeDs1(await archives.read(town.ds1Names[index]!))
|
||||||
const libraries = []
|
const libraries = []
|
||||||
|
|
@ -867,9 +890,12 @@ async function loadLiveRuntime(
|
||||||
entrances: [],
|
entrances: [],
|
||||||
warps: [],
|
warps: [],
|
||||||
waypoints: [],
|
waypoints: [],
|
||||||
quadrants,
|
variants,
|
||||||
|
variantParam: 'variant',
|
||||||
|
variant: variants[index] ?? '',
|
||||||
|
quadrants: variants,
|
||||||
quadrantParam: 'quadrant',
|
quadrantParam: 'quadrant',
|
||||||
quadrant: quadrants[index] ?? '',
|
quadrant: variants[index] ?? '',
|
||||||
cellsX: scene.cellsX,
|
cellsX: scene.cellsX,
|
||||||
cellsY: scene.cellsY,
|
cellsY: scene.cellsY,
|
||||||
widthPx: scene.widthPx,
|
widthPx: scene.widthPx,
|
||||||
|
|
@ -1535,13 +1561,17 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
||||||
}
|
}
|
||||||
|
|
||||||
if (next.source === 'pack') {
|
if (next.source === 'pack') {
|
||||||
history.replaceState(null, '', `?act=${String(next.act)}&level=${encodeURIComponent(next.quadrant)}`)
|
history.replaceState(null, '', `?act=${String(next.act)}&level=${encodeURIComponent(next.variant)}`)
|
||||||
void populatePackSelectors(next.base, { act: next.act, label: next.quadrant })
|
void populatePackSelectors(next.base, { act: next.act, label: next.variant })
|
||||||
}
|
}
|
||||||
|
|
||||||
registerWaypoints(next)
|
registerWaypoints(next)
|
||||||
state.level = next.level
|
state.level = next.level
|
||||||
state.act = next.act
|
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.cellsX = next.cellsX
|
||||||
state.cellsY = next.cellsY
|
state.cellsY = next.cellsY
|
||||||
state.pagesTotal = next.pages.length
|
state.pagesTotal = next.pages.length
|
||||||
|
|
@ -1558,11 +1588,13 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
||||||
* @param toLevelId - the level to move to.
|
* @param toLevelId - the level to move to.
|
||||||
* @param fadeMs - how long to black out for.
|
* @param fadeMs - how long to black out for.
|
||||||
* @param override - an explicit landing sub-tile, for waypoints and portals.
|
* @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 (
|
const travel = async (
|
||||||
toLevelId: number,
|
toLevelId: number,
|
||||||
fadeMs: number,
|
fadeMs: number,
|
||||||
override?: { x: number; y: number },
|
override?: { x: number; y: number },
|
||||||
|
fromSide?: Side,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (travelling || runtime.source !== 'pack') return
|
if (travelling || runtime.source !== 'pack') return
|
||||||
travelling = true
|
travelling = true
|
||||||
|
|
@ -1576,6 +1608,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
||||||
WORLD_VARIANT_SEED + toLevelId,
|
WORLD_VARIANT_SEED + toLevelId,
|
||||||
{ width: canvas.width, height: canvas.height },
|
{ width: canvas.width, height: canvas.height },
|
||||||
runtime.charBases,
|
runtime.charBases,
|
||||||
|
fromSide,
|
||||||
)
|
)
|
||||||
if (next === null) {
|
if (next === null) {
|
||||||
status.textContent = `资源包里没有关卡 ${String(toLevelId)}。`
|
status.textContent = `资源包里没有关卡 ${String(toLevelId)}。`
|
||||||
|
|
@ -1622,7 +1655,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
||||||
|
|
||||||
for (const entrance of runtime.entrances) {
|
for (const entrance of runtime.entrances) {
|
||||||
if (!near(entrance.x, entrance.y, SEAM_TRIGGER_SUBTILES)) continue
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1973,7 +2006,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
||||||
// reader and stops the overlay from competing with the frame it describes.
|
// reader and stops the overlay from competing with the frame it describes.
|
||||||
if (renderStarted >= hudNextMs) {
|
if (renderStarted >= hudNextMs) {
|
||||||
hudNextMs = renderStarted + HUD_INTERVAL_MS
|
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(runtime.cellsX)}×${String(runtime.cellsY)} 格 · ${String(runtime.floors.length)} 地面 / ${String(runtime.walls.length)} 墙`
|
||||||
+ ` · 页 ${String(state.pagesLoaded)}/${String(state.pagesTotal)}`
|
+ ` · 页 ${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)}`
|
+ ` · 对象 ${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.base = base
|
||||||
state.act = runtime.act
|
state.act = runtime.act
|
||||||
state.level = runtime.level
|
state.level = runtime.level
|
||||||
|
state.variant = runtime.variant
|
||||||
|
state.variants = [...runtime.variants]
|
||||||
state.quadrant = runtime.quadrant
|
state.quadrant = runtime.quadrant
|
||||||
state.quadrants = [...runtime.quadrants]
|
state.quadrants = [...runtime.quadrants]
|
||||||
state.cellsX = runtime.cellsX
|
state.cellsX = runtime.cellsX
|
||||||
|
|
@ -2260,18 +2295,19 @@ function populateLiveSelectors(runtime: MapRuntime): void {
|
||||||
option.selected = true
|
option.selected = true
|
||||||
sceneSelect.replaceChildren(option)
|
sceneSelect.replaceChildren(option)
|
||||||
sceneSelect.disabled = false
|
sceneSelect.disabled = false
|
||||||
variantSelect.replaceChildren(...runtime.quadrants.map(name => {
|
variantSelect.replaceChildren(...runtime.variants.map(name => {
|
||||||
const item = document.createElement('option')
|
const item = document.createElement('option')
|
||||||
item.value = name
|
item.value = name
|
||||||
item.textContent = name
|
item.textContent = name
|
||||||
item.selected = name === runtime.quadrant
|
item.selected = name === runtime.variant
|
||||||
return item
|
return item
|
||||||
}))
|
}))
|
||||||
variantSelect.disabled = runtime.quadrants.length === 0
|
variantSelect.disabled = runtime.variants.length === 0
|
||||||
actSelect.onchange = () => {
|
actSelect.onchange = () => {
|
||||||
const params = new URLSearchParams(location.search)
|
const params = new URLSearchParams(location.search)
|
||||||
params.set('act', actSelect.value)
|
params.set('act', actSelect.value)
|
||||||
params.delete('level')
|
params.delete('level')
|
||||||
|
params.delete('variant')
|
||||||
params.delete('quadrant')
|
params.delete('quadrant')
|
||||||
location.search = params.toString()
|
location.search = params.toString()
|
||||||
}
|
}
|
||||||
|
|
@ -2279,8 +2315,9 @@ function populateLiveSelectors(runtime: MapRuntime): void {
|
||||||
variantSelect.onchange = () => {
|
variantSelect.onchange = () => {
|
||||||
const params = new URLSearchParams(location.search)
|
const params = new URLSearchParams(location.search)
|
||||||
params.set('act', String(runtime.act))
|
params.set('act', String(runtime.act))
|
||||||
params.set(runtime.quadrantParam, variantSelect.value)
|
params.set(runtime.variantParam, variantSelect.value)
|
||||||
params.delete(runtime.quadrantParam === 'level' ? 'quadrant' : 'level')
|
params.delete(runtime.variantParam === 'level' ? 'variant' : 'level')
|
||||||
|
params.delete('quadrant')
|
||||||
location.search = params.toString()
|
location.search = params.toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2323,7 +2360,7 @@ async function boot(): Promise<void> {
|
||||||
const runtime = await loadPackRuntime(packBase, act, param('level', ''), viewport, charBases)
|
const runtime = await loadPackRuntime(packBase, act, param('level', ''), viewport, charBases)
|
||||||
if (runtime === null) continue
|
if (runtime === null) continue
|
||||||
publish(runtime, packBase)
|
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, {
|
const renderer = new SpriteRenderer(canvas, {
|
||||||
onContextLost: () => {
|
onContextLost: () => {
|
||||||
status.textContent = 'WebGL 上下文丢失,正在尝试恢复…'
|
status.textContent = 'WebGL 上下文丢失,正在尝试恢复…'
|
||||||
|
|
@ -2357,7 +2394,8 @@ async function boot(): Promise<void> {
|
||||||
location.reload()
|
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)
|
publish(runtime, base)
|
||||||
runScene(runtime, renderer, started)
|
runScene(runtime, renderer, started)
|
||||||
return
|
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