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

@@ -36,7 +36,7 @@ export default function App() {
playResolvedChoice: combatFlow.playResolvedChoice,
});
const { companionRenderStates } = useNpcInteractionFlow(gameState);
const { companionRenderStates, buildCompanionRenderStates } = useNpcInteractionFlow(gameState);
const settings = useGameSettings();
const persistence = useGamePersistence({
@@ -144,6 +144,7 @@ export default function App() {
inventoryUi: storyFlow.inventoryUi,
battleRewardUi: storyFlow.battleRewardUi,
questUi: storyFlow.questUi,
goalUi: storyFlow.goalUi,
};
const gameShellEntry = {
@@ -158,6 +159,7 @@ export default function App() {
const gameShellCompanions = {
companionRenderStates,
buildCompanionRenderStates,
onBenchCompanion: handleBenchCompanion,
onActivateRosterCompanion: handleActivateRosterCompanion,
};

View File

@@ -34,6 +34,7 @@ import {
getHostileNpcPresetById,
getMonsterPresetsByWorld,
} from '../data/hostileNpcPresets';
import { buildMedievalNpcVisualFromCustomWorldVisual } from '../data/medievalNpcVisuals';
import {
buildEncounterAttributeRumors,
resolveEncounterAttributeProfile,
@@ -777,34 +778,55 @@ export function AdventureEntityModal({
<div className="flex flex-col items-center text-center">
<div className="flex h-44 w-full max-w-[16rem] items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20">
{selection.kind === 'player' && playerCharacter ? (
<CharacterAnimator
state={AnimationState.IDLE}
character={playerCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(
playerCharacter,
)}
/>
playerCharacter.visual ? (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(playerCharacter.visual)}
scale={2.08}
/>
) : (
<CharacterAnimator
state={AnimationState.IDLE}
character={playerCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(
playerCharacter,
)}
/>
)
) : selection.kind === 'companion' &&
companionCharacter ? (
<CharacterAnimator
state={AnimationState.IDLE}
character={companionCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(
companionCharacter,
)}
/>
companionCharacter.visual ? (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(companionCharacter.visual)}
scale={2.08}
/>
) : (
<CharacterAnimator
state={AnimationState.IDLE}
character={companionCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(
companionCharacter,
)}
/>
)
) : npcCharacter ? (
<CharacterAnimator
state={AnimationState.IDLE}
character={npcCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(npcCharacter)}
/>
npcCharacter.visual ? (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(npcCharacter.visual)}
scale={2.08}
/>
) : (
<CharacterAnimator
state={AnimationState.IDLE}
character={npcCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(npcCharacter)}
/>
)
) : hostileNpcPreset ? (
<HostileNpcAnimator
hostileNpc={hostileNpcPreset}
@@ -925,9 +947,9 @@ export function AdventureEntityModal({
)}
{relatedConsequences.length > 0 && (
<div className="space-y-1">
{relatedConsequences.map((record) => (
{relatedConsequences.map((record, index) => (
<div
key={record.id}
key={record.id || `consequence-${record.title}-${index}`}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-400"
>
<span className="text-white">{record.title}</span>
@@ -939,9 +961,9 @@ export function AdventureEntityModal({
)}
{recentChronicleEntries.length > 0 && (
<div className="space-y-1">
{recentChronicleEntries.map((entry) => (
{recentChronicleEntries.map((entry, index) => (
<div
key={entry.id}
key={entry.id || `chronicle-${entry.title}-${index}`}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2"
>
<div className="text-sm font-medium text-white">
@@ -961,9 +983,9 @@ export function AdventureEntityModal({
)}
{sceneResidues.length > 0 && (
<div className="space-y-1">
{sceneResidues.map((residue) => (
{sceneResidues.map((residue, index) => (
<div
key={residue.id}
key={residue.id || `residue-${residue.title}-${index}`}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-400"
>
<span className="text-white">{residue.title}</span>

View File

@@ -30,14 +30,15 @@ import { getScenePresetById } from '../data/scenePresets';
import { getOptionImpactSummary } from '../hooks/combatStoryUtils';
import type { BattleRewardUi, QuestFlowUi } from '../hooks/useStoryGeneration';
import type {
CampEvent,
ChapterState,
Character,
GoalHandoff,
GoalPulseEvent,
GoalStackState,
InventoryItem,
JourneyBeat,
NpcBattleMode,
QuestLogEntry,
SetpieceDirective,
StoryMoment,
StoryOption,
WorldType,
@@ -67,6 +68,9 @@ interface AdventurePanelProps {
worldType: WorldType | null;
quests: QuestLogEntry[];
questUi: QuestFlowUi;
goalStack: GoalStackState;
goalPulse: GoalPulseEvent | null;
onDismissGoalPulse: () => void;
battleRewardUi: BattleRewardUi;
playerHp: number;
playerMaxHp: number;
@@ -95,9 +99,6 @@ interface AdventurePanelProps {
onSaveAndExit: () => void;
chapterState?: ChapterState | null;
journeyBeat?: JourneyBeat | null;
recentChronicleSummary?: string | null;
currentCampEvent?: CampEvent | null;
setpieceDirective?: SetpieceDirective | null;
}
const AdventurePanelOverlays = lazy(async () => {
@@ -250,6 +251,19 @@ function formatPlayTime(playTimeMs: number) {
return `${minutes}${String(seconds).padStart(2, '0')}`;
}
function getOptionGoalAffordanceClass(option: StoryOption) {
switch (option.goalAffordance?.relation) {
case 'advance':
return 'text-amber-200/85';
case 'support':
return 'text-sky-200/80';
case 'detour':
return 'text-zinc-400';
default:
return 'text-zinc-500';
}
}
function RewardItemIconGrid({
items,
selectedItemId,
@@ -589,6 +603,9 @@ export function AdventurePanel({
worldType,
quests,
questUi,
goalStack,
goalPulse,
onDismissGoalPulse,
battleRewardUi,
playerHp,
playerMaxHp,
@@ -602,9 +619,6 @@ export function AdventurePanel({
onSaveAndExit,
chapterState = null,
journeyBeat = null,
recentChronicleSummary = null,
currentCampEvent = null,
setpieceDirective = null,
}: AdventurePanelProps) {
const isDialogueStory = currentStory.displayMode === 'dialogue';
const dialogueTurns = currentStory.dialogue ?? [];
@@ -615,7 +629,12 @@ export function AdventurePanel({
currentStory.deferredOptions?.length,
);
const saveAndExitDisabled = isLoading || isStoryStreaming;
const [isChapterPanelOpen, setIsChapterPanelOpen] = useState(false);
const primaryQuestGoal = goalStack.activeGoal?.sourceKind === 'quest'
? goalStack.activeGoal
: goalStack.immediateStepGoal?.sourceKind === 'quest'
? goalStack.immediateStepGoal
: null;
const [isGoalPanelOpen, setIsGoalPanelOpen] = useState(false);
const [isQuestPanelOpen, setIsQuestPanelOpen] = useState(false);
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
const [isStatsPanelOpen, setIsStatsPanelOpen] = useState(false);
@@ -627,6 +646,7 @@ export function AdventurePanel({
string | null
>(null);
const [rewardQuestId, setRewardQuestId] = useState<string | null>(null);
const [rewardQuestHandoff, setRewardQuestHandoff] = useState<GoalHandoff | null>(null);
const [selectedRewardItemQuestId, setSelectedRewardItemQuestId] = useState<
string | null
>(null);
@@ -636,6 +656,8 @@ export function AdventurePanel({
const [selectedBattleRewardItemId, setSelectedBattleRewardItemId] = useState<
string | null
>(null);
const lastAutoOpenedGoalRef = useRef<string | null>(null);
const lastAutoOpenedPulseRef = useRef<string | null>(null);
const battleReward = battleRewardUi.reward;
const hasCompletedQuest = useMemo(
() => quests.some((quest) => isQuestReadyToClaim(quest)),
@@ -712,6 +734,32 @@ export function AdventurePanel({
setSelectedBattleRewardItemId(null);
}, [battleReward]);
useEffect(() => {
if (!primaryQuestGoal) {
return;
}
if (lastAutoOpenedGoalRef.current === primaryQuestGoal.id) {
return;
}
lastAutoOpenedGoalRef.current = primaryQuestGoal.id;
setIsGoalPanelOpen(true);
}, [primaryQuestGoal]);
useEffect(() => {
if (!goalPulse) {
return;
}
if (lastAutoOpenedPulseRef.current === goalPulse.id) {
return;
}
lastAutoOpenedPulseRef.current = goalPulse.id;
setIsGoalPanelOpen(true);
}, [goalPulse]);
useEffect(() => {
const container = storyScrollContainerRef.current;
if (!container) return;
@@ -813,7 +861,7 @@ export function AdventurePanel({
[statistics],
);
const shouldMountAdventureOverlays =
isChapterPanelOpen ||
isGoalPanelOpen ||
isSettingsPanelOpen ||
isStatsPanelOpen ||
isQuestPanelOpen ||
@@ -834,6 +882,11 @@ export function AdventurePanel({
onChoice(option);
};
const handleDismissGoalPanel = () => {
setIsGoalPanelOpen(false);
onDismissGoalPulse();
};
return (
<div className="relative flex min-h-0 flex-1 flex-col">
<button
@@ -854,35 +907,34 @@ export function AdventurePanel({
</button>
<button
type="button"
onClick={() => setIsChapterPanelOpen(true)}
onClick={() => {
setIsQuestPanelOpen(true);
onDismissGoalPulse();
}}
className="fixed right-0 z-[26] flex min-w-[3.1rem] flex-col items-center gap-1 rounded-l-xl border border-r-0 border-white/10 bg-black/78 pl-2 pr-1.5 py-2 text-[10px] text-zinc-200 shadow-[0_8px_18px_rgba(0,0,0,0.35)] backdrop-blur-md transition hover:text-white"
style={{ top: 'calc(env(safe-area-inset-top, 0px) + 7vh)' }}
>
<ScrollText className="h-4 w-4" />
<span className="leading-none"></span>
{chapterState?.title ? (
<span className="max-w-[3.6rem] truncate text-[9px] text-zinc-400">
{chapterState.title}
</span>
) : null}
</button>
<button
type="button"
onClick={() => setIsQuestPanelOpen(true)}
className="fixed right-0 z-[26] flex min-w-[3.1rem] flex-col items-center gap-1 rounded-l-xl border border-r-0 border-white/10 bg-black/78 pl-2 pr-1.5 py-2 text-[10px] text-zinc-200 shadow-[0_8px_18px_rgba(0,0,0,0.35)] backdrop-blur-md transition hover:text-white"
style={{ top: 'calc(env(safe-area-inset-top, 0px) + 14.5vh)' }}
>
{hasCompletedQuest && (
{(hasCompletedQuest || goalPulse) && (
<span
aria-hidden="true"
className="absolute -left-1 top-1 h-3.5 w-3.5 rounded-full border border-red-200/45 bg-red-500 shadow-[0_0_14px_rgba(239,68,68,0.55)]"
className={`absolute -left-1 top-1 h-3.5 w-3.5 rounded-full border shadow-[0_0_14px_rgba(245,158,11,0.5)] ${
hasCompletedQuest
? 'border-red-200/45 bg-red-500 shadow-[0_0_14px_rgba(239,68,68,0.55)]'
: 'border-amber-200/45 bg-amber-500'
}`}
/>
)}
<PixelIcon src={CHROME_ICONS.map} className="h-4 w-4" />
<span className="leading-none"></span>
<span className="rounded-full border border-white/10 bg-white/10 px-1.5 py-0.5 text-[9px] text-white">
{quests.length}
</span>
{primaryQuestGoal?.title ? (
<span className="max-w-[3.6rem] truncate text-[9px] text-zinc-400">
{primaryQuestGoal.title}
</span>
) : (
<span className="rounded-full border border-white/10 bg-white/10 px-1.5 py-0.5 text-[9px] text-white">
{quests.length}
</span>
)}
</button>
{aiError && (
@@ -1058,6 +1110,11 @@ export function AdventurePanel({
{getCompactOptionDetailText(option)}
</div>
)}
{option.goalAffordance?.label && (
<div className={`mt-1 text-[10px] ${getOptionGoalAffordanceClass(option)}`}>
{option.goalAffordance.label}
</div>
)}
{optionImpactSummary && (
<div className="mt-1 text-[10px] text-zinc-500">
{optionImpactSummary}
@@ -1083,8 +1140,8 @@ export function AdventurePanel({
onMusicVolumeChange={onMusicVolumeChange}
onSaveAndExit={onSaveAndExit}
saveAndExitDisabled={saveAndExitDisabled}
isChapterPanelOpen={isChapterPanelOpen}
setIsChapterPanelOpen={setIsChapterPanelOpen}
isGoalPanelOpen={isGoalPanelOpen}
setIsGoalPanelOpen={setIsGoalPanelOpen}
isQuestPanelOpen={isQuestPanelOpen}
setIsQuestPanelOpen={setIsQuestPanelOpen}
isSettingsPanelOpen={isSettingsPanelOpen}
@@ -1093,15 +1150,17 @@ export function AdventurePanel({
setIsStatsPanelOpen={setIsStatsPanelOpen}
chapterState={chapterState}
journeyBeat={journeyBeat}
recentChronicleSummary={recentChronicleSummary}
currentCampEvent={currentCampEvent}
setpieceDirective={setpieceDirective}
goalStack={goalStack}
goalPulse={goalPulse}
onDismissGoalPulse={handleDismissGoalPanel}
selectedQuest={selectedQuest}
setSelectedQuestId={setSelectedQuestId}
completionNoticeQuest={completionNoticeQuest}
setCompletionNoticeQuestId={setCompletionNoticeQuestId}
rewardQuest={rewardQuest}
setRewardQuestId={setRewardQuestId}
rewardQuestHandoff={rewardQuestHandoff}
setRewardQuestHandoff={setRewardQuestHandoff}
selectedRewardItemQuestId={selectedRewardItemQuestId}
setSelectedRewardItemQuestId={setSelectedRewardItemQuestId}
selectedRewardItemId={selectedRewardItemId}

View File

@@ -49,22 +49,34 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
DEFAULT_ANIMATIONS[state] ??
character.animationMap?.[AnimationState.IDLE] ??
DEFAULT_ANIMATIONS[AnimationState.IDLE];
const startFrame = config.startFrame ?? 1;
const frameCount = config.frames;
const animationSignature = [
state,
config.basePath ?? '',
config.folder,
config.prefix,
config.file ?? '',
config.extension ?? 'png',
startFrame,
frameCount,
].join('::');
useEffect(() => {
setFrameIndex(config.startFrame || 1);
setFrameIndex(startFrame);
if (config.frames <= 1) return;
if (frameCount <= 1) return;
const interval = setInterval(() => {
const endFrame = startFrame + frameCount - 1;
const interval = window.setInterval(() => {
setFrameIndex(prev => {
const start = config.startFrame || 1;
const end = start + config.frames - 1;
return prev >= end ? start : prev + 1;
return prev >= endFrame ? startFrame : prev + 1;
});
}, 100);
return () => clearInterval(interval);
}, [config]);
return () => window.clearInterval(interval);
}, [animationSignature, frameCount, startFrame]);
const frameNumber = frameIndex.toString().padStart(2, '0');
const normalizedBasePath = config.basePath?.replace(/\/+$/u, '');

View File

@@ -14,6 +14,7 @@ import {
getCharacterMaxMana,
getInventoryItems,
} from '../data/characterPresets';
import { buildMedievalNpcVisualFromCustomWorldVisual } from '../data/medievalNpcVisuals';
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
import {
AnimationState,
@@ -36,6 +37,7 @@ import {
CharacterAttributeGrid,
CharacterSkillsList,
} from './CharacterInfoShared';
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
import { PixelIcon } from './PixelIcon';
interface CharacterDetailModalProps {
@@ -161,7 +163,10 @@ export function CharacterDetailModal({
worldType,
customWorldProfile,
);
const resourceLabels = getResourceLabelsForWorld(worldType);
const resourceLabels = getResourceLabelsForWorld(
worldType,
customWorldProfile,
);
return (
<motion.div
@@ -204,13 +209,20 @@ export function CharacterDetailModal({
<Section title="资料">
<div className="flex flex-col items-center text-center">
<div className="flex h-44 w-full max-w-[16rem] items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20">
<CharacterAnimator
state={AnimationState.IDLE}
character={character}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(character)}
/>
{character.visual ? (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(character.visual)}
scale={2.08}
/>
) : (
<CharacterAnimator
state={AnimationState.IDLE}
character={character}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(character)}
/>
)}
</div>
<div className="mt-3 rounded-full border border-sky-400/25 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-sky-100">

View File

@@ -25,6 +25,7 @@ import {
getEquipmentRarityLabel,
getEquipmentSlotLabel,
} from '../data/equipmentEffects';
import { buildMedievalNpcVisualFromCustomWorldVisual } from '../data/medievalNpcVisuals';
import type { CharacterChatTarget } from '../hooks/useStoryGeneration';
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
import {
@@ -61,6 +62,7 @@ import {
StatusRow,
} from './CharacterInfoShared';
import type { GameCanvasEntitySelection } from './GameCanvas';
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
import { PixelIcon } from './PixelIcon';
interface CharacterPanelProps {
@@ -613,15 +615,22 @@ export function CharacterPanel({
>
<div className="flex flex-col items-center text-center">
<div className="flex h-36 w-full max-w-[15rem] items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20 sm:h-40">
<CharacterAnimator
state={AnimationState.IDLE}
character={selectedMember.character}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(
selectedMember.character,
)}
/>
{selectedMember.character.visual ? (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(selectedMember.character.visual)}
scale={2.08}
/>
) : (
<CharacterAnimator
state={AnimationState.IDLE}
character={selectedMember.character}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(
selectedMember.character,
)}
/>
)}
</div>
<div className="mt-3 text-base font-bold text-white">
{selectedMember.character.name}

View File

@@ -4,6 +4,11 @@ import {
getCustomWorldSceneRelativePositionLabel,
normalizeCustomWorldLandmarks,
} from '../data/customWorldSceneGraph';
import {
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImageMap,
} from '../data/customWorldVisuals';
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import { buildCustomWorldCreatorIntentDisplayText } from '../services/customWorldCreatorIntent';
import { AnimationState, Character, CustomWorldProfile } from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
@@ -229,6 +234,15 @@ export function CustomWorldEntityCatalog({
() => new Map(profile.landmarks.map((landmark) => [landmark.id, landmark])),
[profile.landmarks],
);
const landmarkImageById = useMemo(
() => resolveCustomWorldLandmarkImageMap(profile),
[profile],
);
const resolvedCampScene = useMemo(() => resolveCustomWorldCampScene(profile), [profile]);
const resolvedCampImageSrc = useMemo(
() => resolveCustomWorldCampSceneImage(profile),
[profile],
);
const previewCharacterById = useMemo(
() => new Map(profile.playableNpcs.map((role, index) => [role.id, previewCharacters[index] ?? null])),
[previewCharacters, profile.playableNpcs],
@@ -394,6 +408,23 @@ export function CustomWorldEntityCatalog({
</Section>
) : null}
<Section title="开局归处" subtitle="玩家进入自定义世界后的第一处落脚点,也会直接作为开场场景背景。">
<div className="space-y-3">
<ImageFrame
src={resolvedCampImageSrc}
alt={resolvedCampScene.name}
fallbackLabel={resolvedCampScene.name.slice(0, 4) || '归处'}
tone="landscape"
/>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-200">
{resolvedCampScene.name}
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-7 text-zinc-300">
{resolvedCampScene.description}
</div>
</div>
</Section>
<Section title="档案规模" subtitle="结果页现在会同时维护场景角色归属和场景之间的相对位置连接关系。">
<div className="grid grid-cols-1 gap-2 text-center text-[11px] text-zinc-300 sm:grid-cols-3">
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
@@ -766,7 +797,12 @@ export function CustomWorldEntityCatalog({
</div>
) : null}
<ImageFrame src={landmark.imageSrc} alt={landmark.name} fallbackLabel={landmark.name.slice(0, 4) || '场景'} tone="landscape" />
<ImageFrame
src={landmarkImageById.get(landmark.id) ?? landmark.imageSrc}
alt={landmark.name}
fallbackLabel={landmark.name.slice(0, 4) || '场景'}
tone="landscape"
/>
<div className="text-sm leading-7 text-zinc-300">{landmark.description}</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">
{landmark.dangerLevel || '未填写'}

View File

@@ -14,7 +14,8 @@ import {
} from '../data/customWorldSceneGraph';
import {
getAllCustomWorldSceneImages,
getDefaultCustomWorldSceneImage,
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImage,
} from '../data/customWorldVisuals';
import {
type CustomWorldSceneImageResult,
@@ -24,6 +25,7 @@ import {
buildCustomWorldSceneImagePrompt,
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
} from '../services/customWorld';
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import {
AnimationState,
CustomWorldLandmark,
@@ -612,7 +614,25 @@ function SceneImageGenerationModal({
const [latestResult, setLatestResult] =
useState<CustomWorldSceneImageResult | null>(null);
const previewImageSrc = latestResult?.imageSrc ?? landmark.imageSrc;
const previewImageSrc = useMemo(() => {
if (latestResult?.imageSrc) {
return latestResult.imageSrc;
}
const landmarkIndex = profile.landmarks.findIndex(
(entry) => entry.id === landmark.id,
);
return resolveCustomWorldLandmarkImage(
profile,
landmark,
landmarkIndex >= 0 ? landmarkIndex : profile.landmarks.length,
profile.landmarks
.filter((entry) => entry.id !== landmark.id)
.map((entry) => entry.imageSrc)
.filter((imageSrc): imageSrc is string => Boolean(imageSrc)),
);
}, [landmark, latestResult, profile]);
const handleGenerate = async () => {
if (!prompt.trim()) {
@@ -1238,6 +1258,29 @@ function WorldEditor({
onClose: () => void;
}) {
const [draft, setDraft] = useDraft(profile);
const [isCampPresetPickerOpen, setIsCampPresetPickerOpen] = useState(false);
const [isCampAiGenerateOpen, setIsCampAiGenerateOpen] = useState(false);
const presetImages = useMemo(() => getAllCustomWorldSceneImages(), []);
const resolvedCampScene = useMemo(
() => resolveCustomWorldCampScene(draft),
[draft],
);
const resolvedCampImageSrc = useMemo(
() => resolveCustomWorldCampSceneImage(draft),
[draft],
);
const campSceneDraft = useMemo<CustomWorldLandmark>(
() => ({
id: 'custom-scene-camp',
name: resolvedCampScene.name,
description: resolvedCampScene.description,
dangerLevel: resolvedCampScene.dangerLevel,
imageSrc: resolvedCampScene.imageSrc,
sceneNpcIds: [],
connections: [],
}),
[resolvedCampScene],
);
return (
<ModalShell
@@ -1289,6 +1332,84 @@ function WorldEditor({
rows={3}
/>
</Field>
<Field label="开局归处名称">
<TextInput
value={resolvedCampScene.name}
onChange={(value) =>
setDraft((current) => ({
...current,
camp: {
...resolveCustomWorldCampScene(current),
name: value,
},
}))
}
/>
</Field>
<Field label="开局归处描述">
<TextArea
value={resolvedCampScene.description}
onChange={(value) =>
setDraft((current) => ({
...current,
camp: {
...resolveCustomWorldCampScene(current),
description: value,
},
}))
}
rows={4}
/>
</Field>
<Field label="开局归处危险度">
<TextInput
value={resolvedCampScene.dangerLevel}
onChange={(value) =>
setDraft((current) => ({
...current,
camp: {
...resolveCustomWorldCampScene(current),
dangerLevel: value,
},
}))
}
/>
</Field>
<ImageField
label="开局归处背景"
value={resolvedCampImageSrc}
onChange={(value) =>
setDraft((current) => ({
...current,
camp: {
...resolveCustomWorldCampScene(current),
imageSrc: value || undefined,
},
}))
}
fallbackLabel={resolvedCampScene.name.slice(0, 4) || '归处'}
tone="landscape"
showInput={false}
previewOverlay={<SceneSparringPreview profile={draft} />}
footer={(
<div className="space-y-3">
<div className="flex flex-wrap gap-3">
<ActionButton
label="预设选择"
onClick={() => setIsCampPresetPickerOpen(true)}
tone="sky"
/>
<ActionButton
label="智能生成"
onClick={() => setIsCampAiGenerateOpen(true)}
/>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300">
</div>
</div>
)}
/>
<Field label="玩家原始设定">
<TextArea
value={draft.settingText}
@@ -1298,6 +1419,38 @@ function WorldEditor({
rows={4}
/>
</Field>
{isCampPresetPickerOpen ? (
<ScenePresetPickerModal
selectedSrc={resolvedCampScene.imageSrc}
presetImages={presetImages}
onSelect={(value) =>
setDraft((current) => ({
...current,
camp: {
...resolveCustomWorldCampScene(current),
imageSrc: value,
},
}))
}
onClose={() => setIsCampPresetPickerOpen(false)}
/>
) : null}
{isCampAiGenerateOpen ? (
<SceneImageGenerationModal
profile={draft}
landmark={campSceneDraft}
onApply={(result) => {
setDraft((current) => ({
...current,
camp: {
...resolveCustomWorldCampScene(current),
imageSrc: result.imageSrc,
},
}));
}}
onClose={() => setIsCampAiGenerateOpen(false)}
/>
) : null}
<SaveBar
onClose={onClose}
onSave={() => {
@@ -1769,6 +1922,21 @@ function LandmarkEditor({
npc: CustomWorldNpc;
} | null>(null);
const presetImages = useMemo(() => getAllCustomWorldSceneImages(), []);
const resolvedDraftImageSrc = useMemo(() => {
const landmarkIndex = profile.landmarks.findIndex(
(entry) => entry.id === draft.id,
);
return resolveCustomWorldLandmarkImage(
profile,
draft,
landmarkIndex >= 0 ? landmarkIndex : profile.landmarks.length,
profile.landmarks
.filter((entry) => entry.id !== draft.id)
.map((entry) => entry.imageSrc)
.filter((imageSrc): imageSrc is string => Boolean(imageSrc)),
);
}, [draft, profile]);
const storyNpcById = useMemo(
() => new Map(draftStoryNpcs.map((npc) => [npc.id, npc])),
[draftStoryNpcs],
@@ -1847,7 +2015,7 @@ function LandmarkEditor({
<div className="space-y-4">
<ImageField
label="场景图片"
value={draft.imageSrc}
value={resolvedDraftImageSrc}
onChange={(value) =>
setDraft((current) => ({
...current,
@@ -2367,11 +2535,7 @@ function createLandmark(profile: CustomWorldProfile): CustomWorldLandmark {
name: `自定义场景${profile.landmarks.length + 1}`,
description: '',
dangerLevel: '中',
imageSrc: getDefaultCustomWorldSceneImage(
profile.id || profile.name,
profile.landmarks.length,
profile.templateWorldType,
),
imageSrc: undefined,
sceneNpcIds: profile.storyNpcs.slice(0, 3).map((npc) => npc.id),
connections: previousLandmark
? [

View File

@@ -7,6 +7,7 @@ import {BottomTab} from '../hooks/useGameFlow';
import {
type BattleRewardUi,
type CharacterChatUi,
type GoalFlowUi,
type InventoryFlowUi,
type QuestFlowUi,
type StoryGenerationNpcUi,
@@ -49,6 +50,7 @@ interface GameShellStoryProps {
inventoryUi: InventoryFlowUi;
battleRewardUi: BattleRewardUi;
questUi: QuestFlowUi;
goalUi: GoalFlowUi;
}
interface GameShellEntryProps {
@@ -63,6 +65,7 @@ interface GameShellEntryProps {
interface GameShellCompanionProps {
companionRenderStates: CompanionRenderState[];
buildCompanionRenderStates: (state: GameState) => CompanionRenderState[];
onBenchCompanion: (npcId: string) => void;
onActivateRosterCompanion: (npcId: string, swapNpcId?: string | null) => void;
}
@@ -201,6 +204,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
inventoryUi,
battleRewardUi,
questUi,
goalUi,
} = story;
const {
hasSavedGame,
@@ -211,7 +215,12 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
handleBackToWorldSelect,
handleCharacterSelect,
} = entry;
const {companionRenderStates, onBenchCompanion, onActivateRosterCompanion} = companions;
const {
companionRenderStates,
buildCompanionRenderStates,
onBenchCompanion,
onActivateRosterCompanion,
} = companions;
const {musicVolume, onMusicVolumeChange} = audio;
const [clockNow, setClockNow] = useState(() => Date.now());
@@ -287,13 +296,18 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
[gameState.characterChats],
);
const visibleCompanionRenderStates = useMemo(
() => buildCompanionRenderStates(visibleGameState),
[buildCompanionRenderStates, visibleGameState],
);
const canvasCompanionRenderStates = useMemo(() => {
const activeEncounterNpcId = visibleGameState.currentEncounter?.kind === 'npc'
? visibleGameState.currentEncounter.id ?? null
: null;
if (!activeEncounterNpcId) return companionRenderStates;
return companionRenderStates.filter(companion => companion.npcId !== activeEncounterNpcId);
}, [companionRenderStates, visibleGameState.currentEncounter]);
if (!activeEncounterNpcId) return visibleCompanionRenderStates;
return visibleCompanionRenderStates.filter(companion => companion.npcId !== activeEncounterNpcId);
}, [visibleCompanionRenderStates, visibleGameState.currentEncounter]);
const livePlayTimeMs = useMemo(
() => getLiveGamePlayTimeMs(gameState.runtimeStats, clockNow),
@@ -530,6 +544,9 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
worldType={visibleGameState.worldType}
quests={visibleGameState.quests}
questUi={questUi}
goalStack={goalUi.goalStack}
goalPulse={goalUi.pulse}
onDismissGoalPulse={goalUi.dismissPulse}
battleRewardUi={battleRewardUi}
playerHp={visibleGameState.playerHp}
playerMaxHp={visibleGameState.playerMaxHp}
@@ -542,15 +559,6 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
journeyBeat={
visibleGameState.storyEngineMemory?.currentJourneyBeat ?? null
}
recentChronicleSummary={
visibleGameState.storyEngineMemory?.continueGameDigest ?? null
}
currentCampEvent={
visibleGameState.storyEngineMemory?.currentCampEvent ?? null
}
setpieceDirective={
visibleGameState.storyEngineMemory?.currentSetpieceDirective ?? null
}
statistics={adventureStatistics}
musicVolume={musicVolume}
onMusicVolumeChange={onMusicVolumeChange}

View File

@@ -2,10 +2,11 @@ import { AnimatePresence, motion } from 'motion/react';
import type { ReactNode } from 'react';
import { resolveInventoryItemUseEffect } from '../data/inventoryEffects';
import { buildInventoryItemDescription } from '../data/itemPresentation';
import type { Character, InventoryItem, WorldType } from '../types';
import {
CHROME_ICONS,
getInventoryCategoryIcon,
getInventoryItemVisualSrc,
getNineSliceStyle,
UI_CHROME,
} from '../uiAssets';
@@ -66,31 +67,14 @@ function getInventoryRarityTheme(rarity: InventoryItem['rarity']) {
}
function getInventoryItemIcon(item: InventoryItem) {
return item.iconSrc ?? getInventoryCategoryIcon(item.category);
return getInventoryItemVisualSrc(item);
}
function buildInventoryItemSummary(
item: InventoryItem,
useEffect: ReturnType<typeof resolveInventoryItemUseEffect>,
) {
if (item.description?.trim()) return item.description;
if (!useEffect)
return `${item.name} 当前更适合作为交易、拆解或后续锻造素材使用。`;
const parts = [
useEffect.hpRestore > 0 ? `恢复 ${useEffect.hpRestore} 点气血` : null,
useEffect.manaRestore > 0 ? `恢复 ${useEffect.manaRestore} 点灵力` : null,
useEffect.cooldownReduction > 0
? `额外推进 ${useEffect.cooldownReduction} 回合冷却`
: null,
useEffect.buildBuffs.length > 0
? `获得 ${useEffect.buildBuffs.map((buff) => buff.name).join('、')}`
: null,
].filter(Boolean);
return parts.length > 0
? `${item.name} 可以立即使用,${parts.join('')}`
: `${item.name} 当前更适合作为交易、拆解或后续锻造素材使用。`;
return buildInventoryItemDescription(item, useEffect);
}
function buildInventorySlots(items: InventoryItem[], minimumSlotCount: number) {

View File

@@ -14,6 +14,10 @@ import {
getEquipmentSlotLabel,
} from '../data/equipmentEffects';
import { isInventoryItemUsable, resolveInventoryItemUseEffect } from '../data/inventoryEffects';
import {
buildInventoryItemDescription,
getInventoryTagLabels,
} from '../data/itemPresentation';
import {
buildInitialNpcState,
getGiftCandidates,
@@ -21,7 +25,7 @@ import {
} from '../data/npcInteractions';
import { StoryGenerationNpcUi } from '../hooks/useStoryGeneration';
import { GameState, InventoryItem } from '../types';
import { CHROME_ICONS, getInventoryCategoryIcon, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { CHROME_ICONS, getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelIcon } from './PixelIcon';
interface NpcModalsProps {
@@ -39,7 +43,7 @@ function getNpcEncounterKey(encounter: NonNullable<GameState['currentEncounter']
}
function getItemVisualSrc(item: InventoryItem) {
return item.iconSrc ?? getInventoryCategoryIcon(item.category);
return getInventoryItemVisualSrc(item);
}
function buildTradeUseEffectText(
@@ -412,7 +416,7 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
</div>
<p className="text-sm leading-relaxed text-zinc-300">
{tradeDetailItem.description || `${tradeDetailItem.name}可用于交易、装备,或在合适时机直接使用。`}
{buildInventoryItemDescription(tradeDetailItem, tradeDetailUseEffect)}
</p>
<div className="grid grid-cols-2 gap-2 text-xs text-zinc-300">
@@ -423,7 +427,7 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
{isInventoryItemUsable(tradeDetailItem) ? '可立即使用' : '不可即时使用'}
</div>
<div className="col-span-2 rounded-lg border border-white/8 bg-black/20 px-3 py-2">
{tradeDetailItem.tags.join(' / ') || '无'}
{getInventoryTagLabels(tradeDetailItem.tags).join(' / ') || '无'}
</div>
</div>

View File

@@ -245,7 +245,7 @@ export function CustomWorldCreatorModal(props: CustomWorldCreatorModalProps) {
value={draftText}
onChange={(event) => updateDraftText(event.target.value)}
rows={8}
placeholder="例:一个雨雾笼罩的海上武侠世界,旧朝遗臣、海盗盟约沉船秘术纠缠在一起……"
placeholder="例:一个被潮雾与失落列岛切碎的边境世界,旧盟约沉船秘术与灯塔守望者纠缠在一起……"
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40"
/>

View File

@@ -14,20 +14,32 @@ import {AnimatePresence, motion} from 'motion/react';
import {formatCurrency} from '../../data/economy';
import {getHostileNpcPresetById} from '../../data/hostileNpcPresets';
import {type InventoryUseEffect, isInventoryItemUsable} from '../../data/inventoryEffects';
import {
buildInventoryItemDescription,
getInventoryTagLabels,
} from '../../data/itemPresentation';
import {getRarityLabel} from '../../data/npcInteractions';
import {isQuestReadyToClaim} from '../../data/questFlow';
import {getScenePresetById} from '../../data/scenePresets';
import type {BattleRewardUi, QuestFlowUi} from '../../hooks/useStoryGeneration';
import { sortQuestsForGoalPanel } from '../../services/storyEngine/goalDirector';
import type {
CampEvent,
ChapterState,
EquipmentSlotId,
GoalHandoff,
GoalPulseEvent,
GoalStackState,
InventoryItem,
JourneyBeat,
QuestLogEntry,
SetpieceDirective,
WorldType,
} from '../../types';
import {CHROME_ICONS, getInventoryCategoryIcon, getNineSliceStyle, UI_CHROME} from '../../uiAssets';
import {
CHROME_ICONS,
getInventoryItemVisualSrc,
getNineSliceStyle,
UI_CHROME,
} from '../../uiAssets';
import {HostileNpcAnimator} from '../HostileNpcAnimator';
import {PixelIcon} from '../PixelIcon';
@@ -66,8 +78,8 @@ interface AdventurePanelOverlaysProps {
onMusicVolumeChange: (value: number) => void;
onSaveAndExit: () => void;
saveAndExitDisabled: boolean;
isChapterPanelOpen: boolean;
setIsChapterPanelOpen: (open: boolean) => void;
isGoalPanelOpen: boolean;
setIsGoalPanelOpen: (open: boolean) => void;
isQuestPanelOpen: boolean;
setIsQuestPanelOpen: (open: boolean) => void;
isSettingsPanelOpen: boolean;
@@ -76,15 +88,17 @@ interface AdventurePanelOverlaysProps {
setIsStatsPanelOpen: (open: boolean) => void;
chapterState: ChapterState | null;
journeyBeat: JourneyBeat | null;
recentChronicleSummary: string | null;
currentCampEvent: CampEvent | null;
setpieceDirective: SetpieceDirective | null;
goalStack: GoalStackState;
goalPulse: GoalPulseEvent | null;
onDismissGoalPulse: () => void;
selectedQuest: QuestLogEntry | null;
setSelectedQuestId: (questId: string | null) => void;
completionNoticeQuest: QuestLogEntry | null;
setCompletionNoticeQuestId: (questId: string | null) => void;
rewardQuest: QuestLogEntry | null;
setRewardQuestId: (questId: string | null) => void;
rewardQuestHandoff: GoalHandoff | null;
setRewardQuestHandoff: (handoff: GoalHandoff | null) => void;
selectedRewardItemQuestId: string | null;
setSelectedRewardItemQuestId: (questId: string | null) => void;
selectedRewardItemId: string | null;
@@ -98,87 +112,402 @@ interface AdventurePanelOverlaysProps {
getQuestStatusLabel: (status: QuestLogEntry['status']) => string;
}
function getChapterStageLabel(stage: ChapterState['stage'] | null | undefined) {
switch (stage) {
case 'opening':
return '开篇';
case 'expansion':
return '展开';
case 'turning_point':
return '转折';
case 'climax':
return '高潮';
case 'aftermath':
return '余波';
default:
return '进行中';
function compactSceneTaskLabel(sceneName: string | null | undefined, fallback: string) {
if (!sceneName?.trim()) {
return fallback;
}
const cleaned = sceneName.replace(/["']/gu, '').trim();
return cleaned.length > 8 ? cleaned.slice(0, 8) : cleaned;
}
function getJourneyBeatLabel(beatType: JourneyBeat['beatType'] | null | undefined) {
function formatTaskTitle(title: string, fallback = '当前任务') {
const cleaned = title
.replace(/["']/gu, '')
.replace(/[·|:].*$/u, '')
.replace(/[,.!?;].*$/u, '')
.trim();
if (!cleaned) {
return fallback;
}
return cleaned.length > 12 ? cleaned.slice(0, 10) : cleaned;
}
function buildJourneyTaskCardCopy(params: {
beatType: JourneyBeat['beatType'] | null | undefined;
sceneName: string;
fallbackCondition: string;
}) {
const { beatType, sceneName, fallbackCondition } = params;
const sceneLabel = compactSceneTaskLabel(sceneName, '前方区域');
switch (beatType) {
case 'approach':
return '接近';
return {
title: `前往${sceneLabel}`,
description: `${sceneLabel} 一带出现了值得跟进的新线索,继续靠近,看看那里到底发生了什么。`,
condition: `前往 ${sceneName},确认新的线索。`,
progress: '靠近线索',
};
case 'investigation':
return '调查';
return {
title: `调查${sceneLabel}`,
description: `${sceneLabel} 出现了新的异常和痕迹,继续调查,查清这里到底隐藏着什么。`,
condition: `${sceneName} 调查线索或异常。`,
progress: '调查进行中',
};
case 'camp':
return '休整';
return {
title: '回营整备',
description: '先整理队伍、资源和状态,再决定下一段任务的推进方式。',
condition: '返回营地,整理队伍或与同伴交谈。',
progress: '整备中',
};
case 'conflict':
return '冲突';
return {
title: `处理${sceneLabel}`,
description: `${sceneLabel} 的冲突已经浮出水面,需要继续推进并正面处理。`,
condition: `${sceneName} 处理当前冲突。`,
progress: '冲突处理中',
};
case 'boss_prelude':
return '决战前奏';
return {
title: `备战${sceneLabel}`,
description: '关键战斗已经逼近,先把线索和状态准备好。',
condition: fallbackCondition,
progress: '战前准备',
};
case 'climax':
return '高潮';
return {
title: `决断${sceneLabel}`,
description: '决定结果的对峙已经临近,继续推进到最终现场。',
condition: fallbackCondition,
progress: '决战临近',
};
case 'recovery':
return '恢复';
return {
title: '收束结果',
description: '刚结束的事件还在留下影响,先整理结果,再决定下一步去向。',
condition: fallbackCondition,
progress: '结果收束中',
};
default:
return '旅程';
return {
title: '继续推进',
description: `${sceneLabel} 一带还有没查清的事,继续推进当前线索。`,
condition: fallbackCondition,
progress: '推进中',
};
}
}
function getCampEventLabel(eventType: CampEvent['eventType'] | null | undefined) {
switch (eventType) {
case 'private_talk':
return '私话';
case 'party_banter':
return '同行插话';
case 'conflict':
return '争执';
case 'comfort':
return '安抚';
case 'reveal':
return '透露';
case 'decision':
return '抉择';
function buildCurrentTaskCardCopy(params: {
goalStack: GoalStackState;
goalPulse: GoalPulseEvent | null;
journeyBeat: JourneyBeat | null;
sceneName: string;
}) {
const { goalStack, goalPulse, journeyBeat, sceneName } = params;
const primaryGoal = goalStack.activeGoal ?? goalStack.northStarGoal;
const stepGoal = goalStack.immediateStepGoal ?? primaryGoal;
if (!primaryGoal || !stepGoal) {
return null;
}
if (primaryGoal.sourceKind === 'quest') {
const description = primaryGoal.whyNow || primaryGoal.promiseText;
return {
eyebrow: goalPulse?.title ?? '当前任务',
title: formatTaskTitle(primaryGoal.title, '当前任务'),
description,
condition: stepGoal.nextStepText,
progress: stepGoal.progressLabel ?? primaryGoal.progressLabel ?? '推进中',
pulseNote:
goalPulse?.detail && goalPulse.detail !== description && goalPulse.detail !== stepGoal.nextStepText
? goalPulse.detail
: null,
};
}
const journeyCopy = buildJourneyTaskCardCopy({
beatType: journeyBeat?.beatType,
sceneName,
fallbackCondition: stepGoal.nextStepText,
});
return {
eyebrow: goalPulse?.title ?? '当前任务',
title: journeyCopy.title,
description: journeyCopy.description,
condition: journeyCopy.condition,
progress: journeyCopy.progress,
pulseNote:
goalPulse?.detail && goalPulse.detail !== journeyCopy.description && goalPulse.detail !== journeyCopy.condition
? goalPulse.detail
: null,
};
}
function getQuestSceneName(quest: QuestLogEntry, worldType: WorldType | null) {
if (!quest.sceneId) {
return '当前区域';
}
if (!worldType) {
return quest.sceneId;
}
return getScenePresetById(worldType, quest.sceneId)?.name ?? quest.sceneId;
}
function getQuestHostileNpcName(quest: QuestLogEntry, worldType: WorldType | null) {
if (!quest.objective.targetHostileNpcId) {
return null;
}
return worldType
? getHostileNpcPresetById(worldType, quest.objective.targetHostileNpcId)?.name
?? quest.objective.targetHostileNpcId
: quest.objective.targetHostileNpcId;
}
function buildQuestConditionText(
quest: QuestLogEntry,
worldType: WorldType | null,
) {
if (isQuestReadyToClaim(quest)) {
return `返回找 ${quest.issuerNpcName} 交付任务并领取奖励。`;
}
if (quest.status === 'turned_in') {
return '任务已经交付。';
}
const activeStep = quest.steps?.find(step => step.id === quest.activeStepId)
?? quest.steps?.find(step => step.progress < step.requiredCount)
?? null;
const objective = activeStep ?? quest.objective;
const sceneName = getQuestSceneName(quest, worldType);
switch (objective.kind) {
case 'defeat_hostile_npc':
return `击败 ${getQuestHostileNpcName(quest, worldType) ?? '指定敌人'}`;
case 'inspect_treasure':
return `${sceneName} 调查宝藏或异常线索。`;
case 'spar_with_npc':
return `${quest.issuerNpcName} 完成一场切磋。`;
case 'talk_to_npc':
return `返回找 ${quest.issuerNpcName} 对话。`;
case 'reach_scene':
return `前往 ${objective.targetSceneId ?? sceneName}`;
case 'deliver_item':
return `把指定物品交给 ${quest.issuerNpcName}`;
default:
return '营地事件';
return activeStep?.revealText ?? quest.summary;
}
}
function getSetpieceLabel(setpieceType: SetpieceDirective['setpieceType'] | null | undefined) {
switch (setpieceType) {
case 'boss_prelude':
return '决战前奏';
case 'showdown':
return '对峙';
case 'climax':
return '高潮';
case 'aftermath':
return '余波';
default:
return '剧情节点';
function getQuestProgressText(quest: QuestLogEntry) {
if (isQuestReadyToClaim(quest)) {
return '待交付';
}
if (quest.status === 'turned_in') {
return '已交付';
}
const activeStep = quest.steps?.find(step => step.id === quest.activeStepId)
?? quest.steps?.find(step => step.progress < step.requiredCount)
?? null;
const progressSource = activeStep ?? quest;
const requiredCount =
'requiredCount' in progressSource
? progressSource.requiredCount
: quest.objective.requiredCount;
return `${progressSource.progress}/${requiredCount}`;
}
function TaskTemplateCard({
eyebrow,
title,
description,
condition,
progress,
reward,
onRewardItemSelect,
tone = 'default',
}: {
eyebrow: string;
title: string;
description: string;
condition: string;
progress?: string | null;
reward?: QuestLogEntry['reward'] | null;
onRewardItemSelect?: ((itemId: string) => void) | null;
tone?: 'default' | 'main';
}) {
if (!title.trim() && !description.trim() && !condition.trim()) {
return null;
}
return (
<div
className={`rounded-2xl border px-4 py-3.5 ${
tone === 'main'
? 'border-amber-300/15 bg-[radial-gradient(circle_at_top,rgba(245,158,11,0.13),transparent_65%),rgba(0,0,0,0.24)]'
: 'border-white/8 bg-black/20'
}`}
>
<div className="min-w-0">
<div className={`text-[10px] tracking-[0.24em] ${tone === 'main' ? 'text-amber-200/80' : 'text-zinc-500'}`}>
{eyebrow}
</div>
<div className="mt-2 text-sm font-semibold text-white">
{formatTaskTitle(title)}
</div>
</div>
<div className="mt-3 text-xs leading-relaxed text-zinc-400">
<span className="text-zinc-500"></span>
{description}
</div>
<div className="mt-2 text-xs leading-relaxed text-zinc-300">
<span className="text-zinc-500"></span>
{condition}
</div>
{progress ? (
<div className="mt-2 text-[11px] leading-relaxed text-zinc-500">
{progress}
</div>
) : null}
{reward ? (
<QuestRewardIconStrip
reward={reward}
onSelectItem={onRewardItemSelect ?? undefined}
/>
) : null}
</div>
);
}
function QuestRewardIconStrip({
reward,
onSelectItem,
}: {
reward: QuestLogEntry['reward'];
onSelectItem?: (itemId: string) => void;
}) {
const hasItems = reward.items.length > 0;
return (
<div className="mt-3 rounded-xl border border-amber-300/10 bg-black/18 px-3 py-2">
<div className="flex items-center justify-between gap-3">
<div className="text-[10px] tracking-[0.2em] text-amber-200/75">
</div>
<div className="text-[10px] text-zinc-500">
+{reward.affinityBonus} · {reward.currency}
</div>
</div>
{hasItems ? (
<div className="mt-2 flex flex-wrap gap-2">
{reward.items.map(item => (
<button
key={item.id}
type="button"
onClick={() => onSelectItem?.(item.id)}
className="group relative flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-black/30 transition hover:border-amber-200/30"
title={item.name}
aria-label={`查看任务奖励 ${item.name}`}
>
<PixelIcon
src={getQuestRewardItemIcon(item)}
alt={item.name}
className="h-6 w-6"
/>
<span className="absolute -bottom-1 -right-1 rounded-full border border-black/40 bg-black/75 px-1 text-[9px] text-white">
{item.quantity}
</span>
</button>
))}
</div>
) : (
<div className="mt-2 text-[11px] text-zinc-500">
</div>
)}
</div>
);
}
function GoalFocusCard({
goalStack,
goalPulse,
journeyBeat,
sceneName,
}: {
goalStack: GoalStackState;
goalPulse: GoalPulseEvent | null;
journeyBeat: JourneyBeat | null;
sceneName: string;
}) {
const cardCopy = buildCurrentTaskCardCopy({
goalStack,
goalPulse,
journeyBeat,
sceneName,
});
const primaryGoal = goalStack.activeGoal ?? goalStack.northStarGoal;
if (!cardCopy || primaryGoal?.sourceKind !== 'quest') {
return null;
}
return (
<div className="space-y-4">
{cardCopy.pulseNote ? (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-[11px] leading-relaxed text-zinc-300">
{cardCopy.pulseNote}
</div>
) : null}
<TaskTemplateCard
eyebrow={cardCopy.eyebrow}
title={cardCopy.title}
description={cardCopy.description}
condition={cardCopy.condition}
progress={cardCopy.progress}
tone="main"
/>
{(
(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.sceneHint
|| (goalStack.immediateStepGoal ?? goalStack.activeGoal)?.npcHint
) ? (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-[11px] leading-relaxed text-zinc-400">
{(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.sceneHint
? `地点:${(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.sceneHint}`
: null}
{(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.sceneHint
&& (goalStack.immediateStepGoal ?? goalStack.activeGoal)?.npcHint
? ' · '
: null}
{(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.npcHint
? `相关人物:${(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.npcHint}`
: null}
</div>
) : null}
</div>
);
}
function getQuestRewardItemIcon(item: InventoryItem) {
if (item.iconSrc) return item.iconSrc;
if (item.tags.includes('weapon')) return '/UI/Icon_Eq_Weapon.png';
if (item.tags.includes('armor')) return '/UI/Icon_Eq_Chest.png';
if (item.tags.includes('relic')) return '/Icons/47_treasure.png';
if (item.tags.includes('healing')) return '/Icons/12_potion.png';
if (item.tags.includes('mana')) return '/UI/Hud_icon_magic.png';
if (item.tags.includes('material')) return '/Icons/45_crystal.png';
return getInventoryCategoryIcon(item.category);
return getInventoryItemVisualSrc(item);
}
function getRewardItemFrameClass(rarity: InventoryItem['rarity']) {
@@ -197,21 +526,7 @@ function getRewardItemFrameClass(rarity: InventoryItem['rarity']) {
}
function buildRewardItemDescription(item: InventoryItem) {
if (item.description?.trim()) return item.description;
const traits: string[] = [];
if (item.tags.includes('healing')) traits.push('在冒险中恢复生命值');
if (item.tags.includes('mana')) traits.push('恢复法力值或技能节奏');
if (item.tags.includes('weapon')) traits.push('适合进攻型构筑');
if (item.tags.includes('armor')) traits.push('适合防御型构筑');
if (item.tags.includes('relic')) traits.push('作为稀有遗物奖励');
if (item.tags.includes('material')) traits.push('可用于制作');
if (traits.length === 0) {
return `${item.name}${item.category} 奖励物品,可用于后续路线规划、交易或构筑规划。`;
}
return `${item.name}${item.category} 奖励物品,${traits.join('')}`;
return buildInventoryItemDescription(item);
}
function getQuestObjectivePresentation(quest: QuestLogEntry, worldType: WorldType | null, sceneName: string) {
@@ -437,7 +752,7 @@ function QuestObjectiveCard({
<div className="space-y-3">
<div className="grid gap-2 sm:grid-cols-2">
<div className="rounded-xl border border-white/10 bg-black/25 px-3 py-2.5 text-zinc-100">
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500"></div>
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500"></div>
<div className="mt-2 text-sm font-medium text-white">{presentation.primaryLabel}</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/25 px-3 py-2.5 text-zinc-100">
@@ -481,8 +796,8 @@ export function AdventurePanelOverlays({
onMusicVolumeChange,
onSaveAndExit,
saveAndExitDisabled,
isChapterPanelOpen,
setIsChapterPanelOpen,
isGoalPanelOpen,
setIsGoalPanelOpen,
isQuestPanelOpen,
setIsQuestPanelOpen,
isSettingsPanelOpen,
@@ -491,15 +806,17 @@ export function AdventurePanelOverlays({
setIsStatsPanelOpen,
chapterState,
journeyBeat,
recentChronicleSummary,
currentCampEvent,
setpieceDirective,
goalStack,
goalPulse,
onDismissGoalPulse,
selectedQuest,
setSelectedQuestId,
completionNoticeQuest,
setCompletionNoticeQuestId,
rewardQuest,
setRewardQuestId,
rewardQuestHandoff,
setRewardQuestHandoff,
selectedRewardItemQuestId,
setSelectedRewardItemQuestId,
selectedRewardItemId,
@@ -513,117 +830,90 @@ export function AdventurePanelOverlays({
getQuestStatusLabel,
}: AdventurePanelOverlaysProps) {
const battleReward = battleRewardUi.reward;
const sortedQuests = sortQuestsForGoalPanel(quests, goalStack);
const activeGoalQuest =
goalStack.activeGoal?.sourceKind === 'quest'
? quests.find(quest => quest.id === goalStack.activeGoal?.sourceId) ?? null
: null;
const shouldShowQuestUpdateModal = Boolean(activeGoalQuest && isGoalPanelOpen);
const selectQuestRewardItem = (quest: QuestLogEntry, itemId: string) => {
setSelectedBattleRewardItemId(null);
setSelectedRewardItemQuestId(quest.id);
setSelectedRewardItemId(itemId);
};
const closeGoalPanel = () => {
setIsGoalPanelOpen(false);
onDismissGoalPulse();
};
return (
<>
<AnimatePresence>
{isChapterPanelOpen && (
{shouldShowQuestUpdateModal && (
<motion.div
initial={{opacity: 0}}
animate={{opacity: 1}}
exit={{opacity: 0}}
className="fixed inset-0 z-[76] flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4"
onClick={() => setIsChapterPanelOpen(false)}
className="fixed inset-0 z-[77] flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4"
onClick={closeGoalPanel}
>
<motion.div
initial={{opacity: 0, scale: 0.96, y: 8}}
animate={{opacity: 1, scale: 1, y: 0}}
exit={{opacity: 0, scale: 0.96, y: 8}}
transition={{duration: 0.18, ease: 'easeOut'}}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(86vh,40rem)] w-full max-w-lg flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(84vh,34rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()}
>
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0 pr-10">
<div className="text-sm font-semibold text-white">
{chapterState?.title ?? '当前章节'}
{goalPulse ? '任务更新' : '当前任务'}
</div>
<div className="mt-1 text-[11px] text-zinc-500">
</div>
</div>
<button
type="button"
onClick={() => setIsChapterPanelOpen(false)}
onClick={closeGoalPanel}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4 scrollbar-hide">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
</div>
<div className="mt-2 text-lg font-semibold text-white">
{chapterState?.title ?? '旅程推进中'}
</div>
{chapterState && (
<div className="mt-2 inline-flex rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] text-zinc-300">
{getChapterStageLabel(chapterState.stage)}
</div>
)}
{chapterState?.chapterSummary && (
<div className="mt-3 text-sm leading-relaxed text-zinc-300">
{chapterState.chapterSummary}
</div>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4 scrollbar-hide">
<GoalFocusCard
goalStack={goalStack}
goalPulse={goalPulse}
journeyBeat={journeyBeat}
sceneName={statistics.currentSceneName}
/>
</div>
{journeyBeat && (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
</div>
<div className="mt-2 text-sm font-semibold text-white">
{getJourneyBeatLabel(journeyBeat.beatType)} · {journeyBeat.title}
</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">
{journeyBeat.emotionalGoal}
</div>
</div>
)}
{currentCampEvent && (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
</div>
<div className="mt-2 text-sm font-semibold text-white">
{getCampEventLabel(currentCampEvent.eventType)} · {currentCampEvent.title}
</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">
{currentCampEvent.triggerReason}
</div>
</div>
)}
{setpieceDirective && (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
</div>
<div className="mt-2 text-sm font-semibold text-white">
{getSetpieceLabel(setpieceDirective.setpieceType)} · {setpieceDirective.title}
</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">
{setpieceDirective.dramaticQuestion}
</div>
</div>
)}
{recentChronicleSummary && (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
</div>
<div className="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-zinc-300">
{recentChronicleSummary}
</div>
</div>
)}
<div className="flex items-center justify-end gap-2 border-t border-white/10 px-4 py-3 sm:px-5">
{(goalStack.activeGoal?.sourceKind === 'quest' || goalStack.immediateStepGoal?.sourceKind === 'quest') ? (
<button
type="button"
onClick={() => {
closeGoalPanel();
setIsQuestPanelOpen(true);
}}
className="rounded-full border border-white/12 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-200 transition hover:text-white"
>
</button>
) : null}
<button
type="button"
onClick={closeGoalPanel}
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-white"
style={getNineSliceStyle(UI_CHROME.choiceButton, {paddingX: 14, paddingY: 8})}
>
</button>
</div>
</motion.div>
</motion.div>
@@ -845,20 +1135,53 @@ export function AdventurePanelOverlays({
</div>
<div className="flex-1 overflow-y-auto p-3 scrollbar-hide">
{(() => {
if (!activeGoalQuest) {
return null;
}
const currentTaskCard = buildCurrentTaskCardCopy({
goalStack,
goalPulse,
journeyBeat,
sceneName: statistics.currentSceneName,
});
if (!currentTaskCard) {
return null;
}
return (
<TaskTemplateCard
eyebrow={currentTaskCard.eyebrow}
title={currentTaskCard.title}
description={currentTaskCard.description}
condition={currentTaskCard.condition}
progress={currentTaskCard.progress}
reward={activeGoalQuest?.reward ?? null}
onRewardItemSelect={
activeGoalQuest
? itemId => selectQuestRewardItem(activeGoalQuest, itemId)
: null
}
tone="main"
/>
);
})()}
{quests.length > 0 ? (
<div className="space-y-2">
{quests.map(quest => (
<div className={`${activeGoalQuest ? 'mt-3' : ''} space-y-2`}>
{sortedQuests.map(quest => (
<button
key={quest.id}
type="button"
onClick={() => setSelectedQuestId(quest.id)}
className="w-full rounded-xl border border-white/8 bg-black/20 px-3 py-2.5 text-left transition hover:border-white/15"
className="w-full rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-left transition hover:border-white/15"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-white">{quest.title}</div>
<div className="mt-1 text-[11px] text-zinc-500">{quest.issuerNpcName}</div>
<div className="mt-1 text-xs leading-relaxed text-zinc-400">{quest.summary}</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[10px] tracking-[0.2em] text-zinc-500">
{goalStack.activeGoal?.sourceKind === 'quest' && goalStack.activeGoal.sourceId === quest.id
? '当前主任务'
: '任务'}
</div>
<span className={`rounded-full px-2 py-0.5 text-[10px] ${
isQuestReadyToClaim(quest)
@@ -870,6 +1193,25 @@ export function AdventurePanelOverlays({
{getQuestStatusLabel(quest.status)}
</span>
</div>
<div className="mt-2 text-sm font-semibold text-white">
{formatTaskTitle(quest.title)}
</div>
<div className="mt-2 text-xs leading-relaxed text-zinc-400">
<span className="text-zinc-500"></span>
{quest.description || quest.narrativeBinding?.playerHook || quest.summary}
</div>
<div className="mt-1 text-xs leading-relaxed text-zinc-300">
<span className="text-zinc-500"></span>
{buildQuestConditionText(quest, worldType)}
</div>
<div className="mt-2 text-[11px] leading-relaxed text-zinc-500">
{quest.issuerNpcName}
{` · 任务进度:${getQuestProgressText(quest)}`}
</div>
<QuestRewardIconStrip
reward={quest.reward}
onSelectItem={itemId => selectQuestRewardItem(quest, itemId)}
/>
</button>
))}
</div>
@@ -917,10 +1259,16 @@ export function AdventurePanelOverlays({
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4 scrollbar-hide sm:p-5">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500"></div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">{selectedQuest.description}</div>
</div>
<TaskTemplateCard
eyebrow="任务详情"
title={selectedQuest.title}
description={selectedQuest.description || selectedQuest.narrativeBinding?.playerHook || selectedQuest.summary}
condition={buildQuestConditionText(selectedQuest, worldType)}
progress={getQuestProgressText(selectedQuest)}
reward={selectedQuest.reward}
onRewardItemSelect={itemId => selectQuestRewardItem(selectedQuest, itemId)}
tone="main"
/>
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.25fr)_minmax(0,0.75fr)]">
<QuestObjectiveCard
@@ -950,6 +1298,7 @@ export function AdventurePanelOverlays({
if (!claimed) return;
setSelectedBattleRewardItemId(null);
setRewardQuestId(selectedQuest.id);
setRewardQuestHandoff(claimed.handoff);
setSelectedRewardItemQuestId(selectedQuest.id);
setSelectedRewardItemId(selectedQuest.reward.items[0]?.id ?? null);
}}
@@ -992,7 +1341,9 @@ export function AdventurePanelOverlays({
<div className="text-lg font-semibold text-white"></div>
<div className="text-sm text-zinc-300">{completionNoticeQuest.title}</div>
<div className="rounded-xl border border-emerald-400/15 bg-emerald-500/10 px-3 py-3 text-sm text-emerald-50">
{goalStack.immediateStepGoal?.sourceKind === 'quest'
? goalStack.immediateStepGoal.nextStepText
: '可前往任务日志领取奖励。'}
</div>
<div className="flex justify-center">
<button
@@ -1023,6 +1374,7 @@ export function AdventurePanelOverlays({
className="fixed inset-0 z-[71] flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4"
onClick={() => {
setRewardQuestId(null);
setRewardQuestHandoff(null);
setSelectedRewardItemId(null);
setSelectedRewardItemQuestId(null);
}}
@@ -1045,6 +1397,7 @@ export function AdventurePanelOverlays({
type="button"
onClick={() => {
setRewardQuestId(null);
setRewardQuestHandoff(null);
setSelectedRewardItemId(null);
setSelectedRewardItemQuestId(null);
}}
@@ -1055,6 +1408,19 @@ export function AdventurePanelOverlays({
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{rewardQuestHandoff ? (
<div className="mb-4 rounded-2xl border border-violet-300/15 bg-[radial-gradient(circle_at_top,rgba(139,92,246,0.14),transparent_65%),rgba(0,0,0,0.24)] p-3">
<div className="text-[10px] tracking-[0.24em] text-violet-200/80">
</div>
<div className="mt-2 text-sm font-semibold text-white">
{rewardQuestHandoff.title}
</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">
{rewardQuestHandoff.detail}
</div>
</div>
) : null}
<QuestRewardGrid
quest={rewardQuest}
worldType={worldType}
@@ -1223,7 +1589,7 @@ export function AdventurePanelOverlays({
)}
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 text-xs text-zinc-300">
: {selectedRewardItem.tags.join(' / ') || '无'}
: {getInventoryTagLabels(selectedRewardItem.tags).join(' / ') || '无'}
</div>
</div>
</motion.div>

View File

@@ -12,7 +12,6 @@ import {
type ScenePresetInfo,
type WorldType,
} from '../../types';
import {CharacterAnimator} from '../CharacterAnimator';
import {HostileNpcAnimator} from '../HostileNpcAnimator';
import {MedievalNpcAnimator} from '../MedievalNpcAnimator';
import {getRenderableNpcFacing} from '../npcRenderUtils';
@@ -31,7 +30,6 @@ import {
mapHostileNpcAnimationToCharacterState,
MONSTER_RENDER_OFFSETS,
ROLE_CHARACTER_FRAME_CLASS,
ROLE_CHARACTER_SPRITE_CLASS,
RoleCharacterSprite,
SCENE_TRANSITION_LOWER_COMPANION_DELAY_S,
SCENE_TRANSITION_UPPER_COMPANION_DELAY_S,
@@ -166,19 +164,11 @@ export function GameCanvasEntityLayer({
</div>
)}
<div className={ROLE_CHARACTER_FRAME_CLASS}>
<div
className="h-full w-full"
style={{
transform:
(sceneTransitionPhase === 'idle' ? companion.facing : 'right') === 'left'
? 'scaleX(-1)'
: undefined,
}}
>
<CharacterAnimator
<div className={companion.hp <= 0 ? 'opacity-45 grayscale' : undefined}>
<RoleCharacterSprite
state={sceneTransitionPhase === 'idle' ? companion.animationState : AnimationState.RUN}
character={companion.character}
className={`${ROLE_CHARACTER_SPRITE_CLASS} ${companion.hp <= 0 ? 'opacity-45 grayscale' : ''}`}
facing={sceneTransitionPhase === 'idle' ? (companion.facing ?? 'right') : 'right'}
/>
</div>
</div>
@@ -220,13 +210,13 @@ export function GameCanvasEntityLayer({
ariaLabel={playerCharacter ? `查看${playerCharacter.name}详情` : undefined}
className="relative block"
>
<div className="relative" style={{transform: effectivePlayerFacing === 'left' ? 'scaleX(-1)' : undefined}}>
<div className="relative">
<div className={ROLE_CHARACTER_FRAME_CLASS}>
{playerCharacter && (
<CharacterAnimator
<RoleCharacterSprite
state={effectivePlayerAnimationState}
character={playerCharacter}
className={ROLE_CHARACTER_SPRITE_CLASS}
facing={effectivePlayerFacing}
/>
)}
</div>
@@ -248,15 +238,18 @@ export function GameCanvasEntityLayer({
if (!npcEncounter) return null;
const config = monsters.find(item => item.id === hostileNpc.id);
const renderOffset = MONSTER_RENDER_OFFSETS[hostileNpc.id] ?? {x: 0, y: 0};
const npcMonsterConfig = npcEncounter?.monsterPresetId
const npcCharacter = npcEncounter?.characterId ? getCharacterById(npcEncounter.characterId) : null;
const npcMonsterConfig = !npcCharacter && npcEncounter?.monsterPresetId
? monsters.find(item => item.id === npcEncounter.monsterPresetId) ?? config ?? null
: null;
const npcCharacter = npcEncounter?.characterId ? getCharacterById(npcEncounter.characterId) : null;
const npcSceneSpriteFacing =
npcCharacter
? hostileNpc.facing
: getRenderableNpcFacing(npcEncounter, hostileNpc.facing, {medievalVisual: true});
const npcCombatHpTop = getNpcCombatHpTop(npcEncounter?.characterId, npcEncounter?.monsterPresetId);
const npcCombatHpTop = getNpcCombatHpTop(
npcCharacter ? npcEncounter?.characterId : null,
npcCharacter ? null : npcEncounter?.monsterPresetId,
);
const hostileNpcBottomOffsetPx = npcMonsterConfig
? HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX
: 0;
@@ -350,7 +343,7 @@ export function GameCanvasEntityLayer({
encounter.kind !== 'treasure' && encounter.characterId
? getCharacterById(encounter.characterId)
: null;
const peacefulMonsterConfig =
const peacefulMonsterConfig = !peacefulResolvedCharacter &&
encounter.kind === 'npc' && encounter.monsterPresetId
? monsters.find(item => item.id === encounter.monsterPresetId) ?? null
: null;

View File

@@ -2,7 +2,6 @@ import {useEffect, useLayoutEffect, useRef, useState} from 'react';
import {resolveRuleWorldType} from '../../data/customWorldRuntime';
import {MONSTERS_BY_WORLD, PLAYER_BASE_X_METERS} from '../../data/hostileNpcs';
import {getWorldCampScenePreset} from '../../data/scenePresets';
import {AnimationState, WorldType} from '../../types';
import {GameCanvasEffectLayer} from './GameCanvasEffectLayer';
import {GameCanvasEntityLayer} from './GameCanvasEntityLayer';
@@ -51,8 +50,6 @@ export function GameCanvasRuntime({
const resolvedWorldType = worldType ? resolveRuleWorldType(worldType) ?? WorldType.WUXIA : null;
const backgroundSrc = currentScenePreset?.imageSrc
|| (resolvedWorldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png');
const campSceneId = worldType ? getWorldCampScenePreset(worldType)?.id ?? null : null;
const showOpeningCampOverlay = Boolean(!inBattle && currentScenePreset?.id && currentScenePreset.id === campSceneId);
const monsters = resolvedWorldType ? MONSTERS_BY_WORLD[resolvedWorldType] : [];
const groundBottom = '18%';
const stageLiftPx = 68;
@@ -154,7 +151,6 @@ export function GameCanvasRuntime({
backgroundSrc={backgroundSrc}
currentScenePreset={currentScenePreset}
resolvedWorldType={resolvedWorldType}
showOpeningCampOverlay={showOpeningCampOverlay}
sceneTitleSpinToken={sceneTitleSpinToken}
onSceneNameClick={onSceneNameClick}
onBackgroundLoadError={() => setBackgroundLoadFailed(true)}

View File

@@ -3,17 +3,13 @@ import {AnimatePresence, motion} from 'motion/react';
import {type ScenePresetInfo, WorldType} from '../../types';
import {CHROME_ICONS, getNineSliceStyle, UI_CHROME} from '../../uiAssets';
import {PixelIcon} from '../PixelIcon';
import {
OPENING_CAMP_OVERLAY_SRC,
SCENE_TITLE_GEAR_FILTER,
} from './GameCanvasShared';
import { SCENE_TITLE_GEAR_FILTER } from './GameCanvasShared';
interface GameCanvasSceneLayerProps {
backgroundLoadFailed: boolean;
backgroundSrc: string;
currentScenePreset: ScenePresetInfo | null;
resolvedWorldType: WorldType | null;
showOpeningCampOverlay: boolean;
sceneTitleSpinToken: number;
onSceneNameClick?: (() => void) | null;
onBackgroundLoadError: () => void;
@@ -24,7 +20,6 @@ export function GameCanvasSceneLayer({
backgroundSrc,
currentScenePreset,
resolvedWorldType,
showOpeningCampOverlay,
sceneTitleSpinToken,
onSceneNameClick = null,
onBackgroundLoadError,
@@ -55,19 +50,6 @@ export function GameCanvasSceneLayer({
<div className="pointer-events-none absolute inset-0 opacity-10 mix-blend-overlay [background-image:radial-gradient(circle_at_20%_20%,rgba(255,255,255,0.14),transparent_20%),radial-gradient(circle_at_80%_30%,rgba(255,255,255,0.08),transparent_18%),radial-gradient(circle_at_50%_80%,rgba(255,255,255,0.06),transparent_22%)]" />
{showOpeningCampOverlay && (
<img
src={OPENING_CAMP_OVERLAY_SRC}
alt=""
aria-hidden="true"
className="pointer-events-none absolute bottom-[9%] left-1/2 z-[1] w-[min(92%,980px)] -translate-x-1/2 object-contain opacity-95"
style={{
imageRendering: 'pixelated',
filter: 'drop-shadow(0 12px 30px rgba(0, 0, 0, 0.42))',
}}
/>
)}
{currentScenePreset && (
<div className="absolute left-1/2 top-3 z-20 -translate-x-1/2">
<motion.div

View File

@@ -2,6 +2,7 @@ import React, {useEffect, useState} from 'react';
import {getCharacterById} from '../../data/characterPresets';
import {METERS_TO_PIXELS} from '../../data/hostileNpcs';
import {buildMedievalNpcVisualFromCustomWorldVisual} from '../../data/medievalNpcVisuals';
import {
AnimationState,
Character,
@@ -14,6 +15,7 @@ import {
WorldType,
} from '../../types';
import {CharacterAnimator} from '../CharacterAnimator';
import {MedievalNpcAnimator} from '../MedievalNpcAnimator';
export type GameCanvasEntitySelection =
| {kind: 'player'}
@@ -66,7 +68,6 @@ export const GENERIC_NPC_EFFECT_TARGET_OFFSET_PX = -16;
export const HOSTILE_NPC_SCENE_INSET_PX = 28;
export const HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX = -18;
export const CHAT_BUBBLE_SPRITE_SRC = '/chat.png';
export const OPENING_CAMP_OVERLAY_SRC = '/scene_bg/hut.png';
export const CHAT_BUBBLE_FRAME_WIDTH = 27;
export const CHAT_BUBBLE_FRAME_HEIGHT = 22;
export const CHAT_BUBBLE_FRAME_COUNT = 12;
@@ -219,6 +220,17 @@ export function RoleCharacterSprite({
state: AnimationState;
facing: 'left' | 'right';
}) {
if (character.visual) {
return (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(character.visual)}
className="origin-bottom"
scale={1.36}
facing={facing}
/>
);
}
return (
<div className="h-full w-full" style={{transform: facing === 'left' ? 'scaleX(-1)' : undefined}}>
<CharacterAnimator

View File

@@ -4,6 +4,7 @@ import type { BottomTab } from '../../hooks/useGameFlow';
import type {
BattleRewardUi,
CharacterChatUi,
GoalFlowUi,
InventoryFlowUi,
QuestFlowUi,
} from '../../hooks/useStoryGeneration';
@@ -62,6 +63,7 @@ export function GameShellMainContent({
inventoryUi,
battleRewardUi,
questUi,
goalUi,
companionRenderStates,
characterChatSummaries,
openOverlayPanel,
@@ -98,6 +100,7 @@ export function GameShellMainContent({
inventoryUi: InventoryFlowUi;
battleRewardUi: BattleRewardUi;
questUi: QuestFlowUi;
goalUi: GoalFlowUi;
companionRenderStates: CompanionRenderState[];
characterChatSummaries: Record<string, string>;
openOverlayPanel: (panel: 'character' | 'inventory') => void;
@@ -171,6 +174,7 @@ export function GameShellMainContent({
inventoryUi={inventoryUi}
battleRewardUi={battleRewardUi}
questUi={questUi}
goalUi={goalUi}
companionRenderStates={companionRenderStates}
characterChatSummaries={characterChatSummaries}
openOverlayPanel={openOverlayPanel}

View File

@@ -33,6 +33,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
inventoryUi,
battleRewardUi,
questUi,
goalUi,
} = story;
const {
hasSavedGame,
@@ -43,7 +44,12 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
handleBackToWorldSelect,
handleCharacterSelect,
} = entry;
const {companionRenderStates, onBenchCompanion, onActivateRosterCompanion} = companions;
const {
companionRenderStates,
buildCompanionRenderStates,
onBenchCompanion,
onActivateRosterCompanion,
} = companions;
const {musicVolume, onMusicVolumeChange} = audio;
const [clockNow, setClockNow] = useState(() => Date.now());
@@ -119,13 +125,18 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
[gameState.characterChats],
);
const visibleCompanionRenderStates = useMemo(
() => buildCompanionRenderStates(visibleGameState),
[buildCompanionRenderStates, visibleGameState],
);
const canvasCompanionRenderStates = useMemo(() => {
const activeEncounterNpcId = visibleGameState.currentEncounter?.kind === 'npc'
? visibleGameState.currentEncounter.id ?? null
: null;
if (!activeEncounterNpcId) return companionRenderStates;
return companionRenderStates.filter(companion => companion.npcId !== activeEncounterNpcId);
}, [companionRenderStates, visibleGameState.currentEncounter]);
if (!activeEncounterNpcId) return visibleCompanionRenderStates;
return visibleCompanionRenderStates.filter(companion => companion.npcId !== activeEncounterNpcId);
}, [visibleCompanionRenderStates, visibleGameState.currentEncounter]);
const livePlayTimeMs = useMemo(
() => getLiveGamePlayTimeMs(gameState.runtimeStats, clockNow),
@@ -229,6 +240,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
inventoryUi={inventoryUi}
battleRewardUi={battleRewardUi}
questUi={questUi}
goalUi={goalUi}
companionRenderStates={companionRenderStates}
characterChatSummaries={characterChatSummaries}
openOverlayPanel={openOverlayPanel}

View File

@@ -4,6 +4,7 @@ import type { BottomTab } from '../../hooks/useGameFlow';
import type {
BattleRewardUi,
CharacterChatUi,
GoalFlowUi,
InventoryFlowUi,
QuestFlowUi,
} from '../../hooks/useStoryGeneration';
@@ -69,6 +70,7 @@ export function GameShellStoryPanels({
inventoryUi,
battleRewardUi,
questUi,
goalUi,
companionRenderStates,
characterChatSummaries,
openOverlayPanel,
@@ -94,6 +96,7 @@ export function GameShellStoryPanels({
inventoryUi: InventoryFlowUi;
battleRewardUi: BattleRewardUi;
questUi: QuestFlowUi;
goalUi: GoalFlowUi;
companionRenderStates: CompanionRenderState[];
characterChatSummaries: Record<string, string>;
openOverlayPanel: (panel: 'character' | 'inventory') => void;
@@ -199,6 +202,9 @@ export function GameShellStoryPanels({
worldType={visibleGameState.worldType}
quests={visibleGameState.quests}
questUi={questUi}
goalStack={goalUi.goalStack}
goalPulse={goalUi.pulse}
onDismissGoalPulse={goalUi.dismissPulse}
battleRewardUi={battleRewardUi}
playerHp={visibleGameState.playerHp}
playerMaxHp={visibleGameState.playerMaxHp}
@@ -211,15 +217,6 @@ export function GameShellStoryPanels({
journeyBeat={
visibleGameState.storyEngineMemory?.currentJourneyBeat ?? null
}
recentChronicleSummary={
visibleGameState.storyEngineMemory?.continueGameDigest ?? null
}
currentCampEvent={
visibleGameState.storyEngineMemory?.currentCampEvent ?? null
}
setpieceDirective={
visibleGameState.storyEngineMemory?.currentSetpieceDirective ?? null
}
statistics={adventureStatistics}
musicVolume={musicVolume}
onMusicVolumeChange={onMusicVolumeChange}

View File

@@ -8,6 +8,7 @@ import {
readSavedCustomWorldProfiles,
upsertSavedCustomWorldProfile,
} from '../../data/customWorldLibrary';
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import { getScenePreset } from '../../data/scenePresets';
import {
type CustomWorldGenerationProgress,
@@ -18,6 +19,7 @@ import {
buildCustomWorldCreatorIntentGenerationText,
createEmptyCustomWorldCreatorIntent,
} from '../../services/customWorldCreatorIntent';
import { detectCustomWorldThemeMode } from '../../services/customWorldTheme';
import {
type CustomWorldCreatorIntent,
type CustomWorldGenerationMode,
@@ -217,30 +219,24 @@ export function PreGameSelectionFlow({
const savedCustomWorldCards = useMemo(
() =>
savedCustomWorldProfiles.map((profile, index) => {
const anchorWorldType = profile.templateWorldType;
savedCustomWorldProfiles.map((profile) => {
const themeMode = detectCustomWorldThemeMode(profile);
const leadCharacter =
buildCustomWorldPlayableCharacters(profile)[0] ?? null;
return {
id: profile.id,
profile,
texture:
anchorWorldType === WorldType.WUXIA
? UI_CHROME.worldButtonWuxia
: UI_CHROME.worldButtonXianxia,
sceneImage:
profile.landmarks[0]?.imageSrc ??
getScenePreset(anchorWorldType, (index % 3) + 1)?.imageSrc ??
getScenePreset(anchorWorldType, 0)?.imageSrc ??
'',
texture: UI_CHROME.panel,
sceneImage: resolveCustomWorldCampSceneImage(profile) ?? '',
featurePortrait: leadCharacter?.portrait ?? '',
featureIcon:
anchorWorldType === WorldType.WUXIA
themeMode === 'martial'
? WORLD_SELECT_ICONS.wuxia
: WORLD_SELECT_ICONS.xianxia,
accentLabel:
anchorWorldType === WorldType.WUXIA ? '武侠基础' : '仙侠基础',
: themeMode === 'arcane'
? WORLD_SELECT_ICONS.xianxia
: CHROME_ICONS.refreshOptions,
accentLabel: '自定义世界',
};
}),
[savedCustomWorldProfiles],
@@ -900,10 +896,8 @@ export function PreGameSelectionFlow({
<div className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
</div>
<div className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
{world.accentLabel === '武侠基础'
? '武侠'
: '仙侠'}
<div className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
{world.accentLabel}
</div>
</div>
<div className="mt-auto">

View File

@@ -2,6 +2,7 @@ import type { BottomTab } from '../../hooks/useGameFlow';
import type {
BattleRewardUi,
CharacterChatUi,
GoalFlowUi,
InventoryFlowUi,
QuestFlowUi,
StoryGenerationNpcUi,
@@ -37,6 +38,7 @@ export interface GameShellStoryProps {
inventoryUi: InventoryFlowUi;
battleRewardUi: BattleRewardUi;
questUi: QuestFlowUi;
goalUi: GoalFlowUi;
}
export interface GameShellEntryProps {
@@ -51,6 +53,7 @@ export interface GameShellEntryProps {
export interface GameShellCompanionProps {
companionRenderStates: CompanionRenderState[];
buildCompanionRenderStates: (state: GameState) => CompanionRenderState[];
onBenchCompanion: (npcId: string) => void;
onActivateRosterCompanion: (npcId: string, swapNpcId?: string | null) => void;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,8 @@
import { AnimationState } from '../../types';
import {
AnimationState,
type Character,
type CharacterAnimationConfig,
} from '../../types';
export const MASTER_VISUAL_WIDTH = 1024;
export const MASTER_VISUAL_HEIGHT = 1536;
@@ -35,6 +39,80 @@ export type DraftAnimationClip = {
loop: boolean;
frameWidth: number;
frameHeight: number;
previewVideoPath?: string;
};
const DEFAULT_CHARACTER_ANIMATIONS: Record<
AnimationState,
CharacterAnimationConfig
> = {
[AnimationState.ACQUIRE]: {
frames: 1,
prefix: 'acquire',
folder: 'acquire',
},
[AnimationState.ATTACK]: { frames: 1, prefix: 'Attack', folder: 'attack' },
[AnimationState.RUN]: { frames: 1, prefix: 'Run', folder: 'run' },
[AnimationState.DOUBLE_JUMP]: {
frames: 1,
prefix: 'double jump',
folder: 'double jump',
},
[AnimationState.JUMP_ATTACK]: {
frames: 1,
prefix: 'jump attack',
folder: 'jump attack',
},
[AnimationState.DASH]: { frames: 1, prefix: 'dash', folder: 'dash' },
[AnimationState.HURT]: { frames: 1, prefix: 'hurt', folder: 'hurt' },
[AnimationState.DIE]: { frames: 1, prefix: 'die', folder: 'die' },
[AnimationState.CLIMB]: { frames: 1, prefix: 'Climb', folder: 'climb' },
[AnimationState.SKILL1]: { frames: 1, prefix: 'skill1', folder: 'skill1' },
[AnimationState.SKILL1_JUMP]: {
frames: 1,
prefix: 'skill1 jump',
folder: 'skill1 jump',
},
[AnimationState.SKILL1_BULLET]: {
frames: 1,
prefix: 'skill1 bullet',
folder: 'skill1 bullet',
},
[AnimationState.SKILL1_BULLET_FX]: {
frames: 1,
prefix: 'skill1 bullet FX',
folder: 'skill1 bullet FX',
},
[AnimationState.SKILL2]: { frames: 1, prefix: 'skill2', folder: 'skill2' },
[AnimationState.SKILL2_JUMP]: {
frames: 1,
prefix: 'skill2 jump',
folder: 'skill2 jump',
},
[AnimationState.SKILL3]: { frames: 1, prefix: 'skill3', folder: 'skill3' },
[AnimationState.SKILL3_JUMP]: {
frames: 1,
prefix: 'skill3 jump',
folder: 'skill3 jump',
},
[AnimationState.SKILL3_BULLET]: {
frames: 1,
prefix: 'skill3 bullet',
folder: 'skill3 bullet',
},
[AnimationState.SKILL3_BULLET_FX]: {
frames: 1,
prefix: 'skill3 bullet FX',
folder: 'skill3 bullet FX',
},
[AnimationState.SKILL4]: { frames: 1, prefix: 'skill4', folder: 'skill4' },
[AnimationState.WALL_SLIDE]: {
frames: 1,
prefix: 'Wall Slide',
folder: 'Wall Slide',
},
[AnimationState.IDLE]: { frames: 1, prefix: 'Idle', folder: 'idle' },
[AnimationState.JUMP]: { frames: 1, prefix: 'Jump', folder: 'jump' },
};
type PoseTransform = {
@@ -52,7 +130,11 @@ type ActionTemplate = {
frames: number;
fps: number;
loop: boolean;
poseAt: (progress: number, frameIndex: number, totalFrames: number) => PoseTransform;
poseAt: (
progress: number,
frameIndex: number,
totalFrames: number,
) => PoseTransform;
};
const ACTION_TEMPLATES: Record<AnimationState, ActionTemplate> = {
@@ -226,67 +308,133 @@ const ACTION_TEMPLATES: Record<AnimationState, ActionTemplate> = {
frames: 6,
fps: 10,
loop: false,
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
poseAt: () => ({
offsetX: 0,
offsetY: 0,
scaleX: 1,
scaleY: 1,
rotation: 0,
}),
},
[AnimationState.SKILL1_JUMP]: {
frames: 6,
fps: 10,
loop: false,
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
poseAt: () => ({
offsetX: 0,
offsetY: 0,
scaleX: 1,
scaleY: 1,
rotation: 0,
}),
},
[AnimationState.SKILL1_BULLET]: {
frames: 4,
fps: 10,
loop: false,
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
poseAt: () => ({
offsetX: 0,
offsetY: 0,
scaleX: 1,
scaleY: 1,
rotation: 0,
}),
},
[AnimationState.SKILL1_BULLET_FX]: {
frames: 4,
fps: 10,
loop: false,
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
poseAt: () => ({
offsetX: 0,
offsetY: 0,
scaleX: 1,
scaleY: 1,
rotation: 0,
}),
},
[AnimationState.SKILL2]: {
frames: 6,
fps: 10,
loop: false,
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
poseAt: () => ({
offsetX: 0,
offsetY: 0,
scaleX: 1,
scaleY: 1,
rotation: 0,
}),
},
[AnimationState.SKILL2_JUMP]: {
frames: 6,
fps: 10,
loop: false,
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
poseAt: () => ({
offsetX: 0,
offsetY: 0,
scaleX: 1,
scaleY: 1,
rotation: 0,
}),
},
[AnimationState.SKILL3]: {
frames: 6,
fps: 10,
loop: false,
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
poseAt: () => ({
offsetX: 0,
offsetY: 0,
scaleX: 1,
scaleY: 1,
rotation: 0,
}),
},
[AnimationState.SKILL3_JUMP]: {
frames: 6,
fps: 10,
loop: false,
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
poseAt: () => ({
offsetX: 0,
offsetY: 0,
scaleX: 1,
scaleY: 1,
rotation: 0,
}),
},
[AnimationState.SKILL3_BULLET]: {
frames: 4,
fps: 10,
loop: false,
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
poseAt: () => ({
offsetX: 0,
offsetY: 0,
scaleX: 1,
scaleY: 1,
rotation: 0,
}),
},
[AnimationState.SKILL3_BULLET_FX]: {
frames: 4,
fps: 10,
loop: false,
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
poseAt: () => ({
offsetX: 0,
offsetY: 0,
scaleX: 1,
scaleY: 1,
rotation: 0,
}),
},
[AnimationState.SKILL4]: {
frames: 6,
fps: 10,
loop: false,
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
poseAt: () => ({
offsetX: 0,
offsetY: 0,
scaleX: 1,
scaleY: 1,
rotation: 0,
}),
},
};
@@ -309,6 +457,19 @@ export function loadImageFromSource(source: string) {
});
}
function loadVideoFromSource(source: string) {
return new Promise<HTMLVideoElement>((resolve, reject) => {
const video = document.createElement('video');
video.crossOrigin = 'anonymous';
video.preload = 'auto';
video.muted = true;
video.playsInline = true;
video.onloadeddata = () => resolve(video);
video.onerror = () => reject(new Error(`加载视频失败:${source}`));
video.src = source;
});
}
function createCanvas(width: number, height: number) {
const canvas = document.createElement('canvas');
canvas.width = width;
@@ -317,7 +478,51 @@ function createCanvas(width: number, height: number) {
if (!context) {
throw new Error('无法创建画布上下文');
}
return {canvas, context};
return { canvas, context };
}
function drawContainedSource(
context: CanvasRenderingContext2D,
source: CanvasImageSource,
sourceWidth: number,
sourceHeight: number,
options: {
width: number;
height: number;
translateX?: number;
translateY?: number;
scale?: number;
rotation?: number;
alpha?: number;
},
) {
const {
width,
height,
translateX = 0,
translateY = 0,
scale = 1,
rotation = 0,
alpha = 1,
} = options;
const fitScale = Math.min(width / sourceWidth, height / sourceHeight);
const drawWidth = sourceWidth * fitScale * scale;
const drawHeight = sourceHeight * fitScale * scale;
const centerX = width / 2 + translateX;
const centerY = height / 2 + translateY;
context.save();
context.globalAlpha = alpha;
context.translate(centerX, centerY);
context.rotate(rotation);
context.drawImage(
source,
-drawWidth / 2,
-drawHeight / 2,
drawWidth,
drawHeight,
);
context.restore();
}
function drawContainedImage(
@@ -342,18 +547,15 @@ function drawContainedImage(
rotation = 0,
alpha = 1,
} = options;
const fitScale = Math.min(width / image.width, height / image.height);
const drawWidth = image.width * fitScale * scale;
const drawHeight = image.height * fitScale * scale;
const centerX = width / 2 + translateX;
const centerY = height / 2 + translateY;
context.save();
context.globalAlpha = alpha;
context.translate(centerX, centerY);
context.rotate(rotation);
context.drawImage(image, -drawWidth / 2, -drawHeight / 2, drawWidth, drawHeight);
context.restore();
drawContainedSource(context, image, image.width, image.height, {
width,
height,
translateX,
translateY,
scale,
rotation,
alpha,
});
}
export async function buildVisualCandidatesFromSource(source: string) {
@@ -365,13 +567,22 @@ export async function buildVisualCandidatesFromSource(source: string) {
translateY: number;
tint?: string;
}> = [
{id: 'balanced', label: '平衡构图', scale: 1, translateY: 0},
{id: 'closer', label: '主体更近', scale: 1.08, translateY: 18},
{id: 'lighter', label: '轻提主体', scale: 0.96, translateY: -22, tint: 'rgba(16, 185, 129, 0.08)'},
{ id: 'balanced', label: '平衡构图', scale: 1, translateY: 0 },
{ id: 'closer', label: '主体更近', scale: 1.08, translateY: 18 },
{
id: 'lighter',
label: '轻提主体',
scale: 0.96,
translateY: -22,
tint: 'rgba(16, 185, 129, 0.08)',
},
];
return variants.map((variant) => {
const {canvas, context} = createCanvas(MASTER_VISUAL_WIDTH, MASTER_VISUAL_HEIGHT);
const { canvas, context } = createCanvas(
MASTER_VISUAL_WIDTH,
MASTER_VISUAL_HEIGHT,
);
context.clearRect(0, 0, canvas.width, canvas.height);
drawContainedImage(context, image, {
width: canvas.width * 0.82,
@@ -432,11 +643,11 @@ function drawTintOverlay(
context.restore();
}
function renderPoseFrame(
image: HTMLImageElement,
pose: PoseTransform,
) {
const {canvas, context} = createCanvas(GENERATED_FRAME_WIDTH, GENERATED_FRAME_HEIGHT);
function renderPoseFrame(image: HTMLImageElement, pose: PoseTransform) {
const { canvas, context } = createCanvas(
GENERATED_FRAME_WIDTH,
GENERATED_FRAME_HEIGHT,
);
context.clearRect(0, 0, canvas.width, canvas.height);
drawShadow(context, canvas.width, canvas.height, pose);
@@ -452,7 +663,13 @@ function renderPoseFrame(
context.globalAlpha = alpha;
context.translate(centerX + offsetX, bottomY);
context.rotate(pose.rotation);
context.drawImage(image, -drawWidth / 2, -drawHeight, drawWidth, drawHeight);
context.drawImage(
image,
-drawWidth / 2,
-drawHeight,
drawWidth,
drawHeight,
);
context.restore();
};
@@ -476,11 +693,9 @@ export async function buildAnimationClipFromMaster(
) {
const image = await loadImageFromSource(masterSource);
const template = ACTION_TEMPLATES[animation];
const frames = Array.from({length: template.frames}, (_, frameIndex) => {
const frames = Array.from({ length: template.frames }, (_, frameIndex) => {
const progress =
template.frames <= 1
? 0
: frameIndex / Math.max(1, template.frames - 1);
template.frames <= 1 ? 0 : frameIndex / Math.max(1, template.frames - 1);
return renderPoseFrame(
image,
template.poseAt(progress, frameIndex, template.frames),
@@ -496,3 +711,309 @@ export async function buildAnimationClipFromMaster(
frameHeight: GENERATED_FRAME_HEIGHT,
} satisfies DraftAnimationClip;
}
function applyGreenScreenAlpha(
context: CanvasRenderingContext2D,
width: number,
height: number,
) {
const imageData = context.getImageData(0, 0, width, height);
const pixels = imageData.data;
for (let index = 0; index < pixels.length; index += 4) {
const red = pixels[index] ?? 0;
const green = pixels[index + 1] ?? 0;
const blue = pixels[index + 2] ?? 0;
const alpha = pixels[index + 3] ?? 0;
const greenLead = green - Math.max(red, blue);
if (alpha === 0) {
continue;
}
if (green > 96 && greenLead > 34) {
const fade = Math.max(0, 255 - greenLead * 5);
pixels[index + 3] = Math.min(alpha, fade);
if (greenLead > 60) {
pixels[index + 3] = 0;
}
}
}
context.putImageData(imageData, 0, 0);
}
async function normalizeFrameSourceToDataUrl(
frameSource: string,
options: {
frameWidth: number;
frameHeight: number;
applyChromaKey: boolean;
},
) {
const image = await loadImageFromSource(frameSource);
const { canvas, context } = createCanvas(
options.frameWidth,
options.frameHeight,
);
context.clearRect(0, 0, canvas.width, canvas.height);
drawContainedImage(context, image, {
width: canvas.width,
height: canvas.height,
});
if (options.applyChromaKey) {
applyGreenScreenAlpha(context, canvas.width, canvas.height);
}
return canvas.toDataURL('image/png');
}
function seekVideo(video: HTMLVideoElement, targetTime: number) {
return new Promise<void>((resolve, reject) => {
if (Math.abs(video.currentTime - targetTime) < 0.001) {
window.requestAnimationFrame(() => resolve());
return;
}
const handleSeeked = () => {
cleanup();
resolve();
};
const handleError = () => {
cleanup();
reject(new Error('视频定位失败'));
};
const cleanup = () => {
video.removeEventListener('seeked', handleSeeked);
video.removeEventListener('error', handleError);
};
video.addEventListener('seeked', handleSeeked, { once: true });
video.addEventListener('error', handleError, { once: true });
video.currentTime = Math.max(0, targetTime);
});
}
export async function buildAnimationClipFromImageSources(
sources: string[],
options: {
animation: AnimationState;
fps: number;
loop: boolean;
frameWidth?: number;
frameHeight?: number;
applyChromaKey?: boolean;
},
) {
const frameWidth = options.frameWidth ?? GENERATED_FRAME_WIDTH;
const frameHeight = options.frameHeight ?? GENERATED_FRAME_HEIGHT;
const frames = await Promise.all(
sources.map((source) =>
normalizeFrameSourceToDataUrl(source, {
frameWidth,
frameHeight,
applyChromaKey: options.applyChromaKey ?? false,
}),
),
);
return {
animation: options.animation,
frames,
fps: Math.max(1, options.fps),
loop: options.loop,
frameWidth,
frameHeight,
} satisfies DraftAnimationClip;
}
export async function buildAnimationClipFromVideoSource(
videoSource: string,
options: {
animation: AnimationState;
fps: number;
loop: boolean;
frameCount?: number;
frameWidth?: number;
frameHeight?: number;
applyChromaKey?: boolean;
},
) {
const video = await loadVideoFromSource(videoSource);
const frameWidth = options.frameWidth ?? GENERATED_FRAME_WIDTH;
const frameHeight = options.frameHeight ?? GENERATED_FRAME_HEIGHT;
const duration =
Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 1;
const derivedFrameCount = Math.max(
2,
options.frameCount ?? Math.round(duration * Math.max(1, options.fps)),
);
const { canvas, context } = createCanvas(frameWidth, frameHeight);
const frames: string[] = [];
for (let frameIndex = 0; frameIndex < derivedFrameCount; frameIndex += 1) {
const progress = options.loop
? frameIndex / derivedFrameCount
: frameIndex / Math.max(1, derivedFrameCount - 1);
const targetTime = Math.min(duration - 0.001, duration * progress);
await seekVideo(video, targetTime);
context.clearRect(0, 0, canvas.width, canvas.height);
drawContainedSource(context, video, video.videoWidth, video.videoHeight, {
width: canvas.width,
height: canvas.height,
});
if (options.applyChromaKey) {
applyGreenScreenAlpha(context, canvas.width, canvas.height);
}
frames.push(canvas.toDataURL('image/png'));
}
return {
animation: options.animation,
frames,
fps: Math.max(1, options.fps),
loop: options.loop,
frameWidth,
frameHeight,
previewVideoPath: videoSource,
} satisfies DraftAnimationClip;
}
function getCharacterAnimationConfig(
character: Character,
animation: AnimationState,
) {
return (
character.animationMap?.[animation] ??
DEFAULT_CHARACTER_ANIMATIONS[animation] ??
character.animationMap?.[AnimationState.IDLE] ??
DEFAULT_CHARACTER_ANIMATIONS[AnimationState.IDLE]
);
}
function getCharacterAnimationFrameSources(
character: Character,
animation: AnimationState,
) {
const config = getCharacterAnimationConfig(character, animation);
const startFrame = config.startFrame || 1;
const frameCount = Math.max(1, config.frames);
const normalizedBasePath = config.basePath?.replace(/\/+$/u, '');
return Array.from({ length: frameCount }, (_, index) => {
const frameNumber = String(startFrame + index).padStart(2, '0');
if (normalizedBasePath) {
return config.file
? `${normalizedBasePath}/${encodeURIComponent(config.file)}`
: `${normalizedBasePath}/${config.prefix}${frameNumber}.${config.extension ?? 'png'}`;
}
const folder = encodeURIComponent(character.assetFolder);
const variant = encodeURIComponent(character.assetVariant);
const animationFolder = encodeURIComponent(config.folder);
return config.file
? `/character/${folder}/${variant}/Hero/${animationFolder}/${encodeURIComponent(config.file)}`
: `/character/${folder}/${variant}/Hero/${animationFolder}/${config.prefix}${frameNumber}.${config.extension ?? 'png'}`;
});
}
function waitFrame(ms: number) {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
function blobToDataUrl(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result ?? ''));
reader.onerror = () => reject(reader.error ?? new Error('读取 Blob 失败'));
reader.readAsDataURL(blob);
});
}
function pickRecordMimeType() {
const candidates = [
'video/webm;codecs=vp9',
'video/webm;codecs=vp8',
'video/webm',
];
if (typeof MediaRecorder === 'undefined') {
return '';
}
return (
candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ??
''
);
}
export async function buildReferenceVideoFromCharacterAnimation(
character: Character,
animation: AnimationState,
options: {
fps?: number;
width?: number;
height?: number;
repeatLoops?: number;
} = {},
) {
if (typeof MediaRecorder === 'undefined') {
throw new Error('当前浏览器不支持 MediaRecorder无法生成内置模板视频。');
}
const frameSources = getCharacterAnimationFrameSources(character, animation);
const images = await Promise.all(
frameSources.map((frameSource) => loadImageFromSource(frameSource)),
);
const width = options.width ?? GENERATED_FRAME_WIDTH;
const height = options.height ?? GENERATED_FRAME_HEIGHT;
const fps = Math.max(1, options.fps ?? 8);
const repeatLoops = Math.max(1, options.repeatLoops ?? 2);
const { canvas, context } = createCanvas(width, height);
const stream = canvas.captureStream(fps);
const mimeType = pickRecordMimeType();
const recorder = mimeType
? new MediaRecorder(stream, { mimeType })
: new MediaRecorder(stream);
const chunks: BlobPart[] = [];
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunks.push(event.data);
}
};
const stopPromise = new Promise<Blob>((resolve) => {
recorder.onstop = () => {
resolve(
new Blob(chunks, { type: recorder.mimeType || 'video/webm' }),
);
};
});
recorder.start();
for (let loopIndex = 0; loopIndex < repeatLoops; loopIndex += 1) {
for (const image of images) {
context.clearRect(0, 0, canvas.width, canvas.height);
drawContainedImage(context, image, {
width: canvas.width,
height: canvas.height,
});
await waitFrame(Math.max(40, Math.round(1000 / fps)));
}
}
await waitFrame(80);
recorder.stop();
const blob = await stopPromise;
return blobToDataUrl(blob);
}

View File

@@ -1,42 +1,163 @@
import { parseApiErrorMessage } from '../../editor/shared/jsonClient';
import {
fetchJson,
parseApiErrorMessage,
} from '../../editor/shared/jsonClient';
export const CHARACTER_VISUAL_PUBLISH_API_PATH = '/api/character-visual/publish';
export const CHARACTER_VISUAL_GENERATE_API_PATH =
'/api/character-visual/generate';
export const CHARACTER_VISUAL_PUBLISH_API_PATH =
'/api/character-visual/publish';
export const CHARACTER_VISUAL_JOB_API_PATH = '/api/character-visual/jobs';
export const CHARACTER_ANIMATION_GENERATE_API_PATH = '/api/animation/generate';
export const CHARACTER_ANIMATION_PUBLISH_API_PATH = '/api/animation/publish';
export const CHARACTER_ANIMATION_JOB_API_PATH = '/api/animation/jobs';
export const CHARACTER_ANIMATION_IMPORT_VIDEO_API_PATH =
'/api/animation/import-video';
export const CHARACTER_ANIMATION_TEMPLATES_API_PATH =
'/api/animation/templates';
export type CharacterVisualSourceMode =
| 'text-to-image'
| 'image-to-image'
| 'upload';
export type CharacterAnimationStrategy =
| 'image-sequence'
| 'image-to-video'
| 'motion-transfer'
| 'reference-to-video';
export type CharacterMotionTransferModel =
| 'wan2.2-animate-move'
| 'wan2.2-animate-mix';
export type CharacterVisualDraft = {
id: string;
label: string;
imageSrc: string;
width: number;
height: number;
};
export type CharacterVisualGenerationPayload = {
characterId: string;
sourceMode: Exclude<CharacterVisualSourceMode, 'upload'>;
promptText: string;
referenceImageDataUrls: string[];
candidateCount: number;
imageModel: string;
size: string;
};
export type CharacterVisualPublishPayload = {
characterId: string;
sourceMode: 'text-to-image' | 'image-to-image' | 'upload';
sourceMode: CharacterVisualSourceMode;
promptText: string;
selectedPreviewDataUrl: string;
previewDataUrls: string[];
selectedPreviewSource: string;
previewSources: string[];
width: number;
height: number;
};
export type CharacterAnimationGenerationPayload = {
characterId: string;
strategy: CharacterAnimationStrategy;
animation: string;
promptText: string;
visualSource: string;
referenceImageDataUrls: string[];
referenceVideoDataUrls: string[];
lastFrameImageDataUrl?: string;
frameCount: number;
fps: number;
durationSeconds: number;
loop: boolean;
useChromaKey: boolean;
resolution: string;
imageSequenceModel: string;
videoModel: string;
referenceVideoModel: string;
motionTransferModel: CharacterMotionTransferModel;
};
export type CharacterAnimationDraftPayload = {
framesDataUrls: string[];
fps: number;
loop: boolean;
frameWidth: number;
frameHeight: number;
previewVideoPath?: string;
};
export async function publishCharacterVisualAsset(
payload: CharacterVisualPublishPayload,
export type CharacterAnimationTemplate = {
id: string;
label: string;
animation: string;
promptSuffix: string;
notes: string;
};
export type CharacterAssetJobStatus = {
taskId: string;
kind: 'visual' | 'animation';
status: 'queued' | 'running' | 'completed' | 'failed';
characterId: string;
animation?: string;
strategy?: CharacterAnimationStrategy;
model: string;
prompt: string;
createdAt: string;
updatedAt: string;
result?: Record<string, unknown>;
errorMessage?: string;
};
export async function generateCharacterVisualCandidates(
payload: CharacterVisualGenerationPayload,
) {
const response = await fetch(CHARACTER_VISUAL_PUBLISH_API_PATH, {
const response = await fetch(CHARACTER_VISUAL_GENERATE_API_PATH, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(
parseApiErrorMessage(responseText, '发布角色主形象失败'),
parseApiErrorMessage(responseText, '生成角色主形象候选失败'),
);
}
return JSON.parse(responseText) as {
ok: true;
taskId: string;
model: string;
prompt: string;
drafts: CharacterVisualDraft[];
};
}
export async function fetchCharacterVisualJobStatus(taskId: string) {
return fetchJson<CharacterAssetJobStatus>(
`${CHARACTER_VISUAL_JOB_API_PATH}/${encodeURIComponent(taskId)}`,
'读取角色主形象任务状态失败',
);
}
export async function publishCharacterVisualAsset(
payload: CharacterVisualPublishPayload,
) {
const response = await fetch(CHARACTER_VISUAL_PUBLISH_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(parseApiErrorMessage(responseText, '发布角色主形象失败'));
}
return JSON.parse(responseText) as {
ok: true;
assetId: string;
@@ -46,6 +167,78 @@ export async function publishCharacterVisualAsset(
};
}
export async function generateCharacterAnimationDraft(
payload: CharacterAnimationGenerationPayload,
) {
const response = await fetch(CHARACTER_ANIMATION_GENERATE_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(parseApiErrorMessage(responseText, '生成角色动作草稿失败'));
}
return JSON.parse(responseText) as
| {
ok: true;
taskId: string;
strategy: 'image-sequence';
model: string;
prompt: string;
imageSources: string[];
}
| {
ok: true;
taskId: string;
strategy: 'image-to-video' | 'motion-transfer' | 'reference-to-video';
model: string;
prompt: string;
previewVideoPath: string;
};
}
export async function fetchCharacterAnimationJobStatus(taskId: string) {
return fetchJson<CharacterAssetJobStatus>(
`${CHARACTER_ANIMATION_JOB_API_PATH}/${encodeURIComponent(taskId)}`,
'读取角色动作任务状态失败',
);
}
export async function fetchCharacterAnimationTemplates() {
return fetchJson<{
ok: true;
templates: CharacterAnimationTemplate[];
}>(CHARACTER_ANIMATION_TEMPLATES_API_PATH, '读取动作模板列表失败');
}
export async function importCharacterAnimationVideo(payload: {
characterId: string;
animation: string;
videoSource: string;
sourceLabel?: string;
}) {
const response = await fetch(CHARACTER_ANIMATION_IMPORT_VIDEO_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(parseApiErrorMessage(responseText, '导入动作视频失败'));
}
return JSON.parse(responseText) as {
ok: true;
importedVideoPath: string;
draftId: string;
saveMessage: string;
};
}
export async function publishCharacterAnimationAssets(payload: {
characterId: string;
visualAssetId: string;
@@ -53,15 +246,13 @@ export async function publishCharacterAnimationAssets(payload: {
}) {
const response = await fetch(CHARACTER_ANIMATION_PUBLISH_API_PATH, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(
parseApiErrorMessage(responseText, '发布角色基础动作失败'),
);
throw new Error(parseApiErrorMessage(responseText, '发布角色基础动作失败'));
}
return JSON.parse(responseText) as {

View File

@@ -1 +1,6 @@
{}
{
"sword-princess": {
"generatedVisualAssetId": "visual-1775558475200",
"portrait": "/generated-characters/sword-princess/visual/visual-1775558475200/master.png"
}
}

View File

@@ -0,0 +1,231 @@
import { afterEach, describe, expect, it } from 'vitest';
import { buildExpandedCustomWorldProfile } from '../services/customWorldBuilder';
import {
buildCustomWorldRuntimeCharacters,
getCharacterById,
resolveEncounterRecruitCharacter,
setRuntimeCharacterOverrides,
} from './characterPresets';
import { setRuntimeCustomWorldProfile } from './customWorldRuntime';
function createRole(index: number) {
return {
name: `角色${index + 1}`,
title: `头衔${index + 1}`,
role: `身份${index + 1}`,
description: `角色描述${index + 1}`,
backstory: `角色背景${index + 1}`,
personality: `角色性格${index + 1}`,
motivation: `角色动机${index + 1}`,
combatStyle: `角色战斗风格${index + 1}`,
initialAffinity: 18,
relationshipHooks: [`关系${index + 1}`],
tags: [`标签${index + 1}`],
backstoryReveal: {
publicSummary: `公开背景${index + 1}`,
chapters: [
{
id: `surface-${index + 1}`,
title: '表层来意',
affinityRequired: 10,
teaser: `提示${index + 1}-1`,
content: `内容${index + 1}-1`,
contextSnippet: `摘要${index + 1}-1`,
},
{
id: `scar-${index + 1}`,
title: '旧事裂痕',
affinityRequired: 30,
teaser: `提示${index + 1}-2`,
content: `内容${index + 1}-2`,
contextSnippet: `摘要${index + 1}-2`,
},
{
id: `hidden-${index + 1}`,
title: '隐藏执念',
affinityRequired: 55,
teaser: `提示${index + 1}-3`,
content: `内容${index + 1}-3`,
contextSnippet: `摘要${index + 1}-3`,
},
{
id: `final-${index + 1}`,
title: '最终底牌',
affinityRequired: 80,
teaser: `提示${index + 1}-4`,
content: `内容${index + 1}-4`,
contextSnippet: `摘要${index + 1}-4`,
},
],
},
skills: [
{ name: `技能${index + 1}-1`, summary: '技能摘要1', style: '起手压制' },
{ name: `技能${index + 1}-2`, summary: '技能摘要2', style: '机动周旋' },
{ name: `技能${index + 1}-3`, summary: '技能摘要3', style: '爆发终结' },
],
initialItems: [
{
name: `武器${index + 1}`,
category: '武器',
quantity: 1,
rarity: 'rare' as const,
description: '武器描述',
tags: ['武器标签'],
},
{
name: `补给${index + 1}`,
category: '消耗品',
quantity: 2,
rarity: 'uncommon' as const,
description: '补给描述',
tags: ['补给标签'],
},
{
name: `信物${index + 1}`,
category: '专属物品',
quantity: 1,
rarity: 'rare' as const,
description: '信物描述',
tags: ['信物标签'],
},
],
};
}
describe('characterPresets custom world runtime characters', () => {
afterEach(() => {
setRuntimeCharacterOverrides(null);
setRuntimeCustomWorldProfile(null);
});
it('hydrates story npcs into runtime characters and preserves custom dossiers', () => {
const profile = buildExpandedCustomWorldProfile(
{
name: '裂潮边城',
subtitle: '潮痕未褪',
summary: '一座围绕潮路、断桥和夜港旧案展开的世界。',
tone: '潮湿、压抑、克制',
playerGoal: '查清夜港失踪案和潮路背后的势力牵连。',
templateWorldType: 'WUXIA',
playableNpcs: Array.from({ length: 5 }, (_, index) => ({
...createRole(index),
templateCharacterId:
index === 0
? 'sword-princess'
: index === 1
? 'archer-hero'
: index === 2
? 'girl-hero'
: index === 3
? 'punch-hero'
: 'fighter-4',
})),
storyNpcs: [
{
...createRole(10),
name: '沈雾',
title: '潮路领航人',
role: '夜港向导',
description: '熟悉潮路暗栈与旧渡的人。',
backstory: '曾在断桥坠潮夜里失去整队同伴。',
personality: '谨慎冷静,先观察再表态。',
motivation: '想把失踪航线重新找出来。',
combatStyle: '短刀试探后再借地形逼近。',
initialAffinity: 12,
relationshipHooks: ['断桥旧案', '夜港潮路'],
tags: ['码头', '潮路', '短刀'],
imageSrc: '/custom/npcs/shenwu.png',
visual: {
race: 'human',
bodyColor: 'blue',
headIndex: 2,
hairColorIndex: 3,
hairStyleFrame: 5,
facialHairEnabled: false,
facialHairColorIndex: 1,
facialHairStyleFrame: 0,
mainHand: {
type: 'melee',
file: 'dagger.png',
frameIndex: 4,
},
},
},
{
...createRole(11),
name: '陆沉',
title: '断桥守更',
role: '守桥人',
description: '夜里守着断桥口旧灯火的人。',
},
{
...createRole(12),
name: '顾潮',
title: '潮册记录员',
role: '账房记录员',
description: '在潮账房里整理失踪名册的人。',
},
],
landmarks: [
{
name: '夜港旧栈',
description: '潮雾和旧木桥把视线切成断续几段。',
dangerLevel: 'medium',
sceneNpcNames: ['沈雾', '陆沉', '顾潮'],
connections: [
{
targetLandmarkName: '断桥外沿',
relativePosition: 'forward',
summary: '顺着潮路继续前压就是断桥外沿。',
},
],
},
{
name: '断桥外沿',
description: '旧桥断口还挂着潮湿残旗。',
dangerLevel: 'high',
sceneNpcNames: ['沈雾', '陆沉', '顾潮'],
connections: [
{
targetLandmarkName: '夜港旧栈',
relativePosition: 'back',
summary: '沿旧潮路退回夜港旧栈。',
},
],
},
],
},
'玩家想要一个围绕夜港潮路与断桥旧案展开的世界。',
);
setRuntimeCustomWorldProfile(profile);
const runtimeCharacters = buildCustomWorldRuntimeCharacters(profile);
setRuntimeCharacterOverrides(runtimeCharacters);
const storyRole = profile.storyNpcs[0];
expect(storyRole).toBeTruthy();
const storyCharacter = getCharacterById(storyRole!.id);
const runtimeStoryCharacter = runtimeCharacters.find(
(character) => character.id === storyRole!.id,
);
expect(storyCharacter).toBeTruthy();
expect(runtimeStoryCharacter).toBeTruthy();
expect(storyCharacter?.name).toBe('沈雾');
expect(storyCharacter?.title).toBe('潮路领航人');
expect(storyCharacter?.backstory).toContain('断桥坠潮夜');
expect(storyCharacter?.skills[0]?.name).toBe('技能11-1');
expect(storyCharacter?.portrait).toBe('/custom/npcs/shenwu.png');
expect(storyCharacter?.visual).toEqual(storyRole?.visual);
expect(storyCharacter?.groundOffsetY).toBe(22);
const recruitCharacter = resolveEncounterRecruitCharacter({
characterId: storyRole!.id,
context: storyRole!.role,
npcName: storyRole!.name,
});
expect(recruitCharacter?.id).toBe(storyRole!.id);
expect(recruitCharacter?.name).toBe('沈雾');
});
});

View File

@@ -12,6 +12,7 @@ import {
ConversationGuardStyle,
ConversationTruthStyle,
ConversationWarmStyle,
CustomWorldNpc,
CustomWorldPlayableNpc,
CustomWorldProfile,
Encounter,
@@ -214,11 +215,77 @@ function buildCharacterResourceProfile(character: Character) {
};
}
type CustomWorldRuntimeRole = CustomWorldPlayableNpc | (CustomWorldNpc & {
templateCharacterId?: string;
});
function buildFallbackCustomRuntimeRole(character: Character): CustomWorldRuntimeRole {
return {
id: character.id,
name: character.name,
title: character.title,
role: character.title,
description: character.description,
backstory: character.backstory,
personality: character.personality,
motivation: character.description,
combatStyle: character.skills.map(skill => skill.name).join('、'),
initialAffinity: 18,
relationshipHooks: character.combatTags?.slice(0, 3) ?? [],
tags: character.combatTags ?? [],
backstoryReveal: {
publicSummary: character.description,
chapters: [
{
id: 'surface',
title: '表层来意',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
teaser: character.description,
content: character.backstory,
contextSnippet: character.backstory,
},
{
id: 'scar',
title: '旧事裂痕',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
teaser: character.backstory,
content: character.backstory,
contextSnippet: character.backstory,
},
{
id: 'hidden',
title: '隐藏执念',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
teaser: character.personality,
content: character.personality,
contextSnippet: character.personality,
},
{
id: 'final',
title: '最终底牌',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
teaser: character.skills[0]?.name ?? character.title,
content: character.backstory,
contextSnippet: character.backstory,
},
],
},
skills: character.skills.slice(0, 3).map((skill, index) => ({
id: `preset-skill-${index + 1}`,
name: skill.name,
summary: skill.name,
style: skill.style,
})),
initialItems: [],
visual: character.visual,
};
}
function hydrateCharacterRoleData(
character: Character,
options: {
customWorldProfile?: CustomWorldProfile | null;
customRole?: CustomWorldPlayableNpc | null;
customRole?: CustomWorldRuntimeRole | null;
} = {},
) {
const wuxiaSchema = getPresetWorldAttributeSchema(WorldType.WUXIA);
@@ -227,64 +294,7 @@ function hydrateCharacterRoleData(
const xianxiaProfile = buildCharacterAttributeProfile(character, xianxiaSchema);
const customProfile = options.customWorldProfile
? buildCustomWorldPlayableNpcAttributeProfile(
options.customRole ?? {
id: character.id,
name: character.name,
title: character.title,
role: character.title,
description: character.description,
backstory: character.backstory,
personality: character.personality,
motivation: character.description,
combatStyle: character.skills.map(skill => skill.name).join('、'),
initialAffinity: 18,
relationshipHooks: character.combatTags?.slice(0, 3) ?? [],
tags: character.combatTags ?? [],
backstoryReveal: {
publicSummary: character.description,
chapters: [
{
id: 'surface',
title: '表层来意',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
teaser: character.description,
content: character.backstory,
contextSnippet: character.backstory,
},
{
id: 'scar',
title: '旧事裂痕',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
teaser: character.backstory,
content: character.backstory,
contextSnippet: character.backstory,
},
{
id: 'hidden',
title: '隐藏执念',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
teaser: character.personality,
content: character.personality,
contextSnippet: character.personality,
},
{
id: 'final',
title: '最终底牌',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
teaser: character.skills[0]?.name ?? character.title,
content: character.backstory,
contextSnippet: character.backstory,
},
],
},
skills: character.skills.slice(0, 3).map((skill, index) => ({
id: `preset-skill-${index + 1}`,
name: skill.name,
summary: skill.name,
style: skill.style,
})),
initialItems: [],
},
options.customRole ?? buildFallbackCustomRuntimeRole(character),
options.customWorldProfile.attributeSchema,
character.attributes,
)
@@ -377,6 +387,28 @@ function hashText(value: string) {
return hash;
}
function resolveFallbackRecruitTemplateCharacterId(source: string) {
if (/|||||||||/u.test(source)) {
return 'archer-hero';
}
if (/|||||||||/u.test(source)) {
return 'fighter-4';
}
if (/||||||/u.test(source)) {
return 'punch-hero';
}
if (/||||||||/u.test(source)) {
return 'girl-hero';
}
if (/|使|||殿/u.test(source)) {
return 'sword-princess';
}
return RECRUIT_CHARACTER_FALLBACKS[hashText(source) % RECRUIT_CHARACTER_FALLBACKS.length]
?? RECRUIT_CHARACTER_FALLBACKS[0]
?? 'sword-princess';
}
export function resolveEncounterRecruitCharacter(
encounter: Pick<Encounter, 'characterId' | 'context' | 'npcName'>,
) {
@@ -385,27 +417,7 @@ export function resolveEncounterRecruitCharacter(
}
const source = `${encounter.context} ${encounter.npcName}`;
if (/|||||||||/u.test(source)) {
return getCharacterById('archer-hero');
}
if (/|||||||||/u.test(source)) {
return getCharacterById('fighter-4');
}
if (/||||||/u.test(source)) {
return getCharacterById('punch-hero');
}
if (/||||||||/u.test(source)) {
return getCharacterById('girl-hero');
}
if (/|使|||殿/u.test(source)) {
return getCharacterById('sword-princess');
}
const fallbackId = RECRUIT_CHARACTER_FALLBACKS[hashText(source) % RECRUIT_CHARACTER_FALLBACKS.length]
?? RECRUIT_CHARACTER_FALLBACKS[0]
?? 'sword-princess';
return getCharacterById(fallbackId);
return getCharacterById(resolveFallbackRecruitTemplateCharacterId(source));
}
export function getCharacterEquipment(character: Character) {
@@ -1509,7 +1521,7 @@ function clampInteger(value: number, min: number, max: number) {
function buildCustomWorldSkillVariant(
profile: CustomWorldProfile,
baseCharacter: Character,
role: CustomWorldPlayableNpc,
role: CustomWorldRuntimeRole,
skill: CharacterSkillDefinition,
index: number,
) {
@@ -1590,14 +1602,12 @@ function buildCustomWorldAdventureOpening(
});
}
function buildCustomWorldCharacter(baseCharacter: Character, profile: CustomWorldProfile, index: number) {
const role = profile.playableNpcs[index];
if (!role) {
return baseCharacter;
}
function buildCustomWorldRoleCharacter(
baseCharacter: Character,
profile: CustomWorldProfile,
role: CustomWorldRuntimeRole,
) {
const combatTags = deriveCustomWorldCharacterCombatTags(profile, role, baseCharacter);
const opening = buildCustomWorldAdventureOpening(profile, {
...baseCharacter,
name: role.name,
@@ -1609,11 +1619,15 @@ function buildCustomWorldCharacter(baseCharacter: Character, profile: CustomWorl
return hydrateCharacterRoleData({
...baseCharacter,
id: role.id,
name: role.name,
title: role.title,
description: role.description,
backstory: role.backstory,
backstoryReveal: role.backstoryReveal,
portrait: ('imageSrc' in role && role.imageSrc?.trim()) || baseCharacter.portrait,
visual: 'visual' in role ? role.visual : undefined,
groundOffsetY: 'visual' in role && role.visual ? 22 : baseCharacter.groundOffsetY,
personality: role.personality,
conversationStyle: inferConversationStyleFromText([
role.personality,
@@ -1640,6 +1654,40 @@ function buildCustomWorldCharacter(baseCharacter: Character, profile: CustomWorl
});
}
function pickCustomWorldRoleTemplateCharacter(
role: CustomWorldRuntimeRole,
fallbackIndex: number,
) {
const fallbackTemplateCharacter = PRESET_CHARACTERS[
fallbackIndex % Math.max(1, PRESET_CHARACTERS.length)
] ?? PRESET_CHARACTERS[0];
if (!fallbackTemplateCharacter) {
throw new Error('Missing preset characters for custom world generation');
}
const explicitTemplateCharacter = role.templateCharacterId
? PRESET_CHARACTERS.find(character => character.id === role.templateCharacterId) ?? null
: null;
if (explicitTemplateCharacter) {
return explicitTemplateCharacter;
}
const heuristicTemplateCharacter = PRESET_CHARACTERS.find(
character =>
character.id === resolveFallbackRecruitTemplateCharacterId([
role.role,
role.name,
role.title,
role.combatStyle,
role.description,
role.personality,
role.tags.join(' '),
].join(' ')),
);
return heuristicTemplateCharacter ?? fallbackTemplateCharacter;
}
export function buildCustomWorldPlayableCharacters(profile: CustomWorldProfile | null) {
if (!profile) {
return PRESET_CHARACTERS;
@@ -1650,29 +1698,41 @@ export function buildCustomWorldPlayableCharacters(profile: CustomWorldProfile |
}
return profile.playableNpcs.map((role, index) => {
const fallbackTemplateCharacter = PRESET_CHARACTERS[index % Math.max(1, PRESET_CHARACTERS.length)]
?? PRESET_CHARACTERS[0];
if (!fallbackTemplateCharacter) {
throw new Error('Missing preset characters for custom world generation');
}
const templateCharacter = PRESET_CHARACTERS.find(character => character.id === role.templateCharacterId)
?? fallbackTemplateCharacter;
const templateCharacter = pickCustomWorldRoleTemplateCharacter(role, index);
const customCharacter = buildCustomWorldCharacter(templateCharacter, {
...profile,
playableNpcs: [{
return buildCustomWorldRoleCharacter(
templateCharacter,
profile,
{
...role,
templateCharacterId: role.templateCharacterId ?? templateCharacter.id,
}],
}, 0);
return {
...customCharacter,
id: role.id,
} satisfies Character;
},
);
});
}
export function buildCustomWorldRuntimeCharacters(profile: CustomWorldProfile | null) {
if (!profile) {
return [] as Character[];
}
const playableCharacters = buildCustomWorldPlayableCharacters(profile);
const storyCharacters = profile.storyNpcs.map((role, index) => {
const templateCharacter = pickCustomWorldRoleTemplateCharacter(
role,
profile.playableNpcs.length + index,
);
return buildCustomWorldRoleCharacter(
templateCharacter,
profile,
role,
);
});
return [...playableCharacters, ...storyCharacters];
}
export function setRuntimeCharacterOverrides(characters: Character[] | null) {
runtimeCharacterOverrides.clear();
runtimeCustomWorldCharacters = characters ? [...characters] : [];
@@ -1885,12 +1945,26 @@ export function buildCharacterBackstoryPromptContext(
].filter((snippet): snippet is string => Boolean(snippet));
}
function getCustomWorldRoleSceneIds(profile: CustomWorldProfile, characterId: string) {
const sceneIds = profile.landmarks.flatMap((landmark, index) =>
landmark.sceneNpcIds.includes(characterId)
? [`custom-scene-landmark-${index + 1}`]
: [],
);
return [...new Set(sceneIds)];
}
export function getCharacterHomeSceneId(worldType: WorldType, characterId: string) {
if (isCustomWorldType(worldType)) {
const profile = getRuntimeCustomWorldProfile();
if (!profile || profile.landmarks.length === 0) {
return 'custom-scene-camp';
}
const roleSceneIds = getCustomWorldRoleSceneIds(profile, characterId);
if (roleSceneIds.length > 0) {
return roleSceneIds[0] ?? 'custom-scene-camp';
}
const characterIndex = runtimeCustomWorldCharacters.findIndex(character => character.id === characterId);
const landmarkIndex = Math.max(0, characterIndex) % profile.landmarks.length;
return `custom-scene-landmark-${landmarkIndex + 1}`;
@@ -1906,6 +1980,10 @@ export function getCharacterNpcSceneIds(worldType: WorldType, characterId: strin
if (!profile || profile.landmarks.length === 0) {
return ['custom-scene-camp'];
}
const roleSceneIds = getCustomWorldRoleSceneIds(profile, characterId);
if (roleSceneIds.length > 0) {
return ['custom-scene-camp', ...roleSceneIds].slice(0, 3);
}
const characterIndex = runtimeCustomWorldCharacters.findIndex(character => character.id === characterId);
const firstScene = `custom-scene-landmark-${(Math.max(0, characterIndex) % profile.landmarks.length) + 1}`;
const secondScene = `custom-scene-landmark-${((Math.max(0, characterIndex) + 1) % profile.landmarks.length) + 1}`;

View File

@@ -23,6 +23,7 @@ const TEMPLATE_CHARACTER_TAGS: Record<string, string[]> = {
};
const THEME_FALLBACK_TAGS: Record<CustomWorldThemeMode, string[]> = {
mythic: [],
martial: [],
arcane: ['\u6cd5\u4fee', '\u7b26\u9635', '\u6cd5\u529b'],
machina: ['\u5de5\u5de7', '\u63a7\u573a', '\u62a4\u4f53'],

View File

@@ -1,5 +1,6 @@
import {
Character,
CustomWorldNpc,
CustomWorldPlayableNpc,
CustomWorldProfile,
CustomWorldRoleInitialItem,
@@ -82,7 +83,7 @@ function inferEquipmentSlotFromCategory(category: string): EquipmentSlotId | nul
}
function buildExplicitRoleInventoryItem(
role: CustomWorldPlayableNpc,
role: CustomWorldPlayableNpc | CustomWorldNpc,
item: CustomWorldRoleInitialItem,
index: number,
): InventoryItem {
@@ -111,7 +112,9 @@ function buildExplicitRoleInventoryItem(
};
}
function buildExplicitRoleInventoryItems(role: CustomWorldPlayableNpc | null) {
function buildExplicitRoleInventoryItems(
role: CustomWorldPlayableNpc | CustomWorldNpc | null,
) {
if (!role) {
return [] as InventoryItem[];
}
@@ -121,10 +124,15 @@ function buildExplicitRoleInventoryItems(role: CustomWorldPlayableNpc | null) {
);
}
function resolveCustomWorldPlayableRole(profile: CustomWorldProfile, character: Character) {
function resolveCustomWorldRole(
profile: CustomWorldProfile,
character: Character,
) {
return profile.playableNpcs.find(role => role.id === character.id)
?? profile.storyNpcs.find(role => role.id === character.id)
?? profile.playableNpcs.find(role => role.templateCharacterId === character.id)
?? profile.playableNpcs.find(role => role.name === character.name)
?? profile.storyNpcs.find(role => role.name === character.name)
?? null;
}
@@ -172,7 +180,11 @@ function collectChineseNgrams(value: string, minSize = 2, maxSize = 4, limit = 1
return grams;
}
function buildKeywordBundle(profile: CustomWorldProfile, character: Character, role: CustomWorldPlayableNpc | null) {
function buildKeywordBundle(
profile: CustomWorldProfile,
character: Character,
role: CustomWorldPlayableNpc | CustomWorldNpc | null,
) {
const roleTexts = [
role?.title ?? '',
role?.description ?? '',
@@ -271,7 +283,7 @@ export function buildCustomWorldStarterEquipmentItems(
} satisfies Record<EquipmentSlotId, InventoryItem | null>;
}
const role = resolveCustomWorldPlayableRole(profile, character);
const role = resolveCustomWorldRole(profile, character);
const explicitItems = buildExplicitRoleInventoryItems(role);
const explicitWeapon =
explicitItems.find(item => item.equipmentSlotId === 'weapon') ?? null;
@@ -327,7 +339,7 @@ export function buildCustomWorldStarterInventoryItems(
return [] as InventoryItem[];
}
const role = resolveCustomWorldPlayableRole(profile, character);
const role = resolveCustomWorldRole(profile, character);
const explicitItems = buildExplicitRoleInventoryItems(role);
const bundle = buildKeywordBundle(profile, character, role);
const consumables = queryItems(`inventory:${character.id}:consumables`, {

View File

@@ -1,5 +1,6 @@
import {isRecord, readStoredJson, writeStoredJson} from '../persistence/storage';
import {generateWorldAttributeSchema} from '../services/attributeSchemaGenerator';
import { buildFallbackCustomWorldCampScene } from '../services/customWorldCamp';
import {
buildCustomWorldAnchorPackFromIntent,
deriveCustomWorldLockStateFromIntent,
@@ -514,6 +515,26 @@ function normalizeLandmark(value: unknown, index: number): CustomWorldLandmark |
};
}
function normalizeCampScene(
value: unknown,
fallbackProfile: Pick<
CustomWorldProfile,
'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType'
>,
) {
const fallback = buildFallbackCustomWorldCampScene(fallbackProfile);
if (!isRecord(value)) {
return fallback;
}
return {
name: toText(value.name, fallback.name),
description: toText(value.description, fallback.description),
dangerLevel: toText(value.dangerLevel, fallback.dangerLevel),
imageSrc: toText(value.imageSrc) || undefined,
};
}
function normalizeLandmarkDraft(
value: unknown,
index: number,
@@ -569,6 +590,14 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
const summary = toText(value.summary);
const tone = toText(value.tone);
const playerGoal = toText(value.playerGoal);
const camp = normalizeCampScene(value.camp, {
name,
summary,
tone,
playerGoal,
settingText,
templateWorldType,
});
const generatedAttributeSchema = generateWorldAttributeSchema({
worldType: WorldType.CUSTOM,
worldName: name,
@@ -613,6 +642,7 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
.map((entry, index) => normalizeItem(entry, index))
.filter((entry): entry is CustomWorldItem => Boolean(entry))
: [],
camp,
landmarks: normalizeCustomWorldLandmarks({
landmarks: landmarkDrafts,
storyNpcs,

View File

@@ -1,4 +1,8 @@
import { resolveCustomWorldAnchorWorldType } from '../services/customWorldTheme';
import {
type CustomWorldThemeMode,
detectCustomWorldThemeMode,
resolveCustomWorldAnchorWorldType,
} from '../services/customWorldTheme';
import { CustomWorldItem, CustomWorldProfile, InventoryItem, WorldTemplateType, WorldType } from '../types';
let runtimeCustomWorldProfile: CustomWorldProfile | null = null;
@@ -94,9 +98,13 @@ const CATEGORY_DEFAULT_TAGS: Record<string, string[]> = {
: ['rare', '线索'],
: ['rare', '剧情关键'],
};
const WORLD_ITEM_PREFIXES: Record<WorldTemplateType, string[]> = {
[WorldType.WUXIA]: ['江湖', '风雨', '断桥', '青锋', '旧案', '夜行'],
[WorldType.XIANXIA]: ['灵潮', '云阙', '星砂', '裂界', '玄脉', '天舟'],
const WORLD_ITEM_PREFIXES: Record<CustomWorldThemeMode, string[]> = {
mythic: ['远岸', '回声', '曙迹', '长旅', '微光', '新铭'],
martial: ['风雨', '断桥', '青锋', '旧案', '夜行', '残影'],
arcane: ['灵纹', '道痕', '云篆', '星芒', '界辉', '玉简'],
machina: ['铁脊', '脉冲', '新星', '等离', '钢律', '核列'],
tide: ['潮纹', '霜浪', '天澜', '海晕', '潮歌', '沧流'],
rift: ['裂痕', '灰域', '界桥', '断层', '回响', '前哨'],
};
const WORLD_ITEM_NOUNS: Record<string, string[]> = {
: ['刃', '剑', '弓', '枪', '印', '锤'],
@@ -127,7 +135,7 @@ function getWorldSeedLabel(profile: CustomWorldProfile) {
const fromSetting = sanitizeNameFragment(profile.settingText);
if (fromSetting) return fromSetting;
return profile.templateWorldType === WorldType.XIANXIA ? '灵境' : '江湖';
return '旅境';
}
function buildRuntimeItemTags(
@@ -228,7 +236,7 @@ function buildProceduralRuntimeItem(
options: RuntimeCustomWorldItemQueryOptions,
index: number,
) {
const anchorWorldType = resolveCustomWorldAnchorWorldType(profile);
const themeMode = detectCustomWorldThemeMode(profile);
const seed = hashText(`${profile.id}:${seedKey}:${index}`);
const defaultCategory = DEFAULT_RUNTIME_CATEGORIES[0] ?? 'world-item';
const categories = compactStrings(options.categories?.length ? options.categories : [...DEFAULT_RUNTIME_CATEGORIES]);
@@ -236,7 +244,7 @@ function buildProceduralRuntimeItem(
const rarityFloorValue = getRarityFloorValue(options.rarityFloor);
const rarity = inferRuntimeItemRarity(seed, rarityFloorValue);
const tags = buildRuntimeItemTags(category, options, seed);
const prefixPool = WORLD_ITEM_PREFIXES[anchorWorldType];
const prefixPool = WORLD_ITEM_PREFIXES[themeMode];
const nounPool = WORLD_ITEM_NOUNS[category] ?? WORLD_ITEM_NOUNS.;
const fallbackNounPool = ['sigil', 'relic', 'token', 'seal', 'core', 'mark'];
const resolvedNounPool = nounPool ?? fallbackNounPool;

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

View File

@@ -67,11 +67,15 @@ function createGameState(): GameState {
}
describe('hostileNpcPresets', () => {
it('combines preset loot with runtime semantic drops', () => {
it('combines preset loot with runtime semantic drops', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0);
vi.stubGlobal(
'fetch',
vi.fn().mockRejectedValue(new TypeError('network disabled in test')),
);
try {
const loot = rollHostileNpcLoot(createGameState(), [
const loot = await rollHostileNpcLoot(createGameState(), [
{
id: 'monster-03',
name: '断骨祟灵',
@@ -82,7 +86,11 @@ describe('hostileNpcPresets', () => {
expect(
loot.some(item => item.runtimeMetadata?.generationChannel === 'monster_drop'),
).toBe(true);
expect(
loot.find(item => item.id === 'monster-loot:bone-dust')?.runtimeMetadata?.storyFingerprint,
).toBeTruthy();
} finally {
vi.unstubAllGlobals();
randomSpy.mockRestore();
}
});

View File

@@ -1,12 +1,18 @@
import { HostileNpcAnimationConfig, HostileNpcSpriteConfig } from '../components/HostileNpcAnimator';
import { GameState, InventoryItem, MonsterLootEntry, RoleActionDefinition, SceneHostileNpc, WorldType } from '../types';
import {generateRuntimeItemAiIntents} from '../services/runtimeItemAiDirector';
import { GameState, InventoryItem, MonsterLootEntry, RoleActionDefinition, RuntimeItemPlan, SceneHostileNpc, WorldType } from '../types';
import { buildMonsterAttributeProfile } from './attributeProfileGenerator';
import { buildDefaultAxisVector } from './attributeResolver';
import {normalizeBuildTags} from './buildTags';
import { buildRuntimeCustomWorldInventoryItems, resolveRuleWorldType } from './customWorldRuntime';
import hostileNpcOverridesJson from './hostileNpcOverrides.json';
import { buildRuntimeItemGenerationContext } from './runtimeItemContext';
import { buildDirectedRuntimeReward } from './runtimeItemDirector';
import { flattenDirectedRuntimeRewardItems } from './runtimeItemNarrative';
import { generateDirectedRuntimeReward } from './runtimeItemDirector';
import {
applyRuntimeItemNarrativeToExistingItem,
buildRuntimeItemAiIntent,
flattenDirectedRuntimeRewardItems,
} from './runtimeItemNarrative';
import { getPresetWorldAttributeSchema } from './worldAttributeSchemas';
export interface HostileNpcPreset extends HostileNpcSpriteConfig {
@@ -940,12 +946,15 @@ const ALL_HOSTILE_NPC_PRESETS = BASE_HOSTILE_NPC_PRESETS.map(basePreset => merge
export const HOSTILE_NPC_PRESETS_BY_WORLD: Record<WorldType, HostileNpcPreset[]> = {
[WorldType.WUXIA]: ALL_HOSTILE_NPC_PRESETS.filter(monster => monster.worldType === WorldType.WUXIA),
[WorldType.XIANXIA]: ALL_HOSTILE_NPC_PRESETS.filter(monster => monster.worldType === WorldType.XIANXIA),
[WorldType.CUSTOM]: ALL_HOSTILE_NPC_PRESETS.filter(monster => monster.worldType === WorldType.WUXIA),
[WorldType.CUSTOM]: [...ALL_HOSTILE_NPC_PRESETS],
};
export const MONSTER_PRESETS_BY_WORLD = HOSTILE_NPC_PRESETS_BY_WORLD;
export function getHostileNpcPresetById(worldType: WorldType, monsterId: string) {
if (worldType === WorldType.CUSTOM) {
return HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.CUSTOM].find(monster => monster.id === monsterId) ?? null;
}
const resolvedWorldType = resolveRuleWorldType(worldType) ?? WorldType.WUXIA;
return HOSTILE_NPC_PRESETS_BY_WORLD[resolvedWorldType].find(monster => monster.id === monsterId) ?? null;
}
@@ -953,6 +962,9 @@ export function getHostileNpcPresetById(worldType: WorldType, monsterId: string)
export const getMonsterPresetById = getHostileNpcPresetById;
export function getHostileNpcPresetsByWorld(worldType: WorldType) {
if (worldType === WorldType.CUSTOM) {
return HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.CUSTOM];
}
const resolvedWorldType = resolveRuleWorldType(worldType) ?? WorldType.WUXIA;
return HOSTILE_NPC_PRESETS_BY_WORLD[resolvedWorldType];
}
@@ -963,7 +975,100 @@ export function getHostileNpcPresetOverrideById(monsterId: string) {
return HOSTILE_NPC_OVERRIDES[monsterId] ?? null;
}
export function rollHostileNpcLoot(
function inferRuntimePlanFromLootItem(
item: InventoryItem,
context: ReturnType<typeof buildRuntimeItemGenerationContext>,
index: number,
): RuntimeItemPlan {
const normalizedBuildTags = normalizeBuildTags(item.tags, 3);
const targetBuildDirection = normalizedBuildTags.length > 0
? normalizedBuildTags
: normalizeBuildTags(context.playerBuildTags, 3);
return {
slot: item.rarity === 'epic' || item.rarity === 'legendary'
? 'primary'
: index === 0
? 'secondary'
: 'support',
itemKind: item.category === '武器' || item.category === '护甲'
? 'equipment'
: item.category === '消耗品' || item.tags.includes('consumable') || item.tags.includes('healing') || item.tags.includes('mana')
? 'consumable'
: item.category === '材料' || item.tags.includes('material')
? 'material'
: item.category === '专属品' || item.category === '专属物' || item.category === '专属物品'
? 'quest'
: 'relic',
permanence: item.category === '材料'
? 'resource'
: item.category === '消耗品'
? 'timed'
: 'permanent',
narrativeWeight: item.rarity === 'epic' || item.rarity === 'legendary' ? 'heavy' : 'medium',
targetBuildDirection: targetBuildDirection.length > 0 ? targetBuildDirection : ['均衡'],
relationAnchor: context.encounter?.monsterPresetId
? {
type: 'monster' as const,
monsterId: context.encounter.monsterPresetId,
monsterName: context.encounter.npcName,
}
: context.encounterNpcName
? {
type: 'npc' as const,
npcId: context.encounterNpcId ?? undefined,
npcName: context.encounterNpcName,
roleText: context.encounterContextText ?? undefined,
}
: {
type: 'scene' as const,
sceneId: context.sceneId ?? undefined,
sceneName: context.sceneName ?? '战场余烬',
},
} satisfies RuntimeItemPlan;
}
async function decoratePresetLootWithNarrative(
items: InventoryItem[],
context: ReturnType<typeof buildRuntimeItemGenerationContext>,
seedKeyPrefix: string,
) {
if (items.length <= 0) return items;
const plans = items.map((item, index) => inferRuntimePlanFromLootItem(item, context, index));
const fallbackIntents = plans.map(plan => buildRuntimeItemAiIntent(context, plan));
let intents = fallbackIntents;
try {
intents = await generateRuntimeItemAiIntents({
context,
plans,
});
} catch (error) {
console.warn('[HostileNpcPresets] preset loot narrative fallback', error);
}
return items.map((item, index) =>
applyRuntimeItemNarrativeToExistingItem({
item: {
...item,
runtimeMetadata: {
origin: 'procedural',
generationChannel: 'monster_drop',
relationAnchor: plans[index]!.relationAnchor,
seedKey: `${seedKeyPrefix}:preset:${index}`,
sourceReason: intents[index]!.reasonToAppear,
},
},
context,
plan: plans[index]!,
intent: intents[index]!,
preserveName: true,
}),
);
}
export async function rollHostileNpcLoot(
state: GameState,
defeatedHostileNpcs: Array<Pick<SceneHostileNpc, 'id' | 'name'>>,
) {
@@ -979,7 +1084,7 @@ export function rollHostileNpcLoot(
));
}
return defeatedHostileNpcs.flatMap(monster => {
const rewardBatches = await Promise.all(defeatedHostileNpcs.map(async monster => {
const preset = getHostileNpcPresetById(state.worldType!, monster.id);
const presetLoot = preset
? preset.lootTable
@@ -1001,13 +1106,19 @@ export function rollHostileNpcLoot(
monsterPresetId: monster.id,
},
});
const directedReward = buildDirectedRuntimeReward(context, {
seedKey: `monster-loot:${monster.id}:${monster.name}:${state.currentScenePreset?.id ?? 'scene'}`,
itemCount: 2,
fixedKinds: ['material', 'consumable'],
fixedPermanence: ['resource', 'timed'],
});
const seedKey = `monster-loot:${monster.id}:${monster.name}:${state.currentScenePreset?.id ?? 'scene'}`;
const [decoratedPresetLoot, directedReward] = await Promise.all([
decoratePresetLootWithNarrative(presetLoot, context, seedKey),
generateDirectedRuntimeReward(context, {
seedKey,
itemCount: 2,
fixedKinds: ['material', 'consumable'],
fixedPermanence: ['resource', 'timed'],
}),
]);
const runtimeItems = flattenDirectedRuntimeRewardItems(directedReward);
return [...presetLoot, ...runtimeItems];
});
return [...decoratedPresetLoot, ...runtimeItems];
}));
return rewardBatches.flat();
}

View File

@@ -21,7 +21,7 @@ export const MAX_HOSTILE_NPCS_PER_ENCOUNTER = 3;
export const HOSTILE_NPCS_BY_WORLD: Record<WorldType, HostileNpcSpriteConfig[]> = {
[WorldType.WUXIA]: HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.WUXIA].map(({ description: _description, introAction: _introAction, baseStats: _baseStats, worldType: _worldType, ...spriteConfig }) => spriteConfig),
[WorldType.XIANXIA]: HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.XIANXIA].map(({ description: _description, introAction: _introAction, baseStats: _baseStats, worldType: _worldType, ...spriteConfig }) => spriteConfig),
[WorldType.CUSTOM]: HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.WUXIA].map(({ description: _description, introAction: _introAction, baseStats: _baseStats, worldType: _worldType, ...spriteConfig }) => spriteConfig),
[WorldType.CUSTOM]: HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.CUSTOM].map(({ description: _description, introAction: _introAction, baseStats: _baseStats, worldType: _worldType, ...spriteConfig }) => spriteConfig),
};
export const MONSTERS_BY_WORLD = HOSTILE_NPCS_BY_WORLD;

View File

@@ -0,0 +1,105 @@
import type {InventoryItem} from '../types';
import {getBuildTagDefinition} from './buildTags';
import type {InventoryUseEffect} from './inventoryEffects';
const STRUCTURAL_TAG_LABELS: Record<string, string> = {
weapon: '武器',
armor: '护甲',
relic: '遗物',
material: '材料',
consumable: '消耗品',
healing: '疗伤',
mana: '法力',
rare: '稀有',
wuxia: '武侠',
xianxia: '仙侠',
neutral: '中性',
};
function dedupeStrings(values: Array<string | null | undefined>) {
return [...new Set(values.map(value => value?.trim() ?? '').filter(Boolean))];
}
function buildEffectSummaryParts(
item: InventoryItem,
useEffect: InventoryUseEffect | null,
) {
if (useEffect) {
return [
useEffect.hpRestore > 0 ? `恢复 ${useEffect.hpRestore} 点气血` : null,
useEffect.manaRestore > 0 ? `恢复 ${useEffect.manaRestore} 点灵力` : null,
useEffect.cooldownReduction > 0
? `额外推进 ${useEffect.cooldownReduction} 回合冷却`
: null,
useEffect.buildBuffs.length > 0
? `获得 ${useEffect.buildBuffs.map(buff => buff.name).join('、')}`
: null,
];
}
return [
item.tags.includes('healing') ? '在冒险中恢复生命值' : null,
item.tags.includes('mana') ? '帮助回转灵力与技能节奏' : null,
item.tags.includes('weapon') ? '适合进攻型构筑' : null,
item.tags.includes('armor') ? '适合防御型构筑' : null,
item.tags.includes('relic') ? '可作为稀有遗物长期携带' : null,
item.tags.includes('material') ? '可用于制作、锻造或交换' : null,
];
}
export function getInventoryTagLabel(tag: string) {
const normalized = tag.trim();
if (!normalized) return '';
const buildTag = getBuildTagDefinition(normalized);
if (buildTag) {
return buildTag.label;
}
return STRUCTURAL_TAG_LABELS[normalized.toLowerCase()] ?? normalized;
}
export function getInventoryTagLabels(tags: string[]) {
return dedupeStrings(tags.map(getInventoryTagLabel));
}
export function buildInventoryItemDescription(
item: InventoryItem,
useEffect: InventoryUseEffect | null = null,
) {
if (item.description?.trim()) return item.description.trim();
const storyFingerprint = item.runtimeMetadata?.storyFingerprint;
if (storyFingerprint) {
return [
storyFingerprint.visibleClue,
`${storyFingerprint.witnessMark} ${storyFingerprint.unresolvedQuestion}`,
`它会在此刻出现,是因为${storyFingerprint.currentAppearanceReason}`,
].join(' ');
}
const parts = buildEffectSummaryParts(item, useEffect).filter(
(part): part is string => Boolean(part),
);
if (parts.length > 0) {
return `${item.name} 当前可提供这些作用:${parts.join('')}`;
}
switch (item.category) {
case '武器':
return `${item.name} 更适合作为当前战利品中的主战装备,后续可用于替换现有武器或继续锻造。`;
case '护甲':
return `${item.name} 适合用来补足防护与承伤能力,也可留作后续重铸素材。`;
case '饰品':
case '稀有品':
case '专属品':
case '专属物':
case '专属物品':
return `${item.name} 更偏向长期携带与构筑补强,也可能牵出额外线索。`;
case '材料':
return `${item.name} 可用于制作、锻造、交易,或为后续掉落组合做准备。`;
default:
return `${item.name} 可用于后续路线规划、交易或构筑调整。`;
}
}

View File

@@ -536,6 +536,30 @@ export function buildMedievalNpcVisualOverrideFromCustomWorldVisual(visual: Cust
};
}
export function buildMedievalNpcVisualFromCustomWorldVisual(
visual: CustomWorldNpcVisual,
): MedievalNpcVisualSpec {
const override = buildMedievalNpcVisualOverrideFromCustomWorldVisual(visual);
const race = override.race ?? 'human';
return {
race,
bodySrc: override.bodySrc ?? buildBodyPath('black'),
headSrc: override.headSrc ?? buildRaceAssetPath(race, 'head', 1),
hairSrc: override.hairSrc ?? buildRaceAssetPath(race, 'hair', 1),
handSrc: override.handSrc ?? buildRaceAssetPath(race, 'hand', 1),
facialHairSrc: override.facialHairSrc,
headgear: override.headgear,
mainHand: override.mainHand,
offHand: override.offHand,
bodyFrames: override.bodyFrames ?? [0, 1, 2, 3],
headFrame: override.headFrame ?? 0,
hairFrame: override.hairFrame ?? 0,
handFrame: override.handFrame ?? 0,
facialHairFrame: override.facialHairFrame,
};
}
export function getNpcVisualOverrideById(overrideId: string) {
return NPC_VISUAL_OVERRIDES[overrideId] ?? null;
}

View File

@@ -260,6 +260,24 @@ function clampProgress(progress: number | undefined, requiredCount: number) {
return Math.max(0, Math.min(normalizeCount(requiredCount), Math.round(progress ?? 0)));
}
function compactQuestLabel(label: string, maxLength = 6) {
const trimmed = label.trim();
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed;
}
function normalizeQuestTitle(rawTitle: string, fallbackTitle: string) {
const title = rawTitle
.replace(/["']/gu, '')
.replace(/[,.!?;:].*$/u, '')
.trim();
if (title && title.length <= 12) {
return title;
}
return fallbackTitle.length <= 12 ? fallbackTitle : fallbackTitle.slice(0, 10);
}
function getScenePrimaryThreat(scene: QuestSceneSnapshot | null, worldType: WorldType | null): SceneQuestThreat | null {
if (!scene) {
return null;
@@ -749,7 +767,7 @@ export function buildFallbackQuestIntent(params: QuestCompilationRequest): Quest
if (threat?.kind === 'defeat_hostile_npc') {
const hostileNpcName = threat.targetHostileNpcName;
return {
title: `压制 ${hostileNpcName}`,
title: `压制${compactQuestLabel(hostileNpcName, 8)}`,
description: `${issuerNpcName} 希望你先处理掉 ${scene?.name ?? '前方区域'} 徘徊的 ${hostileNpcName},再回来交换后续情报。`,
summary: `击退 ${hostileNpcName},然后回去和 ${issuerNpcName} 交谈`,
narrativeType: 'bounty',
@@ -770,7 +788,7 @@ export function buildFallbackQuestIntent(params: QuestCompilationRequest): Quest
if (threat?.kind === 'inspect_treasure' && scene) {
return {
title: `探明 ${scene.name} 的异常`,
title: `${compactQuestLabel(scene.name)}异动`,
description: `${issuerNpcName} 不确定 ${scene.name} 一带出现的异动是真是假,想让你先去看清楚,再回来对一遍情报。`,
summary: `调查 ${scene.name} 的异常,然后回去向 ${issuerNpcName} 汇报`,
narrativeType: 'investigation',
@@ -790,7 +808,7 @@ export function buildFallbackQuestIntent(params: QuestCompilationRequest): Quest
}
return {
title: `${issuerNpcName} 过几招`,
title: `${compactQuestLabel(issuerNpcName)}试炼`,
description: `${issuerNpcName} 想先亲自试一试你的成色,再决定要不要把更关键的事继续交给你。`,
summary: `${issuerNpcName} 切磋一场,然后回来把话说透`,
narrativeType: 'trial',
@@ -813,6 +831,7 @@ export function compileQuestIntentToQuest(
params: QuestCompilationRequest,
intent: QuestIntent,
): QuestLogEntry | null {
const fallbackIntent = buildFallbackQuestIntent(params);
const primaryStep = buildPrimaryQuestStep({
issuerNpcId: params.issuerNpcId,
issuerNpcName: params.issuerNpcName,
@@ -842,9 +861,9 @@ export function compileQuestIntentToQuest(
issuerNpcName: params.issuerNpcName,
sceneId: params.scene?.id ?? null,
questArchetype: intent.narrativeType,
title: intent.title.trim() || buildFallbackQuestIntent(params).title,
description: intent.description.trim() || buildFallbackQuestIntent(params).description,
summary: intent.summary.trim() || buildFallbackQuestIntent(params).summary,
title: normalizeQuestTitle(intent.title, fallbackIntent.title),
description: intent.description.trim() || fallbackIntent.description,
summary: intent.summary.trim() || fallbackIntent.summary,
steps,
reward,
rewardText,

View File

@@ -73,9 +73,10 @@ export function buildRuntimeItemAiIntent(
context: RuntimeItemGenerationContext,
plan: RuntimeItemPlan,
): RuntimeItemAiIntent {
const anchorLabel = resolveAnchorLabel(plan.relationAnchor);
const sourceSeed = sanitizeFragment(context.sceneName, 4)
|| sanitizeFragment(context.customWorldProfile?.name, 4)
|| sanitizeFragment(resolveAnchorLabel(plan.relationAnchor), 4)
|| sanitizeFragment(anchorLabel, 4)
|| '旧誓';
const functionalBias: RuntimeItemAiIntent['desiredFunctionalBias'] = [];
@@ -94,10 +95,12 @@ export function buildRuntimeItemAiIntent(
return {
shortNameSeed: sourceSeed,
sourcePhrase: resolveAnchorLabel(plan.relationAnchor),
reasonToAppear: `${resolveAnchorLabel(plan.relationAnchor)}与最近局势把它推到了你面前。`,
sourcePhrase: anchorLabel,
reasonToAppear: context.generationChannel === 'monster_drop'
? `${anchorLabel}倒下后,${context.sceneName ?? '这片战场'}里最值得带走的残留被翻了出来。`
: `${anchorLabel}与最近局势把它推到了你面前。`,
relationHooks: [
context.encounterContextText ?? context.sceneName ?? resolveAnchorLabel(plan.relationAnchor),
context.encounterContextText ?? context.sceneName ?? anchorLabel,
...context.recentActions,
].filter(Boolean).slice(0, 2) as string[],
desiredBuildTags: [...new Set([
@@ -114,16 +117,16 @@ export function buildRuntimeItemAiIntent(
: 'martial',
visibleClue:
context.relatedNpcNarrativeProfile?.visibleLine
?? `${resolveAnchorLabel(plan.relationAnchor)}身上留下的旧痕`,
?? `${anchorLabel}身上留下的旧痕`,
witnessMark:
context.relatedNpcNarrativeProfile?.debtOrBurden
?? `${resolveAnchorLabel(plan.relationAnchor)}尚未散尽的使用痕`,
?? `${anchorLabel}尚未散尽的使用痕`,
unfinishedBusiness:
context.relatedNpcNarrativeProfile?.contradiction
?? `${resolveAnchorLabel(plan.relationAnchor)}背后还有没说完的问题`,
?? `${anchorLabel}背后还有没说完的问题`,
hiddenHook:
context.relatedNpcNarrativeProfile?.taboo
?? `${resolveAnchorLabel(plan.relationAnchor)}为什么会在此刻重新出现`,
?? `${anchorLabel}为什么会在此刻重新出现`,
reactionHooks: [
...(context.relatedNpcNarrativeProfile?.reactionHooks ?? []),
...(context.activeThreadIds ?? []),
@@ -133,6 +136,8 @@ export function buildRuntimeItemAiIntent(
? 'quest_evidence'
: plan.itemKind === 'material'
? 'scene_relic'
: plan.relationAnchor.type === 'monster'
? 'monster_trophy'
: plan.relationAnchor.type === 'npc'
? 'npc_relic'
: 'faction_issue',
@@ -165,6 +170,38 @@ export function applyRuntimeItemNarrative(params: {
} satisfies InventoryItem;
}
export function applyRuntimeItemNarrativeToExistingItem(params: {
item: InventoryItem;
context: RuntimeItemGenerationContext;
plan: RuntimeItemPlan;
intent: RuntimeItemAiIntent;
preserveName?: boolean;
}) {
const fingerprint = buildRuntimeItemStoryFingerprint(params);
const runtimeMetadata =
params.item.runtimeMetadata ?? {
origin: 'procedural' as const,
generationChannel: params.context.generationChannel,
relationAnchor: params.plan.relationAnchor,
seedKey: `${params.context.generationChannel}:${params.item.id}`,
sourceReason: params.intent.reasonToAppear,
};
return {
...params.item,
name: params.preserveName
? params.item.name
: buildCarrierNarrativeName(params),
description: buildCarrierNarrativeDescription(params),
runtimeMetadata: {
...runtimeMetadata,
relationAnchor: runtimeMetadata.relationAnchor ?? params.plan.relationAnchor,
sourceReason: params.intent.reasonToAppear,
storyFingerprint: fingerprint,
},
} satisfies InventoryItem;
}
export function describeRuntimeRelationAnchor(anchor: RuntimeRelationAnchor | undefined) {
if (!anchor) return '无明确锚点';
return `${anchor.type}:${resolveAnchorLabel(anchor)}`;

View File

@@ -1,8 +1,12 @@
import fs from 'node:fs';
import path from 'node:path';
import { WorldType } from '../types';
import { getDefaultCustomWorldSceneImage } from './customWorldVisuals';
import { CustomWorldProfile, WorldType } from '../types';
import {
getDefaultCustomWorldSceneImage,
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImageMap,
} from './customWorldVisuals';
import { getScenePresetsByWorld } from './scenePresets';
function resolvePublicAssetPath(assetPath: string) {
@@ -30,4 +34,88 @@ describe('scene background assets', () => {
expect(fs.existsSync(resolvePublicAssetPath(wuxiaImage))).toBe(true);
expect(fs.existsSync(resolvePublicAssetPath(xianxiaImage))).toBe(true);
});
it('keeps ungenerated custom world scenes on independent matched backgrounds', () => {
const generatedImage =
'/generated-custom-world-scenes/test-world/generated-ruins.png';
const profile: CustomWorldProfile = {
id: 'custom-world-test',
settingText: '荒城断碑与边关旧营并存的武侠世界',
name: '断碑边城',
subtitle: '烽烟未熄',
summary: '边关旧营与残城废墟彼此相望,玩家要追查旧案余烬。',
tone: '压抑、克制、潜伏危机',
playerGoal: '追查残城旧案背后的真相',
templateWorldType: WorldType.WUXIA,
majorFactions: [],
coreConflicts: ['边关旧案复起'],
attributeSchema: {
id: 'schema:test',
worldId: 'custom:test',
schemaVersion: 1,
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: '断碑边城',
settingSummary: '边关旧案',
tone: '压抑',
conflictCore: '旧案复起',
},
slots: [],
},
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [
{
id: 'landmark-1',
name: '残城旧营',
description: '断墙、军帐与营火灰烬混在一起,像一处被遗弃的边关驻地。',
dangerLevel: 'high',
imageSrc: generatedImage,
sceneNpcIds: [],
connections: [],
},
{
id: 'landmark-2',
name: '雾锁渡桥',
description: '古桥横跨冷河,雾气压在水面上,只有残灯还在摇晃。',
dangerLevel: 'medium',
sceneNpcIds: [],
connections: [],
},
{
id: 'landmark-3',
name: '地宫裂隙',
description: '墓道向下坍塌,石阶与机关残痕一路通往地底深处。',
dangerLevel: 'extreme',
sceneNpcIds: [],
connections: [],
},
],
themePack: null,
storyGraph: null,
creatorIntent: null,
anchorPack: null,
lockState: null,
generationMode: 'full',
generationStatus: 'complete',
};
const landmarkImageMap = resolveCustomWorldLandmarkImageMap(profile);
const secondImage = landmarkImageMap.get('landmark-2');
const thirdImage = landmarkImageMap.get('landmark-3');
const campImage = resolveCustomWorldCampSceneImage(profile);
expect(landmarkImageMap.get('landmark-1')).toBe(generatedImage);
expect(secondImage).toBeTruthy();
expect(thirdImage).toBeTruthy();
expect(secondImage).not.toBe(generatedImage);
expect(thirdImage).not.toBe(generatedImage);
expect(secondImage).not.toBe(thirdImage);
expect(campImage).toBeTruthy();
expect(campImage).not.toBe(generatedImage);
expect(fs.existsSync(resolvePublicAssetPath(secondImage!))).toBe(true);
expect(fs.existsSync(resolvePublicAssetPath(thirdImage!))).toBe(true);
expect(fs.existsSync(resolvePublicAssetPath(campImage))).toBe(true);
});
});

View File

@@ -177,6 +177,7 @@ describe('scenePresets custom world npc mapping', () => {
expect(scene).toBeTruthy();
expect(npc).toBeTruthy();
expect(npc?.characterId).toBe(npc?.id);
expect(npc?.title).toBe('潮路领航人');
expect(npc?.backstory).toContain('断桥坠潮夜');
expect(npc?.personality).toContain('谨慎冷静');

View File

@@ -1,5 +1,5 @@
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import { buildCustomCampSceneName } from '../services/customWorldPresentation';
import { resolveCustomWorldAnchorWorldType } from '../services/customWorldTheme';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
@@ -26,7 +26,11 @@ import {
} from './characterPresets';
import { resolveCustomWorldNpcMonsterPreset } from './customWorldNpcMonsters';
import { getRuntimeCustomWorldProfile, resolveRuleWorldType } from './customWorldRuntime';
import { getMonsterPresetById } from './hostileNpcPresets';
import {
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImageMap,
} from './customWorldVisuals';
import { getMonsterPresetById, getMonsterPresetsByWorld } from './hostileNpcPresets';
import sceneNpcOverridesJson from './sceneNpcOverrides.json';
import sceneOverridesJson from './sceneOverrides.json';
@@ -124,18 +128,6 @@ function collectWorldImagePool(worldType: WorldType, requiredCount: number) {
return refs;
}
function collectAllImagePool() {
const refs: string[] = [];
for (const pack of PACKS) {
for (let imageNumber = 1; imageNumber <= pack.count; imageNumber += 1) {
refs.push(buildImagePath(pack.packName, imageNumber));
}
}
return refs;
}
function uniqueStrings(values: string[]) {
return [...new Set(values.filter(Boolean))];
}
@@ -305,7 +297,6 @@ export function buildEncounterFromSceneNpc(
function buildCustomSceneNpc(
npc: CustomWorldProfile['storyNpcs'][number],
profile: CustomWorldProfile,
anchorWorldType: WorldType,
): SceneNpc {
const themePack = profile.themePack ?? buildThemePackFromWorldProfile(profile);
const storyGraph =
@@ -316,7 +307,7 @@ function buildCustomSceneNpc(
);
const monsterPreset =
npc.initialAffinity < 0
? resolveCustomWorldNpcMonsterPreset(npc, anchorWorldType)
? resolveCustomWorldNpcMonsterPreset(npc)
: null;
const hostile = npc.initialAffinity < 0 || Boolean(monsterPreset);
const attributeProfile = monsterPreset?.attributeProfile
@@ -339,6 +330,7 @@ function buildCustomSceneNpc(
return {
id: npc.id,
characterId: npc.id,
name: npc.name,
title: npc.title,
role: npc.role,
@@ -384,11 +376,10 @@ function buildCustomSceneId(kind: 'camp' | 'landmark', index = 0) {
}
function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
const allImages = collectAllImagePool();
const imageOffset = hashText(profile.id || profile.name) % Math.max(1, allImages.length);
const anchorWorldType = resolveCustomWorldAnchorWorldType(profile);
const baseMonsterPool: string[] = getScenePresetsByWorld(anchorWorldType)
.flatMap((scene: ScenePreset) => getSceneHostileNpcPresetIds(scene))
const campSceneProfile = resolveCustomWorldCampScene(profile);
const landmarkImageMap = resolveCustomWorldLandmarkImageMap(profile);
const baseMonsterPool: string[] = getMonsterPresetsByWorld(WorldType.CUSTOM)
.map((monster) => monster.id)
.filter((monsterId: string, index: number, array: string[]) => array.indexOf(monsterId) === index);
const fallbackMonsterIds: string[] = baseMonsterPool.length > 0 ? baseMonsterPool : [];
const playableCharacters = buildCustomWorldPlayableCharacters(profile);
@@ -423,16 +414,16 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
sceneId: landmarkSceneIds[index] ?? '',
relativePosition:
index === 0 ? 'forward' : index === 1 ? 'left' : 'right',
summary: `营地可直接通往${landmark.name}`,
summary: `${campSceneProfile.name}可直接通往${landmark.name}`,
}))
.filter((connection) => connection.sceneId) as SceneConnectionInfo[];
const customScenes: ScenePreset[] = [
{
id: campSceneId,
name: buildCustomCampSceneName(profile),
description: `你在${profile.name}的临时营地整备行装。${profile.summary}`,
description: campSceneProfile.description,
worldType: WorldType.CUSTOM,
imageSrc: profile.landmarks[0]?.imageSrc ?? allImages[imageOffset] ?? '',
imageSrc: resolveCustomWorldCampSceneImage(profile),
connectedSceneIds: campConnections.map((connection) => connection.sceneId),
connections: campConnections,
forwardSceneId: pickForwardSceneIdFromConnections(campConnections),
@@ -452,7 +443,7 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
.map((npcId) => customStoryNpcById.get(npcId))
.filter(Boolean)
.map((npc) =>
buildCustomSceneNpc(npc!, profile, anchorWorldType),
buildCustomSceneNpc(npc!, profile),
);
if (sceneNpcs.length < 3) {
profile.storyNpcs
@@ -461,7 +452,7 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
)
.slice(0, 3 - sceneNpcs.length)
.forEach((npc) =>
sceneNpcs.push(buildCustomSceneNpc(npc, profile, anchorWorldType)),
sceneNpcs.push(buildCustomSceneNpc(npc, profile)),
);
}
const landmarkConnections = landmark.connections
@@ -489,7 +480,7 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
? ({
sceneId: campSceneId,
relativePosition: 'back',
summary: '可回到临时营地整备',
summary: `可回到${campSceneProfile.name}整备`,
} satisfies SceneConnectionInfo)
: null;
const connections = [
@@ -502,7 +493,7 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
const monsterSliceStart = (index * 2) % Math.max(1, fallbackMonsterIds.length || 1);
const seedMonsterIds: string[] = fallbackMonsterIds.slice(monsterSliceStart, monsterSliceStart + 2);
const hostileNpcs = seedMonsterIds
.map((monsterId: string) => buildHostileSceneNpc(buildCustomSceneId('landmark', index), anchorWorldType, monsterId))
.map((monsterId: string) => buildHostileSceneNpc(buildCustomSceneId('landmark', index), WorldType.CUSTOM, monsterId))
.filter(Boolean) as SceneNpc[];
const combinedNpcs = [...sceneNpcs, ...hostileNpcs];
@@ -511,7 +502,7 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
name: landmark.name,
description: landmark.description,
worldType: WorldType.CUSTOM,
imageSrc: landmark.imageSrc ?? allImages[(imageOffset + index + 1) % Math.max(1, allImages.length)] ?? '',
imageSrc: landmarkImageMap.get(landmark.id) ?? '',
connectedSceneIds,
connections,
forwardSceneId: pickForwardSceneIdFromConnections(connections),
@@ -1090,6 +1081,3 @@ export function buildSceneEntityCatalogText(worldType: WorldType, sceneId: strin
`当前场景残痕:${residueText}`,
].join('\n');
}

View File

@@ -145,6 +145,7 @@ describe('escapeFlow', () => {
expect(resolved.inBattle).toBe(false);
expect(resolved.currentEncounter).toBeNull();
expect(resolved.currentBattleNpcId).toBeNull();
expect(resolved.sceneHostileNpcs).toEqual([]);
expect(resolved.playerFacing).toBe('right');
expect(resolved.scrollWorld).toBe(false);
expect(resolved.playerX).toBeLessThan(0.2);
@@ -185,4 +186,3 @@ describe('escapeFlow', () => {
expect(result.playerFacing).toBe('right');
});
});

View File

@@ -54,7 +54,7 @@ export function buildEscapeAfterSequence(
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sceneHostileNpcs: resetCombatPresentation(state.sceneHostileNpcs, escapePlayerX),
sceneHostileNpcs: [],
playerX: escapePlayerX,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,

View File

@@ -210,6 +210,7 @@ describe('buildResolvedChoiceState', () => {
expect(resolved.battlePlan).toBeNull();
expect(resolved.afterSequence.inBattle).toBe(false);
expect(resolved.afterSequence.currentEncounter).toBeNull();
expect(resolved.afterSequence.sceneHostileNpcs).toEqual([]);
expect(resolved.afterSequence.playerFacing).toBe('right');
});
@@ -232,4 +233,3 @@ describe('buildResolvedChoiceState', () => {
expect(resolved.afterSequence.currentEncounter).toBeNull();
});
});

View File

@@ -253,8 +253,22 @@ describe('createStoryChoiceActions', () => {
const afterSequence = {
...state,
inBattle: false,
sceneHostileNpcs: [],
playerX: -1.2,
};
const setBattleReward = vi.fn();
const incrementRuntimeStats = vi.fn((inputState: GameState) => inputState);
const buildStoryContextFromState = vi.fn(() => ({
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
inBattle: false,
playerX: -1.2,
playerFacing: 'right' as const,
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
}));
const { handleChoice } = createStoryChoiceActions({
gameState: state,
@@ -264,24 +278,14 @@ describe('createStoryChoiceActions', () => {
setCurrentStory: vi.fn(),
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
setBattleReward,
buildResolvedChoiceState: vi.fn(() => ({
optionKind: 'escape' as const,
battlePlan: null,
afterSequence,
})),
playResolvedChoice: vi.fn().mockResolvedValue(afterSequence),
buildStoryContextFromState: vi.fn(() => ({
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
inBattle: false,
playerX: -1.2,
playerFacing: 'right',
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
})),
buildStoryContextFromState,
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState: vi.fn(),
@@ -290,7 +294,7 @@ describe('createStoryChoiceActions', () => {
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats,
getCampCompanionTravelScene: vi.fn(() => null),
startOpeningAdventure: vi.fn(),
enterNpcInteraction: vi.fn(() => false),
@@ -314,7 +318,23 @@ describe('createStoryChoiceActions', () => {
const history = mockedGenerateNextStep.mock.calls[0]?.[3] as StoryMoment[];
expect(history.map((entry) => `${entry.historyRole}:${entry.text}`)).toEqual([
'action:挥刀抢攻',
'result:你已经摆脱与山狼的交战,暂时把对方甩在身后,当前不再处于战斗状态。',
'result:你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。',
]);
expect(buildStoryContextFromState).toHaveBeenCalledWith(
expect.objectContaining({
inBattle: false,
sceneHostileNpcs: [],
}),
expect.objectContaining({
lastFunctionId: 'battle_escape_breakout',
recentActionResult: '你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。',
}),
);
expect(setBattleReward).toHaveBeenCalledTimes(1);
expect(setBattleReward).toHaveBeenCalledWith(null);
expect(incrementRuntimeStats).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ hostileNpcsDefeated: 0 }),
);
});
});

View File

@@ -61,7 +61,11 @@ type BuildNpcStory = (
type BuildStoryContextFromState = (
state: GameState,
extras?: { lastFunctionId?: string | null; observeSignsRequested?: boolean },
extras?: {
lastFunctionId?: string | null;
observeSignsRequested?: boolean;
recentActionResult?: string | null;
},
) => StoryGenerationContext;
type UpdateQuestLog = (
@@ -111,8 +115,8 @@ function buildCombatResolutionContextText(params: {
.map((hostileNpc) => hostileNpc.name)
.join('、');
return hostileNames
? `你已经摆脱${hostileNames}的交战,暂时把对方甩在身后,当前不再处于战斗状态。`
: '你已成功脱刚才的交战,当前不再处于战斗状态。';
? `你已成功逃脱,${hostileNames}的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。`
: '你已成功脱刚才的交战,当前不再处于战斗状态。';
}
if (
@@ -136,12 +140,19 @@ function buildCombatResolutionContextText(params: {
: `眼前这一轮交战已经结束,当前不再处于战斗状态。${lootText}`;
}
function buildHostileNpcBattleReward(
async function buildHostileNpcBattleReward(
state: GameState,
afterSequence: GameState,
optionKind: ResolvedChoiceState['optionKind'],
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'],
): BattleRewardSummary | null {
if (!state.worldType || state.currentBattleNpcId || !state.inBattle || afterSequence.inBattle) {
): Promise<BattleRewardSummary | null> {
if (
optionKind === 'escape'
|| !state.worldType
|| state.currentBattleNpcId
|| !state.inBattle
|| afterSequence.inBattle
) {
return null;
}
@@ -155,7 +166,7 @@ function buildHostileNpcBattleReward(
return null;
}
const rolledItems = rollHostileNpcLoot(
const rolledItems = await rollHostileNpcLoot(
state,
defeatedHostileNpcs.map(hostileNpc => ({
id: hostileNpc.id,
@@ -424,7 +435,12 @@ export function createStoryChoiceActions({
);
const projectedBattleReward = shouldUseLocalNpcVictory
? null
: buildHostileNpcBattleReward(baseChoiceState, projectedState, getResolvedSceneHostileNpcs);
: await buildHostileNpcBattleReward(
baseChoiceState,
projectedState,
resolvedChoice.optionKind,
getResolvedSceneHostileNpcs,
);
const projectedStateWithBattleReward = projectedBattleReward
? appendStoryEngineCarrierMemory({
...projectedState,
@@ -462,6 +478,7 @@ export function createStoryChoiceActions({
buildStoryContextFromState(projectedStateWithBattleReward, {
lastFunctionId: option.functionId,
observeSignsRequested: option.functionId === 'idle_observe_signs',
recentActionResult: combatResolutionContextText,
}),
projectedAvailableOptions ? { availableOptions: projectedAvailableOptions } : undefined,
);
@@ -548,7 +565,7 @@ export function createStoryChoiceActions({
}
const response = responseResult.value!;
const defeatedHostileNpcIds = baseChoiceState.currentBattleNpcId
const defeatedHostileNpcIds = baseChoiceState.currentBattleNpcId || resolvedChoice.optionKind === 'escape'
? []
: getResolvedSceneHostileNpcs(baseChoiceState)
.map(hostileNpc => hostileNpc.id)

View File

@@ -161,16 +161,17 @@ describe('sessionActions', () => {
});
it('applies quest rewards to currency, inventory, and issuer affinity in one state transition', () => {
const nextState = applyQuestRewardClaim(createBaseState(), 'quest-1');
const rewardClaim = applyQuestRewardClaim(createBaseState(), 'quest-1');
expect(nextState).not.toBeNull();
if (!nextState) {
expect(rewardClaim).not.toBeNull();
if (!rewardClaim) {
throw new Error('Expected quest reward claim state');
}
expect(nextState.quests[0]?.status).toBe('turned_in');
expect(nextState.playerCurrency).toBe(17);
expect(nextState.playerInventory.find(item => item.id === 'reward-herb')?.quantity).toBe(2);
expect(nextState.npcStates['npc-trader']?.affinity).toBe(7);
expect(rewardClaim.nextState.quests[0]?.status).toBe('turned_in');
expect(rewardClaim.nextState.playerCurrency).toBe(17);
expect(rewardClaim.nextState.playerInventory.find((item) => item.id === 'reward-herb')?.quantity).toBe(2);
expect(rewardClaim.nextState.npcStates['npc-trader']?.affinity).toBe(7);
expect(rewardClaim).toHaveProperty('handoff');
});
});

View File

@@ -10,6 +10,7 @@ import {
markQuestTurnedIn,
} from '../../data/questFlow';
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
import { buildGoalHandoffFromState } from '../../services/storyEngine/goalDirector';
import type {
GameState,
StoryMoment,
@@ -36,15 +37,18 @@ export function acknowledgeQuestCompletionState(
export function applyQuestRewardClaim(
state: GameState,
questId: string,
): GameState | null {
): {
nextState: GameState;
handoff: ReturnType<typeof buildGoalHandoffFromState>;
} | null {
const quest = findQuestById(state.quests, questId);
if (!quest || quest.status !== 'completed') {
if (!quest || (quest.status !== 'completed' && quest.status !== 'ready_to_turn_in')) {
return null;
}
const issuerNpcState = state.npcStates[quest.issuerNpcId];
return appendStoryEngineCarrierMemory({
const nextState = appendStoryEngineCarrierMemory({
...state,
quests: markQuestTurnedIn(state.quests, questId),
playerCurrency: state.playerCurrency + quest.reward.currency,
@@ -59,6 +63,11 @@ export function applyQuestRewardClaim(
}
: state.npcStates,
}, quest.reward.items);
return {
nextState,
handoff: buildGoalHandoffFromState(nextState),
};
}
export function createStorySessionActions({
@@ -83,13 +92,16 @@ export function createStorySessionActions({
};
const claimQuestReward = (questId: string) => {
const nextState = applyQuestRewardClaim(gameState, questId);
if (!nextState) {
return false;
const rewardClaim = applyQuestRewardClaim(gameState, questId);
if (!rewardClaim) {
return null;
}
setGameState(nextState);
return true;
setGameState(rewardClaim.nextState);
return {
questId,
handoff: rewardClaim.handoff,
};
};
const resetStoryState = () => {

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { AnimationState, type StoryOption } from '../../types';
import { resolveStoryResponseOptions } from './storyResponseOptions';
function createOption(
functionId: string,
actionText: string,
priority = 0,
): StoryOption {
return {
functionId,
actionText,
text: actionText,
priority,
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
};
}
describe('storyResponseOptions', () => {
it('keeps rewritten actionText when camp companion follow-up uses available options', () => {
const availableOptions = [
createOption('npc_chat', '先聊聊营地安排', 3),
createOption('npc_gift', '把旧礼物递给你', 2),
createOption('camp_travel_home_scene', '前往旧地点', 1),
];
const responseOptions = [
createOption('npc_chat', '顺着你刚才的话继续问下去', 3),
createOption('npc_gift', '把刚挑好的礼物正式交给你', 2),
createOption('camp_travel_home_scene', '前往云河渡', 1),
];
const resolved = resolveStoryResponseOptions({
responseOptions,
availableOptions,
getSanitizedOptions: () => {
throw new Error('available options branch should not sanitize');
},
});
expect(resolved.map((option) => option.actionText)).toEqual([
'顺着你刚才的话继续问下去',
'把刚挑好的礼物正式交给你',
'前往云河渡',
]);
});
it('falls back to available options when the response omits them entirely', () => {
const availableOptions = [
createOption('npc_chat', '继续交谈', 2),
createOption('camp_travel_home_scene', '前往山门', 1),
];
const resolved = resolveStoryResponseOptions({
responseOptions: [],
availableOptions,
getSanitizedOptions: () => {
throw new Error('available options fallback should not sanitize');
},
});
expect(resolved.map((option) => option.actionText)).toEqual([
'继续交谈',
'前往山门',
]);
});
});

View File

@@ -0,0 +1,30 @@
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
import type { StoryOption } from '../../types';
type ResolveStoryResponseOptionsParams = {
responseOptions: StoryOption[];
availableOptions?: StoryOption[] | null;
optionCatalog?: StoryOption[] | null;
getSanitizedOptions: () => StoryOption[];
};
export function resolveStoryResponseOptions({
responseOptions,
availableOptions = null,
optionCatalog = null,
getSanitizedOptions,
}: ResolveStoryResponseOptionsParams) {
if (availableOptions) {
return sortStoryOptionsByPriority(
responseOptions.length > 0 ? responseOptions : availableOptions,
);
}
if (optionCatalog) {
return sortStoryOptionsByPriority(
responseOptions.length > 0 ? responseOptions : optionCatalog,
);
}
return sortStoryOptionsByPriority(getSanitizedOptions());
}

View File

@@ -1,5 +1,8 @@
import type {
Encounter,
GoalHandoff,
GoalPulseEvent,
GoalStackState,
InventoryItem,
} from '../../types';
@@ -72,7 +75,16 @@ export interface InventoryFlowUi {
export interface QuestFlowUi {
acknowledgeQuestCompletion: (questId: string) => void;
claimQuestReward: (questId: string) => boolean;
claimQuestReward: (questId: string) => {
questId: string;
handoff: GoalHandoff | null;
} | null;
}
export interface GoalFlowUi {
goalStack: GoalStackState;
pulse: GoalPulseEvent | null;
dismissPulse: () => void;
}
export interface BattleRewardSummary {

View File

@@ -18,6 +18,10 @@ function scheduleTone(
detune?: number;
},
) {
if (context.state === 'closed') {
return;
}
const oscillator = context.createOscillator();
const gainNode = context.createGain();
const attack = options.attack ?? 0.05;
@@ -98,6 +102,11 @@ export function useBackgroundMusic({
const AudioContextCtor = window.AudioContext ?? (window as AudioWindow).webkitAudioContext;
if (!AudioContextCtor) return null;
if (contextRef.current?.state === 'closed') {
contextRef.current = null;
masterGainRef.current = null;
}
if (!contextRef.current) {
contextRef.current = new AudioContextCtor();
}
@@ -123,6 +132,11 @@ export function useBackgroundMusic({
}
const { context, masterGain } = graph;
if (context.state === 'closed') {
stopLoop();
return;
}
const progression = [
[220, 277.18, 329.63],
[246.94, 311.13, 369.99],
@@ -208,8 +222,12 @@ export function useBackgroundMusic({
masterGainRef.current.gain.value = 0.0001;
}
if (contextRef.current) {
void contextRef.current.close();
const context = contextRef.current;
contextRef.current = null;
masterGainRef.current = null;
if (context && context.state !== 'closed') {
void context.close();
}
}, [stopLoop]);
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import {
buildCustomWorldPlayableCharacters,
buildCustomWorldRuntimeCharacters,
createCharacterSkillCooldowns,
getCharacterMaxHp,
getCharacterMaxMana,
@@ -110,7 +110,7 @@ export function useGameFlow() {
useEffect(() => {
setRuntimeCustomWorldProfile(gameState.customWorldProfile);
setRuntimeCharacterOverrides(
gameState.customWorldProfile ? buildCustomWorldPlayableCharacters(gameState.customWorldProfile) : null,
gameState.customWorldProfile ? buildCustomWorldRuntimeCharacters(gameState.customWorldProfile) : null,
);
}, [gameState.customWorldProfile]);
@@ -124,7 +124,7 @@ export function useGameFlow() {
const resolvedWorldType = customWorldProfile ? WorldType.CUSTOM : type;
setRuntimeCustomWorldProfile(customWorldProfile);
setRuntimeCharacterOverrides(
customWorldProfile ? buildCustomWorldPlayableCharacters(customWorldProfile) : null,
customWorldProfile ? buildCustomWorldRuntimeCharacters(customWorldProfile) : null,
);
const initialScenePreset = getScenePreset(resolvedWorldType, 0) ?? null;
setIsMapOpen(false);

View File

@@ -0,0 +1,209 @@
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {getCharacterById} from '../data/characterPresets';
import {AnimationState, type Character, type GameState,WorldType} from '../types';
import {buildCompanionRenderStatesForGameState} from './useNpcInteractionFlow';
vi.mock('../data/characterPresets', () => ({
getCharacterById: vi.fn(),
}));
function createTestCharacter(id: string, name: string): Character {
return {
id,
name,
title: '测试同伴',
description: '用于测试的角色',
backstory: '测试背景',
avatar: '/test-avatar.png',
portrait: '/test-portrait.png',
assetFolder: 'test-character',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 10,
intelligence: 10,
spirit: 10,
},
personality: 'steady',
skills: [
{
id: 'basic-strike',
name: '试探一击',
animation: AnimationState.ATTACK,
damage: 10,
manaCost: 0,
cooldownTurns: 0,
range: 1,
style: 'steady',
},
],
adventureOpenings: {},
};
}
function createBaseState(): GameState {
return {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: createTestCharacter('player', '主角'),
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
}
describe('buildCompanionRenderStatesForGameState', () => {
beforeEach(() => {
vi.mocked(getCharacterById).mockReset();
});
it('builds render states from the provided transition snapshot', () => {
const companionCharacter = createTestCharacter('companion-a', '阿青');
vi.mocked(getCharacterById).mockImplementation((characterId: string) => {
if (characterId === companionCharacter.id || characterId === 'player') {
return companionCharacter;
}
return null;
});
const transitionSnapshot: GameState = {
...createBaseState(),
playerFacing: 'left',
animationState: AnimationState.ATTACK,
companions: [
{
npcId: 'npc-aqing',
characterId: companionCharacter.id,
joinedAtAffinity: 10,
hp: 36,
maxHp: 48,
mana: 12,
maxMana: 18,
skillCooldowns: {basicStrike: 1},
offsetX: 14,
offsetY: -6,
transitionMs: 90,
},
],
};
const renderStates = buildCompanionRenderStatesForGameState({
gameState: transitionSnapshot,
presentationByNpcId: {
'npc-aqing': {
animationState: AnimationState.ACQUIRE,
entryOffsetX: 28,
entryOffsetY: 12,
transitionMs: 240,
recruitToken: 42,
},
},
observeFacingByNpcId: {
'npc-aqing': 'right',
},
});
expect(renderStates).toHaveLength(1);
expect(renderStates[0]).toMatchObject({
npcId: 'npc-aqing',
character: companionCharacter,
hp: 36,
maxHp: 48,
mana: 12,
maxMana: 18,
animationState: AnimationState.ACQUIRE,
slot: 'upper',
facing: 'right',
entryOffsetX: 42,
entryOffsetY: 6,
transitionMs: 240,
recruitToken: 42,
});
});
it('lets callers render a visible snapshot even if the live state already changed', () => {
const companionCharacter = createTestCharacter('companion-b', '小舟');
vi.mocked(getCharacterById).mockImplementation((characterId: string) => {
if (characterId === companionCharacter.id || characterId === 'player') {
return companionCharacter;
}
return null;
});
const visibleSnapshot: GameState = {
...createBaseState(),
scrollWorld: true,
companions: [
{
npcId: 'npc-xiaozhou',
characterId: companionCharacter.id,
joinedAtAffinity: 18,
hp: 30,
maxHp: 30,
mana: 9,
maxMana: 12,
skillCooldowns: {},
},
],
};
const liveState: GameState = {
...createBaseState(),
companions: [],
};
const visibleRenderStates = buildCompanionRenderStatesForGameState({
gameState: visibleSnapshot,
});
const liveRenderStates = buildCompanionRenderStatesForGameState({
gameState: liveState,
});
expect(visibleRenderStates).toHaveLength(1);
expect(visibleRenderStates[0]?.animationState).toBe(AnimationState.RUN);
expect(liveRenderStates).toHaveLength(0);
});
});

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { getCharacterAnimationDurationMs } from '../data/characterCombat';
import { getCharacterById } from '../data/characterPresets';
@@ -12,6 +12,9 @@ type CompanionRecruitPresentation = {
recruitToken: number;
};
type CompanionPresentationMap = Record<string, CompanionRecruitPresentation>;
type CompanionObserveFacingMap = Record<string, 'left' | 'right'>;
const RECRUIT_ENTRY_OFFSET_X = 148;
const RECRUIT_ENTRY_OFFSET_Y = 10;
const MIN_RECRUIT_PHASE_MS = 180;
@@ -26,6 +29,53 @@ function randomObservePause() {
return Math.round(OBSERVE_MIN_PAUSE_MS + Math.random() * (OBSERVE_MAX_PAUSE_MS - OBSERVE_MIN_PAUSE_MS));
}
export function buildCompanionRenderStatesForGameState(params: {
gameState: GameState;
presentationByNpcId?: CompanionPresentationMap;
observeFacingByNpcId?: CompanionObserveFacingMap;
}) {
const {
gameState,
presentationByNpcId = {},
observeFacingByNpcId = {},
} = params;
return (gameState.companions ?? [])
.map((companion, index) => {
const character = getCharacterById(companion.characterId);
if (!character) return null;
const presentation = presentationByNpcId[companion.npcId];
return {
npcId: companion.npcId,
character,
hp: companion.hp,
maxHp: companion.maxHp,
mana: companion.mana,
maxMana: companion.maxMana,
skillCooldowns: companion.skillCooldowns,
animationState: presentation?.animationState ?? (
companion.hp <= 0
? companion.animationState ?? AnimationState.DIE
: gameState.scrollWorld
? AnimationState.RUN
: gameState.inBattle
? companion.animationState ?? AnimationState.IDLE
: gameState.animationState
),
actionMode: companion.actionMode ?? 'idle',
slot: index % 2 === 0 ? 'upper' : 'lower',
facing: observeFacingByNpcId[companion.npcId] ?? gameState.playerFacing,
entryOffsetX: (presentation?.entryOffsetX ?? 0) + (companion.offsetX ?? 0),
entryOffsetY: (presentation?.entryOffsetY ?? 0) + (companion.offsetY ?? 0),
transitionMs: presentation?.transitionMs ?? companion.transitionMs ?? 0,
recruitToken: presentation?.recruitToken,
} satisfies CompanionRenderState;
})
.filter(Boolean) as CompanionRenderState[];
}
export function useNpcInteractionFlow(gameState: GameState) {
const [presentationByNpcId, setPresentationByNpcId] = useState<Record<string, CompanionRecruitPresentation>>({});
const [observeFacingByNpcId, setObserveFacingByNpcId] = useState<Record<string, 'left' | 'right'>>({});
@@ -216,42 +266,16 @@ export function useNpcInteractionFlow(gameState: GameState) {
});
}, [gameState.ambientIdleMode, gameState.companions]);
const companionRenderStates: CompanionRenderState[] = (gameState.companions ?? [])
.map((companion, index) => {
const character = getCharacterById(companion.characterId);
if (!character) return null;
const buildCompanionRenderStates = useCallback((state: GameState) => buildCompanionRenderStatesForGameState({
gameState: state,
presentationByNpcId,
observeFacingByNpcId,
}), [observeFacingByNpcId, presentationByNpcId]);
const presentation = presentationByNpcId[companion.npcId];
return {
npcId: companion.npcId,
character,
hp: companion.hp,
maxHp: companion.maxHp,
mana: companion.mana,
maxMana: companion.maxMana,
skillCooldowns: companion.skillCooldowns,
animationState: presentation?.animationState ?? (
companion.hp <= 0
? companion.animationState ?? AnimationState.DIE
: gameState.scrollWorld
? AnimationState.RUN
: gameState.inBattle
? companion.animationState ?? AnimationState.IDLE
: gameState.animationState
),
actionMode: companion.actionMode ?? 'idle',
slot: index % 2 === 0 ? 'upper' : 'lower',
facing: observeFacingByNpcId[companion.npcId] ?? gameState.playerFacing,
entryOffsetX: (presentation?.entryOffsetX ?? 0) + (companion.offsetX ?? 0),
entryOffsetY: (presentation?.entryOffsetY ?? 0) + (companion.offsetY ?? 0),
transitionMs: presentation?.transitionMs ?? companion.transitionMs ?? 0,
recruitToken: presentation?.recruitToken,
};
})
.filter(Boolean) as CompanionRenderState[];
const companionRenderStates = buildCompanionRenderStates(gameState);
return {
companionRenderStates,
buildCompanionRenderStates,
};
}

View File

@@ -1,5 +1,5 @@
import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
getCharacterAdventureOpening,
@@ -43,6 +43,7 @@ import {
} from '../data/stateFunctions';
import { applyStoryReasoningRecovery } from '../data/storyRecovery';
import { generateInitialStory, generateNextStep } from '../services/ai';
import { hasMixedNarrativeLanguage } from '../services/narrativeLanguage';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
@@ -62,6 +63,11 @@ import {
buildCompanionArcStates,
} from '../services/storyEngine/companionArcDirector';
import { syncNpcNarrativeState } from '../services/storyEngine/echoMemory';
import {
buildGoalStackState,
createGoalPulseSnapshot,
deriveGoalPulseEvent,
} from '../services/storyEngine/goalDirector';
import { resolveCurrentJourneyBeat } from '../services/storyEngine/journeyBeatPlanner';
import { buildVisibilitySliceFromFacts } from '../services/storyEngine/knowledgeContract';
import { buildKnowledgeGraph } from '../services/storyEngine/knowledgeGraph';
@@ -115,9 +121,11 @@ import {
} from './story/progressionActions';
import { createStorySessionActions } from './story/sessionActions';
import { resolveNpcInteractionDecision } from './story/storyGenerationState';
import { resolveStoryResponseOptions } from './story/storyResponseOptions';
import type {
BattleRewardSummary,
BattleRewardUi,
GoalFlowUi,
QuestFlowUi,
} from './story/uiTypes';
import { useStoryOptions } from './useStoryOptions';
@@ -143,6 +151,7 @@ export type {
BattleRewardSummary,
BattleRewardUi,
GiftModalState,
GoalFlowUi,
InventoryFlowUi,
QuestFlowUi,
RecruitModalState,
@@ -174,13 +183,13 @@ function _buildLocalCharacterChatSummary(
.slice(-4)
.map(
(turn) =>
`${turn.speaker === 'player' ? 'Player' : character.name}: ${turn.text}`,
`${turn.speaker === 'player' ? '玩家' : character.name}${turn.text}`,
)
.join(' ');
const currentSummary = latestTurns
? `${character.name} is becoming more open in private conversation. Recent exchange: ${latestTurns}`
: `${character.name} is willing to continue private conversation and gradually trusts the player more.`;
? `${character.name}在私下交谈里变得更愿意坦率回应。最近交流:${latestTurns}`
: `${character.name}愿意继续私下交谈,对玩家的信任也在慢慢加深。`;
if (!previousSummary) {
return currentSummary.slice(0, 118);
}
@@ -204,6 +213,7 @@ function buildPartyRelationshipNotes(state: GameState) {
if (seenCharacterIds.has(characterId)) return;
const character = getCharacterById(characterId);
const summary = getCharacterChatRecord(state, characterId).summary.trim();
if (hasMixedNarrativeLanguage(summary)) return;
if (!character || !summary) return;
seenCharacterIds.add(characterId);
@@ -222,6 +232,23 @@ function buildPartyRelationshipNotes(state: GameState) {
return lines.length > 0 ? lines.join('\n') : null;
}
function describeScenePressureLevel(
pressureLevel: 'low' | 'medium' | 'high' | 'extreme' | null | undefined,
) {
switch (pressureLevel) {
case 'low':
return '低';
case 'medium':
return '中';
case 'high':
return '高';
case 'extreme':
return '极高';
default:
return null;
}
}
function buildRecentConversationEventText(state: GameState) {
const recentText = state.storyHistory
.slice(-6)
@@ -289,17 +316,17 @@ function describeConversationSituation(
) {
switch (situation) {
case 'camp_first_contact':
return 'This is the first quiet moment at camp, so the tone should stay careful, observant, and lightly probing.';
return '这是营地里第一次真正静下来对话的时刻,语气要保持谨慎、观察和轻微试探。';
case 'camp_followup':
return 'The first camp exchange already happened, so this can pick up the previous thread and go a little deeper.';
return '营地里的第一轮试探已经发生过了,这一轮应当顺着刚才的话头稍微往深处接。';
case 'post_battle_breath':
return 'A fight just ended. The immediate danger is lower, but both sides are still tense and catching their breath.';
return '一场交锋刚结束,眼前危险稍缓,但双方都还带着余悸和紧绷。';
case 'shared_danger_coordination':
return 'Danger is still active, so the conversation should stay short, direct, and practical.';
return '危险还没过去,对话应当短、准、直接,优先服务眼前判断。';
case 'private_followup':
return 'This is not a strict first meeting anymore. It works best as a continuation of something half-said a moment ago.';
return '这已经不是严格意义上的初见,更适合作为刚才未说完那句话的延续。';
default:
return 'They have only just met, and both sides are still deciding how much they can trust the other.';
return '双方才刚真正对上话,此刻仍在判断彼此能信到什么程度。';
}
}
@@ -308,17 +335,17 @@ function describeConversationTalkPriority(
) {
switch (situation) {
case 'camp_first_contact':
return 'Start with immediate impressions, mutual attitude, and the atmosphere at camp instead of over-explaining motives.';
return '优先写眼前印象、彼此态度和营地气氛,不要一上来就把动机讲透。';
case 'camp_followup':
return 'Start by picking up the unresolved thread from the last exchange, then decide whether to press further.';
return '先接住上一轮还没说透的话头,再决定要不要继续往下追问。';
case 'post_battle_breath':
return 'Talk about the clash that just happened and how each side judged the other before moving deeper.';
return '先谈刚刚那次交锋以及彼此的判断,再视情况往更深处推进。';
case 'shared_danger_coordination':
return 'Focus on the most useful judgment, danger, and next step instead of expanding into long background exposition.';
return '先说最有用的判断、危险和下一步,不要扩成大段背景说明。';
case 'private_followup':
return 'Pick up the current thread and relationship shift instead of resetting the conversation back to a first meeting.';
return '承接当前话头和关系变化,不要把对话又写回刚见面时的节奏。';
default:
return 'Probe stance and现场 judgment first instead of fully exposing motive and secrets.';
return '先试探态度和现场判断,不要急着把来意和秘密一次摊开。';
}
}
@@ -388,6 +415,7 @@ function buildStoryContextFromState(
pendingSceneEncounter?: boolean;
lastFunctionId?: string | null;
observeSignsRequested?: boolean;
recentActionResult?: string | null;
openingCampBackground?: string | null;
openingCampDialogue?: string | null;
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
@@ -462,8 +490,8 @@ function buildStoryContextFromState(
state.currentScenePreset?.mutationStateText
? `最新世界变化:${state.currentScenePreset.mutationStateText}`
: null,
state.currentScenePreset?.currentPressureLevel
? `当前区域压力等级:${state.currentScenePreset.currentPressureLevel}`
describeScenePressureLevel(state.currentScenePreset?.currentPressureLevel)
? `当前区域压力等级:${describeScenePressureLevel(state.currentScenePreset?.currentPressureLevel)}`
: null,
]
.filter(Boolean)
@@ -473,7 +501,7 @@ function buildStoryContextFromState(
? [
baseSceneDescription,
sceneMutationDescription,
'Observed entity pool:',
'当前可观察实体池:',
buildSceneEntityCatalogText(
state.worldType,
state.currentScenePreset?.id ?? null,
@@ -608,12 +636,31 @@ function buildStoryContextFromState(
const compiledPacks = state.customWorldProfile
? compileCampaignFromWorldProfile({ profile: state.customWorldProfile })
: null;
const goalStack = buildGoalStackState({
quests: state.quests,
worldType: state.worldType,
chapterState,
journeyBeat,
setpieceDirective,
currentCampEvent,
currentSceneName: state.currentScenePreset?.name ?? null,
});
const activeScenarioPack =
resolveScenarioPack(state.activeScenarioPackId)
?? compiledPacks?.scenarioPack
?? null;
const activeCampaignPack = compiledPacks?.campaignPack ?? null;
const fallbackChapterRecap = buildChapterRecap({
state: { ...state, chapterState } as GameState,
});
const safeEncounterRelationshipSummary =
state.currentEncounter?.characterId
? getCharacterChatRecord(state, state.currentEncounter.characterId)
.summary
.trim()
: '';
return applyAdaptiveTuningToPromptContext({
context: {
playerHp: state.playerHp,
@@ -631,6 +678,7 @@ function buildStoryContextFromState(
pendingSceneEncounter: extras.pendingSceneEncounter ?? false,
lastFunctionId: extras.lastFunctionId ?? null,
observeSignsRequested: extras.observeSignsRequested ?? false,
recentActionResult: extras.recentActionResult ?? null,
lastObserveSignsReport:
state.lastObserveSignsSceneId === (state.currentScenePreset?.id ?? null)
? (state.lastObserveSignsReport ?? null)
@@ -682,6 +730,7 @@ function buildStoryContextFromState(
actState: storyEngineMemory.actState ?? null,
chapterState,
journeyBeat,
goalStack,
currentCampEvent,
setpieceDirective,
activeScenarioPack,
@@ -699,14 +748,18 @@ function buildStoryContextFromState(
recentWorldMutations,
recentFactionTensionStates: storyEngineMemory.factionTensionStates ?? [],
recentChronicleSummary:
recentChronicleSummary || buildChapterRecap({ state: { ...state, chapterState } as GameState }),
recentChronicleSummary.trim() &&
!hasMixedNarrativeLanguage(recentChronicleSummary)
? recentChronicleSummary
: fallbackChapterRecap,
narrativeQaReport: storyEngineMemory.narrativeQaReport ?? null,
releaseGateReport: storyEngineMemory.releaseGateReport ?? null,
simulationRunResults: storyEngineMemory.simulationRunResults ?? [],
branchBudgetPressure: storyEngineMemory.branchBudgetStatus?.pressure ?? null,
encounterRelationshipSummary: state.currentEncounter?.characterId
? getCharacterChatRecord(state, state.currentEncounter.characterId)
.summary || null
? !hasMixedNarrativeLanguage(safeEncounterRelationshipSummary)
? safeEncounterRelationshipSummary || null
: null
: null,
partyRelationshipNotes: buildPartyRelationshipNotes(state),
customWorldProfile: state.customWorldProfile ?? null,
@@ -727,7 +780,7 @@ function buildNpcPreviewStory(
return {
text:
overrideText ??
`${encounter.npcName} waits ahead, as if letting you decide whether to engage first.`,
`${encounter.npcName}正停在前方,像是在等你先决定要不要真正把注意力落到他身上。`,
options: [buildNpcPreviewTalkOption(encounter)],
};
}
@@ -753,10 +806,7 @@ function buildNpcPreviewStory(
return {
text:
overrideText ??
encounter.npcName +
' appears near ' +
(state.currentScenePreset?.name ?? 'the path ahead') +
', but you have not fully committed your attention to them yet.',
`${encounter.npcName}出现在${state.currentScenePreset?.name ?? '前方道路'}附近,但你还没有真正把全部注意力落到对方身上。`,
options: [buildNpcPreviewTalkOption(encounter), ...locationOptions],
};
}
@@ -1013,11 +1063,13 @@ function buildCampCompanionOpeningResultText(
worldType: WorldType | null,
) {
const opening = getCharacterAdventureOpening(character, worldType);
const campSceneName =
worldType ? getWorldCampScenePreset(worldType)?.name ?? '归处' : '归处';
if (!opening) {
return `${encounter.npcName} 已经来到你身边。在营地,你稍作停顿,决定下一步去向何方。`;
return `${encounter.npcName} 已经来到你身边。在${campSceneName},你稍作停顿,决定下一步去向何方。`;
}
return `${encounter.npcName}营地来到你身边。你们首先就“${opening.immediateConcern ?? '前方不稳定的道路'}”交换了意见,而双方都暂时保留了部分真相。`;
return `${encounter.npcName}${campSceneName}来到你身边。你们首先就“${opening.immediateConcern ?? '前方不稳定的道路'}”交换了意见,而双方都暂时保留了部分真相。`;
}
function _buildCampCompanionChatResultText(
@@ -1114,6 +1166,9 @@ export function useStoryGeneration({
);
const [preparedOpeningAdventure, setPreparedOpeningAdventure] =
useState<PreparedOpeningAdventure | null>(null);
const [goalPulse, setGoalPulse] = useState<GoalFlowUi['pulse']>(null);
const previousGoalPulseSnapshotRef =
useRef<ReturnType<typeof createGoalPulseSnapshot> | null>(null);
const { characterChatUi, clearCharacterChatModal } = useCharacterChatFlow({
gameState,
setGameState,
@@ -1465,17 +1520,13 @@ export function useStoryGeneration({
optionCatalog: StoryOption[] | null = null,
) => ({
text: response.text,
options: sortStoryOptionsByPriority(
availableOptions
? isCampCompanionEncounter(state.currentEncounter)
? availableOptions
: response.options
: optionCatalog
? response.options.length > 0
? response.options
: optionCatalog
: sanitizeOptions(response.options, character, state),
),
options: resolveStoryResponseOptions({
responseOptions: response.options,
availableOptions,
optionCatalog,
getSanitizedOptions: () =>
sanitizeOptions(response.options, character, state),
}),
}),
[],
);
@@ -1809,12 +1860,65 @@ export function useStoryGeneration({
startOpeningAdventure,
]);
const runtimeGoalStack = useMemo(
() =>
buildGoalStackState({
quests: gameState.quests,
worldType: gameState.worldType,
chapterState:
gameState.chapterState
?? gameState.storyEngineMemory?.currentChapter
?? null,
journeyBeat: gameState.storyEngineMemory?.currentJourneyBeat ?? null,
setpieceDirective:
gameState.storyEngineMemory?.currentSetpieceDirective ?? null,
currentCampEvent:
gameState.storyEngineMemory?.currentCampEvent ?? null,
currentSceneName: gameState.currentScenePreset?.name ?? null,
}),
[
gameState.chapterState,
gameState.currentScenePreset?.name,
gameState.quests,
gameState.storyEngineMemory?.currentCampEvent,
gameState.storyEngineMemory?.currentChapter,
gameState.storyEngineMemory?.currentJourneyBeat,
gameState.storyEngineMemory?.currentSetpieceDirective,
gameState.worldType,
],
);
useEffect(() => {
const currentSnapshot = createGoalPulseSnapshot(
gameState.quests,
runtimeGoalStack,
);
const previousSnapshot = previousGoalPulseSnapshotRef.current;
if (!previousSnapshot) {
previousGoalPulseSnapshotRef.current = currentSnapshot;
return;
}
const nextPulse = deriveGoalPulseEvent({
previous: previousSnapshot,
quests: gameState.quests,
goalStack: runtimeGoalStack,
});
if (nextPulse) {
setGoalPulse(nextPulse);
}
previousGoalPulseSnapshotRef.current = currentSnapshot;
}, [
gameState.quests,
runtimeGoalStack,
]);
const {
displayedOptions,
canRefreshOptions,
handleRefreshOptions,
resetStoryOptions,
} = useStoryOptions(currentStory);
} = useStoryOptions(currentStory, runtimeGoalStack);
const { handleChoice } = createStoryChoiceActions({
gameState,
currentStory,
@@ -1852,6 +1956,9 @@ export function useStoryGeneration({
fallbackCompanionName: FALLBACK_COMPANION_NAME,
turnVisualMs: TURN_VISUAL_MS,
});
const dismissGoalPulse = useCallback(() => {
setGoalPulse(null);
}, []);
const clearStoryRuntimeUi = useCallback(() => {
resetStoryOptions();
@@ -1859,9 +1966,16 @@ export function useStoryGeneration({
setIsLoading(false);
setPreparedOpeningAdventure(null);
setBattleReward(null);
dismissGoalPulse();
previousGoalPulseSnapshotRef.current = null;
npcInteractionFlow.clearNpcInteractionUi();
clearCharacterChatModal();
}, [clearCharacterChatModal, npcInteractionFlow, resetStoryOptions]);
}, [
clearCharacterChatModal,
dismissGoalPulse,
npcInteractionFlow,
resetStoryOptions,
]);
const {
acknowledgeQuestCompletion,
@@ -1900,6 +2014,11 @@ export function useStoryGeneration({
acknowledgeQuestCompletion,
claimQuestReward,
} satisfies QuestFlowUi,
goalUi: {
goalStack: runtimeGoalStack,
pulse: goalPulse,
dismissPulse: dismissGoalPulse,
} satisfies GoalFlowUi,
npcUi: npcInteractionFlow.npcUi,
characterChatUi,
inventoryUi,

View File

@@ -1,32 +1,85 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { sortStoryOptionsByPriority } from '../data/stateFunctions';
import { StoryMoment } from '../types';
import { annotateStoryOptionsWithGoalAffordance } from '../services/storyEngine/goalDirector';
import type { GoalStackState, StoryMoment } from '../types';
const OPTION_PAGE_SIZE = 3;
export function useStoryOptions(currentStory: StoryMoment | null) {
const [optionPool, setOptionPool] = useState(currentStory?.options ?? []);
export function useStoryOptions(
currentStory: StoryMoment | null,
goalStack?: GoalStackState | null,
) {
const [optionWindowStart, setOptionWindowStart] = useState(0);
useEffect(() => {
const activeOptionPool = useMemo(() => {
if (!currentStory) {
setOptionPool([]);
setOptionWindowStart(0);
return;
return [];
}
setOptionPool(sortStoryOptionsByPriority(currentStory.options));
setOptionWindowStart(0);
}, [currentStory]);
return sortStoryOptionsByPriority(
annotateStoryOptionsWithGoalAffordance(
currentStory.options,
goalStack,
),
);
}, [currentStory, goalStack]);
const activeOptionPool = useMemo(
() => (optionPool.length > 0 ? optionPool : currentStory?.options ?? []),
[currentStory?.options, optionPool],
const optionPoolSignature = useMemo(
() =>
activeOptionPool
.map((option) =>
[
option.functionId,
option.actionText,
option.text ?? '',
option.goalAffordance?.goalId ?? '',
option.goalAffordance?.relation ?? '',
].join('::'),
)
.join('||'),
[activeOptionPool],
);
useEffect(() => {
setOptionWindowStart(0);
}, [currentStory, optionPoolSignature]);
const displayedOptions = useMemo(
() => activeOptionPool.slice(optionWindowStart, optionWindowStart + OPTION_PAGE_SIZE),
() => {
const windowOptions = activeOptionPool.slice(
optionWindowStart,
optionWindowStart + OPTION_PAGE_SIZE,
);
if (
windowOptions.some(
(option) => option.goalAffordance?.relation === 'advance',
)
) {
return windowOptions;
}
const pinnedAdvanceOption =
activeOptionPool.find(
(option) => option.goalAffordance?.relation === 'advance',
) ?? null;
if (!pinnedAdvanceOption) {
return windowOptions;
}
return [
pinnedAdvanceOption,
...windowOptions
.filter(
(option) =>
!(
option.functionId === pinnedAdvanceOption.functionId
&& option.actionText === pinnedAdvanceOption.actionText
),
)
.slice(0, OPTION_PAGE_SIZE - 1),
];
},
[activeOptionPool, optionWindowStart],
);
@@ -45,7 +98,6 @@ export function useStoryOptions(currentStory: StoryMoment | null) {
}, [activeOptionPool.length, optionWindowStart]);
const resetStoryOptions = useCallback(() => {
setOptionPool([]);
setOptionWindowStart(0);
}, []);

View File

@@ -15,6 +15,9 @@ export type AppRouteMatch =
| {
kind: 'preset-editor';
initialTab: PresetEditorTab;
}
| {
kind: 'qwen-sprite-tool';
};
export type ResolvedAppRoute = {
@@ -33,6 +36,9 @@ const PresetEditorApp = lazy(async () => {
default: module.PresetEditor,
};
}) as AppRouteComponent;
const QwenSpriteToolApp = lazy(
() => import('../tools/QwenSpriteSheetTool'),
) as AppRouteComponent;
const PRESET_EDITOR_ROUTES: Array<{
prefixes: string[];
@@ -60,6 +66,12 @@ const PRESET_EDITOR_ROUTES: Array<{
},
];
const QWEN_SPRITE_TOOL_PREFIXES = [
'/qwen-sprite-tool',
'/sprite-tool',
'/pixelmotion-qwen',
];
function normalizeRoutePath(pathname: string) {
const trimmedPathname = pathname.trim().toLowerCase();
@@ -80,6 +92,16 @@ function matchesRoutePrefix(pathname: string, prefix: string) {
export function matchAppRoute(pathname: string): AppRouteMatch {
const normalizedPathname = normalizeRoutePath(pathname);
const isQwenSpriteToolRoute = QWEN_SPRITE_TOOL_PREFIXES.some((prefix) =>
matchesRoutePrefix(normalizedPathname, prefix),
);
if (isQwenSpriteToolRoute) {
return {
kind: 'qwen-sprite-tool',
};
}
const presetRoute = PRESET_EDITOR_ROUTES.find((route) =>
route.prefixes.some((prefix) =>
matchesRoutePrefix(normalizedPathname, prefix),
@@ -101,6 +123,15 @@ export function matchAppRoute(pathname: string): AppRouteMatch {
export function resolveAppRoute(pathname: string): ResolvedAppRoute {
const matchedRoute = matchAppRoute(pathname);
if (matchedRoute.kind === 'qwen-sprite-tool') {
return {
kind: matchedRoute.kind,
loadingEyebrow: '正在载入精灵表工坊',
loadingText: '正在载入 Qwen 精灵表工具...',
Component: QwenSpriteToolApp,
};
}
if (matchedRoute.kind === 'preset-editor') {
return {
kind: matchedRoute.kind,

View File

@@ -1,6 +1,15 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
import { getScenePresetsByWorld } from '../data/scenePresets';
import type {
Character,
Encounter,
SceneHostileNpc,
StoryMoment,
StoryOption,
} from '../types';
import { AnimationState, WorldType } from '../types';
const {
connectivityError,
@@ -27,19 +36,12 @@ vi.mock('./llmClient', () => ({
streamPlainTextCompletion: streamPlainTextCompletionMock,
}));
import type {
Character,
Encounter,
SceneHostileNpc,
StoryMoment,
StoryOption,
} from '../types';
import { AnimationState, WorldType } from '../types';
import {
generateCharacterPanelChatSuggestions,
generateCustomWorldProfile,
generateCustomWorldSceneImage,
generateInitialStory,
generateNextStep,
streamCharacterPanelChatReply,
streamNpcRecruitDialogue,
} from './ai';
@@ -393,6 +395,123 @@ describe('ai orchestration fallbacks', () => {
expect(response.storyText.length).toBeGreaterThan(0);
});
it('repairs mixed-language story text before returning the story response', async () => {
const availableOptions = [
createStoryOption({
functionId: 'idle_explore_forward',
actionText: '继续沿山道探路。',
text: '继续沿山道探路。',
}),
];
requestChatMessageContentMock
.mockResolvedValueOnce(
JSON.stringify({
storyText: 'The forest is quiet. 你听见远处的风声。',
encounter: null,
options: [
{
functionId: 'idle_explore_forward',
actionText: 'Move forward carefully.',
},
],
}),
)
.mockResolvedValueOnce(
JSON.stringify({
storyText: '林间重新安静下来,你听见远处的风声。',
encounter: null,
options: [
{
functionId: 'idle_explore_forward',
actionText: '继续沿山道探路。',
},
],
}),
);
const response = await generateInitialStory(
WorldType.WUXIA,
playerCharacter,
monsters,
context,
{ availableOptions },
);
expect(response.storyText).toBe('林间重新安静下来,你听见远处的风声。');
expect(response.options[0]?.actionText).toBe('继续沿山道探路。');
expect(requestChatMessageContentMock).toHaveBeenCalledTimes(2);
expect(requestChatMessageContentMock.mock.calls[1]?.[2]).toEqual(
expect.objectContaining({
debugLabel: 'story-language-repair',
}),
);
});
it('ignores generated encounter payloads during post-battle continuations when no new scene encounter is pending', async () => {
const availableOptions = [
createStoryOption({
functionId: 'idle_explore_forward',
actionText: '先稳住呼吸,再看看前面的动静。',
text: '先稳住呼吸,再看看前面的动静。',
}),
];
const sceneWithNpc = getScenePresetsByWorld(WorldType.WUXIA).find(
(scene) => (scene.npcs?.length ?? 0) > 0,
);
const targetNpcId = sceneWithNpc?.npcs?.[0]?.id;
if (!sceneWithNpc || !targetNpcId) {
throw new Error('Expected a wuxia scene with at least one npc preset.');
}
requestChatMessageContentMock.mockResolvedValue(
JSON.stringify({
storyText: '山道总算安静下来,你收住气息,重新判断前路。',
encounter: {
kind: 'npc',
npcId: targetNpcId,
},
options: [
{
functionId: 'idle_explore_forward',
actionText: '先稳住呼吸,再看看前面的动静。',
},
],
}),
);
const response = await generateNextStep(
WorldType.WUXIA,
playerCharacter,
[],
[
{
text: '挥刀抢攻',
options: [],
historyRole: 'action',
},
{
text: '山道客已经败下阵来。',
options: [],
historyRole: 'result',
},
],
'挥刀抢攻',
createContext({
sceneId: sceneWithNpc.id,
sceneName: sceneWithNpc.name,
sceneDescription: sceneWithNpc.description,
pendingSceneEncounter: false,
}),
{ availableOptions },
);
expect(response.encounter).toBeUndefined();
expect(response.options).toEqual(availableOptions);
const userPrompt = requestChatMessageContentMock.mock.calls.at(-1)?.[1];
expect(userPrompt).toContain('encounter 必须为 null');
expect(userPrompt).toContain('战斗结束后的续写');
});
it('returns offline character chat suggestions when the plain-text client reports connectivity errors', async () => {
requestPlainTextCompletionMock.mockRejectedValue(connectivityError);

View File

@@ -101,6 +101,7 @@ import {
parseJsonResponseText as parseJsonResponseTextFromParser,
parseLineListContent as parseLineListContentFromParser,
} from './llmParsers';
import { hasMixedNarrativeLanguage } from './narrativeLanguage';
import {
buildNpcRecruitDialoguePrompt,
buildStrictNpcChatDialoguePrompt,
@@ -146,6 +147,11 @@ const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
不要输出 Markdown、代码块、解释、注释或额外文字。
尽量保留原始语义,只修复格式问题;必要时可以补齐缺失的引号、逗号、括号、数组闭合或缺失字段。`;
const STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT = `你是 RPG 中文叙事文本修复器。
你会收到一个已经解析过的剧情 JSON 对象。
你的唯一任务是把 storyText 和 options[].actionText 中的英文句子、中英混杂句式、英文解释改写成自然中文。
必须保持 JSON 结构、encounter、options 数量、options 顺序以及每个 functionId 完全不变。
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释、注释或额外文字。`;
const CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的自定义世界 JSON 生成器。
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`;
const CUSTOM_WORLD_FRAMEWORK_PLAYABLE_OUTLINE_BATCH_SIZE = 5;
@@ -1447,6 +1453,15 @@ function buildEncounterDrivenResolution(
};
}
function resolveSafeGeneratedActionText(actionText: string | undefined) {
const trimmed = actionText?.trim();
if (!trimmed || hasMixedNarrativeLanguage(trimmed)) {
return undefined;
}
return trimmed;
}
function resolveOptionsFromFunctionIds(
items: RawOptionItem[],
worldType: WorldType,
@@ -1463,7 +1478,11 @@ function resolveOptionsFromFunctionIds(
return items
.map((item) =>
resolveFunctionOption(item.functionId, functionContext, item.actionText),
resolveFunctionOption(
item.functionId,
functionContext,
resolveSafeGeneratedActionText(item.actionText),
),
)
.filter(Boolean) as StoryOption[];
}
@@ -1525,7 +1544,7 @@ function resolveOptionsFromProvidedOptions(
if (!matchedOption) return;
consumedOptions.add(matchedOption);
const rewrittenText = item.actionText?.trim();
const rewrittenText = resolveSafeGeneratedActionText(item.actionText);
resolved.push({
...cloneStoryOption(matchedOption),
actionText: rewrittenText || matchedOption.actionText,
@@ -1566,7 +1585,7 @@ function resolveOptionsFromOptionCatalog(
const matchedOption = bucket?.shift();
if (!matchedOption) return;
const rewrittenText = item.actionText?.trim();
const rewrittenText = resolveSafeGeneratedActionText(item.actionText);
resolved.push({
...cloneStoryOption(matchedOption),
actionText: rewrittenText || matchedOption.actionText,
@@ -1662,6 +1681,112 @@ function buildOfflineResponse(
};
}
function buildStoryLanguageRepairPrompt(response: AIResponse) {
return [
'请把下面 JSON 中的 storyText 与 options[].actionText 修复为自然中文。',
'只改写叙事和选项文案,不要改变 encounter、options 数量、顺序或 functionId。',
'如果英文只是角色名或地点名,可以保留名字本体;其余英文句子、英文解释和中英混杂表达都必须改成中文。',
JSON.stringify(
{
storyText: response.storyText,
encounter: response.encounter ?? null,
options: response.options.map((option) => ({
functionId: option.functionId,
actionText: option.actionText,
})),
},
null,
2,
),
].join('\n\n');
}
function needsStoryLanguageRepair(response: AIResponse) {
return hasMixedNarrativeLanguage(response.storyText);
}
function buildStoryLanguageFallbackText(
context: StoryGenerationContext,
inBattle: boolean,
) {
if (inBattle) {
return '敌意仍压在眼前,战斗局势还没有真正松开。';
}
if (context.encounterName) {
return `${context.encounterName}的态度与周围气氛都出现了新的变化,你需要立刻判断接下来如何应对。`;
}
return `${context.sceneName || '眼前区域'}里的气氛又有了新的变化,你需要继续判断下一步。`;
}
function finalizeStoryNarrativeLanguage(
response: AIResponse,
context: StoryGenerationContext,
inBattle: boolean,
): AIResponse {
if (!needsStoryLanguageRepair(response)) {
return response;
}
return {
...response,
storyText: buildStoryLanguageFallbackText(context, inBattle),
};
}
async function repairStoryNarrativeLanguage(
response: AIResponse,
worldType: WorldType,
character: Character,
monsters: SceneHostileNpc[],
context: StoryGenerationContext,
requestOptions: StoryRequestOptions,
) {
const responseBattleState = buildEncounterDrivenResolution(
worldType,
monsters,
context,
response.encounter,
).inBattle;
if (!needsStoryLanguageRepair(response)) {
return finalizeStoryNarrativeLanguage(response, context, responseBattleState);
}
try {
const repairedContent = await requestChatMessageContent(
STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT,
buildStoryLanguageRepairPrompt(response),
{
debugLabel: 'story-language-repair',
},
);
const repairedResponse = normalizeResponse(
parseJsonResponseTextFromParser(repairedContent),
worldType,
character,
monsters,
context,
requestOptions,
);
const repairedBattleState = buildEncounterDrivenResolution(
worldType,
monsters,
context,
repairedResponse.encounter,
).inBattle;
return finalizeStoryNarrativeLanguage(
repairedResponse,
context,
repairedBattleState,
);
} catch (error) {
console.warn('Failed to repair mixed-language story response:', error);
return finalizeStoryNarrativeLanguage(response, context, responseBattleState);
}
}
function normalizeResponse(
raw: unknown,
worldType: WorldType,
@@ -1766,7 +1891,7 @@ async function requestCompletion(
debugLabel: 'story-completion',
});
return normalizeResponse(
const response = normalizeResponse(
parseJsonResponseTextFromParser(content),
worldType,
character,
@@ -1774,6 +1899,15 @@ async function requestCompletion(
context,
requestOptions,
);
return repairStoryNarrativeLanguage(
response,
worldType,
character,
monsters,
context,
requestOptions,
);
}
export async function generateCustomWorldSceneImage({
@@ -1895,7 +2029,7 @@ export async function generateCustomWorldProfile(
landmarks: [],
} satisfies CustomWorldGenerationFramework;
reporter.complete('framework', {
phaseDetail: `世界框架已确定,基础模板锚定为${frameworkBase.templateWorldType === WorldType.WUXIA ? '武侠' : '仙侠'}`,
phaseDetail: '世界框架已确定,开始围绕你的设定继续编译题材层与关键对象。',
});
reporter.begin('theme-pack', {
phaseDetail: '正在提炼题材适配层词汇与命名范式。',

View File

@@ -21,6 +21,7 @@ import type {
EquipmentLoadout,
FacingDirection,
FactionTensionState,
GoalStackState,
InventoryItem,
JourneyBeat,
KnowledgeFact,
@@ -69,6 +70,7 @@ export interface StoryGenerationContext {
lastFunctionId?: string | null;
observeSignsRequested?: boolean;
lastObserveSignsReport?: string | null;
recentActionResult?: string | null;
encounterKind?: string | null;
encounterName?: string | null;
encounterDescription?: string | null;
@@ -117,6 +119,7 @@ export interface StoryGenerationContext {
actState?: ActState | null;
chapterState?: ChapterState | null;
journeyBeat?: JourneyBeat | null;
goalStack?: GoalStackState | null;
currentCampEvent?: CampEvent | null;
setpieceDirective?: SetpieceDirective | null;
encounterNarrativeProfile?: ActorNarrativeProfile | null;

View File

@@ -38,6 +38,20 @@ function buildCustomThemeSlots(input: AttributeSchemaGenerationInput) {
templateWorldType: /||||/u.test(input.settingText) ? WorldType.XIANXIA : WorldType.WUXIA,
});
if (themeMode === 'mythic') {
return {
schemaName: '叙境六维',
slots: [
{ slotId: 'axis_a', name: '体魄', definition: '承受正面压力与长期消耗的底子。', positiveSignals: ['稳固', '抗压'], negativeSignals: ['脆弱', '虚浮'], combatUseText: '扛住冲击、保持站位。', socialUseText: '给人可靠、能顶事的感觉。', explorationUseText: '在漫长旅途中维持可行动状态。' },
{ slotId: 'axis_b', name: '身法', definition: '换位、腾挪、抢时机与穿行环境的能力。', positiveSignals: ['灵动', '迅捷'], negativeSignals: ['迟滞', '笨拙'], combatUseText: '变线、闪避、抢位和追击。', socialUseText: '反应快,懂得顺势调整说法。', explorationUseText: '穿越复杂地形与危险通路。' },
{ slotId: 'axis_c', name: '识见', definition: '看清局势、拆解线索与判断轻重缓急的能力。', positiveSignals: ['洞察', '判断'], negativeSignals: ['误判', '迟钝'], combatUseText: '看穿敌方破绽与局势变化。', socialUseText: '识别真假、试探与隐藏立场。', explorationUseText: '整理线索、辨认路径与推断风险。' },
{ slotId: 'axis_d', name: '胆魄', definition: '在高压局势里依然敢于推进和拍板的力量。', positiveSignals: ['果断', '压场'], negativeSignals: ['退缩', '犹疑'], combatUseText: '顶着压力推进战局。', socialUseText: '在僵局里定调并逼出回应。', explorationUseText: '面对未知异象仍敢继续前探。' },
{ slotId: 'axis_e', name: '牵引', definition: '与人、物、线索和环境建立联动的能力。', positiveSignals: ['协同', '共鸣'], negativeSignals: ['脱节', '孤立'], combatUseText: '借协同和牵制形成连锁。', socialUseText: '建立合作、说服和互信。', explorationUseText: '从人情、物件和场景之间串起通路。' },
{ slotId: 'axis_f', name: '定力', definition: '在变化与消耗中稳住节奏、拉回状态的能力。', positiveSignals: ['稳定', '续航'], negativeSignals: ['失衡', '崩乱'], combatUseText: '久战不乱,能重新控住节奏。', socialUseText: '情绪稳定,不轻易被带偏。', explorationUseText: '在长线推进中持续保持判断和行动力。' },
] satisfies WorldAttributeSlot[],
};
}
if (themeMode === 'machina') {
return {
schemaName: '机潮六轴',
@@ -81,10 +95,8 @@ function buildCustomThemeSlots(input: AttributeSchemaGenerationInput) {
}
return {
schemaName: input.worldType === WorldType.XIANXIA ? '灵界六轴' : '江湖六脉',
slots: getPresetWorldAttributeSchema(
/||||/u.test(input.settingText) ? WorldType.XIANXIA : WorldType.WUXIA,
).slots,
schemaName: '叙境六维',
slots: getPresetWorldAttributeSchema(WorldType.WUXIA).slots,
};
}

View File

@@ -14,6 +14,7 @@ import {
CharacterBackstoryChapter,
CharacterBackstoryRevealConfig,
CustomWorldAnchorPack,
CustomWorldCampScene,
CustomWorldItem,
CustomWorldLandmark,
CustomWorldNpc,
@@ -27,6 +28,7 @@ import {
WorldType,
} from '../types';
import { generateWorldAttributeSchema } from './attributeSchemaGenerator';
import { buildFallbackCustomWorldCampScene } from './customWorldCamp';
import {
buildCustomWorldAnchorPackFromIntent,
deriveCustomWorldLockStateFromIntent,
@@ -104,6 +106,12 @@ export interface CustomWorldGenerationLandmarkOutline {
connections: CustomWorldGenerationLandmarkConnectionOutline[];
}
export interface CustomWorldGenerationCampOutline {
name: string;
description: string;
dangerLevel: string;
}
export interface CustomWorldGenerationFramework {
settingText: string;
name: string;
@@ -114,6 +122,7 @@ export interface CustomWorldGenerationFramework {
templateWorldType: WorldType;
majorFactions: string[];
coreConflicts: string[];
camp: CustomWorldGenerationCampOutline;
playableNpcs: CustomWorldGenerationRoleOutline[];
storyNpcs: CustomWorldGenerationRoleOutline[];
landmarks: CustomWorldGenerationLandmarkOutline[];
@@ -508,32 +517,28 @@ function buildSeedPhrase(settingText: string, fallback: string) {
}
function buildWorldName(settingText: string, worldType: WorldType) {
const seed = buildSeedPhrase(
settingText,
worldType === WorldType.XIANXIA ? '灵潮' : '江湖',
);
const suffix = worldType === WorldType.XIANXIA ? '界' : '录';
const seed = buildSeedPhrase(settingText, '新旅');
const suffix = worldType === WorldType.XIANXIA ? '境' : '域';
return `${seed}${suffix}`;
}
function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile {
const templateWorldType = inferWorldTypeFromSetting(settingText);
const name = buildWorldName(settingText, templateWorldType);
const subtitle =
templateWorldType === WorldType.XIANXIA ? '灵潮未定' : '风云将起';
const subtitle = '前路未明';
const summary = settingText.trim()
? `这个世界围绕“${settingText.trim().slice(0, 28)}”展开。`
: templateWorldType === WorldType.XIANXIA
? '灵潮未定,旧秩序正在崩裂。'
: '旧案复起,江湖格局正在改变。';
const tone =
templateWorldType === WorldType.XIANXIA
? '空灵、危险、层层递进'
: '紧张、克制、暗流涌动';
const playerGoal =
templateWorldType === WorldType.XIANXIA
? '查清异变源头,在诸方势力之前抢到关键线索'
: '沿着旧案痕迹追查幕后之人,并守住仍值得相信的人与路';
: '一个仍待展开的独立世界正在成形。';
const tone = '未知、紧绷、仍在展开';
const playerGoal = '查清眼前局势的关键矛盾,并守住仍值得相信的人与事';
const camp = buildFallbackCustomWorldCampScene({
name,
summary,
tone,
playerGoal,
settingText: settingText.trim(),
templateWorldType,
});
return {
id: `custom-world-${Date.now().toString(36)}-${slugify(name)}`,
@@ -559,6 +564,7 @@ function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile {
playableNpcs: [],
storyNpcs: [],
items: [],
camp,
landmarks: [],
themePack: null,
storyGraph: null,
@@ -592,6 +598,11 @@ export function normalizeCustomWorldGenerationFramework(
templateWorldType: fallback.templateWorldType,
majorFactions: [],
coreConflicts: [fallback.summary],
camp: {
name: fallback.camp?.name ?? '归舍',
description: fallback.camp?.description ?? '',
dangerLevel: fallback.camp?.dangerLevel ?? 'low',
},
playableNpcs: [],
storyNpcs: [],
landmarks: [],
@@ -623,6 +634,14 @@ export function normalizeCustomWorldGenerationFramework(
templateWorldType,
majorFactions: normalizeTags(item.majorFactions, []),
coreConflicts: normalizeTags(item.coreConflicts, [fallback.summary]),
camp: normalizeCampOutline(item.camp, {
name,
summary: toText(item.summary) || fallback.summary,
tone: toText(item.tone) || fallback.tone,
playerGoal: toText(item.playerGoal) || fallback.playerGoal,
settingText: settingText.trim(),
templateWorldType,
}),
playableNpcs: normalizeRoleOutlineList(item.playableNpcs, {
titleFallback: '未定称号',
defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY,
@@ -649,6 +668,11 @@ export function buildCustomWorldRawProfileFromFramework(
templateWorldType: framework.templateWorldType,
majorFactions: framework.majorFactions,
coreConflicts: framework.coreConflicts,
camp: {
name: framework.camp.name,
description: framework.camp.description,
dangerLevel: framework.camp.dangerLevel,
},
playableNpcs: framework.playableNpcs.map((npc) => ({
name: npc.name,
title: npc.title,
@@ -818,6 +842,24 @@ function normalizeRoleOutlineList(
return normalized;
}
function normalizeCampOutline(
value: unknown,
fallbackProfile: Pick<
CustomWorldProfile,
'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType'
>,
): CustomWorldGenerationCampOutline {
const fallback = buildFallbackCustomWorldCampScene(fallbackProfile);
const item =
value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
return {
name: toText(item.name) || fallback.name,
description: toText(item.description) || fallback.description,
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
};
}
function normalizeLandmarkOutlineList(value: unknown) {
return toRecordArray(value)
.map((item) => {
@@ -910,6 +952,25 @@ function normalizeLandmarkDraftList(value: unknown) {
.filter((entry) => entry.name);
}
function normalizeCampScene(
value: unknown,
fallbackProfile: Pick<
CustomWorldProfile,
'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType'
>,
): CustomWorldCampScene {
const fallback = buildFallbackCustomWorldCampScene(fallbackProfile);
const item =
value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
return {
name: toText(item.name) || fallback.name,
description: toText(item.description) || fallback.description,
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
imageSrc: toText(item.imageSrc) || undefined,
};
}
export function normalizeCustomWorldProfile(
raw: unknown,
settingText: string,
@@ -949,6 +1010,14 @@ export function normalizeCustomWorldProfile(
const playableNpcs = normalizePlayableNpcList(item.playableNpcs);
const storyNpcs = normalizeStoryNpcList(item.storyNpcs);
const landmarkDrafts = normalizeLandmarkDraftList(item.landmarks);
const camp = normalizeCampScene(item.camp, {
name,
summary,
tone,
playerGoal,
settingText: settingText.trim(),
templateWorldType,
});
return {
id:
@@ -970,6 +1039,7 @@ export function normalizeCustomWorldProfile(
playableNpcs,
storyNpcs,
items: normalizeItemList(item.items),
camp,
landmarks: normalizeCustomWorldLandmarks({
landmarks: landmarkDrafts,
storyNpcs,
@@ -1033,6 +1103,7 @@ function buildFrameworkSummaryText(
framework.coreConflicts.length > 0
? `核心冲突:${framework.coreConflicts.join('、')}`
: '',
`开局归处:${framework.camp.name}${framework.camp.description}`,
landmarkText ? `关键场景:${landmarkText}` : '',
]
.filter(Boolean)
@@ -1113,13 +1184,17 @@ export function validateCustomWorldGenerationFramework(
`自定义世界框架至少需要 ${MIN_CUSTOM_WORLD_LANDMARK_COUNT} 个场景,当前仅有 ${landmarkCount} 个。`,
);
}
if (!framework.camp.name.trim() || !framework.camp.description.trim()) {
throw new Error('自定义世界框架必须包含一个有效的开局归处场景。');
}
}
export function buildCustomWorldFrameworkPrompt(settingText: string) {
return [
'请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。',
'你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。',
'这一步只保留世界顶层信息,不要输出 playableNpcs、storyNpcs、landmarks也不要展开人物和地图细节。',
'这一步只保留世界顶层信息与一个开局归处场景,不要输出 playableNpcs、storyNpcs、landmarks也不要展开人物和地图细节。',
'玩家设定:',
settingText.trim(),
'',
@@ -1132,16 +1207,24 @@ export function buildCustomWorldFrameworkPrompt(settingText: string) {
' "playerGoal": "玩家核心目标",',
' "templateWorldType": "WUXIA|XIANXIA",',
' "majorFactions": ["势力甲", "势力乙"],',
' "coreConflicts": ["冲突甲", "冲突乙"]',
' "coreConflicts": ["冲突甲", "冲突乙"],',
' "camp": {',
' "name": "开局归处名称",',
' "description": "这是玩家进入世界后的第一处落脚点描述",',
' "dangerLevel": "low|medium|high|extreme"',
' }',
'}',
'',
'要求:',
'- 所有生成文本都必须使用中文。',
'- 这一步只输出顶层 8 个字段name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts。',
'- 不要输出 playableNpcs、storyNpcs、landmarks、items也不要输出任何角色和场景细节。',
'- 这一步只输出顶层 9 个字段name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。',
'- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。',
'- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。',
'- camp 必须表示玩家开局时的落脚处,名字不要直接写成“某某营地”,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。',
'- 不要输出 playableNpcs、storyNpcs、landmarks、items也不要输出任何角色和地图细节。',
'- majorFactions 保持 2 到 3 个coreConflicts 保持 2 到 3 个。',
'- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。',
'- 每个字符串尽量简洁subtitle 控制在 8 到 18 个汉字内summary 控制在 16 到 32 个汉字内tone 控制在 6 到 16 个汉字内playerGoal 控制在 16 到 32 个汉字内。',
'- 每个字符串尽量简洁subtitle 控制在 8 到 18 个汉字内summary 控制在 16 到 32 个汉字内tone 控制在 6 到 16 个汉字内playerGoal 控制在 16 到 32 个汉字内camp.description 控制在 18 到 40 个汉字内。',
'- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。',
].join('\n');
}
@@ -1392,9 +1475,10 @@ export function buildCustomWorldFrameworkJsonRepairPrompt(
return [
'下面这段文本本应是自定义世界核心骨架的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。',
'请只输出修复后的 JSON 对象。',
'顶层必须只包含name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts。',
'顶层必须只包含name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。',
'不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。',
'majorFactions 与 coreConflicts 必须是字符串数组。',
'camp 必须是对象且包含name、description、dangerLevel。',
'原始文本:',
responseText.trim(),
].join('\n');
@@ -1437,6 +1521,7 @@ export function buildCustomWorldRoleOutlineBatchPrompt(params: {
'',
'要求:',
`- 必须生成恰好 ${batchCount}${label}`,
'- 这是一个完全独立的自定义世界;不要把角色写成来自“武侠世界”“仙侠世界”等现成世界。',
'- 名称必须具体且互不重复,不要使用 角色1、NPC1、场景角色1 之类的占位名。',
'- 只保留name、title、role、description、initialAffinity、relationshipHooks、tags。',
'- relationshipHooks 最多 1 条tags 保持 1 到 2 个。',
@@ -1509,6 +1594,7 @@ export function buildCustomWorldLandmarkSeedBatchPrompt(params: {
'',
'要求:',
`- 必须生成恰好 ${batchCount} 个 landmarks。`,
'- 这是一个完全独立的自定义世界;不要在场景描述里直接写“武侠世界”“仙侠世界”等现成世界名。',
'- 这一步只保留name、description、dangerLevel。',
'- 不要输出 sceneNpcNames、connections、imageSrc 或其他字段。',
'- 名称必须具体且互不重复,不要使用 场景1、地点1 之类的占位名。',
@@ -1590,6 +1676,7 @@ export function buildCustomWorldLandmarkNetworkBatchPrompt(params: {
'',
'要求:',
`- 只输出这批 ${landmarkBatch.length} 个场景,不要输出其他场景。`,
'- 这是一个完全独立的自定义世界summary 不要带入“武侠”“仙侠”等现成世界名称。',
'- 名称必须与本批次场景骨架完全一致,不得改名。',
'- 每个场景必须提供恰好 3 个唯一 sceneNpcNames且只能从可用场景角色名里选择。',
`- 每个场景必须提供恰好 2 条 connectionsrelativePosition 只能使用:${relativePositionValues}`,
@@ -1662,6 +1749,7 @@ export function buildCustomWorldRoleBatchPrompt(params: {
'',
'要求:',
`- 只输出这批${label},不要输出其他角色、场景或额外顶层字段。`,
'- 这是一个完全独立的自定义世界;不要在角色背景、性格、动机或战斗风格里直接写“武侠世界”“仙侠世界”等现成世界名。',
`- ${key} 的数量必须与本批次名单完全一致。`,
'- 名称必须与批次名单完全一致,不得增删改名。',
'- 只补全 backstory、personality、motivation、combatStyle 这 4 个字段,不要输出 backstoryReveal、skills、initialItems。',
@@ -1717,6 +1805,7 @@ export function buildCustomWorldRoleBatchPrompt(params: {
'',
'要求:',
`- 只输出这批${label},不要输出其他角色、场景或额外顶层字段。`,
'- 这是一个完全独立的自定义世界;不要在公开背景、技能名、物品名或说明里直接带入“武侠”“仙侠”等现成世界名。',
`- ${key} 的数量必须与本批次名单完全一致。`,
'- 名称必须与批次名单完全一致,不得增删改名。',
'- 这一阶段只补全 backstoryReveal、skills、initialItems不要重复输出 title、role、description、backstory、personality、motivation、combatStyle、initialAffinity、relationshipHooks、tags。',
@@ -1791,6 +1880,11 @@ export function buildCustomWorldGenerationPrompt(settingText: string) {
' "playerGoal": "玩家核心目标",',
' "majorFactions": ["势力甲", "势力乙"],',
' "coreConflicts": ["冲突甲", "冲突乙"],',
' "camp": {',
' "name": "开局归处名称",',
' "description": "玩家进入世界后的第一处落脚点描述",',
' "dangerLevel": "low|medium|high|extreme"',
' },',
' "playableNpcs": [',
' {',
' "name": "角色名称",',
@@ -1878,6 +1972,7 @@ export function buildCustomWorldGenerationPrompt(settingText: string) {
'',
'要求:',
'- 所有生成文本都必须使用中文。',
'- camp 必须存在,代表玩家开局时的落脚处;名字不要直接写成“某某营地”,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。',
'- 必须生成恰好 5 个 playableNpcs。',
'- 至少生成 25 个 storyNpcs并保证 playableNpcs + storyNpcs 的唯一名称总数不少于 30。',
'- 至少生成 10 个真正可游玩的 landmarks。',
@@ -1982,6 +2077,7 @@ export function buildCustomWorldReferenceText(
`世界概述:${profile.summary}`,
`世界基调:${profile.tone}`,
`玩家核心目标:${profile.playerGoal}`,
`开局归处:${profile.camp?.name ?? '未设定'}${profile.camp?.description ? `${profile.camp.description}` : ''}`,
`题材适配层:${themePack.displayName};制度词汇 ${themePack.institutionLexicon.slice(0, 4).join('、')};禁忌词 ${themePack.tabooLexicon.slice(0, 4).join('、')};载体类型 ${themePack.artifactClasses.slice(0, 4).join('、')}`,
`当前激活线程:\n${activeThreads.map((thread) => `- ${thread.title}${thread.summary}`).join('\n') || '- 暂无'}`,
`世界属性轴:${profile.attributeSchema.slots.map((slot) => `${slot.name}${slot.definition}`).join('')}`,

View File

@@ -0,0 +1,102 @@
import {
type CustomWorldCampScene,
type CustomWorldProfile,
} from '../types';
import { detectCustomWorldThemeMode } from './customWorldTheme';
type CampProfileSeed = Pick<
CustomWorldProfile,
'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType'
> & {
camp?: Pick<
CustomWorldCampScene,
'name' | 'description' | 'dangerLevel' | 'imageSrc'
> | null;
};
function clampText(value: string, maxLength: number) {
const normalized = value.trim().replace(/\s+/g, ' ');
if (!normalized) {
return '';
}
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}`;
}
function sanitizeCampSeed(name: string) {
const normalized = name.trim().replace(/\s+/g, '');
if (!normalized) {
return '';
}
const stripped = normalized.replace(
/(|||||||||)$/u,
'',
);
const seed = stripped || normalized;
return seed.slice(0, Math.min(seed.length, 4));
}
function buildFallbackCampName(profile: CampProfileSeed) {
const seed =
sanitizeCampSeed(profile.name) ||
'归途';
const themeMode = detectCustomWorldThemeMode(profile);
const suffixByMode = {
mythic: '归舍',
martial: '归舍',
arcane: '栖居',
machina: '整备居',
tide: '潮居',
rift: '界隙居所',
} as const;
return `${seed}${suffixByMode[themeMode]}`;
}
function buildFallbackCampDescription(profile: CampProfileSeed, campName: string) {
const summaryLead = clampText(profile.summary, 24) || '前路尚未明朗';
const goalLead = clampText(profile.playerGoal, 24) || '继续整理线索';
const themeMode = detectCustomWorldThemeMode(profile);
const descriptionByMode = {
mythic: `${campName}是你在${profile.name}暂时安顿的归处,能整备行装、交换判断,并围绕“${goalLead}”继续启程。`,
martial: `${campName}是你在${profile.name}暂时落脚的归处,能整备行装、收拢同伴,并朝“${goalLead}”继续动身。`,
arcane: `${campName}是你在${profile.name}暂时栖身的居所,适合调息、整理法器,并围绕“${summaryLead}”交换判断。`,
machina: `${campName}是你在${profile.name}的临时居所,能检修装备、补齐物资,并为下一段行动重新校准节奏。`,
tide: `${campName}是你在${profile.name}靠潮歇息的住处,能安顿队伍、整理补给,并顺着“${goalLead}”继续前探。`,
rift: `${campName}是你在${profile.name}勉强守住的一处居所,既能短暂停步,也能围绕“${summaryLead}”商量下一步去向。`,
} as const;
return descriptionByMode[themeMode];
}
export function buildFallbackCustomWorldCampScene(
profile: CampProfileSeed,
): CustomWorldCampScene {
const fallbackName = buildFallbackCampName(profile);
return {
name: fallbackName,
description: buildFallbackCampDescription(profile, fallbackName),
dangerLevel: 'low',
};
}
export function resolveCustomWorldCampScene(
profile: CampProfileSeed,
): CustomWorldCampScene {
const fallback = buildFallbackCustomWorldCampScene(profile);
const camp = profile.camp;
return {
name: camp?.name?.trim() || fallback.name,
description: camp?.description?.trim() || fallback.description,
dangerLevel: camp?.dangerLevel?.trim() || fallback.dangerLevel,
imageSrc: camp?.imageSrc?.trim() || undefined,
};
}

View File

@@ -23,8 +23,8 @@ export function buildThemedSkillName(_profile: unknown, style: string, index = 0
return `${style || 'skill'}-${index + 1}`;
}
export function buildCustomCampSceneName(profile: { name?: string } | null | undefined) {
return profile?.name ? `${profile.name} Camp` : 'Camp';
export function buildCustomCampSceneName(profile: { name?: string; camp?: { name?: string | null } | null } | null | undefined) {
return profile?.camp?.name?.trim() || (profile?.name ? `${profile.name}归舍` : '归舍');
}
export function getAttributeLabelsForWorld(_worldType: WorldType | null) {

View File

@@ -11,6 +11,7 @@ import {
ItemUseProfile,
WorldType,
} from '../types';
import { resolveCustomWorldCampScene } from './customWorldCamp';
import {
type CustomWorldThemeMode,
detectCustomWorldThemeMode,
@@ -49,6 +50,30 @@ type WorldPresentation = {
};
const WORLD_PRESENTATIONS: Record<ThemeMode, WorldPresentation> = {
mythic: {
mode: 'mythic',
attributeLabels: { strength: '体魄', agility: '身法', intelligence: '识见', spirit: '心魂' },
hpLabel: '生命',
mpLabel: '心流',
maxHpLabel: '生命上限',
maxMpLabel: '心流上限',
damageLabel: '势能',
guardLabel: '防护',
rangeLabel: '距离',
cooldownLabel: '回整',
manaCostLabel: '心流消耗',
campSuffix: '归舍',
itemPrefixes: ['远岸', '回声', '曙迹', '长旅', '微光', '新铭'],
itemInfixes: ['印', '纹', '辉', '迹', '息', '铭'],
skillPrefixes: ['映', '折', '回', '逐', '临', '流'],
skillSuffixByStyle: {
burst: ['震', '断', '破', '坠'],
steady: ['守', '定', '护', '镇'],
mobility: ['跃', '移', '转', '行'],
finisher: ['终', '决', '落', '尽'],
projectile: ['矢', '刃', '波', '纹'],
},
},
martial: {
mode: 'martial',
attributeLabels: { strength: '力量', agility: '敏捷', intelligence: '智力', spirit: '精神' },
@@ -61,7 +86,7 @@ const WORLD_PRESENTATIONS: Record<ThemeMode, WorldPresentation> = {
rangeLabel: '招距',
cooldownLabel: '调息',
manaCostLabel: '内力消耗',
campSuffix: '行侠客栈',
campSuffix: '归舍',
itemPrefixes: ['风雨', '青锋', '断桥', '冷铁', '旧案', '残影'],
itemInfixes: ['刃','锋','魂','诀','式','影'],
skillPrefixes: ['破','斩','击','御','飞','隐'],
@@ -85,7 +110,7 @@ const WORLD_PRESENTATIONS: Record<ThemeMode, WorldPresentation> = {
rangeLabel: '术距',
cooldownLabel: '回息',
manaCostLabel: '灵韵消耗',
campSuffix: '宗门行馆',
campSuffix: '栖居',
itemPrefixes: ['灵韵', '道纹', '云篆', '星芒', '界辉', '道痕'],
itemInfixes: ['灵','道','法','术','诀','印'],
skillPrefixes: ['灵','道','法','界','星','印'],
@@ -109,7 +134,7 @@ const WORLD_PRESENTATIONS: Record<ThemeMode, WorldPresentation> = {
rangeLabel: '射程',
cooldownLabel: '充能',
manaCostLabel: '能量消耗',
campSuffix: '机动前哨',
campSuffix: '整备居',
itemPrefixes: ['铁脊', '钢律', '脉冲', '核列', '新星', '等离'],
itemInfixes: ['芯', '驱', '链', '阵', '节', '机'],
skillPrefixes: ['超载', '脉冲', '聚核', '磁轨', '新星', '裂火'],
@@ -133,7 +158,7 @@ const WORLD_PRESENTATIONS: Record<ThemeMode, WorldPresentation> = {
rangeLabel: '潮距',
cooldownLabel: '回潮',
manaCostLabel: '潮息消耗',
campSuffix: '潮栖营地',
campSuffix: '潮',
itemPrefixes: ['潮纹', '海晕', '霜浪', '天澜', '潮歌', '沧流'],
itemInfixes: ['潮', '浪', '汐', '海', '涛', '澜'],
skillPrefixes: ['潮', '浪', '汐', '海', '澜', '涌'],
@@ -157,7 +182,7 @@ const WORLD_PRESENTATIONS: Record<ThemeMode, WorldPresentation> = {
rangeLabel: '界距',
cooldownLabel: '复界',
manaCostLabel: '裂能消耗',
campSuffix: '裂界驻营',
campSuffix: '界隙居所',
itemPrefixes: ['裂界', '断层', '边潮', '灰域', '界桥', '前哨'],
itemInfixes: ['锋', '隙', '锚', '印', '界', '核'],
skillPrefixes: ['裂', '断', '界', '相', '折', '迁'],
@@ -364,8 +389,7 @@ export function getResourceLabelsForWorld(worldType: WorldType | null | undefine
export function buildCustomCampSceneName(profile: CustomWorldProfile) {
const presentation = getWorldPresentation(profile);
return `${presentation.itemPrefixes[0]}${presentation.campSuffix}`;
return resolveCustomWorldCampScene(profile).name;
}
export function buildThemedSkillName(

View File

@@ -1,6 +1,12 @@
import { CustomWorldProfile, WorldTemplateType, WorldType } from '../types';
export type CustomWorldThemeMode = 'martial' | 'arcane' | 'machina' | 'tide' | 'rift';
export type CustomWorldThemeMode =
| 'martial'
| 'arcane'
| 'machina'
| 'tide'
| 'rift'
| 'mythic';
export function detectCustomWorldThemeMode(
profile: Pick<CustomWorldProfile, 'settingText' | 'summary' | 'tone' | 'playerGoal' | 'templateWorldType'>,
@@ -13,12 +19,12 @@ export function detectCustomWorldThemeMode(
if (/[]/u.test(source)) return 'arcane';
if (/[]/u.test(source)) return 'martial';
return profile.templateWorldType === WorldType.XIANXIA ? 'arcane' : 'martial';
return 'mythic';
}
export function resolveCustomWorldAnchorWorldType(
profile: Pick<CustomWorldProfile, 'settingText' | 'summary' | 'tone' | 'playerGoal' | 'templateWorldType'>,
): WorldTemplateType {
const themeMode = detectCustomWorldThemeMode(profile);
return themeMode === 'arcane' || themeMode === 'rift' ? WorldType.XIANXIA : WorldType.WUXIA;
return themeMode === 'arcane' ? WorldType.XIANXIA : WorldType.WUXIA;
}

View File

@@ -46,7 +46,11 @@ function logLlmDebug(title: string, payload: unknown) {
}
function normalizeLlmError(error: unknown): never {
if (error instanceof DOMException && error.name === 'AbortError') {
if (
typeof DOMException !== 'undefined'
&& error instanceof DOMException
&& error.name === 'AbortError'
) {
throw new LlmTimeoutError('The LLM request timed out. Please check the network or endpoint.');
}

View File

@@ -0,0 +1,68 @@
const CJK_CHAR_PATTERN = /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/gu;
const LATIN_WORD_PATTERN = /[A-Za-z][A-Za-z'-]{1,}/g;
const LATIN_FRAGMENT_PATTERN =
/[A-Za-z][A-Za-z0-9'"()\-,:;!?/]*(?:\s+[A-Za-z0-9'"()\-,:;!?/]+)+/gu;
const SAFE_LATIN_TOKENS = new Set([
'act',
'ai',
'boss',
'cd',
'hp',
'json',
'llm',
'mp',
'npc',
'qa',
'rpg',
]);
function getCjkCharCount(text: string) {
return text.match(CJK_CHAR_PATTERN)?.length ?? 0;
}
function getSignificantLatinWords(text: string) {
return (text.match(LATIN_WORD_PATTERN) ?? [])
.map((word) => word.toLowerCase())
.filter(
(word) => word.length >= 4 && !SAFE_LATIN_TOKENS.has(word),
);
}
export function hasMixedNarrativeLanguage(text: string) {
const trimmed = text.trim();
if (!trimmed) {
return false;
}
const cjkCharCount = getCjkCharCount(trimmed);
const latinSentenceFragments = (trimmed.match(LATIN_FRAGMENT_PATTERN) ?? [])
.map((fragment) => fragment.trim())
.filter((fragment) => fragment.split(/\s+/u).length >= 2);
const significantLatinWords = getSignificantLatinWords(trimmed);
if (latinSentenceFragments.length > 0) {
return true;
}
if (cjkCharCount > 0 && significantLatinWords.length >= 2) {
return true;
}
return cjkCharCount === 0 && significantLatinWords.length >= 3;
}
export function sanitizePromptNarrativeText(
text: string | null | undefined,
fallback: string | null = null,
) {
if (typeof text !== 'string') {
return fallback;
}
const trimmed = text.trim();
if (!trimmed) {
return fallback;
}
return hasMixedNarrativeLanguage(trimmed) ? fallback : trimmed;
}

View File

@@ -197,4 +197,106 @@ describe('buildUserPrompt', () => {
expect(prompt).not.toContain(npc.backstoryReveal.chapters[3]!.content);
expect(prompt).not.toContain(npc.initialItems[0]!.name);
});
it('requires an empty encounter payload during non-pending follow-up reasoning such as post-battle continuation', () => {
const prompt = buildUserPrompt(
WorldType.WUXIA,
createCharacter(),
[],
[
{
text: '挥刀抢攻',
options: [],
historyRole: 'action',
},
{
text: '山道客已经败下阵来。',
options: [],
historyRole: 'result',
},
],
{
playerHp: 26,
playerMaxHp: 40,
playerMana: 8,
playerMaxMana: 20,
inBattle: false,
playerX: 0,
playerFacing: 'right',
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
sceneId: 'forest_road',
sceneName: '山道',
sceneDescription: '风从林梢压下来,地上还留着刚才交手的痕迹。',
pendingSceneEncounter: false,
},
'挥刀抢攻',
);
expect(prompt).toContain('encounter 必须为 null');
expect(prompt).toContain('战斗结束后的续写');
});
it('does not feed mixed-language history and directive snippets back into story prompts', () => {
const prompt = buildUserPrompt(
WorldType.WUXIA,
createCharacter(),
[],
[
{
text: 'Move forward carefully.',
options: [],
historyRole: 'action',
},
{
text: 'The wind is cold. 你听见山道尽头有脚步声。',
options: [],
historyRole: 'result',
},
],
{
playerHp: 26,
playerMaxHp: 40,
playerMana: 8,
playerMaxMana: 20,
inBattle: false,
playerX: 0,
playerFacing: 'right',
playerAnimation: AnimationState.ATTACK,
skillCooldowns: {},
sceneId: 'forest_road',
sceneName: '山道',
sceneDescription: '风从林梢压下来。',
pendingSceneEncounter: false,
conversationSituation: 'post_battle_breath',
conversationPressure: 'medium',
recentSharedEvent:
'A fight just ended. Both sides are still catching their breath.',
talkPriority:
'Focus on the most useful judgment, danger, and next step.',
partyRelationshipNotes:
'Lan is becoming more open in private conversation.',
recentChronicleSummary: 'Baseline summary from previous run.',
sceneNarrativeDirective: {
primaryPressure: 'Danger is still active near the camp.',
activeThreadIds: ['thread-old-case'],
foregroundActorIds: [],
foregroundCarrierIds: [],
revealBudget: 'low',
emotionalCadence: 'tense',
},
},
'Move forward carefully.',
);
expect(prompt).not.toContain('A fight just ended');
expect(prompt).not.toContain('Focus on the most useful judgment');
expect(prompt).not.toContain('Baseline summary');
expect(prompt).not.toContain('Move forward carefully');
expect(prompt).not.toContain('thread-old-case');
expect(prompt).not.toContain('Danger is still active');
expect(prompt).toContain('战后缓气');
expect(prompt).toContain('紧绷');
expect(prompt).toContain('这一轮的局势已经出现了新的变化。');
});
});

View File

@@ -42,17 +42,15 @@ import {
} from '../types';
import type { StoryGenerationContext } from './aiTypes';
import { buildCustomWorldReferenceText } from './customWorld';
import { sanitizePromptNarrativeText } from './narrativeLanguage';
import { describeGoalStackForPrompt } from './storyEngine/goalDirector';
import { buildStoryPromptHistory } from './storyHistory';
export const SYSTEM_PROMPT = `你是角色扮演 RPG 的剧情推进者,只能返回 JSON 对象不能输出解释、markdown 或代码块。
输出格式必须严格符合:
{
"storyText": "剧情文本",
"encounter": {
"kind": "npc|treasure|none",
"npcId": "仅当 kind=npc 时填写",
"treasureText": "仅当 kind=treasure 时填写"
},
"encounter": null,
"options": [
{
"functionId": "预定义功能ID",
@@ -61,10 +59,18 @@ export const SYSTEM_PROMPT = `你是角色扮演 RPG 的剧情推进者,只能
]
}
只有当提示语明确要求你判断“主角继续推进后下一刻会遇到什么”时,才允许把 "encounter" 改成:
{
"kind": "npc|treasure|none",
"npcId": "仅当 kind=npc 时填写",
"treasureText": "仅当 kind=treasure 时填写"
}
严格规则:
- 所有文本必须是中文。
- 如果提示语给出了特定可选列表,你必须严格保留原有数量和 functionId你可以调整这些特定项的顺序但排序必须参考最近剧情、刚发生的结果、当前局面轻重缓急再重点优化 actionText下文会直接说明每个 function 的行为边界,不是让你发挥的剧本。
- 如果提示语中没有给特定可选列表,则必须输出至少 6 个选项。
- 除非提示语明确要求你判断下一刻遭遇,否则 encounter 必须保持为 null战斗结束后的续写、聊天续写、固定选项续写都不能生成新的 encounter。
- 每个选项只能包含 functionId 和 actionText。
- 没有特定列表时,所有 functionId 必须互不重复。
- 每个选项只能包含一个 function不要把多个动作塞进同一行。
@@ -139,6 +145,329 @@ export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `你要把玩家与这
- 聊天里出现的重要信息、承诺、顾虑或暗示
长度控制在 45 到 120 个字。`;
function describeConversationSituationLabel(
situation: StoryGenerationContext['conversationSituation'],
) {
switch (situation) {
case 'camp_first_contact':
return '营地初次试探';
case 'camp_followup':
return '营地顺势续谈';
case 'post_battle_breath':
return '战后缓气';
case 'shared_danger_coordination':
return '危险中协同';
case 'private_followup':
return '私下续谈';
case 'first_contact_cautious':
return '谨慎初见';
default:
return '当前对话';
}
}
function describeConversationPressureLabel(
pressure: StoryGenerationContext['conversationPressure'],
) {
switch (pressure) {
case 'high':
return '高压';
case 'medium':
return '中压';
case 'low':
return '低压';
default:
return '未知';
}
}
function describeRevealBudgetLabel(revealBudget: string | null | undefined) {
switch (revealBudget) {
case 'low':
return '低';
case 'medium':
return '中';
case 'high':
return '高';
default:
return '未设定';
}
}
function describeEmotionalCadenceLabel(cadence: string | null | undefined) {
switch (cadence) {
case 'tense':
return '紧绷';
case 'curious':
return '试探';
case 'hostile':
return '敌意';
case 'intimate':
return '亲近';
case 'tragic':
return '沉重';
case 'mysterious':
return '迷雾';
default:
return '未设定';
}
}
function describeCompanionReactionTypeLabel(reactionType: string) {
switch (reactionType) {
case 'approve':
return '认可';
case 'disapprove':
return '保留';
case 'concern':
return '担心';
case 'silence':
return '沉默';
case 'curious':
return '被勾起兴趣';
default:
return '反应';
}
}
function describeActStatusLabel(status: string | null | undefined) {
switch (status) {
case 'opening':
return '开场';
case 'midgame':
return '中段';
case 'late_game':
return '后段';
case 'finale':
return '收束';
case 'resolved':
return '已落定';
default:
return '进行中';
}
}
function describeBranchBudgetPressureLabel(
pressure: StoryGenerationContext['branchBudgetPressure'],
) {
switch (pressure) {
case 'low':
return '低';
case 'medium':
return '中';
case 'high':
return '高';
default:
return '未知';
}
}
function describePlayerStyleLabel(style: string | null | undefined) {
switch (style) {
case 'story_first':
return '剧情优先';
case 'explorer':
return '探索驱动';
case 'combat_driver':
return '战斗推进';
case 'companion_bond':
return '同伴关系';
case 'collector':
return '收集倾向';
default:
return '综合型';
}
}
function describeQaSeverityLabel(severity: string) {
switch (severity) {
case 'low':
return '低';
case 'medium':
return '中';
case 'high':
return '高';
default:
return '未知';
}
}
function describeQaCategoryLabel(category: string) {
switch (category) {
case 'consistency':
return '一致性';
case 'pacing':
return '节奏';
case 'payoff':
return '回收';
case 'branch_budget':
return '分支预算';
case 'reveal_leak':
return '信息泄露';
default:
return '叙事问题';
}
}
function describeReleaseGateStatusLabel(status: string | null | undefined) {
switch (status) {
case 'pass':
return '通过';
case 'warn':
return '警告';
case 'block':
return '阻塞';
default:
return '未知';
}
}
function describeChapterStageLabel(stage: string | null | undefined) {
switch (stage) {
case 'opening':
return '开篇';
case 'expansion':
return '展开';
case 'turning_point':
return '转折';
case 'climax':
return '高潮';
case 'aftermath':
return '余波';
default:
return '进行中';
}
}
function describeJourneyBeatLabel(beatType: string | null | undefined) {
switch (beatType) {
case 'approach':
return '接近';
case 'investigation':
return '调查';
case 'camp':
return '休整';
case 'conflict':
return '冲突';
case 'boss_prelude':
return '决战前奏';
case 'climax':
return '高潮';
case 'recovery':
return '恢复';
default:
return '旅程';
}
}
function describeCampEventTypeLabel(eventType: string | null | undefined) {
switch (eventType) {
case 'private_talk':
return '私下谈话';
case 'party_banter':
return '队伍闲谈';
case 'conflict':
return '冲突';
case 'comfort':
return '安抚';
case 'reveal':
return '揭露';
case 'decision':
return '抉择';
default:
return '营地事件';
}
}
function describeSetpieceTypeLabel(setpieceType: string | null | undefined) {
switch (setpieceType) {
case 'boss_prelude':
return '决战前奏';
case 'showdown':
return '对峙';
case 'climax':
return '高潮';
case 'aftermath':
return '余波';
default:
return '高光节点';
}
}
function describeWorldMutationTypeLabel(mutationType: string) {
switch (mutationType) {
case 'scene_text':
return '场景变化';
case 'npc_attitude':
return '人物态度变化';
case 'shop_style':
return '商铺风格变化';
case 'enemy_pressure':
return '敌方压力变化';
case 'route_lock':
return '路径封锁';
case 'route_unlock':
return '路径开启';
default:
return '世界变化';
}
}
function describeAnimationLabel(animation: string | null | undefined) {
switch (animation) {
case 'idle':
return '待机';
case 'acquire':
return '收取';
case 'attack':
return '攻击';
case 'run':
return '奔跑';
case 'jump':
return '跳跃';
case 'double jump':
return '二段跳';
case 'jump attack':
return '跳击';
case 'dash':
return '冲刺';
case 'hurt':
return '受击';
case 'die':
return '倒下';
case 'climb':
return '攀爬';
case 'skill1':
return '技能一';
case 'skill1 jump':
return '技能一起跳';
case 'skill1 bullet':
return '技能一弹道';
case 'skill1 bullet FX':
return '技能一特效';
case 'skill2':
return '技能二';
case 'skill2 jump':
return '技能二起跳';
case 'skill3':
return '技能三';
case 'skill3 jump':
return '技能三起跳';
case 'skill3 bullet':
return '技能三弹道';
case 'skill3 bullet FX':
return '技能三特效';
case 'skill4':
return '技能四';
case 'Wall Slide':
return '贴墙滑行';
case 'move':
return '逼近';
default:
return animation ?? '当前动作';
}
}
export function describeWorld(world: WorldType) {
if (world === WorldType.WUXIA) return '武侠';
if (world === WorldType.XIANXIA) return '仙侠';
@@ -259,12 +588,25 @@ function describeConversationSituationDirective(context: StoryGenerationContext)
return null;
}
const recentSharedEvent = sanitizePromptNarrativeText(
context.recentSharedEvent,
'你们刚共同经历了一段需要承接的局势变化。',
);
const talkPriority = sanitizePromptNarrativeText(
context.talkPriority,
'优先承接眼前局势与刚刚发生的变化。',
);
return [
'当前对话情景控制:',
context.conversationSituation ? `- 情景标签:${context.conversationSituation}` : null,
context.conversationPressure ? `- 当前压力:${context.conversationPressure}` : null,
context.recentSharedEvent ? `- 刚刚共同经历:${context.recentSharedEvent}` : null,
context.talkPriority ? `- 本轮优先说法:${context.talkPriority}` : null,
context.conversationSituation
? `- 情景标签:${describeConversationSituationLabel(context.conversationSituation)}`
: null,
context.conversationPressure
? `- 当前压力:${describeConversationPressureLabel(context.conversationPressure)}`
: null,
recentSharedEvent ? `- 刚刚共同经历:${recentSharedEvent}` : null,
talkPriority ? `- 本轮优先说法:${talkPriority}` : null,
].filter(Boolean).join('\n');
}
@@ -356,12 +698,18 @@ function describeSceneNarrativeDirectiveSection(context: StoryGenerationContext)
}
const directive = context.sceneNarrativeDirective;
const primaryPressure = sanitizePromptNarrativeText(
directive.primaryPressure,
'当前场景仍有未被说透的压力。',
);
return [
'当前场景导演指令:',
`- 主压力:${directive.primaryPressure}`,
`- 激活线程:${directive.activeThreadIds.join('、') || '暂无'}`,
`- 揭示预算:${directive.revealBudget}`,
`- 情绪节奏:${directive.emotionalCadence}`,
primaryPressure ? `- 主压力:${primaryPressure}` : null,
directive.activeThreadIds.length > 0
? `- 当前激活故事线程数量:${directive.activeThreadIds.length}`
: null,
`- 揭示预算:${describeRevealBudgetLabel(directive.revealBudget)}`,
`- 情绪节奏:${describeEmotionalCadenceLabel(directive.emotionalCadence)}`,
].join('\n');
}
@@ -373,8 +721,15 @@ function describeRecentCompanionReactionsSection(context: StoryGenerationContext
return [
'最近一次同行反应:',
...context.recentCompanionReactions.slice(-3).map(
(reaction) =>
`- ${reaction.characterId} / ${reaction.reactionType}${reaction.reason}`,
(reaction) => {
const safeReason = sanitizePromptNarrativeText(
reaction.reason,
'同行角色对你刚才那一步有了新的态度变化。',
);
const speaker =
getCharacterById(reaction.characterId)?.name ?? '同行角色';
return `- ${speaker} / ${describeCompanionReactionTypeLabel(reaction.reactionType)}${safeReason}`;
},
),
].join('\n');
}
@@ -398,10 +753,10 @@ function describeCampaignSection(context: StoryGenerationContext) {
return [
'当前战役状态:',
context.campaignState
? `- Campaign${context.campaignState.title}Act ${context.campaignState.currentActIndex + 1}`
? `- 当前战役${context.campaignState.title} ${context.campaignState.currentActIndex + 1}`
: null,
context.actState
? `- 当前 Act${context.actState.title} / ${context.actState.status} / ${context.actState.theme}`
? `- 当前${context.actState.title} / ${describeActStatusLabel(context.actState.status)} / ${context.actState.theme}`
: null,
].filter(Boolean).join('\n');
}
@@ -431,7 +786,7 @@ function describeConstraintSection(context: StoryGenerationContext) {
`- 禁止模式:${pack.noGoPatterns.join('、') || '暂无'}`,
`- 必须回收:${pack.requiredPayoffs.join('、') || '暂无'}`,
context.branchBudgetPressure
? `- 当前分支预算压力:${context.branchBudgetPressure}`
? `- 当前分支预算压力:${describeBranchBudgetPressureLabel(context.branchBudgetPressure)}`
: null,
].filter(Boolean).join('\n');
}
@@ -444,10 +799,10 @@ function describePackSection(context: StoryGenerationContext) {
return [
'当前内容包:',
context.activeScenarioPack
? `- Scenario Pack${context.activeScenarioPack.title} v${context.activeScenarioPack.version}`
? `- 当前场景包${context.activeScenarioPack.title} v${context.activeScenarioPack.version}`
: null,
context.activeCampaignPack
? `- Campaign Pack${context.activeCampaignPack.title} / ${context.activeCampaignPack.authoringStyle}`
? `- 当前战役包${context.activeCampaignPack.title} / ${context.activeCampaignPack.authoringStyle}`
: null,
].filter(Boolean).join('\n');
}
@@ -459,7 +814,7 @@ function describePlayerStyleSection(context: StoryGenerationContext) {
return [
'当前玩家画像:',
`- 风格:${context.playerStyleProfile.dominantStyle}`,
`- 风格:${describePlayerStyleLabel(context.playerStyleProfile.dominantStyle)}`,
`- 倾向:剧情 ${context.playerStyleProfile.preferenceWeights.story} / 探索 ${context.playerStyleProfile.preferenceWeights.exploration} / 战斗 ${context.playerStyleProfile.preferenceWeights.combat} / 同伴 ${context.playerStyleProfile.preferenceWeights.companion} / 收集 ${context.playerStyleProfile.preferenceWeights.collection}`,
].join('\n');
}
@@ -473,13 +828,14 @@ function describeNarrativeQaSection(context: StoryGenerationContext) {
'当前叙事 QA',
`- 摘要:${context.narrativeQaReport.summary}`,
...context.narrativeQaReport.issues.slice(0, 4).map(
(issue) => `- ${issue.severity}/${issue.category}${issue.summary}`,
(issue) =>
`- ${describeQaSeverityLabel(issue.severity)} / ${describeQaCategoryLabel(issue.category)}${issue.summary}`,
),
context.releaseGateReport
? `- Release Gate${context.releaseGateReport.status} / ${context.releaseGateReport.summary}`
? `- 发布门禁:${describeReleaseGateStatusLabel(context.releaseGateReport.status)} / ${context.releaseGateReport.summary}`
: null,
context.simulationRunResults?.length
? `- Simulation 覆盖:${context.simulationRunResults.length}`
? `- 模拟覆盖:${context.simulationRunResults.length}`
: null,
].join('\n');
}
@@ -492,7 +848,7 @@ function describeChapterSection(context: StoryGenerationContext) {
return [
'当前章节状态:',
`- 标题:${context.chapterState.title}`,
`- 阶段:${context.chapterState.stage}`,
`- 阶段:${describeChapterStageLabel(context.chapterState.stage)}`,
`- 主题:${context.chapterState.theme}`,
`- 摘要:${context.chapterState.chapterSummary}`,
].join('\n');
@@ -505,12 +861,16 @@ function describeJourneyBeatSection(context: StoryGenerationContext) {
return [
'当前旅程段落:',
`- 类型:${context.journeyBeat.beatType}`,
`- 类型:${describeJourneyBeatLabel(context.journeyBeat.beatType)}`,
`- 标题:${context.journeyBeat.title}`,
`- 情绪目标:${context.journeyBeat.emotionalGoal}`,
].join('\n');
}
function describeGoalStackSection(context: StoryGenerationContext) {
return describeGoalStackForPrompt(context.goalStack);
}
function describeCampEventSection(context: StoryGenerationContext) {
if (!context.currentCampEvent) {
return null;
@@ -519,7 +879,7 @@ function describeCampEventSection(context: StoryGenerationContext) {
return [
'当前可触发营地/旅途事件:',
`- 标题:${context.currentCampEvent.title}`,
`- 类型:${context.currentCampEvent.eventType}`,
`- 类型:${describeCampEventTypeLabel(context.currentCampEvent.eventType)}`,
`- 原因:${context.currentCampEvent.triggerReason}`,
].join('\n');
}
@@ -531,7 +891,7 @@ function describeSetpieceSection(context: StoryGenerationContext) {
return [
'当前高光导演指令:',
`- 类型:${context.setpieceDirective.setpieceType}`,
`- 类型:${describeSetpieceTypeLabel(context.setpieceDirective.setpieceType)}`,
`- 标题:${context.setpieceDirective.title}`,
`- 核心问题:${context.setpieceDirective.dramaticQuestion}`,
].join('\n');
@@ -546,7 +906,7 @@ function describeWorldMutationSection(context: StoryGenerationContext) {
'最近世界变化:',
...context.recentWorldMutations.slice(-4).map(
(mutation) =>
`- ${mutation.mutationType} / ${mutation.targetId}${mutation.reason}`,
`- ${describeWorldMutationTypeLabel(mutation.mutationType)}${mutation.reason}`,
),
].join('\n');
}
@@ -560,17 +920,20 @@ function describeFactionTensionSection(context: StoryGenerationContext) {
'当前阵营温度:',
...context.recentFactionTensionStates.slice(0, 4).map(
(tension) =>
`- ${tension.factionId} / 温度 ${tension.temperature}${tension.pressureSummary}`,
`- 温度 ${tension.temperature}${tension.pressureSummary}`,
),
].join('\n');
}
function describeChronicleSection(context: StoryGenerationContext) {
if (!context.recentChronicleSummary?.trim()) {
const chronicleSummary = sanitizePromptNarrativeText(
context.recentChronicleSummary,
);
if (!chronicleSummary) {
return null;
}
return `近期旅程回顾:\n${context.recentChronicleSummary}`;
return `近期旅程回顾:\n${chronicleSummary}`;
}
function buildCustomEncounterBackstoryLines(context: StoryGenerationContext) {
@@ -611,7 +974,12 @@ function buildCustomEncounterBackstoryLines(context: StoryGenerationContext) {
function describeBackstoryContext(label: string, snippets: string[]) {
const normalized = snippets
.map(snippet => snippet.trim())
.map((snippet) =>
sanitizePromptNarrativeText(
snippet,
`${label === '主角背景' ? '主角' : '对方'}仍有自己的来路,但此刻不直接沿用非中文原句。`,
),
)
.filter(Boolean);
if (normalized.length === 0) {
@@ -847,8 +1215,8 @@ function describeFrontEntity(
context.encounterKind === 'npc' && context.encounterAffinityText
? `- 对你的态度:${context.encounterAffinityText}`
: null,
context.encounterRelationshipSummary
? `- 你与对方私下相处补充:${context.encounterRelationshipSummary}`
sanitizePromptNarrativeText(context.encounterRelationshipSummary)
? `- 你与对方私下相处补充:${sanitizePromptNarrativeText(context.encounterRelationshipSummary)}`
: null,
].filter(Boolean).join('\n');
}
@@ -872,7 +1240,7 @@ function describeFrontEntity(
'- 身份:当前最靠前的敌对目标',
`- 描述:${monsterPreset?.description ?? primaryMonster.description}`,
'- 性格:更接近本能性的压迫与试探,会按当前动作持续逼近你',
`- 状态:生命状态 ${describeHpBand(hpRatio)},当前动作 ${primaryMonster.animation},朝向 ${describeFacing(primaryMonster.facing)}`,
`- 状态:生命状态 ${describeHpBand(hpRatio)},当前动作 ${describeAnimationLabel(primaryMonster.animation)},朝向 ${describeFacing(primaryMonster.facing)}`,
...describeAttributeProfileForPrompt('敌对实体', world, context, monsterProfile).map(line => `- ${line}`),
].join('\n');
}
@@ -893,14 +1261,19 @@ function describePlayerState(world: WorldType, character: Character, context: St
`玩家状态:${context.inBattle ? '战斗状态' : '空闲状态'}`,
`当前场景:${sceneName}`,
`场景描述:${sceneDescription}`,
context.lastObserveSignsReport ? `最近一次观察结果:${context.lastObserveSignsReport}` : null,
sanitizePromptNarrativeText(context.lastObserveSignsReport)
? `最近一次观察结果:${sanitizePromptNarrativeText(context.lastObserveSignsReport)}`
: null,
sanitizePromptNarrativeText(context.recentActionResult)
? `刚刚结算结果:${sanitizePromptNarrativeText(context.recentActionResult)}`
: null,
`主角:${character.name}${character.title}`,
`主角描述:${character.description}`,
...playerBackstoryLines,
`主角性格:${character.personality}`,
...describePlayerOpeningByContext(character, world, context),
`世界属性框架:${buildSchemaSummary(schema).map(slot => `${slot.name}${slot.definition}`).join('、')}`,
`主角状态:生命状态 ${describeHpBand(hpRatio)},灵力状态 ${describeManaBand(manaRatio)},整体判断 ${describeOverallBand(hpRatio, manaRatio)},朝向 ${describeFacing(context.playerFacing)},当前动作 ${context.playerAnimation}`,
`主角状态:生命状态 ${describeHpBand(hpRatio)},灵力状态 ${describeManaBand(manaRatio)},整体判断 ${describeOverallBand(hpRatio, manaRatio)},朝向 ${describeFacing(context.playerFacing)},当前动作 ${describeAnimationLabel(context.playerAnimation)}`,
...describeAttributeProfileForPrompt('主角', world, context, attributeProfile),
].filter(Boolean).join('\n');
}
@@ -913,7 +1286,7 @@ function describeMonsters(monsters: SceneHostileNpc[]) {
return monsters
.map(monster => {
const hpRatio = monster.hp / Math.max(monster.maxHp, 1);
return `敌对目标 ${monster.name}:生命状态 ${describeHpBand(hpRatio)},当前动作 ${monster.animation},行为“${monster.action}”,朝向 ${describeFacing(monster.facing)}`;
return `敌对目标 ${monster.name}:生命状态 ${describeHpBand(hpRatio)},当前动作 ${describeAnimationLabel(monster.animation)},行为“${monster.action}”,朝向 ${describeFacing(monster.facing)}`;
})
.join('\n');
}
@@ -928,17 +1301,29 @@ function _describeHistory(history: string[]) {
function describeStoryHistory(history: StoryMoment[]) {
const promptHistory = buildStoryPromptHistory(history);
const previousSummary = sanitizePromptNarrativeText(
promptHistory.previousSummary,
'更早的剧情已经推进过数轮,请只承接既有结果,不直接沿用其中的非中文原句。',
);
const recentOriginalRounds = promptHistory.recentOriginalRounds
.map((item) =>
sanitizePromptNarrativeText(
item,
'这一轮的原始文本里夹杂了非中文描述,续写时只承接已发生的结果与局势变化。',
),
)
.filter(Boolean);
if (!promptHistory.previousSummary && promptHistory.recentOriginalRounds.length === 0) {
if (!previousSummary && recentOriginalRounds.length === 0) {
return '最近剧情:暂无。';
}
return [
promptHistory.previousSummary
? `3轮以前的历史剧情总结\n${promptHistory.previousSummary}`
previousSummary
? `3轮以前的历史剧情总结\n${previousSummary}`
: '3轮以前的历史剧情总结暂无。',
promptHistory.recentOriginalRounds.length > 0
? `最近3轮剧情原文续写时优先承接\n${promptHistory.recentOriginalRounds
recentOriginalRounds.length > 0
? `最近3轮剧情原文续写时优先承接\n${recentOriginalRounds
.map((item, index) => `- 第${index + 1}\n${item}`)
.join('\n')}`
: '最近3轮剧情原文暂无。',
@@ -975,8 +1360,23 @@ function _buildResolvedUserPrompt(
const hasProvidedNpcChatOptions = Boolean(availableOptions?.some(option => option.functionId === 'npc_chat'));
const isOpeningCampDialogue = context.lastFunctionId === 'story_opening_camp_dialogue' && Boolean(context.encounterName);
const hasOpeningCampFollowupContext = hasProvidedNpcChatOptions
&& Boolean(context.openingCampBackground?.trim())
&& Boolean(context.openingCampDialogue?.trim());
&& Boolean(sanitizePromptNarrativeText(context.openingCampBackground))
&& Boolean(sanitizePromptNarrativeText(context.openingCampDialogue));
const partyRelationshipNotes = sanitizePromptNarrativeText(
context.partyRelationshipNotes,
);
const openingCampBackground = sanitizePromptNarrativeText(
context.openingCampBackground,
);
const openingCampDialogue = sanitizePromptNarrativeText(
context.openingCampDialogue,
);
const safeChoice = choice
? sanitizePromptNarrativeText(
choice,
'玩家刚刚做出了一个新的决定。',
)
: null;
const sceneMonsterIds = getSceneHostileNpcPresetIds(scene);
const battleCatalog = scene
? buildFunctionCatalogText({
@@ -1005,6 +1405,7 @@ function _buildResolvedUserPrompt(
describeCampaignSection(context),
describeChapterSection(context),
describeJourneyBeatSection(context),
describeGoalStackSection(context),
describeSceneNarrativeDirectiveSection(context),
describeVisibilitySliceSection(context),
describeConsequenceLedgerSection(context),
@@ -1022,11 +1423,11 @@ function _buildResolvedUserPrompt(
context.encounterName ? `当前面前实体性别:${describeGender(getEncounterGender(context))}` : null,
describeSkills(character, context),
`当前敌对目标状态:\n${describeMonsters(monsters)}`,
context.partyRelationshipNotes ? `同行角色补充关系信息:\n${context.partyRelationshipNotes}` : null,
partyRelationshipNotes ? `同行角色补充关系信息:\n${partyRelationshipNotes}` : null,
describeStoryHistory(history),
hasOpeningCampFollowupContext ? `营地开场背景:\n${context.openingCampBackground}` : null,
hasOpeningCampFollowupContext ? `刚刚发生的第一段营地对话:\n${context.openingCampDialogue}` : null,
choice ? `玩家刚刚选择:${choice}` : '玩家刚进入当前局面。',
hasOpeningCampFollowupContext ? `营地开场背景:\n${openingCampBackground}` : null,
hasOpeningCampFollowupContext ? `刚刚发生的第一段营地对话:\n${openingCampDialogue}` : null,
safeChoice ? `玩家刚刚选择:${safeChoice}` : '玩家刚进入当前局面。',
hasProvidedOptions
? `固定可选项列表(必须保持数量与 functionId 一致,可按最近剧情重排顺序):\n${describeProvidedOptions(availableOptions ?? [])}`
: pendingEncounter
@@ -1048,6 +1449,7 @@ function _buildResolvedUserPrompt(
hasProvidedOptions || !pendingEncounter
? null
: '你必须先判断主角继续推进后下一刻会遇到什么,并在 encounter 中填写结果。若遇到敌对对象,请使用 kind=npc 并填写 npcId若不是场景角色则 options 必须使用空闲 function。',
describeEncounterOutputRequirement(Boolean(pendingEncounter)),
'当敌人状态偏差时,攻击类 actionText 要更像收割、补刀或终结;当主角低血时,恢复类 actionText 要更像稳住伤势、打坐或调息;当主角低蓝时,至少一个 actionText 要体现节省消耗或稳住节奏。',
'storyText 和 options 必须与当前状态严格一致,不能把已经死亡、已经离场或当前不在眼前的实体重新写回画面。',
];
@@ -1141,6 +1543,12 @@ function describeProvidedOptions(options: StoryOption[]) {
.join('\n');
}
function describeEncounterOutputRequirement(pendingEncounter: boolean) {
return pendingEncounter
? '只有当前文明确要求你判断“主角继续推进后下一刻会遇到什么”时encounter 才能填写对象;如果这一刻什么都没遇到,请填写 kind=none。'
: '当前这一步不是遭遇生成流程。encounter 必须为 null保持为空不要生成新的 encounter尤其是战斗结束后的续写、聊天续写、固定选项续写时禁止新增场景实体。';
}
function buildCatalogAwareUserPrompt(
world: WorldType,
character: Character,
@@ -1170,8 +1578,23 @@ function buildCatalogAwareUserPrompt(
const hasProvidedNpcChatOptions = Boolean(availableOptions?.some(option => option.functionId === 'npc_chat'));
const isOpeningCampDialogue = context.lastFunctionId === 'story_opening_camp_dialogue' && Boolean(context.encounterName);
const hasOpeningCampFollowupContext = hasProvidedNpcChatOptions
&& Boolean(context.openingCampBackground?.trim())
&& Boolean(context.openingCampDialogue?.trim());
&& Boolean(sanitizePromptNarrativeText(context.openingCampBackground))
&& Boolean(sanitizePromptNarrativeText(context.openingCampDialogue));
const partyRelationshipNotes = sanitizePromptNarrativeText(
context.partyRelationshipNotes,
);
const openingCampBackground = sanitizePromptNarrativeText(
context.openingCampBackground,
);
const openingCampDialogue = sanitizePromptNarrativeText(
context.openingCampDialogue,
);
const safeChoice = choice
? sanitizePromptNarrativeText(
choice,
'玩家刚刚做出了一个新的决定。',
)
: null;
const battleCatalog = scene
? buildFunctionCatalogText({
...functionContext,
@@ -1199,6 +1622,7 @@ function buildCatalogAwareUserPrompt(
describeCampaignSection(context),
describeChapterSection(context),
describeJourneyBeatSection(context),
describeGoalStackSection(context),
describeSceneNarrativeDirectiveSection(context),
describeVisibilitySliceSection(context),
describeConsequenceLedgerSection(context),
@@ -1217,11 +1641,11 @@ function buildCatalogAwareUserPrompt(
describeConversationSituationDirective(context),
describeEncounterConversationDirective(context),
describeFirstMeaningfulContactDirective(context),
context.partyRelationshipNotes ? `同行角色补充关系信息:\n${context.partyRelationshipNotes}` : null,
partyRelationshipNotes ? `同行角色补充关系信息:\n${partyRelationshipNotes}` : null,
describeStoryHistory(history),
hasOpeningCampFollowupContext ? `营地开场背景:\n${context.openingCampBackground}` : null,
hasOpeningCampFollowupContext ? `刚刚发生的第一段营地对话:\n${context.openingCampDialogue}` : null,
choice ? `玩家刚刚选择:${choice}` : '玩家刚进入当前局面。',
hasOpeningCampFollowupContext ? `营地开场背景:\n${openingCampBackground}` : null,
hasOpeningCampFollowupContext ? `刚刚发生的第一段营地对话:\n${openingCampDialogue}` : null,
safeChoice ? `玩家刚刚选择:${safeChoice}` : '玩家刚进入当前局面。',
hasProvidedOptions
? `固定可选项列表(必须保留数量与 functionId可按最近剧情重排顺序\n${describeProvidedOptions(availableOptions ?? [])}`
: hasOptionCatalog
@@ -1251,6 +1675,7 @@ function buildCatalogAwareUserPrompt(
hasProvidedOptions || hasOptionCatalog || !pendingEncounter
? null
: '你必须先判断主角继续推进后下一刻会遇到什么,并在 encounter 中填写结果。若遇到敌对对象,请使用 kind=npc 并填写 npcId若不是场景角色则 options 必须使用空闲 function。',
describeEncounterOutputRequirement(Boolean(pendingEncounter)),
'当敌人状态偏差时,攻击类 actionText 要更像收割、补刀或终结;当主角低血时,恢复类 actionText 要更像稳住伤势、打坐或调息;当主角低蓝时,至少一个 actionText 要体现节省消耗或稳住节奏。',
'storyText 和 options 必须与当前状态严格一致,不能把已经死亡、已经离场或当前不在眼前的实体重新写回画面。',
];
@@ -1297,6 +1722,20 @@ function buildResolvedNpcChatDialoguePrompt(
topic: string,
resultSummary: string,
) {
const openingCampBackground = sanitizePromptNarrativeText(
context.openingCampBackground,
);
const openingCampDialogue = sanitizePromptNarrativeText(
context.openingCampDialogue,
);
const safeTopic =
sanitizePromptNarrativeText(topic, '眼前刚刚谈到的话头') ?? topic;
const safeResultSummary =
sanitizePromptNarrativeText(
resultSummary,
'这段聊天刚让你们之间的气氛发生了新的变化。',
) ?? resultSummary;
return [
`世界:${describeWorldForPrompt(world, context.customWorldProfile)}`,
describePlayerState(world, character, context),
@@ -1308,6 +1747,7 @@ function buildResolvedNpcChatDialoguePrompt(
describeCampaignSection(context),
describeChapterSection(context),
describeJourneyBeatSection(context),
describeGoalStackSection(context),
describeSceneNarrativeDirectiveSection(context),
describeVisibilitySliceSection(context),
describeConsequenceLedgerSection(context),
@@ -1324,18 +1764,18 @@ function buildResolvedNpcChatDialoguePrompt(
describeSkills(character, context),
`当前敌对目标状态:\n${describeMonsters(monsters)}`,
describeStoryHistory(history),
context.openingCampBackground ? `营地开场背景:\n${context.openingCampBackground}` : null,
context.openingCampDialogue ? `刚刚发生的第一段营地对话:\n${context.openingCampDialogue}` : null,
openingCampBackground ? `营地开场背景:\n${openingCampBackground}` : null,
openingCampDialogue ? `刚刚发生的第一段营地对话:\n${openingCampDialogue}` : null,
`当前交谈对象:${encounterName}`,
`聊天主题:${topic}`,
`关系变化结果:${resultSummary}`,
`聊天主题:${safeTopic}`,
`关系变化结果:${safeResultSummary}`,
describeConversationSituationDirective(context),
describeEncounterConversationDirective(context),
describeFirstMeaningfulContactDirective(context),
context.openingCampBackground && context.openingCampDialogue
openingCampBackground && openingCampDialogue
? '这段 npc_chat 必须承接上面的营地开场背景和第一段对话,像同一段谈话自然往下推进,不要把语气和话题重置成初见模板。'
: null,
`请围绕“${topic}”写一段刚刚发生的对话。必须只输出对白正文,每一行都必须以“你:”或“${encounterName}:”开头。`,
`请围绕“${safeTopic}”写一段刚刚发生的对话。必须只输出对白正文,每一行都必须以“你:”或“${encounterName}:”开头。`,
].filter(Boolean).join('\n\n');
}
@@ -1391,6 +1831,15 @@ export function buildNpcRecruitDialoguePrompt(
invitationText: string,
recruitSummary: string,
) {
const safeInvitationText =
sanitizePromptNarrativeText(invitationText, '我希望你能加入队伍,与我并肩同行。') ??
invitationText;
const safeRecruitSummary =
sanitizePromptNarrativeText(
recruitSummary,
'双方已经具备继续同行的条件。',
) ?? recruitSummary;
return [
`世界:${describeWorldForPrompt(world, context.customWorldProfile)}`,
describePlayerState(world, character, context),
@@ -1402,6 +1851,7 @@ export function buildNpcRecruitDialoguePrompt(
describeCampaignSection(context),
describeChapterSection(context),
describeJourneyBeatSection(context),
describeGoalStackSection(context),
describeSceneNarrativeDirectiveSection(context),
describeVisibilitySliceSection(context),
describeConsequenceLedgerSection(context),
@@ -1419,8 +1869,8 @@ export function buildNpcRecruitDialoguePrompt(
`当前敌对目标状态:\n${describeMonsters(monsters)}`,
describeStoryHistory(history),
`当前招募对象:${encounter.npcName}`,
`玩家邀请:${invitationText}`,
`招募补充条件:${recruitSummary}`,
`玩家邀请:${safeInvitationText}`,
`招募补充条件:${safeRecruitSummary}`,
describeConversationSituationDirective(context),
describeEncounterConversationDirective(context),
describeFirstMeaningfulContactDirective(context),

View File

@@ -27,6 +27,19 @@ function coerceString(value: unknown, fallback: string) {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
function coerceQuestTitle(value: unknown, fallback: string) {
const title = coerceString(value, fallback)
.replace(/["']/gu, '')
.replace(/[,.!?;:].*$/u, '')
.trim();
if (title.length <= 12) {
return title;
}
return fallback.length <= 12 ? fallback : fallback.slice(0, 10);
}
function coerceStringArray(value: unknown, fallback: string[]) {
if (!Array.isArray(value)) {
return fallback;
@@ -82,7 +95,7 @@ function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIn
const intent = rawIntent as Record<string, unknown>;
return {
title: coerceString(intent.title, fallback.title),
title: coerceQuestTitle(intent.title, fallback.title),
description: coerceString(intent.description, fallback.description),
summary: coerceString(intent.summary, fallback.summary),
narrativeType: (
@@ -237,4 +250,3 @@ export async function generateQuestForNpcEncounter(params: {
);
}
}

View File

@@ -138,7 +138,9 @@ export const QUEST_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的任务
- 不要编造奖励、ID、数量、状态或不受支持的规则变化。
- recommendedObjectiveKinds 只是语义建议,不是硬编码结果。
- 优先给出简洁、可玩的任务框架,不要堆砌华丽辞藻。
- 如果当前场景存在威胁或异常,任务应当自然从该局势中生长出来。`;
- title 必须是 4 到 10 个中文字符左右的短任务名,不要写成长句。
- description 解释任务为什么在当前剧情里成立,避免纯规则说明。
- summary 必须是清晰达成条件,优先使用“击败 / 调查 / 返回 / 前往 / 交付 / 切磋”等明确动词,不要写“处理一下”“看看情况”这类模糊表达。`;
export function buildQuestIntentPrompt(params: {
context: QuestGenerationContext;

View File

@@ -0,0 +1,365 @@
import { describe, expect, it } from 'vitest';
import type {
CampEvent,
ChapterState,
GameState,
JourneyBeat,
QuestLogEntry,
SetpieceDirective,
StoryOption,
} from '../../types';
import { AnimationState } from '../../types';
import {
annotateStoryOptionsWithGoalAffordance,
buildGoalHandoffFromState,
buildGoalStackState,
createGoalPulseSnapshot,
deriveGoalPulseEvent,
describeGoalStackForPrompt,
sortQuestsForGoalPanel,
} from './goalDirector';
function createQuest(overrides: Partial<QuestLogEntry> & Pick<QuestLogEntry, 'id' | 'title'>): QuestLogEntry {
return {
id: overrides.id,
issuerNpcId: overrides.issuerNpcId ?? `${overrides.id}-issuer`,
issuerNpcName: overrides.issuerNpcName ?? '林朔',
sceneId: overrides.sceneId ?? 'scene-ruins',
title: overrides.title,
description: overrides.description ?? `${overrides.title} 的说明`,
summary: overrides.summary ?? `${overrides.title} 的摘要`,
objective: overrides.objective ?? {
kind: 'inspect_treasure',
targetSceneId: 'scene-ruins',
requiredCount: 1,
},
progress: overrides.progress ?? 0,
status: overrides.status ?? 'active',
reward: overrides.reward ?? {
affinityBonus: 10,
currency: 20,
items: [],
},
rewardText: overrides.rewardText ?? '奖励已准备',
narrativeBinding: overrides.narrativeBinding,
steps: overrides.steps,
activeStepId: overrides.activeStepId,
threadId: overrides.threadId ?? null,
completionNotified: overrides.completionNotified ?? false,
};
}
function createSceneDirective() {
return {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right' as const,
scrollWorld: false,
monsterChanges: [],
};
}
describe('goalDirector', () => {
it('uses the ready-to-turn-in quest as the current goal and immediate step', () => {
const chapterState: ChapterState = {
id: 'chapter-1',
title: '封桥旧案',
theme: '封桥旧案',
primaryThreadIds: ['thread-bridge'],
stage: 'expansion',
chapterSummary: '桥上的旧案正被重新翻开。',
};
const journeyBeat: JourneyBeat = {
id: 'beat-1',
beatType: 'investigation',
title: '追查旧桥异动',
triggerThreadIds: ['thread-bridge'],
recommendedSceneIds: ['scene-bridge'],
emotionalGoal: '把线索从零散异常收束成可追查的方向。',
};
const setpieceDirective: SetpieceDirective = {
id: 'setpiece-1',
title: '桥门对峙',
setpieceType: 'boss_prelude',
relatedThreadIds: ['thread-bridge'],
sceneFocusId: 'scene-bridge',
dramaticQuestion: '旧桥另一侧到底是谁在阻拦真相?',
};
const currentCampEvent: CampEvent = {
id: 'camp-1',
eventType: 'private_talk',
title: '夜谈未尽之事',
participantCharacterIds: ['companion-1'],
triggerReason: '同伴对桥上的旧案起了新的疑心。',
relatedThreadIds: ['thread-bridge'],
};
const readyQuest = createQuest({
id: 'quest-ready',
title: '回报遗迹调查',
status: 'ready_to_turn_in',
issuerNpcName: '陆清',
threadId: 'thread-bridge',
narrativeBinding: {
origin: 'ai_compiled',
narrativeType: 'investigation',
dramaticNeed: '必须确认遗迹的异动来源。',
issuerGoal: '拿到调查结果后继续推进旧桥线索。',
playerHook: '你已经掌握了最关键的现场信息。',
worldReason: '如果再拖下去,线索会继续散掉。',
followupHooks: [],
},
rewardText: '回去找陆清交付调查结果。',
});
const sideQuest = createQuest({
id: 'quest-side',
title: '整理营地补给',
status: 'active',
narrativeBinding: {
origin: 'fallback_builder',
narrativeType: 'relationship',
dramaticNeed: '营地气氛有些不稳。',
issuerGoal: '先把补给和情绪都稳住。',
playerHook: '这能让后续推进更从容。',
worldReason: '大家都还没完全从上一段冲突里缓过来。',
followupHooks: [],
},
});
const goalStack = buildGoalStackState({
quests: [sideQuest, readyQuest],
worldType: null,
chapterState,
journeyBeat,
setpieceDirective,
currentCampEvent,
currentSceneName: '断桥旧哨',
});
expect(goalStack.northStarGoal?.sourceKind).toBe('setpiece');
expect(goalStack.activeGoal?.sourceKind).toBe('quest');
expect(goalStack.activeGoal?.sourceId).toBe('quest-ready');
expect(goalStack.immediateStepGoal?.title).toContain('陆清');
expect(goalStack.supportGoals.some((goal) => goal.sourceId === 'quest-side')).toBe(true);
expect(goalStack.supportGoals.some((goal) => goal.sourceKind === 'relationship')).toBe(true);
const sortedQuestIds = sortQuestsForGoalPanel([sideQuest, readyQuest], goalStack).map((quest) => quest.id);
expect(sortedQuestIds[0]).toBe('quest-ready');
});
it('falls back to the current journey beat when no quest is active', () => {
const chapterState: ChapterState = {
id: 'chapter-2',
title: '山门前夜',
theme: '山门风声',
primaryThreadIds: ['thread-gate'],
stage: 'opening',
chapterSummary: '风声刚起,矛盾还在缓慢聚拢。',
};
const journeyBeat: JourneyBeat = {
id: 'beat-2',
beatType: 'approach',
title: '接近山门真相',
triggerThreadIds: ['thread-gate'],
recommendedSceneIds: ['scene-gate'],
emotionalGoal: '先把前情、威胁和方向重新拢到一起。',
};
const goalStack = buildGoalStackState({
quests: [],
worldType: null,
chapterState,
journeyBeat,
currentSceneName: '山门外缘',
});
expect(goalStack.northStarGoal?.sourceKind).toBe('chapter');
expect(goalStack.activeGoal?.sourceKind).toBe('journey_beat');
expect(goalStack.immediateStepGoal?.nextStepText).toContain('前往');
expect(goalStack.immediateStepGoal?.nextStepText).toContain('scene-gate');
expect(describeGoalStackForPrompt(goalStack)).toContain('当前玩家任务推进');
});
it('annotates options with advance/support affordances and builds quest reward handoff', () => {
const readyQuest = createQuest({
id: 'quest-ready',
title: '回报遗迹调查',
status: 'ready_to_turn_in',
issuerNpcName: '陆清',
narrativeBinding: {
origin: 'ai_compiled',
narrativeType: 'investigation',
dramaticNeed: '必须确认遗迹的异动来源。',
issuerGoal: '拿到调查结果后继续推进旧桥线索。',
playerHook: '你已经掌握了最关键的现场信息。',
worldReason: '如果再拖下去,线索会继续散掉。',
followupHooks: [],
},
});
const goalStack = buildGoalStackState({
quests: [readyQuest],
worldType: null,
currentSceneName: '断桥旧哨',
});
const options: StoryOption[] = [
{
functionId: 'npc.quest_turn_in',
actionText: '把调查结果告诉陆清',
visuals: createSceneDirective(),
interaction: {
kind: 'npc',
npcId: 'quest-ready-issuer',
action: 'quest_turn_in',
questId: 'quest-ready',
},
},
{
functionId: 'idle_explore_forward',
actionText: '继续向前探查',
visuals: createSceneDirective(),
},
];
const annotated = annotateStoryOptionsWithGoalAffordance(options, goalStack);
expect(annotated[0]?.goalAffordance?.relation).toBe('advance');
expect(annotated[0]?.goalAffordance?.label).toBe('推进当前任务');
expect(annotated[1]?.goalAffordance).toBeNull();
const state = {
worldType: null,
customWorldProfile: null,
playerCharacter: null,
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
storyEngineMemory: {
discoveredFactIds: [],
inferredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
recentSignalIds: [],
recentCompanionReactions: [],
currentChapter: null,
currentJourneyBeatId: null,
currentJourneyBeat: null,
companionArcStates: [],
worldMutations: [],
chronicle: [],
factionTensionStates: [],
currentCampEvent: null,
currentSetpieceDirective: null,
continueGameDigest: null,
},
chapterState: null,
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: {
id: 'scene-ruins',
name: '断桥旧哨',
description: '',
imageSrc: '',
},
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 0,
playerMaxHp: 0,
playerMana: 0,
playerMaxMana: 0,
playerSkillCooldowns: {},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [readyQuest],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
} satisfies GameState;
const handoff = buildGoalHandoffFromState(state);
expect(handoff?.title).toContain('陆清');
expect(handoff?.detail).toContain('结果');
});
it('derives pulse events for newly accepted and newly ready quests', () => {
const acceptedQuest = createQuest({
id: 'quest-accepted',
title: '追查桥上的雾信',
status: 'active',
issuerNpcName: '陆清',
summary: '先去断桥边确认最新痕迹。',
});
const acceptedGoalStack = buildGoalStackState({
quests: [acceptedQuest],
worldType: null,
currentSceneName: '断桥旧哨',
});
const acceptPulse = deriveGoalPulseEvent({
previous: createGoalPulseSnapshot([], acceptedGoalStack),
quests: [acceptedQuest],
goalStack: acceptedGoalStack,
});
expect(acceptPulse?.pulseType).toBe('progress');
expect(acceptPulse?.title).toContain('接取');
const readyQuest = createQuest({
id: 'quest-ready',
title: '回报遗迹调查',
status: 'ready_to_turn_in',
issuerNpcName: '陆清',
summary: '带着结果回去向陆清交待。',
});
const readyGoalStack = buildGoalStackState({
quests: [readyQuest],
worldType: null,
currentSceneName: '断桥旧哨',
});
const readyPulse = deriveGoalPulseEvent({
previous: createGoalPulseSnapshot(
[
{
...readyQuest,
status: 'active',
},
],
readyGoalStack,
),
quests: [readyQuest],
goalStack: readyGoalStack,
});
expect(readyPulse?.pulseType).toBe('ready_to_turn_in');
expect(readyPulse?.detail).toContain('陆清');
});
});

View File

@@ -0,0 +1,895 @@
import { isContinueAdventureOption } from '../../data/functionCatalog';
import { getQuestActiveStep, isQuestReadyToClaim } from '../../data/questFlow';
import { getScenePresetById } from '../../data/scenePresets';
import type {
CampEvent,
ChapterState,
GameState,
GoalHandoff,
GoalLayer,
GoalPulseEvent,
GoalStackEntry,
GoalStackState,
GoalStatus,
GoalTrack,
JourneyBeat,
QuestLogEntry,
SetpieceDirective,
StoryOption,
WorldType,
} from '../../types';
const TERMINAL_QUEST_STATUSES = new Set<QuestLogEntry['status']>([
'turned_in',
'failed',
'expired',
]);
type GoalPulseSnapshot = {
questStatuses: Record<string, QuestLogEntry['status']>;
activeGoalId: string | null;
immediateGoalId: string | null;
immediateGoalText: string | null;
};
function isLiveQuest(quest: QuestLogEntry) {
return !TERMINAL_QUEST_STATUSES.has(quest.status);
}
function getChapterStageLabel(stage: ChapterState['stage']) {
switch (stage) {
case 'opening':
return '开篇';
case 'expansion':
return '展开';
case 'turning_point':
return '转折';
case 'climax':
return '高潮';
case 'aftermath':
return '余波';
default:
return '进行中';
}
}
function getJourneyBeatLabel(beatType: JourneyBeat['beatType']) {
switch (beatType) {
case 'approach':
return '接近';
case 'investigation':
return '调查';
case 'camp':
return '休整';
case 'conflict':
return '冲突';
case 'boss_prelude':
return '决战前奏';
case 'climax':
return '高潮';
case 'recovery':
return '恢复';
default:
return '旅程';
}
}
function getSetpieceLabel(setpieceType: SetpieceDirective['setpieceType']) {
switch (setpieceType) {
case 'boss_prelude':
return '决战前奏';
case 'showdown':
return '对峙';
case 'climax':
return '高潮';
case 'aftermath':
return '余波';
default:
return '剧情节点';
}
}
function cleanTaskTitle(title: string, fallback = '当前任务') {
const cleaned = title
.replace(/["']/gu, '')
.replace(/[·|:].*$/u, '')
.replace(/[,.!?;].*$/u, '')
.trim();
if (!cleaned) {
return fallback;
}
return cleaned.length > 12 ? cleaned.slice(0, 10) : cleaned;
}
function buildJourneyTaskTitle(beatType: JourneyBeat['beatType']) {
switch (beatType) {
case 'approach':
return '靠近线索';
case 'investigation':
return '调查线索';
case 'camp':
return '回营整备';
case 'conflict':
return '处理冲突';
case 'boss_prelude':
return '备战对峙';
case 'climax':
return '完成对峙';
case 'recovery':
return '收束结果';
default:
return '继续推进';
}
}
function buildJourneyTaskCondition(params: {
beatType: JourneyBeat['beatType'];
sceneHint: string | null;
}) {
const { beatType, sceneHint } = params;
const place = sceneHint ?? '当前区域';
switch (beatType) {
case 'approach':
return `前往 ${place},确认新的线索。`;
case 'investigation':
return `${place} 调查线索或异常。`;
case 'camp':
return '返回营地,整理队伍或与同伴交谈。';
case 'conflict':
return `处理 ${place} 的冲突。`;
case 'boss_prelude':
return `前往 ${place},准备关键战斗。`;
case 'climax':
return `${place} 完成关键对峙。`;
case 'recovery':
return '查看任务结果,决定下一步去向。';
default:
return `继续推进 ${place} 的任务。`;
}
}
function resolveJourneySceneHint(params: {
beat: JourneyBeat;
currentSceneName?: string | null;
worldType?: WorldType | null;
}) {
const rawSceneId = params.beat.recommendedSceneIds[0] ?? null;
if (!rawSceneId) {
return params.currentSceneName ?? null;
}
if (!params.worldType) {
return rawSceneId;
}
return getScenePresetById(params.worldType, rawSceneId)?.name
?? params.currentSceneName
?? rawSceneId;
}
export function getGoalTrackLabel(track: GoalTrack) {
switch (track) {
case 'main':
return '主推进';
case 'side':
return '支线';
case 'relationship':
return '关系';
case 'survival':
return '整备';
case 'exploration':
return '探索';
default:
return '任务';
}
}
function getQuestSceneHint(quest: QuestLogEntry, worldType: WorldType | null) {
if (!quest.sceneId) {
return null;
}
if (!worldType) {
return quest.sceneId;
}
return getScenePresetById(worldType, quest.sceneId)?.name ?? quest.sceneId;
}
function getQuestTrack(quest: QuestLogEntry, fallbackTrack: GoalTrack) {
const narrativeType = quest.narrativeBinding?.narrativeType ?? null;
if (narrativeType === 'relationship' || narrativeType === 'trial') {
return 'relationship';
}
if (narrativeType === 'investigation' || quest.objective.kind === 'inspect_treasure') {
return fallbackTrack === 'main' ? 'main' : 'exploration';
}
return fallbackTrack;
}
function getQuestStatus(quest: QuestLogEntry): GoalStatus {
if (isQuestReadyToClaim(quest)) {
return 'ready_to_resolve';
}
if (quest.status === 'turned_in') {
return 'resolved';
}
if (quest.status === 'failed' || quest.status === 'expired') {
return 'archived';
}
if (quest.status === 'discovered') {
return 'teased';
}
return 'active';
}
function getQuestUrgency(quest: QuestLogEntry): GoalStackEntry['urgency'] {
if (isQuestReadyToClaim(quest)) {
return 'high';
}
const narrativeType = quest.narrativeBinding?.narrativeType ?? null;
if (narrativeType === 'investigation' || narrativeType === 'retrieval') {
return 'medium';
}
if (narrativeType === 'relationship' || narrativeType === 'trial') {
return 'low';
}
return 'medium';
}
function getQuestProgressLabel(quest: QuestLogEntry) {
if (isQuestReadyToClaim(quest)) {
return '待交付';
}
const activeStep = getQuestActiveStep(quest);
if (activeStep) {
return `步骤 ${activeStep.progress}/${activeStep.requiredCount}`;
}
return `进度 ${quest.progress}/${quest.objective.requiredCount}`;
}
function buildQuestGoalEntry(params: {
quest: QuestLogEntry;
worldType: WorldType | null;
layer: GoalLayer;
fallbackTrack: GoalTrack;
}) {
const { quest, worldType, layer, fallbackTrack } = params;
const sceneHint = getQuestSceneHint(quest, worldType);
const relatedThreadIds = quest.threadId ? [quest.threadId] : [];
return {
id: `goal:${layer}:${quest.id}`,
sourceKind: 'quest',
sourceId: quest.id,
layer,
track: getQuestTrack(quest, fallbackTrack),
title: quest.title,
promiseText:
quest.narrativeBinding?.playerHook
|| quest.description
|| `${quest.issuerNpcName} 把这件事托付给了你。`,
whyNow:
quest.narrativeBinding?.worldReason
|| `${quest.issuerNpcName} 认为现在正是处理这件事的时机。`,
nextStepText: isQuestReadyToClaim(quest)
? `回去找 ${quest.issuerNpcName} 交付委托并领取报酬。`
: getQuestActiveStep(quest)?.revealText ?? quest.summary,
sceneHint,
npcHint: quest.issuerNpcName,
progressLabel: getQuestProgressLabel(quest),
status: getQuestStatus(quest),
urgency: getQuestUrgency(quest),
relatedThreadIds,
} satisfies GoalStackEntry;
}
function buildQuestImmediateGoal(params: {
quest: QuestLogEntry;
worldType: WorldType | null;
}) {
const { quest, worldType } = params;
const activeStep = getQuestActiveStep(quest);
const sceneHint = getQuestSceneHint(quest, worldType);
if (isQuestReadyToClaim(quest)) {
return {
...buildQuestGoalEntry({
quest,
worldType,
layer: 'immediate_step',
fallbackTrack: 'main',
}),
title: `${quest.issuerNpcName} 交付结果`,
promiseText: '委托已经完成,只差最后汇报和结算。',
whyNow: `${quest.issuerNpcName} 的报酬已经准备好,这一步能把当前委托正式结清。`,
nextStepText: `去找 ${quest.issuerNpcName} 对话,把结果说清楚。`,
sceneHint,
npcHint: quest.issuerNpcName,
} satisfies GoalStackEntry;
}
if (!activeStep) {
return null;
}
return {
...buildQuestGoalEntry({
quest,
worldType,
layer: 'immediate_step',
fallbackTrack: 'main',
}),
title: activeStep.title,
promiseText: activeStep.revealText,
whyNow:
quest.narrativeBinding?.issuerGoal
|| `${quest.issuerNpcName} 的委托正在推进中。`,
nextStepText: activeStep.revealText,
npcHint: activeStep.targetNpcId ? quest.issuerNpcName : null,
progressLabel: `步骤 ${activeStep.progress}/${activeStep.requiredCount}`,
} satisfies GoalStackEntry;
}
function buildChapterNorthStarGoal(params: {
chapterState: ChapterState;
journeyBeat: JourneyBeat | null;
setpieceDirective: SetpieceDirective | null;
worldType: WorldType | null;
currentSceneName?: string | null;
}) {
const { chapterState, journeyBeat, setpieceDirective, worldType, currentSceneName } = params;
const sceneHint = journeyBeat
? resolveJourneySceneHint({
beat: journeyBeat,
currentSceneName,
worldType,
})
: currentSceneName ?? null;
return {
id: `goal:north_star:chapter:${chapterState.id}`,
sourceKind: 'chapter',
sourceId: chapterState.id,
layer: 'north_star',
track: 'main',
title: cleanTaskTitle(chapterState.theme || chapterState.title, '主线任务'),
promiseText: chapterState.chapterSummary,
whyNow: `当前章节已进入${getChapterStageLabel(chapterState.stage)}阶段。`,
nextStepText: setpieceDirective
? `继续收束线索与局势,逼近 ${setpieceDirective.title}`
: journeyBeat
? buildJourneyTaskCondition({
beatType: journeyBeat.beatType,
sceneHint,
})
: `围绕 ${chapterState.theme} 继续推进当前主线。`,
sceneHint: null,
npcHint: null,
progressLabel: getChapterStageLabel(chapterState.stage),
status: 'active',
urgency: chapterState.stage === 'climax' || chapterState.stage === 'turning_point'
? 'high'
: chapterState.stage === 'expansion'
? 'medium'
: 'low',
relatedThreadIds: chapterState.primaryThreadIds,
} satisfies GoalStackEntry;
}
function buildJourneyGoal(params: {
journeyBeat: JourneyBeat;
layer: GoalLayer;
currentSceneName?: string | null;
worldType?: WorldType | null;
}) {
const { journeyBeat, layer, currentSceneName, worldType } = params;
const recommendedSceneHint = resolveJourneySceneHint({
beat: journeyBeat,
currentSceneName,
worldType,
});
const nextStepText = buildJourneyTaskCondition({
beatType: journeyBeat.beatType,
sceneHint: recommendedSceneHint,
});
return {
id: `goal:${layer}:journey:${journeyBeat.id}`,
sourceKind: 'journey_beat',
sourceId: journeyBeat.id,
layer,
track: journeyBeat.beatType === 'camp' ? 'relationship' : 'main',
title: buildJourneyTaskTitle(journeyBeat.beatType),
promiseText: journeyBeat.emotionalGoal,
whyNow: journeyBeat.emotionalGoal || '当前主线需要继续推进。',
nextStepText,
sceneHint: recommendedSceneHint,
npcHint: null,
progressLabel: getJourneyBeatLabel(journeyBeat.beatType),
status: 'active',
urgency: journeyBeat.beatType === 'boss_prelude' || journeyBeat.beatType === 'climax'
? 'high'
: journeyBeat.beatType === 'investigation' || journeyBeat.beatType === 'conflict'
? 'medium'
: 'low',
relatedThreadIds: journeyBeat.triggerThreadIds,
} satisfies GoalStackEntry;
}
function buildSetpieceNorthStarGoal(setpieceDirective: SetpieceDirective) {
return {
id: `goal:north_star:setpiece:${setpieceDirective.id}`,
sourceKind: 'setpiece',
sourceId: setpieceDirective.id,
layer: 'north_star',
track: 'main',
title: setpieceDirective.title,
promiseText: setpieceDirective.dramaticQuestion,
whyNow: `当前局势已经逼近${getSetpieceLabel(setpieceDirective.setpieceType)}`,
nextStepText: `继续收束线索、关系和状态,为 ${setpieceDirective.title} 做准备。`,
sceneHint: setpieceDirective.sceneFocusId ?? null,
npcHint: null,
progressLabel: getSetpieceLabel(setpieceDirective.setpieceType),
status: 'active',
urgency: setpieceDirective.setpieceType === 'climax' || setpieceDirective.setpieceType === 'showdown'
? 'high'
: 'medium',
relatedThreadIds: setpieceDirective.relatedThreadIds,
} satisfies GoalStackEntry;
}
function buildCampEventSupportGoal(currentCampEvent: CampEvent) {
return {
id: `goal:support:camp:${currentCampEvent.id}`,
sourceKind: 'relationship',
sourceId: currentCampEvent.id,
layer: 'support',
track: 'relationship',
title: currentCampEvent.title,
promiseText: currentCampEvent.triggerReason,
whyNow: '队伍里的情绪和关系已经积累到值得回应的程度。',
nextStepText: '留意营地或旅途中新的交流时机,把这段关系事件接住。',
sceneHint: null,
npcHint: null,
progressLabel: '关系事件',
status: 'teased',
urgency: currentCampEvent.eventType === 'conflict' || currentCampEvent.eventType === 'decision'
? 'medium'
: 'low',
relatedThreadIds: currentCampEvent.relatedThreadIds,
} satisfies GoalStackEntry;
}
function resolvePrimaryQuest(quests: QuestLogEntry[]) {
const liveQuests = quests.filter(isLiveQuest);
if (liveQuests.length <= 0) {
return null;
}
return liveQuests.find((quest) => isQuestReadyToClaim(quest))
?? liveQuests.find((quest) => quest.status === 'active')
?? liveQuests.find((quest) => quest.status === 'discovered')
?? liveQuests[0]
?? null;
}
export function buildGoalStackState(params: {
quests: QuestLogEntry[];
worldType: WorldType | null;
chapterState?: ChapterState | null;
journeyBeat?: JourneyBeat | null;
setpieceDirective?: SetpieceDirective | null;
currentCampEvent?: CampEvent | null;
currentSceneName?: string | null;
}) {
const {
quests,
worldType,
chapterState = null,
journeyBeat = null,
setpieceDirective = null,
currentCampEvent = null,
currentSceneName = null,
} = params;
const primaryQuest = resolvePrimaryQuest(quests);
const northStarGoal = setpieceDirective
? buildSetpieceNorthStarGoal(setpieceDirective)
: chapterState
? buildChapterNorthStarGoal({
chapterState,
journeyBeat,
setpieceDirective,
worldType,
currentSceneName,
})
: journeyBeat
? buildJourneyGoal({
journeyBeat,
layer: 'north_star',
currentSceneName,
worldType,
})
: null;
const activeGoal = primaryQuest
? buildQuestGoalEntry({
quest: primaryQuest,
worldType,
layer: 'active_contract',
fallbackTrack: 'main',
})
: journeyBeat
? buildJourneyGoal({
journeyBeat,
layer: 'active_contract',
currentSceneName,
worldType,
})
: currentCampEvent
? buildCampEventSupportGoal(currentCampEvent)
: northStarGoal;
const immediateStepGoal = primaryQuest
? buildQuestImmediateGoal({
quest: primaryQuest,
worldType,
})
: journeyBeat
? buildJourneyGoal({
journeyBeat,
layer: 'immediate_step',
currentSceneName,
worldType,
})
: null;
const supportGoals: GoalStackEntry[] = quests
.filter((quest) => isLiveQuest(quest) && quest.id !== primaryQuest?.id)
.map((quest) =>
buildQuestGoalEntry({
quest,
worldType,
layer: 'support',
fallbackTrack: 'side',
}),
);
if (
currentCampEvent
&& !supportGoals.some((goal) => goal.sourceKind === 'relationship')
) {
supportGoals.push(buildCampEventSupportGoal(currentCampEvent));
}
return {
northStarGoal,
activeGoal,
immediateStepGoal,
supportGoals: supportGoals.slice(0, 2),
} satisfies GoalStackState;
}
function getQuestPanelPriority(params: {
quest: QuestLogEntry;
goalStack: GoalStackState | null | undefined;
}) {
const { quest, goalStack } = params;
if (goalStack?.activeGoal?.sourceKind === 'quest' && goalStack.activeGoal.sourceId === quest.id) {
return 0;
}
if (goalStack?.immediateStepGoal?.sourceKind === 'quest' && goalStack.immediateStepGoal.sourceId === quest.id) {
return 1;
}
if (isQuestReadyToClaim(quest)) {
return 2;
}
if (isLiveQuest(quest)) {
return 3;
}
return 4;
}
export function sortQuestsForGoalPanel(
quests: QuestLogEntry[],
goalStack: GoalStackState | null | undefined,
) {
return [...quests].sort((left, right) => {
const priorityDiff = getQuestPanelPriority({
quest: left,
goalStack,
}) - getQuestPanelPriority({
quest: right,
goalStack,
});
if (priorityDiff !== 0) {
return priorityDiff;
}
if (left.status !== right.status) {
return left.status.localeCompare(right.status);
}
return left.title.localeCompare(right.title, 'zh-CN');
});
}
export function describeGoalStackForPrompt(goalStack: GoalStackState | null | undefined) {
if (!goalStack) {
return null;
}
const lines = [
goalStack.northStarGoal
? `- 长期方向:${goalStack.northStarGoal.title};承诺:${goalStack.northStarGoal.promiseText}`
: null,
goalStack.activeGoal
? `- 当前主任务:${goalStack.activeGoal.title};为什么现在做:${goalStack.activeGoal.whyNow}`
: null,
goalStack.immediateStepGoal
? `- 下一步:${goalStack.immediateStepGoal.nextStepText}`
: null,
goalStack.supportGoals.length > 0
? `- 支持任务:${goalStack.supportGoals.map((goal) => goal.title).join(' / ')}`
: null,
].filter(Boolean);
if (lines.length <= 0) {
return null;
}
return ['当前玩家任务推进:', ...lines].join('\n');
}
function buildQuestOptionGoalAffordance(
option: StoryOption,
goalStack: GoalStackState,
) {
if (option.interaction?.kind !== 'npc') {
return null;
}
if (
option.interaction.action === 'quest_turn_in'
&& goalStack.immediateStepGoal?.sourceKind === 'quest'
) {
return {
goalId: goalStack.immediateStepGoal.id,
relation: 'advance',
label: '推进当前任务',
} satisfies NonNullable<StoryOption['goalAffordance']>;
}
if (option.interaction.action === 'quest_accept') {
const targetGoal = goalStack.activeGoal ?? goalStack.northStarGoal;
if (!targetGoal) {
return null;
}
return {
goalId: targetGoal.id,
relation: goalStack.activeGoal?.sourceKind === 'quest' ? 'detour' : 'support',
label: goalStack.activeGoal?.sourceKind === 'quest' ? '暂接支线' : '接入委托',
} satisfies NonNullable<StoryOption['goalAffordance']>;
}
if (
goalStack.activeGoal?.track === 'relationship'
&& ['chat', 'gift', 'help', 'recruit'].includes(option.interaction.action)
) {
return {
goalId: goalStack.activeGoal.id,
relation: 'advance',
label: '推进关系任务',
} satisfies NonNullable<StoryOption['goalAffordance']>;
}
if (['chat', 'gift', 'help', 'recruit'].includes(option.interaction.action)) {
const targetGoal = goalStack.activeGoal ?? goalStack.northStarGoal;
if (!targetGoal) {
return null;
}
return {
goalId: targetGoal.id,
relation: 'support',
label: '经营关系',
} satisfies NonNullable<StoryOption['goalAffordance']>;
}
return null;
}
export function annotateStoryOptionsWithGoalAffordance(
options: StoryOption[],
goalStack: GoalStackState | null | undefined,
) {
if (!goalStack) {
return options.map((option) => ({
...option,
goalAffordance: null,
}));
}
return options.map((option) => {
const questAffordance = buildQuestOptionGoalAffordance(option, goalStack);
if (questAffordance) {
return {
...option,
goalAffordance: questAffordance,
} satisfies StoryOption;
}
if (
isContinueAdventureOption(option)
&& (
goalStack.immediateStepGoal?.sourceKind === 'journey_beat'
|| goalStack.activeGoal?.sourceKind === 'journey_beat'
|| goalStack.activeGoal?.sourceKind === 'chapter'
|| goalStack.northStarGoal?.sourceKind === 'setpiece'
)
) {
const targetGoal =
goalStack.immediateStepGoal
?? goalStack.activeGoal
?? goalStack.northStarGoal;
if (!targetGoal) {
return {
...option,
goalAffordance: null,
} satisfies StoryOption;
}
return {
...option,
goalAffordance: {
goalId: targetGoal.id,
relation: 'advance',
label: '继续推进',
},
} satisfies StoryOption;
}
return {
...option,
goalAffordance: null,
} satisfies StoryOption;
});
}
export function buildGoalHandoffFromState(state: GameState): GoalHandoff | null {
const goalStack = buildGoalStackState({
quests: state.quests,
worldType: state.worldType,
chapterState: state.chapterState ?? state.storyEngineMemory?.currentChapter ?? null,
journeyBeat: state.storyEngineMemory?.currentJourneyBeat ?? null,
setpieceDirective: state.storyEngineMemory?.currentSetpieceDirective ?? null,
currentCampEvent: state.storyEngineMemory?.currentCampEvent ?? null,
currentSceneName: state.currentScenePreset?.name ?? null,
});
const nextGoal =
goalStack.immediateStepGoal
?? goalStack.activeGoal
?? goalStack.northStarGoal;
if (!nextGoal) {
return null;
}
if (nextGoal.sourceKind !== 'quest') {
return null;
}
return {
goalId: nextGoal.id,
title: nextGoal.title,
detail: nextGoal.nextStepText,
track: nextGoal.track,
} satisfies GoalHandoff;
}
function isRewardReadyStatus(status: QuestLogEntry['status']) {
return status === 'ready_to_turn_in' || status === 'completed';
}
export function createGoalPulseSnapshot(
quests: QuestLogEntry[],
goalStack: GoalStackState | null | undefined,
) {
return {
questStatuses: Object.fromEntries(
quests.map((quest) => [quest.id, quest.status]),
),
activeGoalId: goalStack?.activeGoal?.id ?? null,
immediateGoalId: goalStack?.immediateStepGoal?.id ?? null,
immediateGoalText: goalStack?.immediateStepGoal?.nextStepText ?? null,
} satisfies GoalPulseSnapshot;
}
function buildGoalPulse(params: {
goal: GoalStackEntry;
pulseType: GoalPulseEvent['pulseType'];
title: string;
detail: string;
}) {
const { goal, pulseType, title, detail } = params;
return {
id: `${pulseType}:${goal.id}:${Date.now()}`,
goalId: goal.id,
pulseType,
title,
detail,
track: goal.track,
} satisfies GoalPulseEvent;
}
export function deriveGoalPulseEvent(params: {
previous: GoalPulseSnapshot;
quests: QuestLogEntry[];
goalStack: GoalStackState | null | undefined;
}) {
const { previous, quests, goalStack } = params;
const immediateGoal = goalStack?.immediateStepGoal ?? null;
const activeGoal = goalStack?.activeGoal ?? null;
const fallbackGoal = immediateGoal ?? activeGoal ?? goalStack?.northStarGoal ?? null;
const questGoal =
fallbackGoal && fallbackGoal.sourceKind === 'quest'
? fallbackGoal
: null;
const newQuest = quests.find(
(quest) =>
previous.questStatuses[quest.id] == null
&& !TERMINAL_QUEST_STATUSES.has(quest.status),
);
if (newQuest && questGoal) {
return buildGoalPulse({
goal: questGoal,
pulseType: 'progress',
title: '已接取新任务',
detail: immediateGoal?.nextStepText ?? newQuest.summary,
});
}
const newlyReadyQuest = quests.find((quest) => {
const previousStatus = previous.questStatuses[quest.id];
return !isRewardReadyStatus(previousStatus ?? 'active')
&& isRewardReadyStatus(quest.status);
});
if (newlyReadyQuest && questGoal) {
return buildGoalPulse({
goal: questGoal,
pulseType: 'ready_to_turn_in',
title: '当前任务可交付',
detail: `回去找 ${newlyReadyQuest.issuerNpcName} 对话,把结果说清楚。`,
});
}
if (
questGoal
&& (
previous.immediateGoalId !== (immediateGoal?.id ?? null)
|| previous.immediateGoalText !== (immediateGoal?.nextStepText ?? null)
|| previous.activeGoalId !== (activeGoal?.id ?? null)
)
) {
return buildGoalPulse({
goal: questGoal,
pulseType: 'handoff',
title:
previous.activeGoalId !== (activeGoal?.id ?? null)
? '当前任务已更新'
: '下一步已更新',
detail: questGoal.nextStepText,
});
}
return null;
}

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,18 @@ type ThemePackPreset = Omit<ThemePack, 'id' | 'displayName'> & {
};
const THEME_PACK_PRESETS: Record<string, ThemePackPreset> = {
mythic: {
displayName: '自定义回响',
toneRange: ['未知', '克制', '余波未定', '局势待开'],
institutionLexicon: ['据点', '同盟', '旅团', '档案室', '哨站', '归舍'],
tabooLexicon: ['失约', '旧痕', '越界', '封存', '误触', '回响'],
artifactClasses: ['信物', '残页', '封匣', '样本', '旧钥', '印记'],
actorArchetypes: ['见证者', '守望人', '异乡来客', '带路人', '失序幸存者'],
conflictForms: ['追查', '护送', '回收', '分歧对峙', '失踪追索'],
clueForms: ['痕迹', '记录', '口供', '残片', '旧图'],
namingPatterns: ['地点+余痕+器类', '势力+旧称+用途', '事件+残响+物件'],
revealStyles: ['循序松口', '线索回指', '保留一层', '让事实自己浮出'],
},
martial: {
displayName: '江湖旧事',
toneRange: ['冷峻', '克制', '刀锋般紧绷', '旧案余震'],
@@ -113,7 +125,7 @@ function resolveThemeModeFromWorldType(
if (worldType === 'XIANXIA') {
return 'arcane';
}
return 'martial';
return 'mythic';
}
export function resolveFallbackThemePack(

View File

@@ -1,4 +1,5 @@
import { StoryHistoryRole, StoryMoment, StoryOption } from '../types';
import { sanitizePromptNarrativeText } from './narrativeLanguage';
const RECENT_ROUND_COUNT = 3;
const MAX_SUMMARY_GROUPS = 6;
@@ -41,13 +42,17 @@ function buildStoryRounds(history: StoryMoment[]): StoryHistoryRound[] {
let currentRound: StoryHistoryRound | null = null;
for (const [index, entry] of history.entries()) {
const text = entry.text.trim();
const historyRole = resolveHistoryRole(entry, index);
const text = sanitizePromptNarrativeText(
entry.text,
historyRole === 'action'
? '玩家做出了新的决定。'
: '这一轮的局势已经出现了新的变化。',
);
if (!text) {
continue;
}
const historyRole = resolveHistoryRole(entry, index);
if (historyRole === 'action') {
if (currentRound && hasRoundContent(currentRound)) {
rounds.push(currentRound);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,79 @@
import {
buildDefaultFrameOrder,
buildMasterPrompt,
buildOrderedActiveFrameIndices,
buildOrderedActiveFrameSources,
buildRepairPrompt,
buildSheetPrompt,
getActionTemplateById,
moveFrameOrderItem,
restoreAllFrames,
toggleActiveFrame,
} from './qwenSpriteSheetToolModel';
describe('qwenSpriteSheetToolModel', () => {
it('builds ordered active frame indices from current order and active set', () => {
expect(buildOrderedActiveFrameIndices([3, 1, 0, 2], [0, 2, 3])).toEqual([
3, 0, 2,
]);
});
it('builds ordered active frame sources', () => {
expect(
buildOrderedActiveFrameSources(
['f0', 'f1', 'f2', 'f3'],
[3, 1, 0, 2],
[0, 2, 3],
),
).toEqual(['f3', 'f0', 'f2']);
});
it('moves a frame forward or backward in order', () => {
expect(moveFrameOrderItem([0, 1, 2, 3], 2, -1)).toEqual([0, 2, 1, 3]);
expect(moveFrameOrderItem([0, 1, 2, 3], 1, 1)).toEqual([0, 2, 1, 3]);
expect(moveFrameOrderItem([0, 1, 2, 3], 0, -1)).toEqual([0, 1, 2, 3]);
});
it('toggles active frames without duplicating indices', () => {
expect(toggleActiveFrame([0, 2, 3], 2)).toEqual([0, 3]);
expect(toggleActiveFrame([0, 3], 2)).toEqual([0, 2, 3]);
});
it('restores all frames to the default order', () => {
expect(buildDefaultFrameOrder(4)).toEqual([0, 1, 2, 3]);
expect(restoreAllFrames(4)).toEqual([0, 1, 2, 3]);
});
it('builds a sheet prompt that contains the template structure', () => {
const prompt = buildSheetPrompt({
characterBrief: '黑发青年剑士,右手持长剑。',
actionTemplate: getActionTemplateById('attack_slash'),
extraDirection: '每格边界清晰。',
});
expect(prompt).toContain('4x4');
expect(prompt).toContain('横斩攻击');
expect(prompt).toContain('1-4 帧');
expect(prompt).toContain('黑发青年剑士');
expect(prompt).toContain('每格边界清晰');
expect(prompt).toContain('大头身');
});
it('builds a master prompt with square canvas and chibi ratio', () => {
const prompt = buildMasterPrompt('Q版大头身少女冒险者。');
expect(prompt).toContain('1:1 正方形画布');
expect(prompt).toContain('大头身');
expect(prompt).toContain('2 到 3 头身');
});
it('builds a repair prompt that keeps chibi ratio', () => {
const prompt = buildRepairPrompt({
issueText: '修复头部和手部比例。',
useNeighborLabel: '上一帧',
});
expect(prompt).toContain('上一帧');
expect(prompt).toContain('大头身');
});
});

View File

@@ -0,0 +1,465 @@
export type QwenSpriteActionTemplateId =
| 'idle'
| 'run'
| 'attack_slash'
| 'hurt'
| 'die';
export type QwenSpriteActionTemplate = {
id: QwenSpriteActionTemplateId;
label: string;
loop: boolean;
defaultFps: number;
bodyTravel: string;
weaponRule: string;
sequenceLines: [string, string, string, string];
ending: string;
};
export const DEFAULT_MASTER_NEGATIVE_PROMPT =
'正面视角左朝向镜头透视半身像脚被裁切头顶被裁切多角色复杂背景武器消失武器换手额外手臂额外腿服装变化脸部变化模糊运动模糊文字水印UI 元素';
export const DEFAULT_SHEET_NEGATIVE_PROMPT =
'多角色左右朝向混乱前视图背视图镜头切换景别变化特写脚底裁切头顶裁切缺手缺脚额外肢体武器消失武器换手服装变化脸部变化发型变化动作不连续重复帧过多构图混乱背景复杂强透视运动模糊残影文字水印UI边框覆盖角色';
export const DEFAULT_REPAIR_NEGATIVE_PROMPT =
'多角色,错误朝向,缺手,缺脚,额外肢体,武器消失,武器换手,脸部变化,发型变化,服装变化,模糊,运动模糊,复杂背景,文字,水印';
const CHIBI_STYLE_TEXT =
'Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。';
export const QWEN_SPRITE_ACTION_TEMPLATES: QwenSpriteActionTemplate[] = [
{
id: 'idle',
label: '待机循环',
loop: true,
defaultFps: 8,
bodyTravel: '原地',
weaponRule: '武器始终在主手,位置稳定',
sequenceLines: [
'1-4 帧:稳定站姿,轻微呼吸起伏',
'5-8 帧:胸腔与肩膀轻微抬起,衣摆极轻微变化',
'9-12 帧:呼气回落,重心恢复',
'13-16 帧:逐渐回到与首帧接近的站姿',
],
ending: '第 16 帧自然衔接第 1 帧',
},
{
id: 'run',
label: '奔跑循环',
loop: true,
defaultFps: 12,
bodyTravel: '小幅前移但角色中心基本固定',
weaponRule: '武器始终在主手,不换手',
sequenceLines: [
'1-4 帧:右腿前摆,左腿后蹬,身体略前倾',
'5-8 帧:双腿交叉经过身体下方,手臂反向摆动',
'9-12 帧:左腿前摆,右腿后蹬,继续前倾',
'13-16 帧:完成另一半跑步循环并回到可接第 1 帧的状态',
],
ending: '第 16 帧能无缝接回第 1 帧',
},
{
id: 'attack_slash',
label: '横斩攻击',
loop: false,
defaultFps: 12,
bodyTravel: '中幅前探',
weaponRule: '右手持武器,始终右手,不换手',
sequenceLines: [
'1-4 帧:轻微收身蓄力,武器向后收',
'5-8 帧:重心前压,挥击开始',
'9-12 帧:斩击达到最大幅度,动作力量最强',
'13-16 帧:顺势收招,回到可接下一动作的稳定姿态',
],
ending: '第 16 帧停在收招后稳定姿态',
},
{
id: 'hurt',
label: '受击后仰',
loop: false,
defaultFps: 10,
bodyTravel: '原地或极小后仰',
weaponRule: '武器不要脱手,不要换手',
sequenceLines: [
'1-4 帧:突然受击,头肩后仰',
'5-8 帧:身体失衡最明显',
'9-12 帧:手臂和武器随惯性摆动',
'13-16 帧:逐渐恢复到勉强站稳的姿态',
],
ending: '第 16 帧能接回 idle 或下一个动作',
},
{
id: 'die',
label: '倒地死亡',
loop: false,
defaultFps: 8,
bodyTravel: '明显倒地位移',
weaponRule: '武器不可瞬间消失',
sequenceLines: [
'1-4 帧:受创失衡,重心被打断',
'5-8 帧:身体明显下坠或后仰',
'9-12 帧:倒地过程完成,动作幅度最大',
'13-16 帧:停在清晰的终止姿态',
],
ending: '第 16 帧停在死亡结束姿态,不需要循环',
},
];
export function getActionTemplateById(id: QwenSpriteActionTemplateId) {
return (
QWEN_SPRITE_ACTION_TEMPLATES.find((template) => template.id === id) ??
QWEN_SPRITE_ACTION_TEMPLATES[0]
);
}
export function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result ?? ''));
reader.onerror = () => reject(reader.error ?? new Error('读取文件失败'));
reader.readAsDataURL(file);
});
}
function loadImageFromSource(source: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`加载图片失败:${source}`));
image.src = source;
});
}
function drawContainedImage(
context: CanvasRenderingContext2D,
image: HTMLImageElement,
options: {
x: number;
y: number;
width: number;
height: number;
},
) {
const fitScale = Math.min(
options.width / image.width,
options.height / image.height,
);
const drawWidth = image.width * fitScale;
const drawHeight = image.height * fitScale;
const drawX = options.x + (options.width - drawWidth) / 2;
const drawY = options.y + (options.height - drawHeight) / 2;
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
}
export async function sliceSpriteSheetFrames(
spriteSource: string,
options: {
rows: number;
cols: number;
},
) {
const image = await loadImageFromSource(spriteSource);
const frameWidth = Math.floor(image.width / options.cols);
const frameHeight = Math.floor(image.height / options.rows);
const frames: string[] = [];
for (let rowIndex = 0; rowIndex < options.rows; rowIndex += 1) {
for (let colIndex = 0; colIndex < options.cols; colIndex += 1) {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) {
throw new Error('无法创建画布上下文');
}
canvas.width = frameWidth;
canvas.height = frameHeight;
context.drawImage(
image,
colIndex * frameWidth,
rowIndex * frameHeight,
frameWidth,
frameHeight,
0,
0,
frameWidth,
frameHeight,
);
frames.push(canvas.toDataURL('image/png'));
}
}
return {
frameWidth,
frameHeight,
frames,
width: image.width,
height: image.height,
};
}
export async function extractSpriteFrame(
spriteSource: string,
options: {
rows: number;
cols: number;
frameIndex: number;
outputSize?: number;
},
) {
const sliced = await sliceSpriteSheetFrames(spriteSource, {
rows: options.rows,
cols: options.cols,
});
const frameSource = sliced.frames[options.frameIndex];
if (!frameSource) {
throw new Error('帧索引超出范围。');
}
if (!options.outputSize) {
return frameSource;
}
const image = await loadImageFromSource(frameSource);
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) {
throw new Error('无法创建画布上下文');
}
canvas.width = options.outputSize;
canvas.height = options.outputSize;
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(image, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/png');
}
export async function replaceSpriteFrame(
spriteSource: string,
options: {
rows: number;
cols: number;
frameIndex: number;
replacementSource: string;
},
) {
const spriteImage = await loadImageFromSource(spriteSource);
const replacementImage = await loadImageFromSource(options.replacementSource);
const frameWidth = Math.floor(spriteImage.width / options.cols);
const frameHeight = Math.floor(spriteImage.height / options.rows);
const rowIndex = Math.floor(options.frameIndex / options.cols);
const colIndex = options.frameIndex % options.cols;
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) {
throw new Error('无法创建画布上下文');
}
canvas.width = spriteImage.width;
canvas.height = spriteImage.height;
context.drawImage(spriteImage, 0, 0);
context.clearRect(
colIndex * frameWidth,
rowIndex * frameHeight,
frameWidth,
frameHeight,
);
context.drawImage(
replacementImage,
colIndex * frameWidth,
rowIndex * frameHeight,
frameWidth,
frameHeight,
);
return canvas.toDataURL('image/png');
}
export function buildOrderedActiveFrameIndices(
frameOrder: number[],
activeFrames: number[],
) {
return frameOrder.filter((frameIndex) => activeFrames.includes(frameIndex));
}
export function buildOrderedActiveFrameSources(
frameDataUrls: string[],
frameOrder: number[],
activeFrames: number[],
) {
return buildOrderedActiveFrameIndices(frameOrder, activeFrames)
.map((frameIndex) => frameDataUrls[frameIndex] ?? '')
.filter(Boolean);
}
export async function composeSpriteSheetFromFrames(
frameSources: string[],
options: {
cols: number;
rows?: number;
frameWidth?: number;
frameHeight?: number;
padToGrid?: boolean;
},
) {
if (frameSources.length === 0) {
throw new Error('没有可用于拼接精灵表的帧。');
}
const images = await Promise.all(
frameSources.map((source) => loadImageFromSource(source)),
);
const frameWidth =
options.frameWidth ??
Math.max(...images.map((image) => image.width), 1);
const frameHeight =
options.frameHeight ??
Math.max(...images.map((image) => image.height), 1);
const rows =
options.rows ?? Math.max(1, Math.ceil(images.length / options.cols));
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) {
throw new Error('无法创建画布上下文');
}
canvas.width = frameWidth * options.cols;
canvas.height = frameHeight * rows;
context.clearRect(0, 0, canvas.width, canvas.height);
const totalCells = options.padToGrid ? rows * options.cols : images.length;
for (let index = 0; index < totalCells; index += 1) {
const image = images[index];
if (!image) {
continue;
}
const rowIndex = Math.floor(index / options.cols);
const colIndex = index % options.cols;
drawContainedImage(context, image, {
x: colIndex * frameWidth,
y: rowIndex * frameHeight,
width: frameWidth,
height: frameHeight,
});
}
return {
dataUrl: canvas.toDataURL('image/png'),
rows,
cols: options.cols,
frameWidth,
frameHeight,
frameCount: frameSources.length,
};
}
export function buildMasterPrompt(characterBrief: string) {
return [
'单人全身2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,站立待机姿态,脚底完整可见,武器完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。',
'画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色完整置于画面中央,不要裁切头顶和脚底,不要多角色,不要复杂环境,不要镜头透视,不要特写。',
`风格要求:${CHIBI_STYLE_TEXT} 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,武器握持合理,便于后续连续动作生成。`,
characterBrief.trim(),
]
.filter(Boolean)
.join('\n\n');
}
export function buildSheetPrompt(options: {
characterBrief: string;
actionTemplate: QwenSpriteActionTemplate;
extraDirection: string;
}) {
return [
`使用图1作为唯一角色身份参考。生成一张 4x4 的 sprite sheet共 16 帧,展示同一个角色的连续动作。角色始终朝右,全身完整出现在每一个格子里,脚底始终可见,地面线高度基本一致,角色在每一格中的尺度基本一致,镜头固定不变,不要切换景别,不要切换视角,不要左右翻转。${CHIBI_STYLE_TEXT}`,
`动作名:${options.actionTemplate.label}`,
`是否循环:${options.actionTemplate.loop ? '是' : '否'}`,
`身体位移:${options.actionTemplate.bodyTravel}`,
`武器规则:${options.actionTemplate.weaponRule}`,
...options.actionTemplate.sequenceLines,
`结尾要求:${options.actionTemplate.ending}`,
'输出要求:每一格都要清晰分开,网格顺序从左到右、从上到下,动作连续,首尾关系明确,轮廓稳定,发型稳定,服装结构稳定,武器始终在正确的手中,背景为纯浅色,适合后续切成 sprite frames。',
options.characterBrief.trim(),
options.extraDirection.trim(),
]
.filter(Boolean)
.join('\n');
}
export function buildRepairPrompt(options: {
issueText: string;
useNeighborLabel: '上一帧' | '下一帧';
}) {
return [
`使用图1作为角色身份与服装武器的唯一标准参考图2的动作连续性修复图3这一个单帧。图2代表${options.useNeighborLabel}`,
`要求输出一张单独的动作帧图片不要网格不要背景细节。角色始终朝右全身完整脚底位置稳定保持与图2连续并且与图1是同一个角色。${CHIBI_STYLE_TEXT} 修复图3中的错误使这一帧适合插回原来的 sprite sheet 中。`,
'保持不变:发型、服装结构、主配色、武器类型、朝向。',
`重点修复:${options.issueText.trim() || '修复手脚畸形、武器错误或朝向不一致问题。'}`,
].join('\n');
}
export async function triggerDataUrlDownload(
filename: string,
dataUrl: string,
) {
const response = await fetch(dataUrl);
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(objectUrl);
}
export function triggerJsonDownload(filename: string, value: unknown) {
const blob = new Blob([JSON.stringify(value, null, 2)], {
type: 'application/json',
});
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(objectUrl);
}
export function buildDefaultFrameOrder(frameCount: number) {
return Array.from({ length: frameCount }, (_, index) => index);
}
export function restoreAllFrames(frameCount: number) {
return buildDefaultFrameOrder(frameCount);
}
export function moveFrameOrderItem(
frameOrder: number[],
frameIndex: number,
direction: -1 | 1,
) {
const currentOrderIndex = frameOrder.indexOf(frameIndex);
if (currentOrderIndex < 0) {
return frameOrder;
}
const targetIndex = currentOrderIndex + direction;
if (targetIndex < 0 || targetIndex >= frameOrder.length) {
return frameOrder;
}
const nextOrder = [...frameOrder];
const [item] = nextOrder.splice(currentOrderIndex, 1);
nextOrder.splice(targetIndex, 0, item);
return nextOrder;
}
export function toggleActiveFrame(activeFrames: number[], frameIndex: number) {
if (activeFrames.includes(frameIndex)) {
return activeFrames.filter((item) => item !== frameIndex);
}
return [...activeFrames, frameIndex].sort((left, right) => left - right);
}

View File

@@ -0,0 +1,120 @@
import { parseApiErrorMessage } from '../editor/shared/jsonClient';
const QWEN_SPRITE_MASTER_API_PATH = '/api/qwen-sprite/master';
const QWEN_SPRITE_SHEET_API_PATH = '/api/qwen-sprite/sheet';
const QWEN_SPRITE_FRAME_REPAIR_API_PATH = '/api/qwen-sprite/frame-repair';
const QWEN_SPRITE_SAVE_API_PATH = '/api/qwen-sprite/save';
export type QwenSpriteImageDraft = {
id: string;
label: string;
imageSrc: string;
remoteUrl?: string;
};
export type GenerateQwenSpritePayload = {
promptText: string;
negativePrompt: string;
model: string;
size: string;
promptExtend: boolean;
candidateCount: number;
seed?: number;
referenceImages: string[];
};
export type RepairQwenSpriteFramePayload = {
promptText: string;
negativePrompt: string;
model: string;
size: string;
promptExtend: boolean;
seed?: number;
referenceImages: string[];
};
export type SaveQwenSpriteAssetPayload = {
assetKey: string;
actionKey: string;
masterSource: string;
sheetSource: string;
framesDataUrls: string[];
metadata: Record<string, unknown>;
prompts: Record<string, unknown>;
};
async function postJson<T>(
url: string,
payload: Record<string, unknown>,
fallbackMessage: string,
) {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(parseApiErrorMessage(responseText, fallbackMessage));
}
return JSON.parse(responseText) as T;
}
export async function generateQwenSpriteMaster(
payload: GenerateQwenSpritePayload,
) {
return postJson<{
ok: true;
draftId: string;
drafts: QwenSpriteImageDraft[];
model: string;
size: string;
promptText: string;
negativePrompt: string;
}>(QWEN_SPRITE_MASTER_API_PATH, payload, '生成主图失败');
}
export async function generateQwenSpriteSheet(
payload: GenerateQwenSpritePayload,
) {
return postJson<{
ok: true;
draftId: string;
drafts: QwenSpriteImageDraft[];
model: string;
size: string;
promptText: string;
negativePrompt: string;
}>(QWEN_SPRITE_SHEET_API_PATH, payload, '生成精灵表失败');
}
export async function repairQwenSpriteFrame(
payload: RepairQwenSpriteFramePayload,
) {
return postJson<{
ok: true;
draftId: string;
drafts: QwenSpriteImageDraft[];
repairedFrame: QwenSpriteImageDraft | null;
model: string;
size: string;
promptText: string;
negativePrompt: string;
}>(QWEN_SPRITE_FRAME_REPAIR_API_PATH, payload, '修复帧失败');
}
export async function saveQwenSpriteAsset(
payload: SaveQwenSpriteAssetPayload,
) {
return postJson<{
ok: true;
assetId: string;
assetDir: string;
masterImagePath: string | null;
sheetImagePath: string;
framePaths: string[];
saveMessage: string;
}>(QWEN_SPRITE_SAVE_API_PATH, payload, '保存精灵表失败');
}

View File

@@ -19,6 +19,7 @@ import {
type SkillStyle,
WorldType,
} from './core';
import type { CustomWorldNpcVisual } from './customWorld';
export type SpriteSequenceDefinition =
| {
@@ -142,6 +143,7 @@ export interface Character {
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
groundOffsetY?: number;
visual?: CustomWorldNpcVisual;
animationMap?: Partial<Record<AnimationState, CharacterAnimationConfig>>;
attributes: LegacyAttributeSet;
attributeProfile?: RoleAttributeProfile;

View File

@@ -206,6 +206,13 @@ export interface CustomWorldSceneConnection {
summary: string;
}
export interface CustomWorldCampScene {
name: string;
description: string;
dangerLevel: string;
imageSrc?: string;
}
export interface CustomWorldLandmark {
id: string;
name: string;
@@ -232,6 +239,7 @@ export interface CustomWorldProfile {
playableNpcs: CustomWorldPlayableNpc[];
storyNpcs: CustomWorldNpc[];
items: CustomWorldItem[];
camp?: CustomWorldCampScene | null;
landmarks: CustomWorldLandmark[];
themePack?: ThemePack | null;
storyGraph?: WorldStoryGraph | null;

View File

@@ -7,6 +7,12 @@ import {
import type {InventoryItem} from './items';
import type {SceneDirective} from './scene';
export interface StoryOptionGoalAffordance {
goalId: string;
relation: 'advance' | 'support' | 'detour';
label: string;
}
export interface StoryOption {
functionId: string;
actionText: string;
@@ -16,6 +22,7 @@ export interface StoryOption {
visuals: SceneDirective;
skillProbabilities?: Record<string, number>;
interaction?: StoryOptionInteraction;
goalAffordance?: StoryOptionGoalAffordance | null;
}
export interface QuestReward {

View File

@@ -114,6 +114,77 @@ export interface SceneNarrativeDirective {
emotionalCadence: SceneNarrativeCadence;
}
export type GoalSourceKind =
| 'quest'
| 'chapter'
| 'journey_beat'
| 'thread_contract'
| 'setpiece'
| 'relationship'
| 'survival';
export type GoalTrack =
| 'main'
| 'side'
| 'relationship'
| 'survival'
| 'exploration';
export type GoalStatus =
| 'teased'
| 'active'
| 'blocked'
| 'ready_to_resolve'
| 'resolved'
| 'archived';
export type GoalLayer =
| 'north_star'
| 'active_contract'
| 'immediate_step'
| 'support';
export interface GoalStackEntry {
id: string;
sourceKind: GoalSourceKind;
sourceId: string;
layer: GoalLayer;
track: GoalTrack;
title: string;
promiseText: string;
whyNow: string;
nextStepText: string;
sceneHint?: string | null;
npcHint?: string | null;
progressLabel?: string | null;
status: GoalStatus;
urgency: 'low' | 'medium' | 'high';
relatedThreadIds: string[];
}
export interface GoalStackState {
northStarGoal: GoalStackEntry | null;
activeGoal: GoalStackEntry | null;
immediateStepGoal: GoalStackEntry | null;
supportGoals: GoalStackEntry[];
}
export interface GoalHandoff {
goalId: string;
title: string;
detail: string;
track: GoalTrack;
}
export interface GoalPulseEvent {
id: string;
goalId: string;
pulseType: 'progress' | 'ready_to_turn_in' | 'resolved' | 'handoff';
title: string;
detail: string;
track: GoalTrack;
}
export interface CarrierStoryFingerprint {
visibleClue: string;
witnessMark: string;

View File

@@ -1,4 +1,6 @@
import type { CSSProperties } from 'react';
import type {CSSProperties} from 'react';
import type {InventoryItem} from './types';
export type NineSliceTexture = {
src: string;
@@ -182,12 +184,70 @@ const EQUIPMENT_SLOT_ICONS: Record<string, string> = {
};
const INVENTORY_CATEGORY_ICONS: Record<string, string> = {
: '/UI/Icon_Eq_Weapon.png',
: '/UI/Icon_Eq_Chest.png',
: '/UI/Icon_Eq_ring.png',
: '/Icons/12_potion.png',
: '/Icons/68_relic.png',
: '/Icons/47_treasure.png',
: '/Icons/47_treasure.png',
: '/Icons/47_treasure.png',
: '/Icons/45_crystal.png',
};
const ITEM_SEMANTIC_ICON_RULES: Array<{pattern: RegExp; icon: string}> = [
{pattern: /||||||||script|scroll|book/u, icon: '/Icons/01_Scroll.png'},
{pattern: /|||||sigil|seal|rune/u, icon: '/Icons/69_magic.png'},
{pattern: /||ring/u, icon: '/Icons/15_Silver_ring.png'},
{pattern: /|||neck|amulet/u, icon: '/Icons/13_neck.png'},
{pattern: /|||embers?|torch/u, icon: '/Icons/03_Torch.png'},
{pattern: /||helm|helmet/u, icon: '/Icons/04_helm.png'},
{pattern: /|||||armor|chest/u, icon: '/Icons/05_chest.png'},
{pattern: /|||pants/u, icon: '/Icons/06_pants.png'},
{pattern: /||boots/u, icon: '/Icons/07_boots.png'},
{pattern: /||||||bottle|potion|water/u, icon: '/Icons/12_potion.png'},
{pattern: /||/u, icon: '/Icons/24_Mushroom.png'},
{pattern: /|meat/u, icon: '/Icons/25_Meat.png'},
{pattern: /|apple/u, icon: '/Icons/26_apple.png'},
{pattern: /||skull/u, icon: '/Icons/27_Skull.png'},
{pattern: /|||bag|pouch/u, icon: '/Icons/29_bag.png'},
{pattern: /|mace|hammer/u, icon: '/Icons/30_mace.png'},
{pattern: /|spade/u, icon: '/Icons/31_spade.png'},
{pattern: /||coin/u, icon: '/Icons/32_coin.png'},
{pattern: /||stone/u, icon: '/Icons/33_stone.png'},
{pattern: /||wood/u, icon: '/Icons/34_wood.png'},
{pattern: /|glove|glowes/u, icon: '/Icons/35_glowes.png'},
{pattern: /|flower/u, icon: '/Icons/55_flower.png'},
{pattern: /|leaf/u, icon: '/Icons/37_leaf.png'},
{pattern: /|wand|staff/u, icon: '/Icons/66_wand.png'},
{pattern: /|bow/u, icon: '/Icons/40_bow.png'},
{pattern: /|||arrow/u, icon: '/Icons/41_arrow.png'},
{pattern: /|shield/u, icon: '/Icons/42_shield.png'},
{pattern: /||||rope|tendril/u, icon: '/Icons/44_rope.png'},
{pattern: /||hide|skin|pelt/u, icon: '/Icons/46_skin.png'},
{pattern: /||||treasure|relic|artifact|/u, icon: '/Icons/47_treasure.png'},
{pattern: /|pick|/u, icon: '/Icons/49_pick.png'},
{pattern: /|silver|bar/u, icon: '/Icons/54_silverbar.png'},
{pattern: /||||||lens|core|crystal|gem|pearl/u, icon: '/Icons/45_crystal.png'},
{pattern: /|||mana|magic|essence/u, icon: '/Icons/69_magic.png'},
{pattern: /|bandage/u, icon: '/Icons/67_bandage.png'},
{pattern: /|||blade|fang|sword/u, icon: '/Icons/38_sword.png'},
];
function buildInventoryLookupText(
item: Pick<InventoryItem, 'name' | 'category' | 'tags' | 'equipmentSlotId'>,
) {
return [
item.name,
item.category,
item.equipmentSlotId ?? '',
...(item.tags ?? []),
]
.join(' ')
.trim()
.toLowerCase();
}
export function getEquipmentSlotIcon(slot: string) {
return EQUIPMENT_SLOT_ICONS[slot] ?? '/UI/Icon_Frame.png';
}
@@ -195,3 +255,38 @@ export function getEquipmentSlotIcon(slot: string) {
export function getInventoryCategoryIcon(category: string) {
return INVENTORY_CATEGORY_ICONS[category] ?? '/Icons/28_bag.png';
}
export function getInventoryItemVisualSrc(
item: Pick<InventoryItem, 'iconSrc' | 'name' | 'category' | 'tags' | 'equipmentSlotId'>,
) {
if (item.iconSrc) return item.iconSrc;
const lookupText = buildInventoryLookupText(item);
if (item.equipmentSlotId === 'weapon') {
if (/|bow/u.test(lookupText)) return '/Icons/40_bow.png';
if (/|wand|staff|/u.test(lookupText)) return '/Icons/66_wand.png';
if (/|mace|hammer/u.test(lookupText)) return '/Icons/30_mace.png';
return '/Icons/38_sword.png';
}
if (item.equipmentSlotId === 'armor') {
if (/|shield/u.test(lookupText)) return '/Icons/42_shield.png';
return '/Icons/05_chest.png';
}
if (item.equipmentSlotId === 'relic') {
if (/||ring/u.test(lookupText)) return '/Icons/15_Silver_ring.png';
if (/||neck|amulet/u.test(lookupText)) return '/Icons/13_neck.png';
}
const semanticMatch = ITEM_SEMANTIC_ICON_RULES.find(rule => rule.pattern.test(lookupText));
if (semanticMatch) return semanticMatch.icon;
if (item.tags.includes('mana')) return '/Icons/69_magic.png';
if (item.tags.includes('healing')) return '/Icons/12_potion.png';
if (item.tags.includes('material')) return '/Icons/45_crystal.png';
if (item.tags.includes('relic')) return '/Icons/68_relic.png';
return getInventoryCategoryIcon(item.category);
}