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 | null; usedImageSrcs?: Iterable; }; 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) { 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, ) { 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, index: number, usedImageSrcs?: Iterable, ) { 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(); 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, }, ); }