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

@@ -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 {