Files
Genarrative/src/data/customWorldVisuals.ts
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

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,
},
);
}