593 lines
15 KiB
TypeScript
593 lines
15 KiB
TypeScript
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
|
import {
|
|
collectSceneBucketSignalKeywords,
|
|
resolveSceneBucketForLandmark,
|
|
} from '../services/customWorldReferenceSignals';
|
|
import { detectCustomWorldThemeMode } from '../services/customWorldTheme';
|
|
import {
|
|
type CustomWorldLandmark,
|
|
type CustomWorldProfile,
|
|
type WorldTemplateType,
|
|
WorldType,
|
|
} from '../types';
|
|
import { resolveCustomWorldCompatibilityTemplateWorldType } from '../services/customWorldTheme';
|
|
|
|
const CUSTOM_WORLD_NPC_IMAGE_POOL = [
|
|
'/character/Sword%20Princess/Original/Hero/idle/Idle01.png',
|
|
'/character/Archer%20Hero/Original/Hero/idle/idle01.png',
|
|
'/character/Girl%20Hero%201/Original/Hero/Idle/Idle01.png',
|
|
'/character/Punch%20Hero%203/Original/Hero/Idle/Idle01.png',
|
|
'/character/Fighter%204/original/Hero/idle/idle01.png',
|
|
] as const;
|
|
|
|
const SCENE_BACKGROUND_PACKS = [
|
|
{ packName: 'Pixel Battle Backgrounds - Pack 1', count: 121 },
|
|
{ packName: 'Pixel Battle Backgrounds - Pack 2', count: 119 },
|
|
{ packName: 'Pixel Battle Backgrounds - Pack 3', count: 170 },
|
|
] as const;
|
|
|
|
type SceneImageReference = {
|
|
name: string;
|
|
keywords: string[];
|
|
};
|
|
|
|
const SCENE_MATCH_STOP_CHARS = new Set([
|
|
'的',
|
|
'之',
|
|
'与',
|
|
'和',
|
|
'里',
|
|
'处',
|
|
'中',
|
|
'外',
|
|
'前',
|
|
'后',
|
|
'上',
|
|
'下',
|
|
'左',
|
|
'右',
|
|
'一',
|
|
'二',
|
|
'三',
|
|
'四',
|
|
'五',
|
|
'六',
|
|
'七',
|
|
'八',
|
|
'九',
|
|
'十',
|
|
'场',
|
|
'景',
|
|
'地',
|
|
'方',
|
|
'区',
|
|
'域',
|
|
'路',
|
|
'道',
|
|
'门',
|
|
'台',
|
|
'楼',
|
|
'城',
|
|
'山',
|
|
'林',
|
|
'湖',
|
|
'河',
|
|
'谷',
|
|
'洞',
|
|
'宫',
|
|
'殿',
|
|
'营',
|
|
'崖',
|
|
'桥',
|
|
]);
|
|
|
|
const MARTIAL_TEMPLATE_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
|
|
{
|
|
name: '山门石阶',
|
|
keywords: ['山门', '石阶', '门派', '山路', '石门', '宗门'],
|
|
},
|
|
{
|
|
name: '雨巷长街',
|
|
keywords: ['雨巷', '长街', '街市', '巷道', '城镇', '商铺'],
|
|
},
|
|
{
|
|
name: '竹林古道',
|
|
keywords: ['竹林', '古道', '林路', '林间', '小径', '山径'],
|
|
},
|
|
{
|
|
name: '断垣村落',
|
|
keywords: ['废村', '村落', '断墙', '残垣', '旧屋', '荒宅'],
|
|
},
|
|
{
|
|
name: '古桥渡口',
|
|
keywords: ['桥', '渡口', '河岸', '水路', '码头', '舟船'],
|
|
},
|
|
{
|
|
name: '雾林小径',
|
|
keywords: ['雾林', '迷雾', '树林', '暗林', '阴森', '野路'],
|
|
},
|
|
{
|
|
name: '边关营地',
|
|
keywords: ['营地', '驻地', '营火', '关隘', '边关', '据点', '归舍', '落脚', '住处'],
|
|
},
|
|
{
|
|
name: '地宫通道',
|
|
keywords: ['地宫', '墓道', '通道', '地底', '遗迹', '机关'],
|
|
},
|
|
{
|
|
name: '寺庙前庭',
|
|
keywords: ['寺庙', '庙宇', '神龛', '前庭', '祭坛', '佛堂'],
|
|
},
|
|
{
|
|
name: '矿道深处',
|
|
keywords: ['矿道', '矿坑', '坑道', '矿洞', '洞窟', '地下'],
|
|
},
|
|
{
|
|
name: '铸坊工场',
|
|
keywords: ['铸坊', '工场', '铁匠', '锻造', '熔炉', '火光'],
|
|
},
|
|
{
|
|
name: '宫苑内庭',
|
|
keywords: ['宫苑', '内庭', '庭院', '府邸', '回廊', '深宫'],
|
|
},
|
|
] as const;
|
|
|
|
const ARCANE_TEMPLATE_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
|
|
{
|
|
name: '云海仙门',
|
|
keywords: ['云海', '仙门', '山门', '灵门', '云阶', '门阙'],
|
|
},
|
|
{
|
|
name: '悬空仙岛',
|
|
keywords: ['浮岛', '仙岛', '悬空', '高空', '云岛', '浮空'],
|
|
},
|
|
{
|
|
name: '天宫长廊',
|
|
keywords: ['天宫', '长廊', '回廊', '宫阙', '高处', '仙宫'],
|
|
},
|
|
{
|
|
name: '灵药花圃',
|
|
keywords: ['药圃', '花圃', '灵草', '花海', '园林', '药园'],
|
|
},
|
|
{
|
|
name: '寒玉洞天',
|
|
keywords: ['寒玉', '冰洞', '洞天', '冰面', '寒气', '玉壁'],
|
|
},
|
|
{
|
|
name: '熔岩秘境',
|
|
keywords: ['熔岩', '火山', '赤焰', '岩浆', '灼热', '焦土'],
|
|
},
|
|
{
|
|
name: '雷殿祭坛',
|
|
keywords: ['雷殿', '祭坛', '雷霆', '神殿', '雷光', '仪式'],
|
|
},
|
|
{
|
|
name: '星舟甲板',
|
|
keywords: ['星舟', '甲板', '飞舟', '天舟', '高空', '航线'],
|
|
},
|
|
{
|
|
name: '月湖仙洲',
|
|
keywords: ['月湖', '湖岸', '湖心', '水面', '水边', '倒影'],
|
|
},
|
|
{
|
|
name: '古仙遗迹',
|
|
keywords: ['遗迹', '断碑', '残阵', '古殿', '残墙', '废墟'],
|
|
},
|
|
{
|
|
name: '神木秘境',
|
|
keywords: ['神木', '古树', '巨树', '树海', '灵木', '林境'],
|
|
},
|
|
{
|
|
name: '飞瀑仙崖',
|
|
keywords: ['飞瀑', '瀑布', '仙崖', '崖边', '水幕', '崖壁'],
|
|
},
|
|
] as const;
|
|
|
|
const COMPATIBILITY_TEMPLATE_SCENE_IMAGE_REFERENCES: Record<
|
|
WorldTemplateType,
|
|
readonly SceneImageReference[]
|
|
> = {
|
|
[WorldType.WUXIA]: MARTIAL_TEMPLATE_SCENE_IMAGE_REFERENCES,
|
|
[WorldType.XIANXIA]: ARCANE_TEMPLATE_SCENE_IMAGE_REFERENCES,
|
|
};
|
|
|
|
type CustomWorldSceneImageMatchOptions = {
|
|
profile?: Pick<
|
|
CustomWorldProfile,
|
|
| 'id'
|
|
| 'name'
|
|
| 'summary'
|
|
| 'tone'
|
|
| 'playerGoal'
|
|
| 'settingText'
|
|
| 'templateWorldType'
|
|
| 'camp'
|
|
| 'ownedSettingLayers'
|
|
> | null;
|
|
landmark?: Pick<CustomWorldLandmark, 'id' | 'name' | 'description'> | null;
|
|
usedImageSrcs?: Iterable<string>;
|
|
};
|
|
|
|
function hashText(value: string) {
|
|
let hash = 0;
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
|
}
|
|
return hash >>> 0;
|
|
}
|
|
|
|
function buildSceneImagePath(packName: string, imageNumber: number) {
|
|
const filename = `${imageNumber.toString().padStart(3, '0')}.png`;
|
|
return `/scene_bg/Pixel Battle Backgrounds Mega Pack/${packName}/${filename}`;
|
|
}
|
|
|
|
export function getAllCustomWorldSceneImages() {
|
|
const refs: string[] = [];
|
|
|
|
for (const pack of SCENE_BACKGROUND_PACKS) {
|
|
for (let imageNumber = 1; imageNumber <= pack.count; imageNumber += 1) {
|
|
refs.push(buildSceneImagePath(pack.packName, imageNumber));
|
|
}
|
|
}
|
|
|
|
return refs;
|
|
}
|
|
|
|
function collectWorldSceneImagePool(worldType: WorldTemplateType) {
|
|
const refs: string[] = [];
|
|
let globalIndex = 0;
|
|
|
|
for (const pack of SCENE_BACKGROUND_PACKS) {
|
|
for (let imageNumber = 1; imageNumber <= pack.count; imageNumber += 1) {
|
|
const assignedWorld = globalIndex % 2 === 0 ? WorldType.WUXIA : WorldType.XIANXIA;
|
|
if (assignedWorld === worldType) {
|
|
refs.push(buildSceneImagePath(pack.packName, imageNumber));
|
|
}
|
|
globalIndex += 1;
|
|
}
|
|
}
|
|
|
|
return refs;
|
|
}
|
|
|
|
export function normalizeOptionalImageSrc(value: unknown) {
|
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
}
|
|
|
|
function uniqueStrings(values: Array<string | null | undefined>) {
|
|
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))];
|
|
}
|
|
|
|
function buildSceneReferencePool(worldType: WorldTemplateType) {
|
|
const pool = collectWorldSceneImagePool(worldType);
|
|
const references = COMPATIBILITY_TEMPLATE_SCENE_IMAGE_REFERENCES[worldType] ?? [];
|
|
|
|
return references.map((reference, index) => ({
|
|
...reference,
|
|
imageSrc: pool[index] ?? pool[index % Math.max(pool.length, 1)] ?? '',
|
|
}));
|
|
}
|
|
|
|
function buildOwnedSceneReferencePool(
|
|
profile: Pick<
|
|
CustomWorldProfile,
|
|
'id' | 'name' | 'ownedSettingLayers'
|
|
>,
|
|
) {
|
|
const sceneBuckets =
|
|
profile.ownedSettingLayers?.referenceProfile.sceneBuckets ?? [];
|
|
if (sceneBuckets.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const pool = getAllCustomWorldSceneImages();
|
|
if (pool.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
return sceneBuckets.map((bucket, index) => {
|
|
const offset =
|
|
hashText(`${profile.id || profile.name}:${bucket.id}:${bucket.label}`)
|
|
% pool.length;
|
|
|
|
return {
|
|
name: bucket.label,
|
|
keywords: collectSceneBucketSignalKeywords(bucket),
|
|
imageSrc: pool[(offset + index) % pool.length] ?? '',
|
|
};
|
|
});
|
|
}
|
|
|
|
function buildSourceText(
|
|
seedKey: string,
|
|
index: number,
|
|
worldType: WorldTemplateType,
|
|
options: CustomWorldSceneImageMatchOptions,
|
|
) {
|
|
const profile = options.profile;
|
|
const landmark = options.landmark;
|
|
const themeHints = profile
|
|
? ({
|
|
mythic: '归处 旧痕 路途 异象 线索',
|
|
martial: '刀剑 风尘 旧约 行路 关隘',
|
|
arcane: '云阶 法纹 星辉 秘藏 回响',
|
|
machina: '工坊 轨道 装置 核心 机械',
|
|
tide: '潮雾 港湾 岸线 水路 回潮',
|
|
rift: '裂痕 断层 前线 边界 异压',
|
|
} as const)[detectCustomWorldThemeMode(profile)]
|
|
: (worldType === WorldType.XIANXIA
|
|
? '云阶 法纹 星辉 秘藏 回响'
|
|
: '刀剑 风尘 旧约 行路 关隘');
|
|
|
|
return uniqueStrings([
|
|
profile?.name,
|
|
profile?.summary,
|
|
profile?.tone,
|
|
profile?.playerGoal,
|
|
profile?.settingText,
|
|
themeHints,
|
|
landmark?.name,
|
|
landmark?.description,
|
|
`scene-${index + 1}`,
|
|
seedKey,
|
|
]).join(' ');
|
|
}
|
|
|
|
function buildSignalChars(text: string) {
|
|
return [
|
|
...new Set(
|
|
text
|
|
.replace(/[^\u4e00-\u9fa5]+/g, '')
|
|
.split('')
|
|
.filter((char) => char && !SCENE_MATCH_STOP_CHARS.has(char)),
|
|
),
|
|
];
|
|
}
|
|
|
|
function scoreSceneReference(reference: SceneImageReference, sourceText: string) {
|
|
let score = 0;
|
|
|
|
if (sourceText.includes(reference.name)) {
|
|
score += 24;
|
|
}
|
|
|
|
reference.keywords.forEach((keyword) => {
|
|
if (!keyword || !sourceText.includes(keyword)) {
|
|
return;
|
|
}
|
|
|
|
if (keyword.length >= 4) {
|
|
score += 8;
|
|
return;
|
|
}
|
|
|
|
if (keyword.length === 3) {
|
|
score += 6;
|
|
return;
|
|
}
|
|
|
|
score += 4;
|
|
});
|
|
|
|
buildSignalChars([reference.name, ...reference.keywords].join('')).forEach(
|
|
(char) => {
|
|
if (sourceText.includes(char)) {
|
|
score += 1;
|
|
}
|
|
},
|
|
);
|
|
|
|
return score;
|
|
}
|
|
|
|
function getFirstUnusedImage(
|
|
candidates: string[],
|
|
usedImageSrcs: Set<string>,
|
|
) {
|
|
for (const candidate of candidates) {
|
|
if (candidate && !usedImageSrcs.has(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
return candidates[0] ?? '';
|
|
}
|
|
|
|
export function getDefaultCustomWorldNpcImage(seedKey: string, index: number) {
|
|
const offset = hashText(`${seedKey}:npc:${index}`) % CUSTOM_WORLD_NPC_IMAGE_POOL.length;
|
|
return CUSTOM_WORLD_NPC_IMAGE_POOL[offset];
|
|
}
|
|
|
|
export function getDefaultCustomWorldSceneImage(
|
|
seedKey: string,
|
|
index: number,
|
|
worldType: WorldTemplateType,
|
|
options: CustomWorldSceneImageMatchOptions = {},
|
|
) {
|
|
const ownedReferencePool = options.profile
|
|
? buildOwnedSceneReferencePool(options.profile)
|
|
: [];
|
|
const pool =
|
|
ownedReferencePool.length > 0
|
|
? getAllCustomWorldSceneImages()
|
|
: collectWorldSceneImagePool(worldType);
|
|
if (pool.length === 0) {
|
|
return worldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png';
|
|
}
|
|
|
|
const usedImageSrcs = new Set(
|
|
[...(options.usedImageSrcs ?? [])]
|
|
.map((value) => normalizeOptionalImageSrc(value))
|
|
.filter((value): value is string => Boolean(value)),
|
|
);
|
|
const preferredSceneBucket =
|
|
options.profile && options.landmark
|
|
? resolveSceneBucketForLandmark(
|
|
options.profile as CustomWorldProfile,
|
|
options.landmark,
|
|
)
|
|
: null;
|
|
const sourceText = [
|
|
buildSourceText(seedKey, index, worldType, options),
|
|
preferredSceneBucket?.label ?? '',
|
|
...(preferredSceneBucket
|
|
? collectSceneBucketSignalKeywords(preferredSceneBucket)
|
|
: []),
|
|
].join(' ');
|
|
const referencePool =
|
|
ownedReferencePool.length > 0
|
|
? ownedReferencePool
|
|
: buildSceneReferencePool(worldType);
|
|
const scoredReferences = referencePool
|
|
.map((reference, referenceIndex) => ({
|
|
imageSrc: reference.imageSrc,
|
|
score:
|
|
scoreSceneReference(reference, sourceText)
|
|
+ (
|
|
preferredSceneBucket && reference.name === preferredSceneBucket.label
|
|
? 28
|
|
: 0
|
|
),
|
|
tieBreaker: hashText(`${seedKey}:${reference.name}:${referenceIndex}`),
|
|
}))
|
|
.sort((left, right) => {
|
|
if (right.score !== left.score) {
|
|
return right.score - left.score;
|
|
}
|
|
return left.tieBreaker - right.tieBreaker;
|
|
});
|
|
|
|
const matchedReferenceImages = scoredReferences
|
|
.filter((entry) => entry.score > 0 && entry.imageSrc)
|
|
.map((entry) => entry.imageSrc);
|
|
const matchedReferenceImage = getFirstUnusedImage(
|
|
matchedReferenceImages,
|
|
usedImageSrcs,
|
|
);
|
|
|
|
if (matchedReferenceImage) {
|
|
return matchedReferenceImage;
|
|
}
|
|
|
|
const offset = hashText(`${seedKey}:scene:${index}:${sourceText}`) % pool.length;
|
|
const rotatedPool = [
|
|
...pool.slice(offset),
|
|
...pool.slice(0, offset),
|
|
];
|
|
|
|
return getFirstUnusedImage(rotatedPool, usedImageSrcs);
|
|
}
|
|
|
|
export function resolveCustomWorldLandmarkImage(
|
|
profile: Pick<
|
|
CustomWorldProfile,
|
|
| 'id'
|
|
| 'name'
|
|
| 'summary'
|
|
| 'tone'
|
|
| 'playerGoal'
|
|
| 'settingText'
|
|
| 'templateWorldType'
|
|
| 'compatibilityTemplateWorldType'
|
|
| 'ownedSettingLayers'
|
|
>,
|
|
landmark: Pick<CustomWorldLandmark, 'id' | 'name' | 'description' | 'imageSrc'>,
|
|
index: number,
|
|
usedImageSrcs?: Iterable<string>,
|
|
) {
|
|
const explicitImageSrc = normalizeOptionalImageSrc(landmark.imageSrc);
|
|
if (explicitImageSrc) {
|
|
return explicitImageSrc;
|
|
}
|
|
|
|
return getDefaultCustomWorldSceneImage(
|
|
profile.id || profile.name,
|
|
index,
|
|
resolveCustomWorldCompatibilityTemplateWorldType(profile),
|
|
{
|
|
profile,
|
|
landmark,
|
|
usedImageSrcs,
|
|
},
|
|
);
|
|
}
|
|
|
|
export function resolveCustomWorldLandmarkImageMap(
|
|
profile: Pick<
|
|
CustomWorldProfile,
|
|
| 'id'
|
|
| 'name'
|
|
| 'summary'
|
|
| 'tone'
|
|
| 'playerGoal'
|
|
| 'settingText'
|
|
| 'templateWorldType'
|
|
| 'compatibilityTemplateWorldType'
|
|
| 'landmarks'
|
|
| 'camp'
|
|
| 'ownedSettingLayers'
|
|
>,
|
|
) {
|
|
const usedImageSrcs = new Set(
|
|
profile.landmarks
|
|
.map((landmark) => normalizeOptionalImageSrc(landmark.imageSrc))
|
|
.filter((imageSrc): imageSrc is string => Boolean(imageSrc)),
|
|
);
|
|
const imageMap = new Map<string, string>();
|
|
|
|
profile.landmarks.forEach((landmark, index) => {
|
|
const resolvedImageSrc = resolveCustomWorldLandmarkImage(
|
|
profile,
|
|
landmark,
|
|
index,
|
|
usedImageSrcs,
|
|
);
|
|
if (resolvedImageSrc) {
|
|
imageMap.set(landmark.id, resolvedImageSrc);
|
|
usedImageSrcs.add(resolvedImageSrc);
|
|
}
|
|
});
|
|
|
|
return imageMap;
|
|
}
|
|
|
|
export function resolveCustomWorldCampSceneImage(
|
|
profile: Pick<
|
|
CustomWorldProfile,
|
|
| 'id'
|
|
| 'name'
|
|
| 'summary'
|
|
| 'tone'
|
|
| 'playerGoal'
|
|
| 'settingText'
|
|
| 'templateWorldType'
|
|
| 'compatibilityTemplateWorldType'
|
|
| 'landmarks'
|
|
| 'camp'
|
|
| 'ownedSettingLayers'
|
|
>,
|
|
) {
|
|
const campScene = resolveCustomWorldCampScene(profile);
|
|
const explicitImageSrc = normalizeOptionalImageSrc(campScene.imageSrc);
|
|
if (explicitImageSrc) {
|
|
return explicitImageSrc;
|
|
}
|
|
const landmarkImageMap = resolveCustomWorldLandmarkImageMap(profile);
|
|
const usedImageSrcs = new Set(landmarkImageMap.values());
|
|
|
|
return getDefaultCustomWorldSceneImage(
|
|
profile.id || profile.name,
|
|
-1,
|
|
resolveCustomWorldCompatibilityTemplateWorldType(profile),
|
|
{
|
|
profile,
|
|
landmark: {
|
|
id: 'custom-scene-camp',
|
|
name: campScene.name,
|
|
description: campScene.description,
|
|
},
|
|
usedImageSrcs,
|
|
},
|
|
);
|
|
}
|