Adds level prefabs, theme assets, audio, extensions, and deployment scripts for the Unity WebGL migration. Co-authored-by: Cursor <cursoragent@cursor.com>
319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
import { Direction } from '../core/Define';
|
||
import { LevelConfig, LevelEntityTextures, SpawnConfig, SpawnKind } from '../level/LevelTypes';
|
||
import { resolvePropPlacement } from '../level/EntitySpawnPlacement';
|
||
import type { PropPlacement } from '../level/EntitySpawnPlacement';
|
||
import { getThemeBackground, getThemeEntities, getThemeTextureFolder, isThemeDatabaseReady } from '../theme/ThemeRegistry';
|
||
import {
|
||
entityTextureCandidates,
|
||
vehicleSpriteRoleForDirection,
|
||
vehicleThemeFieldForDirection,
|
||
allVehicleDirectionTextureCandidates,
|
||
type EntitySpriteRole,
|
||
} from './ThemeEntityTextures';
|
||
|
||
const THEME_TEXTURE_FOLDER: Record<string, string> = {
|
||
default: 'default',
|
||
silu: 'silu',
|
||
SILU: 'silu',
|
||
chinese: 'chinese',
|
||
redArmy: 'redArmy',
|
||
redarmy: 'redArmy',
|
||
numMan: 'numMan',
|
||
snow: 'snow',
|
||
sanxing: 'sanxing',
|
||
};
|
||
|
||
function resolveThemeFolder(theme: string | undefined): string {
|
||
if (!theme) return 'silu';
|
||
const trimmed = String(theme).trim();
|
||
if (isThemeDatabaseReady()) return getThemeTextureFolder(trimmed);
|
||
return THEME_TEXTURE_FOLDER[trimmed] ?? THEME_TEXTURE_FOLDER[trimmed.toLowerCase()] ?? trimmed;
|
||
}
|
||
|
||
export interface EntityVisualOptions {
|
||
theme?: string;
|
||
entityTextures?: LevelEntityTextures;
|
||
/** 单个 spawn 的贴图覆盖(可拾取物常用) */
|
||
spawnTexture?: string;
|
||
/** 可拾取物放置高度(Unity Prop / nProp) */
|
||
propPlacement?: PropPlacement;
|
||
}
|
||
|
||
/** 统一为 resources 相对路径,无 .png 后缀 */
|
||
export function normalizeTexturePath(raw: string | undefined): string | undefined {
|
||
if (!raw) return undefined;
|
||
let p = String(raw).trim().replace(/\\/g, '/');
|
||
if (!p) return undefined;
|
||
if (p.startsWith('assets/resources/')) p = p.slice('assets/resources/'.length);
|
||
if (p.startsWith('resources/')) p = p.slice('resources/'.length);
|
||
if (p.startsWith('/')) p = p.slice(1);
|
||
if (p.endsWith('.png')) p = p.slice(0, -4);
|
||
return p;
|
||
}
|
||
|
||
function themePropCandidates(theme: string | undefined): string[] {
|
||
const folder = resolveThemeFolder(theme);
|
||
return [
|
||
`textures/${folder}/Prop_kuai1`,
|
||
'textures/silu/Prop_kuai1',
|
||
];
|
||
}
|
||
|
||
function themeGroundPropCandidates(theme: string | undefined): string[] {
|
||
const folder = resolveThemeFolder(theme);
|
||
return [
|
||
`textures/${folder}/nProp_kuai1`,
|
||
'textures/silu/nProp_kuai1',
|
||
];
|
||
}
|
||
|
||
/** Prop_kuai2 → nProp_kuai2;Prop → nProp */
|
||
export function toGroundPropTexturePath(blockPath: string | undefined): string | undefined {
|
||
const norm = normalizeTexturePath(blockPath);
|
||
if (!norm) return undefined;
|
||
const slash = norm.lastIndexOf('/');
|
||
const dir = slash >= 0 ? norm.slice(0, slash + 1) : '';
|
||
const file = slash >= 0 ? norm.slice(slash + 1) : norm;
|
||
if (file.startsWith('Prop_')) return `${dir}n${file}`;
|
||
if (file === 'Prop') return `${dir}nProp`;
|
||
if (file.startsWith('nProp')) return norm;
|
||
return norm.replace(/\/Prop([^/]*)$/, '/nProp$1');
|
||
}
|
||
|
||
function themeDbEntityPath(
|
||
theme: string | undefined,
|
||
field: keyof NonNullable<ReturnType<typeof getThemeEntities>>,
|
||
): string | undefined {
|
||
const ent = getThemeEntities(theme);
|
||
if (!ent) return undefined;
|
||
return normalizeTexturePath(ent[field]);
|
||
}
|
||
|
||
function themeDbEntityCandidates(
|
||
theme: string | undefined,
|
||
field: keyof NonNullable<ReturnType<typeof getThemeEntities>>,
|
||
role: EntitySpriteRole,
|
||
): string[] {
|
||
const db = themeDbEntityPath(theme, field);
|
||
const legacy = entityTextureCandidates(theme, role);
|
||
return withFallback(db, legacy);
|
||
}
|
||
|
||
function levelVehicleTextureOverride(
|
||
direction: Direction,
|
||
entityTextures: LevelEntityTextures | undefined,
|
||
): string | undefined {
|
||
if (!entityTextures) return undefined;
|
||
const field = vehicleThemeFieldForDirection(direction);
|
||
const direct = normalizeTexturePath(entityTextures[field]);
|
||
if (direct) return direct;
|
||
const front = isEntityFront(direction);
|
||
return normalizeTexturePath(
|
||
front ? entityTextures.vehicleFront : entityTextures.vehicleBack,
|
||
);
|
||
}
|
||
|
||
function withFallback(primary: string | undefined, fallbacks: string[]): string[] {
|
||
const out: string[] = [];
|
||
const norm = normalizeTexturePath(primary);
|
||
if (norm) out.push(norm);
|
||
for (const p of fallbacks) {
|
||
if (!out.includes(p)) out.push(p);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function themePropCandidatesWithDb(theme: string | undefined): string[] {
|
||
const db = themeDbEntityPath(theme, 'prop');
|
||
if (db) return [db];
|
||
return themePropCandidates(theme);
|
||
}
|
||
|
||
function themeGroundPropCandidatesWithDb(
|
||
theme: string | undefined,
|
||
blockPropPath?: string,
|
||
): string[] {
|
||
const dbGround = themeDbEntityPath(theme, 'propGround');
|
||
if (dbGround) return [dbGround];
|
||
const derived = toGroundPropTexturePath(blockPropPath ?? themeDbEntityPath(theme, 'prop'));
|
||
if (derived) return [derived];
|
||
return themeGroundPropCandidates(theme);
|
||
}
|
||
|
||
export function isEntityFront(direction: Direction | undefined): boolean {
|
||
return direction === Direction.South || direction === Direction.East;
|
||
}
|
||
|
||
export function entityFlipX(direction: Direction | undefined): boolean {
|
||
return direction === Direction.West || direction === Direction.East;
|
||
}
|
||
|
||
/** @deprecated 载具已改用四向贴图;玩家仍用 IsFront + flipX */
|
||
export function resolveVehicleOrientation(direction: Direction | undefined): {
|
||
front: boolean;
|
||
flipX: boolean;
|
||
} {
|
||
const dir = direction ?? Direction.North;
|
||
return {
|
||
front: isEntityFront(dir),
|
||
flipX: entityFlipX(dir),
|
||
};
|
||
}
|
||
|
||
export function resolvePlayerTexturePaths(
|
||
direction: Direction | undefined,
|
||
options: EntityVisualOptions = {},
|
||
): string[] {
|
||
const front = isEntityFront(direction);
|
||
const custom = front
|
||
? normalizeTexturePath(options.entityTextures?.playerFront)
|
||
: normalizeTexturePath(options.entityTextures?.playerBack);
|
||
const spawnCustom = normalizeTexturePath(options.spawnTexture);
|
||
const themePaths = themeDbEntityCandidates(
|
||
options.theme,
|
||
front ? 'playerFront' : 'playerBack',
|
||
front ? 'playerFront' : 'playerBack',
|
||
);
|
||
if (spawnCustom) return withFallback(spawnCustom, themePaths);
|
||
if (custom) return withFallback(custom, themePaths);
|
||
return themePaths;
|
||
}
|
||
|
||
/** 载具四向贴图路径(北/东/南/西各一张,转向时直接切换,无 flipX) */
|
||
export function resolveVehicleTexturePaths(
|
||
direction: Direction | undefined,
|
||
options: EntityVisualOptions = {},
|
||
): string[] {
|
||
const dir = direction ?? Direction.North;
|
||
const field = vehicleThemeFieldForDirection(dir);
|
||
const role = vehicleSpriteRoleForDirection(dir);
|
||
const custom = levelVehicleTextureOverride(dir, options.entityTextures);
|
||
const spawnCustom = normalizeTexturePath(options.spawnTexture);
|
||
const themePaths = themeDbEntityCandidates(options.theme, field, role);
|
||
if (spawnCustom) return withFallback(spawnCustom, themePaths);
|
||
if (custom) return withFallback(custom, themePaths);
|
||
return themePaths;
|
||
}
|
||
|
||
/** 预加载主题载具四向贴图 */
|
||
export function collectThemeVehicleTexturePaths(
|
||
theme: string | undefined,
|
||
entityTextures?: LevelEntityTextures,
|
||
): string[] {
|
||
const set = new Set<string>();
|
||
const add = (p: string | undefined) => {
|
||
const n = normalizeTexturePath(p);
|
||
if (n) set.add(n);
|
||
};
|
||
for (const p of allVehicleDirectionTextureCandidates(theme)) add(p);
|
||
if (entityTextures) {
|
||
for (let d = Direction.North; d <= Direction.West; d++) {
|
||
add(levelVehicleTextureOverride(d, entityTextures));
|
||
}
|
||
}
|
||
return Array.from(set);
|
||
}
|
||
|
||
export function resolvePropTexturePaths(options: EntityVisualOptions = {}): string[] {
|
||
const spawnCustom = normalizeTexturePath(options.spawnTexture);
|
||
const levelBlock = normalizeTexturePath(options.entityTextures?.prop);
|
||
const levelGround = normalizeTexturePath(options.entityTextures?.propGround);
|
||
const onGround = options.propPlacement === 'ground';
|
||
if (spawnCustom) {
|
||
const custom = onGround ? toGroundPropTexturePath(spawnCustom) ?? spawnCustom : spawnCustom;
|
||
const fallback = onGround
|
||
? themeGroundPropCandidatesWithDb(options.theme, levelBlock)
|
||
: themePropCandidatesWithDb(options.theme);
|
||
return withFallback(custom, fallback);
|
||
}
|
||
if (onGround && levelGround) {
|
||
return withFallback(levelGround, themeGroundPropCandidatesWithDb(options.theme, levelBlock));
|
||
}
|
||
if (levelBlock) {
|
||
const custom = onGround ? toGroundPropTexturePath(levelBlock) ?? levelBlock : levelBlock;
|
||
return withFallback(custom, onGround
|
||
? themeGroundPropCandidatesWithDb(options.theme, levelBlock)
|
||
: themePropCandidatesWithDb(options.theme));
|
||
}
|
||
return onGround
|
||
? themeGroundPropCandidatesWithDb(options.theme)
|
||
: themePropCandidatesWithDb(options.theme);
|
||
}
|
||
|
||
export function resolveEntityTexturePaths(
|
||
kind: SpawnKind,
|
||
direction: Direction | undefined,
|
||
options: EntityVisualOptions = {},
|
||
): string[] {
|
||
if (kind === 'player') return resolvePlayerTexturePaths(direction, options);
|
||
if (kind === 'vehicle') return resolveVehicleTexturePaths(direction, options);
|
||
if (kind === 'prop' || kind === 'prop_decor') return resolvePropTexturePaths(options);
|
||
return themePropCandidates(options.theme);
|
||
}
|
||
|
||
/** 预加载关卡用到的实体贴图(仅主路径,不扫全量 fallback) */
|
||
export function collectLevelEntityTexturePaths(
|
||
theme: string | undefined,
|
||
entityTextures?: LevelEntityTextures,
|
||
spawns?: SpawnConfig[],
|
||
levelConfig?: Pick<LevelConfig, 'ground' | 'border'>,
|
||
): string[] {
|
||
const set = new Set<string>();
|
||
const add = (p: string | undefined) => {
|
||
const n = normalizeTexturePath(p);
|
||
if (n) set.add(n);
|
||
};
|
||
const addPrimary = (paths: string[]) => {
|
||
for (const p of paths) add(p);
|
||
};
|
||
|
||
add(getThemeBackground(theme));
|
||
|
||
const visualBase: EntityVisualOptions = { theme, entityTextures };
|
||
const list = spawns ?? [];
|
||
|
||
for (const s of list) {
|
||
if (s.kind === 'player') {
|
||
addPrimary(resolvePlayerTexturePaths(
|
||
parseSpawnDirection(s.playerDirection) ?? Direction.South,
|
||
{ ...visualBase, spawnTexture: s.texture },
|
||
));
|
||
} else if (s.kind === 'vehicle') {
|
||
for (let d = Direction.North; d <= Direction.West; d++) {
|
||
addPrimary(resolveVehicleTexturePaths(d, { ...visualBase, spawnTexture: s.texture }));
|
||
}
|
||
} else if (s.kind === 'prop') {
|
||
const placement: PropPlacement = levelConfig
|
||
? resolvePropPlacement(s, levelConfig as LevelConfig)
|
||
: (s.propPlacement ?? 'block');
|
||
addPrimary(resolvePropTexturePaths({
|
||
...visualBase,
|
||
spawnTexture: s.texture,
|
||
propPlacement: placement,
|
||
}));
|
||
} else if (s.kind === 'prop_decor') {
|
||
addPrimary(resolvePropTexturePaths(visualBase));
|
||
}
|
||
}
|
||
|
||
if (!list.length) {
|
||
addPrimary(resolvePlayerTexturePaths(Direction.South, visualBase));
|
||
for (const p of collectThemeVehicleTexturePaths(theme, entityTextures)) add(p);
|
||
addPrimary(resolvePropTexturePaths(visualBase));
|
||
}
|
||
|
||
return Array.from(set);
|
||
}
|
||
|
||
function parseSpawnDirection(raw: unknown): Direction | undefined {
|
||
if (typeof raw === 'number') return raw as Direction;
|
||
if (typeof raw !== 'string') return undefined;
|
||
const name = raw.replace(/^Direction\./, '');
|
||
const key = name as keyof typeof Direction;
|
||
if (Object.prototype.hasOwnProperty.call(Direction, key)) {
|
||
const v = Direction[key];
|
||
if (typeof v === 'number') return v as Direction;
|
||
}
|
||
return undefined;
|
||
}
|