This commit is contained in:
592
src/data/customWorldVisuals.ts
Normal file
592
src/data/customWorldVisuals.ts
Normal file
@@ -0,0 +1,592 @@
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user