Implement scene-based chapter quest progression
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-08 11:58:47 +08:00
parent 9d2fc9e4b8
commit bd9fdcbe31
170 changed files with 18259 additions and 1049 deletions

View File

@@ -1,4 +1,11 @@
import { WorldTemplateType, WorldType } from '../types';
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import { detectCustomWorldThemeMode } from '../services/customWorldTheme';
import {
type CustomWorldLandmark,
type CustomWorldProfile,
type WorldTemplateType,
WorldType,
} from '../types';
const CUSTOM_WORLD_NPC_IMAGE_POOL = [
'/character/Sword%20Princess/Original/Hero/idle/Idle01.png',
@@ -14,6 +21,187 @@ const SCENE_BACKGROUND_PACKS = [
{ packName: 'Pixel Battle Backgrounds - Pack 3', count: 170 },
] as const;
type SceneImageReference = {
name: string;
keywords: string[];
};
const SCENE_MATCH_STOP_CHARS = new Set([
'的',
'之',
'与',
'和',
'里',
'处',
'中',
'外',
'前',
'后',
'上',
'下',
'左',
'右',
'一',
'二',
'三',
'四',
'五',
'六',
'七',
'八',
'九',
'十',
'场',
'景',
'地',
'方',
'区',
'域',
'路',
'道',
'门',
'台',
'楼',
'城',
'山',
'林',
'湖',
'河',
'谷',
'洞',
'宫',
'殿',
'营',
'崖',
'桥',
]);
const WUXIA_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 XIANXIA_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 WORLD_SCENE_IMAGE_REFERENCES: Record<
WorldTemplateType,
readonly SceneImageReference[]
> = {
[WorldType.WUXIA]: WUXIA_SCENE_IMAGE_REFERENCES,
[WorldType.XIANXIA]: XIANXIA_SCENE_IMAGE_REFERENCES,
};
type CustomWorldSceneImageMatchOptions = {
profile?: Pick<
CustomWorldProfile,
| 'id'
| 'name'
| 'summary'
| 'tone'
| 'playerGoal'
| 'settingText'
| 'templateWorldType'
| 'camp'
> | null;
landmark?: Pick<CustomWorldLandmark, 'id' | 'name' | 'description' | 'dangerLevel'> | null;
usedImageSrcs?: Iterable<string>;
};
function hashText(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
@@ -60,6 +248,116 @@ 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 = WORLD_SCENE_IMAGE_REFERENCES[worldType] ?? [];
return references.map((reference, index) => ({
...reference,
imageSrc: pool[index] ?? pool[index % Math.max(pool.length, 1)] ?? '',
}));
}
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,
landmark?.dangerLevel,
`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];
@@ -69,12 +367,152 @@ export function getDefaultCustomWorldSceneImage(
seedKey: string,
index: number,
worldType: WorldTemplateType,
options: CustomWorldSceneImageMatchOptions = {},
) {
const pool = collectWorldSceneImagePool(worldType);
if (pool.length === 0) {
return worldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png';
}
const offset = hashText(`${seedKey}:scene:${index}`) % pool.length;
return pool[offset];
const usedImageSrcs = new Set(
[...(options.usedImageSrcs ?? [])]
.map((value) => normalizeOptionalImageSrc(value))
.filter((value): value is string => Boolean(value)),
);
const sourceText = buildSourceText(seedKey, index, worldType, options);
const referencePool = buildSceneReferencePool(worldType);
const scoredReferences = referencePool
.map((reference, referenceIndex) => ({
imageSrc: reference.imageSrc,
score: scoreSceneReference(reference, sourceText),
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'
>,
landmark: Pick<CustomWorldLandmark, 'id' | 'name' | 'description' | 'dangerLevel' | 'imageSrc'>,
index: number,
usedImageSrcs?: Iterable<string>,
) {
const explicitImageSrc = normalizeOptionalImageSrc(landmark.imageSrc);
if (explicitImageSrc) {
return explicitImageSrc;
}
return getDefaultCustomWorldSceneImage(
profile.id || profile.name,
index,
profile.templateWorldType,
{
profile,
landmark,
usedImageSrcs,
},
);
}
export function resolveCustomWorldLandmarkImageMap(
profile: Pick<
CustomWorldProfile,
| 'id'
| 'name'
| 'summary'
| 'tone'
| 'playerGoal'
| 'settingText'
| 'templateWorldType'
| 'landmarks'
| 'camp'
>,
) {
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'
| 'landmarks'
| 'camp'
>,
) {
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,
profile.templateWorldType,
{
profile,
landmark: {
id: 'custom-scene-camp',
name: campScene.name,
description: campScene.description,
dangerLevel: campScene.dangerLevel,
},
usedImageSrcs,
},
);
}