1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-20 11:30:19 +08:00
parent 50759f3c1e
commit 8a7bd90458
85 changed files with 7290 additions and 1903 deletions

View File

@@ -96,6 +96,10 @@ interface AdventurePanelProps {
scenesTraveled: number;
currentSceneName: string;
playerCurrency: number;
playerLevel?: number;
playerCurrentLevelXp?: number;
playerXpToNextLevel?: number;
playerTotalXp?: number;
inventoryItemCount: number;
inventoryStackCount: number;
activeCompanionCount: number;
@@ -276,6 +280,19 @@ function formatPlayTime(playTimeMs: number) {
return `${minutes}${String(seconds).padStart(2, '0')}`;
}
function getPlayerProgressionRatio(
statistics: AdventurePanelProps['statistics'],
) {
const currentLevelXp = Math.max(0, statistics.playerCurrentLevelXp ?? 0);
const xpToNextLevel = Math.max(0, statistics.playerXpToNextLevel ?? 0);
if (xpToNextLevel <= 0) {
return 1;
}
return Math.max(0, Math.min(1, currentLevelXp / xpToNextLevel));
}
function getOptionGoalAffordanceClass(option: StoryOption) {
switch (option.goalAffordance?.relation) {
case 'advance':
@@ -467,6 +484,17 @@ function QuestRewardGrid({
</div>
</div>
<div className="rounded-xl border border-sky-300/18 bg-sky-500/10 px-3 py-2.5 text-sky-50">
<div className="flex items-center gap-2">
<ScrollText className="h-4 w-4" />
<span className="text-sm font-semibold">
+{quest.reward.experience ?? 0}
</span>
</div>
<div className="mt-1 text-[10px] uppercase tracking-[0.2em] text-sky-100/70">
</div>
</div>
</div>
<RewardItemIconGrid
@@ -661,11 +689,12 @@ export function AdventurePanel({
currentStory.deferredOptions?.length,
);
const saveAndExitDisabled = isLoading || isStoryStreaming;
const primaryQuestGoal = goalStack.activeGoal?.sourceKind === 'quest'
? goalStack.activeGoal
: goalStack.immediateStepGoal?.sourceKind === 'quest'
? goalStack.immediateStepGoal
: null;
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);
@@ -678,7 +707,8 @@ export function AdventurePanel({
string | null
>(null);
const [rewardQuestId, setRewardQuestId] = useState<string | null>(null);
const [rewardQuestHandoff, setRewardQuestHandoff] = useState<GoalHandoff | null>(null);
const [rewardQuestHandoff, setRewardQuestHandoff] =
useState<GoalHandoff | null>(null);
const [selectedRewardItemQuestId, setSelectedRewardItemQuestId] = useState<
string | null
>(null);
@@ -699,7 +729,9 @@ export function AdventurePanel({
const selectedQuest = useMemo(
() =>
quests.find((quest) => quest.id === selectedQuestId) ??
(pendingNpcQuestOffer?.id === selectedQuestId ? pendingNpcQuestOffer : null),
(pendingNpcQuestOffer?.id === selectedQuestId
? pendingNpcQuestOffer
: null),
[pendingNpcQuestOffer, quests, selectedQuestId],
);
const rewardQuest = useMemo(
@@ -899,6 +931,13 @@ export function AdventurePanel({
],
[statistics],
);
const playerLevel = Math.max(1, statistics.playerLevel ?? 1);
const playerCurrentLevelXp = Math.max(
0,
statistics.playerCurrentLevelXp ?? 0,
);
const playerXpToNextLevel = Math.max(0, statistics.playerXpToNextLevel ?? 0);
const playerProgressionRatio = getPlayerProgressionRatio(statistics);
const shouldMountAdventureOverlays =
isGoalPanelOpen ||
isSettingsPanelOpen ||
@@ -1060,6 +1099,27 @@ export function AdventurePanel({
</div>
<div className="mt-auto shrink-0 pb-2">
<div className="mb-2 rounded-xl border border-amber-300/15 bg-[radial-gradient(circle_at_top,rgba(251,191,36,0.14),transparent_65%),rgba(0,0,0,0.24)] px-3 py-2.5">
<div className="flex items-center justify-between gap-3 text-[11px]">
<div className="font-semibold text-amber-50">Lv.{playerLevel}</div>
<div className="text-zinc-400">
{playerXpToNextLevel > 0
? `${playerCurrentLevelXp}/${playerXpToNextLevel}`
: 'MAX'}
</div>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full border border-white/10 bg-black/30">
<div
className="h-full rounded-full bg-[linear-gradient(90deg,rgba(251,191,36,0.78),rgba(253,224,71,0.96))]"
style={{
width:
playerProgressionRatio <= 0
? '0%'
: `${Math.max(6, playerProgressionRatio * 100)}%`,
}}
/>
</div>
</div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<button
@@ -1123,40 +1183,67 @@ export function AdventurePanel({
<div className="p-4" aria-hidden="true" />
) : (
<>
{displayedOptions.map((option, index) => {
const optionImpactSummary = getOptionImpactSummary(
option,
playerCharacter,
playerHp,
playerMaxHp,
playerMana,
playerMaxMana,
playerSkillCooldowns,
currentNpcBattleMode,
);
const isDeferredContinueOption =
hasDeferredAdventureOptions &&
isContinueAdventureOption(option);
const optionDisabled = option.disabled === true;
const compactOptionDetailText = option.disabledReason
? option.disabledReason
: getCompactOptionDetailText(option);
{displayedOptions.map((option, index) => {
const optionImpactSummary = getOptionImpactSummary(
option,
playerCharacter,
playerHp,
playerMaxHp,
playerMana,
playerMaxMana,
playerSkillCooldowns,
currentNpcBattleMode,
);
const isDeferredContinueOption =
hasDeferredAdventureOptions &&
isContinueAdventureOption(option);
const optionDisabled = option.disabled === true;
const compactOptionDetailText = option.disabledReason
? option.disabledReason
: getCompactOptionDetailText(option);
if (isDeferredContinueOption) {
return (
<motion.button
key={`${option.functionId}-${option.actionText}-${index}`}
type="button"
initial={{ opacity: 0, y: 22 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.24, ease: 'easeOut' }}
onClick={() => handleOptionChoice(option)}
className="pixel-nine-slice pixel-pressable pixel-choice-button group w-full text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton)}
>
<div className="flex items-center justify-between">
<span
className={`text-xs ${getOptionActionTextClass(option)}`}
>
{option.actionText}
</span>
<PixelIcon
src={CHROME_ICONS.optionArrow}
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
/>
</div>
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
</div>
</motion.button>
);
}
if (isDeferredContinueOption) {
return (
<motion.button
<button
key={`${option.functionId}-${option.actionText}-${index}`}
type="button"
initial={{ opacity: 0, y: 22 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.24, ease: 'easeOut' }}
onClick={() => handleOptionChoice(option)}
className="pixel-nine-slice pixel-pressable pixel-choice-button group w-full text-left"
disabled={optionDisabled}
className={`pixel-nine-slice pixel-choice-button group w-full text-left ${optionDisabled ? 'cursor-not-allowed opacity-55' : 'pixel-pressable'}`}
style={getNineSliceStyle(UI_CHROME.choiceButton)}
>
<div className="flex items-center justify-between">
<span
className={`text-xs ${getOptionActionTextClass(option)}`}
className={`${isNpcChatMode ? 'text-sm sm:text-[15px]' : 'text-xs'} ${getOptionActionTextClass(option)}`}
>
{option.actionText}
</span>
@@ -1165,85 +1252,61 @@ export function AdventurePanel({
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
/>
</div>
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
</div>
</motion.button>
);
}
return (
<button
key={`${option.functionId}-${option.actionText}-${index}`}
type="button"
onClick={() => handleOptionChoice(option)}
disabled={optionDisabled}
className={`pixel-nine-slice pixel-choice-button group w-full text-left ${optionDisabled ? 'cursor-not-allowed opacity-55' : 'pixel-pressable'}`}
style={getNineSliceStyle(UI_CHROME.choiceButton)}
>
<div className="flex items-center justify-between">
<span
className={`${isNpcChatMode ? 'text-sm sm:text-[15px]' : 'text-xs'} ${getOptionActionTextClass(option)}`}
>
{option.actionText}
</span>
<PixelIcon
src={CHROME_ICONS.optionArrow}
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
/>
</div>
{!isNpcChatMode && compactOptionDetailText && (
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
{compactOptionDetailText}
</div>
)}
{!isNpcChatMode && option.goalAffordance?.label && (
<div className={`mt-1 text-[10px] ${getOptionGoalAffordanceClass(option)}`}>
{option.goalAffordance.label}
</div>
)}
{!isNpcChatMode && optionImpactSummary && !optionDisabled && (
<div className="mt-1 text-[10px] text-zinc-500">
{optionImpactSummary}
</div>
)}
</button>
);
})}
{isNpcChatMode && !isNpcQuestOfferMode ? (
<div className="pixel-nine-slice pixel-panel mt-0.5 border border-white/10 bg-black/25 p-1.5">
<div className="flex min-w-0 items-center gap-2">
<input
value={npcChatDraft}
onChange={(event) => setNpcChatDraft(event.target.value)}
onKeyDown={(event) => {
if (
event.key === 'Enter' &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
submitNpcChatDraft();
}
}}
placeholder={
npcChatState?.customInputPlaceholder ??
'输入你想说的话'
}
className="h-9 min-w-0 flex-1 rounded-md border border-white/10 bg-black/35 px-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-amber-200/40"
maxLength={80}
disabled={isLoading}
/>
<button
type="button"
onClick={submitNpcChatDraft}
disabled={isLoading || !npcChatDraft.trim()}
className="inline-flex h-9 shrink-0 items-center rounded-md border border-amber-300/20 bg-amber-500/10 px-2.5 text-[11px] text-amber-100 transition-colors disabled:cursor-not-allowed disabled:opacity-40 sm:px-3 sm:text-xs"
>
{!isNpcChatMode && compactOptionDetailText && (
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
{compactOptionDetailText}
</div>
)}
{!isNpcChatMode && option.goalAffordance?.label && (
<div
className={`mt-1 text-[10px] ${getOptionGoalAffordanceClass(option)}`}
>
{option.goalAffordance.label}
</div>
)}
{!isNpcChatMode &&
optionImpactSummary &&
!optionDisabled && (
<div className="mt-1 text-[10px] text-zinc-500">
{optionImpactSummary}
</div>
)}
</button>
);
})}
{isNpcChatMode && !isNpcQuestOfferMode ? (
<div className="pixel-nine-slice pixel-panel mt-0.5 border border-white/10 bg-black/25 p-1.5">
<div className="flex min-w-0 items-center gap-2">
<input
value={npcChatDraft}
onChange={(event) => setNpcChatDraft(event.target.value)}
onKeyDown={(event) => {
if (
event.key === 'Enter' &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
submitNpcChatDraft();
}
}}
placeholder={
npcChatState?.customInputPlaceholder ?? '输入你想说的话'
}
className="h-9 min-w-0 flex-1 rounded-md border border-white/10 bg-black/35 px-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-amber-200/40"
maxLength={80}
disabled={isLoading}
/>
<button
type="button"
onClick={submitNpcChatDraft}
disabled={isLoading || !npcChatDraft.trim()}
className="inline-flex h-9 shrink-0 items-center rounded-md border border-amber-300/20 bg-amber-500/10 px-2.5 text-[11px] text-amber-100 transition-colors disabled:cursor-not-allowed disabled:opacity-40 sm:px-3 sm:text-xs"
>
</button>
</div>
</div>
</div>
) : null}
) : null}
</>
)}
</div>
@@ -1307,4 +1370,3 @@ export function AdventurePanel({
</div>
);
}

View File

@@ -0,0 +1,64 @@
// @vitest-environment jsdom
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { AnimationState, type Character } from '../types';
import { CharacterAnimator } from './CharacterAnimator';
function buildCharacter(overrides: Partial<Character> = {}): Character {
return {
id: 'generated-role',
name: '沈砺',
title: '守灯人',
description: '',
backstory: '',
avatar: '/generated/portrait.png',
portrait: '/generated/portrait.png',
assetFolder: 'custom-world',
assetVariant: 'generated',
attributes: {} as Character['attributes'],
personality: '',
skills: [],
adventureOpenings: {},
...overrides,
};
}
describe('CharacterAnimator portrait fallbacks', () => {
it('keeps idle fallback static on the portrait when idle animation is missing', () => {
render(
<CharacterAnimator
state={AnimationState.IDLE}
character={buildCharacter()}
/>,
);
const image = screen.getByRole('img', {
name: /沈砺 idle animation/i,
}) as HTMLImageElement;
expect(image.getAttribute('src')).toBe('/generated/portrait.png');
expect(image.style.transform).toBe('');
});
it('uses a fallen portrait fallback when death animation is missing', () => {
render(
<CharacterAnimator
state={AnimationState.DIE}
character={buildCharacter()}
/>,
);
const image = screen.getByRole('img', {
name: /沈砺 die animation/i,
}) as HTMLImageElement;
expect(image.getAttribute('src')).toBe('/generated/portrait.png');
expect(image.style.animation).toContain(
'character-animator-portrait-death-fall',
);
expect(image.style.transform).toContain('rotate(90deg)');
expect(image.style.transform).toContain('scaleX(-1)');
});
});

View File

@@ -15,28 +15,88 @@ const DEFAULT_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.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.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.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.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.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' },
};
const PORTRAIT_FALLBACK_ANIMATION: CharacterAnimationConfig = {
frames: 1,
prefix: 'portrait',
folder: 'portrait',
fps: 1,
loop: false,
};
const FALLEN_PORTRAIT_STYLE: React.CSSProperties = {
imageRendering: 'pixelated',
transform: 'translateY(16%) rotate(90deg) scaleX(-1) scale(0.82)',
transformOrigin: '50% 85%',
animation:
'character-animator-portrait-death-fall 680ms cubic-bezier(0.22, 0.7, 0.18, 1) forwards',
};
const DEFAULT_IMAGE_STYLE: React.CSSProperties = {
imageRendering: 'pixelated',
};
export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
state,
character,
@@ -45,11 +105,20 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
imageClassName,
playbackRate = 1,
}) => {
const config =
character.animationMap?.[state] ??
const explicitConfig = character.animationMap?.[state];
const usePortraitIdleFallback =
!explicitConfig && state === AnimationState.IDLE;
const usePortraitDeathFallback =
!explicitConfig && state === AnimationState.DIE;
const [hasRenderError, setHasRenderError] = useState(false);
const baseConfig =
explicitConfig ??
DEFAULT_ANIMATIONS[state] ??
character.animationMap?.[AnimationState.IDLE] ??
DEFAULT_ANIMATIONS[AnimationState.IDLE];
const fallbackToPortrait =
usePortraitIdleFallback || usePortraitDeathFallback || hasRenderError;
const config = fallbackToPortrait ? PORTRAIT_FALLBACK_ANIMATION : baseConfig;
const startFrame =
typeof config.startFrame === 'number' && Number.isFinite(config.startFrame)
? Math.max(1, Math.floor(config.startFrame))
@@ -66,6 +135,20 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
const effectivePlaybackRate = Number.isFinite(playbackRate)
? Math.max(0.1, playbackRate)
: 1;
const requestedAnimationSignature = [
state,
character.id,
character.portrait,
baseConfig.basePath ?? '',
baseConfig.folder,
baseConfig.prefix,
baseConfig.file ?? '',
baseConfig.extension ?? 'png',
baseConfig.startFrame ?? 1,
baseConfig.frames,
baseConfig.fps ?? 10,
effectivePlaybackRate,
].join('::');
const animationSignature = [
state,
config.basePath ?? '',
@@ -78,6 +161,11 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
fps,
effectivePlaybackRate,
].join('::');
useEffect(() => {
setHasRenderError(false);
}, [requestedAnimationSignature]);
const endFrame = startFrame + frameCount - 1;
const intervalDelay = Math.max(
40,
@@ -101,16 +189,11 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
}, intervalDelay);
return () => window.clearInterval(interval);
}, [
endFrame,
frameCount,
intervalDelay,
startFrame,
]);
}, [endFrame, frameCount, intervalDelay, startFrame]);
const frameNumber = frameIndex.toString().padStart(2, '0');
const normalizedBasePath = config.basePath?.replace(/\/+$/u, '');
const imagePath = normalizedBasePath
const generatedImagePath = normalizedBasePath
? config.file
? `${normalizedBasePath}/${encodeURIComponent(config.file)}`
: `${normalizedBasePath}/${config.prefix}${frameNumber}.${config.extension ?? 'png'}`
@@ -122,7 +205,15 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
? `/character/${folder}/${variant}/Hero/${animationFolder}/${encodeURIComponent(config.file)}`
: `/character/${folder}/${variant}/Hero/${animationFolder}/${config.prefix}${frameNumber}.${config.extension ?? 'png'}`;
})();
const resolvedImageClassName = `h-full w-full object-contain pixelated ${imageClassName ?? ''}`.trim();
const imagePath = fallbackToPortrait
? character.portrait
: generatedImagePath;
const resolvedImageClassName =
`h-full w-full object-contain pixelated ${imageClassName ?? ''}`.trim();
const imageStyle =
state === AnimationState.DIE && (usePortraitDeathFallback || hasRenderError)
? FALLEN_PORTRAIT_STYLE
: DEFAULT_IMAGE_STYLE;
return (
<div className={`relative ${className ?? ''}`} style={style}>
@@ -130,11 +221,11 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
src={imagePath}
alt={`${character.name} ${state} animation`}
className={resolvedImageClassName}
style={{ imageRendering: 'pixelated' }}
style={imageStyle}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = character.portrait;
target.className = resolvedImageClassName;
if (!hasRenderError) {
setHasRenderError(true);
}
}}
/>
</div>

View File

@@ -1038,7 +1038,7 @@ export function CustomWorldEntityCatalog({
</div>
</div>
<div className="sticky top-0 z-10 -mx-1 space-y-3 bg-[linear-gradient(180deg,rgba(255,252,253,0.98)_0%,rgba(255,244,248,0.94)_76%,rgba(255,244,248,0)_100%)] px-1 pb-3 pt-1 backdrop-blur-sm">
<div className="platform-sticky-fade sticky top-0 z-10 -mx-1 space-y-3 px-1 pb-3 pt-1 backdrop-blur-sm">
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
{RESULT_TABS.map((tab) => (
<div key={tab.id}>

View File

@@ -94,7 +94,7 @@ export function CustomWorldGenerationView({
className="flex h-full min-h-0 flex-col overflow-y-auto overscroll-y-contain pr-1 pb-[max(1rem,env(safe-area-inset-bottom))]"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<div className="sticky top-0 z-20 -mx-3 mb-4 flex items-center justify-between gap-3 bg-[linear-gradient(180deg,rgba(255,247,250,0.96),rgba(255,244,248,0.86),rgba(255,244,248,0))] px-3 pb-3 pt-1 sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-0 sm:pt-0">
<div className="platform-sticky-fade sticky top-0 z-20 -mx-3 mb-4 flex items-center justify-between gap-3 px-3 pb-3 pt-1 backdrop-blur-sm sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-0 sm:pt-0 sm:backdrop-blur-none">
<button
type="button"
onClick={onBack}

View File

@@ -64,18 +64,11 @@ type CustomWorldAiActionConfig = {
frameCount: number;
durationSeconds: number;
loop: boolean;
required: boolean;
fallbackStatusLabel?: string;
};
const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
{
animation: AnimationState.IDLE,
label: '待机',
templateId: 'idle',
fps: 8,
frameCount: 8,
durationSeconds: 4,
loop: true,
},
{
animation: AnimationState.RUN,
label: '奔跑',
@@ -84,6 +77,7 @@ const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
frameCount: 8,
durationSeconds: 4,
loop: true,
required: true,
},
{
animation: AnimationState.ATTACK,
@@ -93,6 +87,18 @@ const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
frameCount: 8,
durationSeconds: 4,
loop: false,
required: true,
},
{
animation: AnimationState.IDLE,
label: '待机',
templateId: 'idle',
fps: 8,
frameCount: 8,
durationSeconds: 4,
loop: true,
required: false,
fallbackStatusLabel: '默认静止',
},
{
animation: AnimationState.DIE,
@@ -102,6 +108,8 @@ const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
frameCount: 8,
durationSeconds: 4,
loop: false,
required: false,
fallbackStatusLabel: '默认倒地动画',
},
];
@@ -698,6 +706,15 @@ export function CustomWorldRoleAssetStudioModal({
const hasAnyGeneratingAnimations = Object.values(generatingAnimationMap).some(
(value) => value === true,
);
const isSelectedAnimationGenerated = hasGeneratedAnimation(
workingRole,
selectedAnimation,
);
const shouldUseSelectedAnimationPreview =
Boolean(previewCharacter) &&
(isSelectedAnimationGenerated ||
selectedAnimation === AnimationState.IDLE ||
selectedAnimation === AnimationState.DIE);
const animationPreviewFrameStyle = useMemo(
() => getAnimationPreviewFrameStyle(selectedAnimationConfig, 440),
[selectedAnimationConfig],
@@ -1258,8 +1275,7 @@ export function CustomWorldRoleAssetStudioModal({
<div className="space-y-4">
<div className="platform-role-studio__preview rounded-3xl p-4">
<div className="platform-role-studio__stage flex min-h-[28rem] items-center justify-center rounded-2xl p-4">
{previewCharacter &&
hasGeneratedAnimation(workingRole, selectedAnimation) ? (
{shouldUseSelectedAnimationPreview && previewCharacter ? (
<div
className="flex items-center justify-center"
style={animationPreviewViewportStyle}
@@ -1276,7 +1292,7 @@ export function CustomWorldRoleAssetStudioModal({
<img
src={previewImageSrc}
alt={workingRole.name}
className="max-h-[28rem] w-full object-contain"
className="max-h-[28rem] w-full object-contain pixelated"
/>
) : (
<div className="px-4 text-sm text-zinc-500"></div>
@@ -1351,12 +1367,13 @@ export function CustomWorldRoleAssetStudioModal({
<div className="text-sm font-semibold text-white">
{item.label}
</div>
<div className="mt-1 text-[11px] text-zinc-400">
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-zinc-400">
{isGenerating
? '后台生成中'
: isSelected
? '当前预览'
: '点击切换'}
<span>{item.required ? '必需动作' : '可选动作'}</span>
</div>
</div>
<StatusBadge
@@ -1368,7 +1385,9 @@ export function CustomWorldRoleAssetStudioModal({
? '生成中'
: isReady
? '已生成'
: '待生成'}
: item.required
? '待生成'
: (item.fallbackStatusLabel ?? '可选')}
</StatusBadge>
</div>
</button>

View File

@@ -247,6 +247,8 @@ export function SkillEffectPreview({
encounter={null}
currentScenePreset={scenePreset}
worldType={worldType}
customWorldProfile={null}
storyEngineMemory={null}
sceneHostileNpcs={sceneHostileNpcs}
playerX={PLAYER_X}
playerOffsetY={0}

File diff suppressed because it is too large Load Diff

View File

@@ -11,18 +11,13 @@ export const GENERATED_FRAME_WIDTH = 192;
export const GENERATED_FRAME_HEIGHT = 256;
export const REQUIRED_BASE_ANIMATIONS: AnimationState[] = [
AnimationState.IDLE,
AnimationState.ACQUIRE,
AnimationState.ATTACK,
AnimationState.RUN,
AnimationState.JUMP,
AnimationState.DOUBLE_JUMP,
AnimationState.JUMP_ATTACK,
AnimationState.DASH,
AnimationState.HURT,
];
export const OPTIONAL_BASE_ANIMATIONS: AnimationState[] = [
AnimationState.IDLE,
AnimationState.DIE,
AnimationState.CLIMB,
AnimationState.WALL_SLIDE,
];
export type DraftVisualCandidate = {
@@ -1094,9 +1089,7 @@ export async function buildReferenceVideoFromCharacterAnimation(
const stopPromise = new Promise<Blob>((resolve) => {
recorder.onstop = () => {
resolve(
new Blob(chunks, { type: recorder.mimeType || 'video/webm' }),
);
resolve(new Blob(chunks, { type: recorder.mimeType || 'video/webm' }));
};
});

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
@@ -27,7 +27,13 @@ function renderAccountModal(overrides?: {
riskBlocks?: AuthRiskBlockSummary[];
sessions?: AuthSessionSummary[];
auditLogs?: AuthAuditLogEntry[];
initialSection?: 'appearance' | 'account' | 'security' | 'devices' | 'logs' | null;
initialSection?:
| 'appearance'
| 'account'
| 'security'
| 'devices'
| 'logs'
| null;
}) {
return render(
<AccountModal
@@ -69,6 +75,14 @@ test('settings header uses a generic title instead of the phone number', () => {
expect(screen.getByRole('dialog', { name: '设置与账号安全' })).toBeTruthy();
expect(screen.getByText('设置与账号安全')).toBeTruthy();
expect(screen.queryByText('138****8000')).toBeNull();
expect(screen.queryByText('选择要管理的内容')).toBeNull();
expect(
screen.queryByText('主题、账号与设备能力统一在独立面板中管理'),
).toBeNull();
expect(screen.queryByText(/^安全状态$/)).toBeNull();
expect(screen.queryByText(/^登录设备$/)).toBeNull();
expect(screen.queryByText(/^操作记录$/)).toBeNull();
expect(screen.queryByText('当前账号状态')).toBeNull();
});
test('account actions open in independent panels instead of inline expansion', async () => {
@@ -80,13 +94,154 @@ test('account actions open in independent panels instead of inline expansion', a
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(accountDialog).toBeTruthy();
expect(within(accountDialog).getByRole('button', { name: '返回' })).toBeTruthy();
expect(within(accountDialog).getByRole('button', { name: '更换手机号' })).toBeTruthy();
expect(
within(accountDialog).getByRole('button', { name: '返回' }),
).toBeTruthy();
expect(
within(accountDialog).getByRole('button', { name: '更换手机号' }),
).toBeTruthy();
expect(screen.queryByLabelText('新手机号')).toBeNull();
await user.click(within(accountDialog).getByRole('button', { name: '更换手机号' }));
await user.click(
within(accountDialog).getByRole('button', { name: '更换手机号' }),
);
const changePhoneDialog = screen.getByRole('dialog', { name: '绑定新手机号' });
const changePhoneDialog = screen.getByRole('dialog', {
name: '绑定新手机号',
});
expect(within(changePhoneDialog).getByLabelText('新手机号')).toBeTruthy();
expect(within(changePhoneDialog).getByLabelText('验证码')).toBeTruthy();
});
test('nested settings panels keep back navigation without an extra close action', async () => {
const user = userEvent.setup();
renderAccountModal();
await user.click(screen.getByRole('button', { name: /账号信息/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(
within(accountDialog).getByRole('button', { name: '返回' }),
).toBeTruthy();
expect(
within(accountDialog).queryByRole('button', { name: '关闭' }),
).toBeNull();
await user.click(
within(accountDialog).getByRole('button', { name: '更换手机号' }),
);
const changePhoneDialog = screen.getByRole('dialog', {
name: '绑定新手机号',
});
expect(
within(changePhoneDialog).getByRole('button', { name: '返回' }),
).toBeTruthy();
expect(
within(changePhoneDialog).queryByRole('button', { name: '关闭' }),
).toBeNull();
});
test('settings overlays move focus away from inert triggers and restore it on back', async () => {
const user = userEvent.setup();
renderAccountModal();
const accountTrigger = screen.getByRole('button', { name: /账号信息/ });
expect(document.activeElement).not.toBe(accountTrigger);
await user.click(accountTrigger);
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
const accountBackButton = within(accountDialog).getByRole('button', {
name: '返回',
});
await waitFor(() => {
expect(document.activeElement).toBe(accountBackButton);
});
const changePhoneTrigger = within(accountDialog).getByRole('button', {
name: '更换手机号',
});
await user.click(changePhoneTrigger);
const changePhoneDialog = screen.getByRole('dialog', {
name: '绑定新手机号',
});
const changePhoneBackButton = within(changePhoneDialog).getByRole('button', {
name: '返回',
});
await waitFor(() => {
expect(document.activeElement).toBe(changePhoneBackButton);
});
await user.click(changePhoneBackButton);
await waitFor(() => {
expect(document.activeElement).toBe(changePhoneTrigger);
});
await user.click(accountBackButton);
await waitFor(() => {
expect(document.activeElement).toBe(accountTrigger);
});
});
test('account panel includes merged security devices and audit sections', async () => {
const user = userEvent.setup();
renderAccountModal({
riskBlocks: [
{
scopeType: 'phone',
title: '手机号保护',
detail: '检测到异常验证行为,已开启保护。',
remainingSeconds: 600,
expiresAt: '2026-04-20T10:00:00.000Z',
},
],
sessions: [
{
sessionId: 'session-1',
clientLabel: 'iPhone 15 Pro',
isCurrent: true,
lastSeenAt: '2026-04-20T09:00:00.000Z',
expiresAt: '2026-04-27T09:00:00.000Z',
ipMasked: '10.0.*.*',
},
],
auditLogs: [
{
id: 'log-1',
title: '登录成功',
detail: '通过手机号验证码完成登录。',
createdAt: '2026-04-20T08:00:00.000Z',
ipMasked: '10.0.*.*',
},
],
});
await user.click(screen.getByRole('button', { name: /账号信息/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(within(accountDialog).getByText('安全状态')).toBeTruthy();
expect(within(accountDialog).getByText('登录设备')).toBeTruthy();
expect(within(accountDialog).getByText('操作记录')).toBeTruthy();
expect(within(accountDialog).getByText('手机号保护')).toBeTruthy();
expect(within(accountDialog).getByText('iPhone 15 Pro')).toBeTruthy();
expect(within(accountDialog).getByText('登录成功')).toBeTruthy();
});
test('legacy nested section requests now open the merged account panel', () => {
renderAccountModal({ initialSection: 'security' });
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(accountDialog).toBeTruthy();
expect(within(accountDialog).getByText('安全状态')).toBeTruthy();
expect(within(accountDialog).getByText('登录设备')).toBeTruthy();
expect(within(accountDialog).getByText('操作记录')).toBeTruthy();
});

View File

@@ -1,8 +1,12 @@
import { type ReactNode, useCallback, useEffect, useState } from 'react';
import {
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import type {
PlatformTheme,
} from '../../../packages/shared/src/contracts/runtime';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
import type {
AuthAuditLogEntry,
AuthCaptchaChallenge,
@@ -51,17 +55,38 @@ type AccountModalProps = {
};
const SETTINGS_SECTIONS: Array<{
id: PlatformSettingsSection;
id: 'appearance' | 'account';
label: string;
detail: string;
}> = [
{ id: 'appearance', label: '主题外观', detail: '亮暗主题' },
{ id: 'account', label: '账号信息', detail: '身份与换绑' },
{ id: 'security', label: '安全状态', detail: '保护与限制' },
{ id: 'devices', label: '登录设备', detail: '会话管理' },
{ id: 'logs', label: '操作记录', detail: '最近动作' },
{ id: 'account', label: '账号信息', detail: '身份与安全' },
];
const ACCOUNT_MODAL_MAX_HEIGHT =
'calc(100vh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) - 2rem)';
type PrimarySettingsSection = (typeof SETTINGS_SECTIONS)[number]['id'];
function normalizeSettingsSection(
section: PlatformSettingsSection | null | undefined,
): PrimarySettingsSection | null {
if (section === 'appearance') {
return 'appearance';
}
if (
section === 'account' ||
section === 'security' ||
section === 'devices' ||
section === 'logs'
) {
return 'account';
}
return null;
}
function resolveLoginMethodLabel(loginMethod: AuthUser['loginMethod']) {
switch (loginMethod) {
case 'wechat':
@@ -88,37 +113,6 @@ function formatSessionTime(value: string) {
});
}
function SectionHeader({
eyebrow,
title,
description,
action,
}: {
eyebrow: string;
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-semibold tracking-[0.24em] text-[var(--platform-cool-text)]">
{eyebrow}
</div>
<div className="mt-2 text-2xl font-semibold text-[var(--platform-text-strong)]">
{title}
</div>
{description ? (
<div className="mt-2 text-sm text-[var(--platform-text-base)]">
{description}
</div>
) : null}
</div>
{action}
</div>
);
}
function SettingsEntryCard({
label,
detail,
@@ -128,12 +122,12 @@ function SettingsEntryCard({
label: string;
detail: string;
summary: string;
onClick: () => void;
onClick: (trigger: HTMLButtonElement) => void;
}) {
return (
<button
type="button"
onClick={onClick}
onClick={(event) => onClick(event.currentTarget)}
className="platform-subpanel w-full rounded-[1.5rem] px-4 py-4 text-left transition hover:border-[var(--platform-surface-hover-border)]"
>
<div className="flex items-start justify-between gap-3">
@@ -179,10 +173,11 @@ function OverlayPanel({
onClick={onBack ?? onClose}
>
<div
className="platform-auth-card flex max-h-full w-full flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6"
className="platform-auth-card flex w-full flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6"
role="dialog"
aria-modal="true"
aria-label={title}
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
@@ -191,6 +186,7 @@ function OverlayPanel({
{onBack ? (
<button
type="button"
autoFocus
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onBack}
>
@@ -212,17 +208,21 @@ function OverlayPanel({
</div>
<div className="flex items-center gap-2">
{action}
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onClose}
>
</button>
{onBack ? null : (
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onClose}
>
</button>
)}
</div>
</div>
<div className="mt-5 min-h-0 flex-1 overflow-y-auto pr-1">{children}</div>
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
{children}
</div>
</div>
</div>
);
@@ -290,7 +290,9 @@ export function AccountModal({
onChangePhone,
}: AccountModalProps) {
const [activeSection, setActiveSection] =
useState<PlatformSettingsSection | null>(initialSection);
useState<PrimarySettingsSection | null>(
normalizeSettingsSection(initialSection),
);
const [isChangePhonePanelOpen, setIsChangePhonePanelOpen] = useState(false);
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
@@ -301,6 +303,21 @@ export function AccountModal({
const [sendingCode, setSendingCode] = useState(false);
const [changingPhone, setChangingPhone] = useState(false);
const [cooldownSeconds, setCooldownSeconds] = useState(0);
const settingsHomeRef = useRef<HTMLDivElement | null>(null);
const sectionTriggerRef = useRef<HTMLButtonElement | null>(null);
const changePhoneTriggerRef = useRef<HTMLButtonElement | null>(null);
const focusAfterNextPaint = useCallback((element: HTMLElement | null) => {
if (!element) {
return;
}
window.requestAnimationFrame(() => {
if (element.isConnected) {
element.focus();
}
});
}, []);
const resetChangePhoneDraft = useCallback(() => {
setPhone('');
@@ -316,12 +333,27 @@ export function AccountModal({
return;
}
setActiveSection(initialSection);
setActiveSection(normalizeSettingsSection(initialSection));
setIsChangePhonePanelOpen(false);
setAccountNotice('');
sectionTriggerRef.current = null;
changePhoneTriggerRef.current = null;
resetChangePhoneDraft();
}, [initialSection, isOpen, resetChangePhoneDraft]);
useEffect(() => {
const settingsHome = settingsHomeRef.current;
if (!settingsHome) {
return;
}
settingsHome.toggleAttribute('inert', activeSection !== null);
return () => {
settingsHome.removeAttribute('inert');
};
}, [activeSection]);
useEffect(() => {
if (cooldownSeconds <= 0) {
return;
@@ -337,15 +369,19 @@ export function AccountModal({
}, [cooldownSeconds]);
const closeSectionPanel = useCallback(() => {
const sectionTrigger = sectionTriggerRef.current;
setIsChangePhonePanelOpen(false);
setActiveSection(null);
resetChangePhoneDraft();
}, [resetChangePhoneDraft]);
focusAfterNextPaint(sectionTrigger);
}, [focusAfterNextPaint, resetChangePhoneDraft]);
const closeChangePhonePanel = useCallback(() => {
const changePhoneTrigger = changePhoneTriggerRef.current;
setIsChangePhonePanelOpen(false);
resetChangePhoneDraft();
}, [resetChangePhoneDraft]);
focusAfterNextPaint(changePhoneTrigger);
}, [focusAfterNextPaint, resetChangePhoneDraft]);
if (!isOpen) {
return null;
@@ -358,46 +394,25 @@ export function AccountModal({
: isPersistingSettings
? '正在同步平台设置...'
: '平台设置已同步';
const latestAuditLog = auditLogs[0];
const accountSummaryCards = [
['登录方式', resolveLoginMethodLabel(user.loginMethod)],
['手机号', user.phoneNumberMasked || '未绑定'],
['微信绑定', user.wechatBound ? '已绑定' : '未绑定'],
[
'账号状态',
user.bindingStatus === 'pending_bind_phone' ? '待绑定手机号' : '已激活',
],
] as const;
const sectionSummaries: Record<PlatformSettingsSection, string> = {
const sectionSummaries: Record<PrimarySettingsSection, string> = {
appearance:
platformTheme === 'dark'
? '当前使用暗色主题。'
: '当前使用亮色主题。',
account: user.phoneNumberMasked
? '查看账号身份与换绑入口。'
: '查看账号身份与绑定状态。',
security: loadingRiskBlocks
? '正在读取安全状态。'
: riskBlocks.length > 0
? `当前有 ${riskBlocks.length} 项保护生效。`
: '当前没有生效中的安全限制。',
devices: loadingSessions
? '正在读取设备会话。'
: sessions.length > 0
? `当前共有 ${sessions.length} 台设备会话。`
: '暂无可展示的登录设备。',
logs: loadingAuditLogs
? '正在读取账号动态。'
: latestAuditLog
? `最近一条记录:${formatSessionTime(latestAuditLog.createdAt)}`
: '暂无账号操作记录。',
platformTheme === 'dark' ? '当前使用暗色主题。' : '当前使用亮色主题。',
account:
user.phoneNumberMasked || user.wechatBound
? '查看身份、安全状态、登录设备与操作记录。'
: '查看账号绑定状态与安全记录。',
};
return (
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[70] flex items-end justify-center overflow-y-auto px-4 sm:items-center`}
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[70] flex items-end justify-center overflow-hidden px-4 sm:items-center`}
style={{
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 1rem)',
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 1rem)',
@@ -405,23 +420,18 @@ export function AccountModal({
onClick={onClose}
>
<div
className="platform-auth-card relative flex max-h-full w-full max-w-5xl flex-col overflow-hidden rounded-[28px] p-5 sm:p-6"
className="platform-auth-card relative flex w-full max-w-5xl flex-col overflow-hidden rounded-[28px] p-5 sm:p-6"
role="dialog"
aria-modal="true"
aria-label="设置与账号安全"
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
</div>
<div className="mt-2 text-2xl font-semibold text-[var(--platform-text-strong)]">
<div className="text-2xl font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-2 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
@@ -432,81 +442,55 @@ export function AccountModal({
</button>
</div>
<div className="mt-5 min-h-0 flex-1 overflow-hidden">
<div
className="min-h-0 h-full overflow-y-auto pr-1"
aria-hidden={activeSection !== null}
>
<div className="space-y-4">
<SectionHeader
eyebrow="设置首页"
title="选择要管理的内容"
description="每项内容都会进入独立面板,不在当前层级堆叠详情。"
/>
<div className="grid gap-3 sm:grid-cols-2">
{SETTINGS_SECTIONS.map((section) => (
<SettingsEntryCard
key={section.id}
label={section.label}
detail={section.detail}
summary={sectionSummaries[section.id]}
onClick={() => {
setAccountNotice('');
setActiveSection(section.id);
}}
/>
))}
</div>
<div className="grid gap-3 lg:grid-cols-2">
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
{platformTheme === 'dark' ? '暗色主题' : '亮色主题'}
</div>
<span className="platform-pill platform-pill--neutral mt-3 inline-flex px-3 py-1 text-[11px]">
{themeStatusText}
</span>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
{user.bindingStatus === 'pending_bind_phone'
? '待绑定手机号'
: '账号已激活'}
</div>
<div className="mt-3 text-xs tracking-[0.18em] text-[var(--platform-text-soft)]">
{resolveLoginMethodLabel(user.loginMethod)}
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<button
type="button"
className="platform-button platform-button--ghost h-11 w-full text-sm"
onClick={() => {
void onLogout();
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
<div ref={settingsHomeRef} className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
{SETTINGS_SECTIONS.map((section) => (
<SettingsEntryCard
key={section.id}
label={section.label}
detail={section.detail}
summary={sectionSummaries[section.id]}
onClick={(trigger) => {
sectionTriggerRef.current = trigger;
setAccountNotice('');
setActiveSection(section.id);
}}
>
退
</button>
<button
type="button"
className="platform-button platform-button--danger h-11 w-full text-sm"
onClick={() => {
void onLogoutAll();
}}
>
退
</button>
/>
))}
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
{platformTheme === 'dark' ? '暗色主题' : '亮色主题'}
</div>
<span className="platform-pill platform-pill--neutral mt-3 inline-flex px-3 py-1 text-[11px]">
{themeStatusText}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<button
type="button"
className="platform-button platform-button--ghost h-11 w-full text-sm"
onClick={() => {
void onLogout();
}}
>
退
</button>
<button
type="button"
className="platform-button platform-button--danger h-11 w-full text-sm"
onClick={() => {
void onLogoutAll();
}}
>
退
</button>
</div>
</div>
</div>
@@ -515,7 +499,7 @@ export function AccountModal({
<OverlayPanel
eyebrow="平台偏好"
title="主题外观"
description="切换平台层的亮色暗色展示。"
description="切换平台亮色暗色主题。"
onBack={closeSectionPanel}
onClose={onClose}
>
@@ -560,7 +544,7 @@ export function AccountModal({
<OverlayPanel
eyebrow="身份信息"
title="账号信息"
description="查看当前登录身份,并通过独立面板处理手机号换绑。"
description="统一查看身份、安全状态、登录设备与最近操作。"
onBack={closeSectionPanel}
onClose={onClose}
>
@@ -600,7 +584,8 @@ export function AccountModal({
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={() => {
onClick={(event) => {
changePhoneTriggerRef.current = event.currentTarget;
setAccountNotice('');
resetChangePhoneDraft();
setIsChangePhonePanelOpen(true);
@@ -610,13 +595,201 @@ export function AccountModal({
</button>
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshRiskBlocks();
}}
>
</button>
</div>
<div className="mt-4 grid gap-3">
{loadingRiskBlocks ? (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
</div>
) : riskBlocks.length > 0 ? (
riskBlocks.map((block) => (
<div
key={`${block.scopeType}:${block.expiresAt}`}
className="platform-banner platform-banner--warning text-sm"
>
<div className="flex items-center justify-between gap-3">
<span>{block.title}</span>
<span className="text-xs">
{' '}
{Math.max(1, Math.ceil(block.remainingSeconds / 60))}{' '}
</span>
</div>
<div className="mt-2 text-xs leading-5">
{block.detail}
</div>
<button
type="button"
className="platform-button platform-button--secondary mt-3 min-h-0 h-9 px-3 text-xs"
onClick={() => {
void onLiftRiskBlock(block.scopeType);
}}
>
</button>
</div>
))
) : (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshSessions();
}}
>
</button>
</div>
<div className="mt-4 grid gap-3">
{loadingSessions ? (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
</div>
) : sessions.length > 0 ? (
sessions.map((session) => (
<div
key={session.sessionId}
className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-base)]"
>
<div className="flex items-center justify-between gap-3">
<span>{session.clientLabel}</span>
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
{session.isCurrent ? '当前设备' : '已登录'}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
{formatSessionTime(session.lastSeenAt)}
</div>
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
{formatSessionTime(session.expiresAt)}
</div>
{session.ipMasked ? (
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
IP{session.ipMasked}
</div>
) : null}
{!session.isCurrent ? (
<button
type="button"
className="platform-button platform-button--danger mt-3 min-h-0 h-9 px-3 text-xs"
onClick={() => {
void onRevokeSession(session.sessionId);
}}
>
线
</button>
) : null}
</div>
))
) : (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshAuditLogs();
}}
>
</button>
</div>
<div className="mt-4 grid gap-3">
{loadingAuditLogs ? (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
</div>
) : auditLogs.length > 0 ? (
auditLogs.map((log) => (
<div
key={log.id}
className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-base)]"
>
<div className="flex items-center justify-between gap-3">
<span>{log.title}</span>
<span className="text-xs text-[var(--platform-text-soft)]">
{formatSessionTime(log.createdAt)}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
{log.detail}
</div>
{log.ipMasked ? (
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
IP{log.ipMasked}
</div>
) : null}
</div>
))
) : (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
</div>
</div>
{isChangePhonePanelOpen ? (
<OverlayPanel
eyebrow="手机号换绑"
title="绑定新手机号"
description="验证码与校验流程继续由后端决定,前端只负责收集输入与展示结果。"
description="输入新手机号并完成验证码验证。"
onBack={closeChangePhonePanel}
onClose={onClose}
>
@@ -644,18 +817,23 @@ export function AccountModal({
/>
<button
type="button"
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
disabled={
sendingCode || cooldownSeconds > 0 || !phone.trim()
}
className="platform-button platform-button--secondary h-11 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
onClick={() => {
void (async () => {
setSendingCode(true);
setChangePhoneError('');
try {
const result = await onSendChangePhoneCode(phone, {
challengeId:
changePhoneCaptchaChallenge?.challengeId,
answer: captchaAnswer,
});
const result = await onSendChangePhoneCode(
phone,
{
challengeId:
changePhoneCaptchaChallenge?.challengeId,
answer: captchaAnswer,
},
);
setCooldownSeconds(result.cooldownSeconds);
setChangePhoneHint(
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
@@ -732,200 +910,6 @@ export function AccountModal({
) : null}
</OverlayPanel>
) : null}
{activeSection === 'security' ? (
<OverlayPanel
eyebrow="安全状态"
title="保护与限制"
description="查看当前生效中的账号保护状态。"
action={(
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshRiskBlocks();
}}
>
</button>
)}
onBack={closeSectionPanel}
onClose={onClose}
>
<div className="grid gap-3">
{loadingRiskBlocks ? (
<div className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
</div>
) : riskBlocks.length > 0 ? (
riskBlocks.map((block) => (
<div
key={`${block.scopeType}:${block.expiresAt}`}
className="platform-banner platform-banner--warning text-sm"
>
<div className="flex items-center justify-between gap-3">
<span>{block.title}</span>
<span className="text-xs">
{Math.max(1, Math.ceil(block.remainingSeconds / 60))}{' '}
</span>
</div>
<div className="mt-2 text-xs leading-5">{block.detail}</div>
<button
type="button"
className="platform-button platform-button--secondary mt-3 min-h-0 h-9 px-3 text-xs"
onClick={() => {
void onLiftRiskBlock(block.scopeType);
}}
>
</button>
</div>
))
) : (
<div className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
</OverlayPanel>
) : null}
{activeSection === 'devices' ? (
<OverlayPanel
eyebrow="会话管理"
title="登录设备"
description="查看当前账号的设备会话状态。"
action={(
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshSessions();
}}
>
</button>
)}
onBack={closeSectionPanel}
onClose={onClose}
>
<div className="space-y-4">
<div className="grid gap-3">
{loadingSessions ? (
<div className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
</div>
) : sessions.length > 0 ? (
sessions.map((session) => (
<div
key={session.sessionId}
className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-base)]"
>
<div className="flex items-center justify-between gap-3">
<span>{session.clientLabel}</span>
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
{session.isCurrent ? '当前设备' : '已登录'}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
{formatSessionTime(session.lastSeenAt)}
</div>
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
{formatSessionTime(session.expiresAt)}
</div>
{session.ipMasked ? (
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
IP{session.ipMasked}
</div>
) : null}
{!session.isCurrent ? (
<button
type="button"
className="platform-button platform-button--danger mt-3 min-h-0 h-9 px-3 text-xs"
onClick={() => {
void onRevokeSession(session.sessionId);
}}
>
线
</button>
) : null}
</div>
))
) : (
<div className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
<button
type="button"
className="platform-button platform-button--danger h-11 w-full text-sm"
onClick={() => {
void onLogoutAll();
}}
>
退
</button>
</div>
</OverlayPanel>
) : null}
{activeSection === 'logs' ? (
<OverlayPanel
eyebrow="账号动态"
title="最近操作"
description="查看最近的账号登录与安全动作。"
action={(
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshAuditLogs();
}}
>
</button>
)}
onBack={closeSectionPanel}
onClose={onClose}
>
<div className="grid gap-3">
{loadingAuditLogs ? (
<div className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
</div>
) : auditLogs.length > 0 ? (
auditLogs.map((log) => (
<div
key={log.id}
className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-base)]"
>
<div className="flex items-center justify-between gap-3">
<span>{log.title}</span>
<span className="text-xs text-[var(--platform-text-soft)]">
{formatSessionTime(log.createdAt)}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
{log.detail}
</div>
{log.ipMasked ? (
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
IP{log.ipMasked}
</div>
) : null}
</div>
))
) : (
<div className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
</OverlayPanel>
) : null}
</div>
</div>
);

View File

@@ -43,3 +43,39 @@ test('draft detail panel renders sections and warnings', () => {
expect(html).toContain('编辑设定');
expect(html).toContain('新增角色');
});
test('draft detail panel renders scene chapter label and background preview', () => {
const html = renderToStaticMarkup(
<CustomWorldAgentDraftDetailPanel
detail={{
id: 'scene-chapter-docks',
kind: 'scene_chapter',
title: '潮汐码头章节',
sections: [
{
id: 'sceneName',
label: '所属场景',
value: '潮汐码头',
},
{
id: 'act:act-docks-1:backgroundImageSrc',
label: '第 1 幕背景图',
value: '/images/scene/docks-act-1.webp',
},
],
linkedIds: ['landmark-docks', 'thread-smuggling'],
locked: false,
editable: true,
editableSectionIds: ['title', 'summary', 'act:act-docks-1:title'],
warningMessages: [],
}}
loading={false}
onClose={() => {}}
onStartEdit={() => {}}
/>,
);
expect(html).toContain('场景章节');
expect(html).toContain('第 1 幕背景图');
expect(html).toContain('img');
});

View File

@@ -28,6 +28,7 @@ function resolveKindLabel(kind: CustomWorldDraftCardDetail['kind']) {
if (kind === 'landmark') return '地点';
if (kind === 'thread') return '线程';
if (kind === 'chapter') return '第一幕';
if (kind === 'scene_chapter') return '场景章节';
return '草稿卡';
}
@@ -72,6 +73,15 @@ export function CustomWorldAgentDraftDetailPanel({
onGenerateLandmark,
onOpenRoleAssetStudio,
}: CustomWorldAgentDraftDetailPanelProps) {
const shouldRenderImagePreview = (
detailKind: CustomWorldDraftCardDetail['kind'],
sectionId: string,
value: string,
) =>
detailKind === 'scene_chapter' &&
sectionId.endsWith(':backgroundImageSrc') &&
value !== '待继续精修';
return (
<section className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
<div className="flex items-start justify-between gap-3">
@@ -168,6 +178,13 @@ export function CustomWorldAgentDraftDetailPanel({
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
{section.label}
</div>
{shouldRenderImagePreview(detail.kind, section.id, section.value) ? (
<img
src={section.value}
alt={section.label}
className="mt-3 h-40 w-full rounded-[1rem] border border-white/10 object-cover"
/>
) : null}
<div className="mt-2 whitespace-pre-wrap text-sm leading-7 text-zinc-100">
{section.value}
</div>

View File

@@ -9,6 +9,7 @@ type CustomWorldAgentDraftDrawerProps = {
const DRAWER_KIND_ORDER: CustomWorldDraftCardSummary['kind'][] = [
'world',
'chapter',
'scene_chapter',
'thread',
'faction',
'character',
@@ -19,6 +20,7 @@ const DRAWER_KIND_ORDER: CustomWorldDraftCardSummary['kind'][] = [
function resolveGroupLabel(kind: CustomWorldDraftCardSummary['kind']) {
if (kind === 'world') return '世界总卡';
if (kind === 'chapter') return '第一幕';
if (kind === 'scene_chapter') return '场景章节';
if (kind === 'thread') return '世界线程';
if (kind === 'faction') return '势力';
if (kind === 'character') return '关键角色';

View File

@@ -46,3 +46,57 @@ test('draft detail panel renders editable form in edit mode', () => {
expect(html).toContain('角色名');
expect(html).toContain('textarea');
});
test('draft detail panel uses textarea for scene chapter act narrative fields', () => {
const html = renderToStaticMarkup(
<CustomWorldAgentDraftDetailPanel
detail={{
id: 'scene-chapter-docks',
kind: 'scene_chapter',
title: '潮汐码头章节',
sections: [
{
id: 'title',
label: '场景章节标题',
value: '潮汐码头章节',
},
{
id: 'act:act-docks-1:summary',
label: '第 1 幕摘要',
value: '玩家刚抵达时,林潮先决定要不要放行。',
},
{
id: 'act:act-docks-1:encounterNpcIds',
label: '第 1 幕相遇 NPC',
value: '林潮\n晏九',
},
{
id: 'act:act-docks-1:transitionHook',
label: '第 1 幕过渡钩子',
value: '确认站位后,真正的封锁者会压上来。',
},
],
linkedIds: ['thread-smuggling'],
locked: false,
editable: true,
editableSectionIds: [
'title',
'act:act-docks-1:summary',
'act:act-docks-1:encounterNpcIds',
'act:act-docks-1:transitionHook',
],
warningMessages: [],
}}
loading={false}
editMode
onClose={() => {}}
onCancelEdit={() => {}}
onSave={() => {}}
/>,
);
expect(html).toContain('第 1 幕摘要');
expect(html).toContain('第 1 幕相遇 NPC');
expect(html).toContain('第 1 幕过渡钩子');
expect(html).toContain('textarea');
});

View File

@@ -15,6 +15,7 @@ type CustomWorldDraftEditPanelProps = {
};
function shouldUseTextarea(sectionId: string, value: string) {
const sceneActField = sectionId.match(/^act:[^:]+:(.+)$/u)?.[1] ?? null;
return (
value.length > 28 ||
value.includes('\n') ||
@@ -26,7 +27,11 @@ function shouldUseTextarea(sectionId: string, value: string) {
sectionId === 'stakes' ||
sectionId === 'openingEvent' ||
sectionId === 'understandingShift' ||
sectionId === 'description'
sectionId === 'description' ||
sceneActField === 'summary' ||
sceneActField === 'encounterNpcIds' ||
sceneActField === 'actGoal' ||
sceneActField === 'transitionHook'
);
}

View File

@@ -2,6 +2,7 @@ import {useEffect, useLayoutEffect, useRef, useState} from 'react';
import {resolveCompatibilityTemplateWorldType} from '../../data/customWorldRuntime';
import {MONSTERS_BY_WORLD, PLAYER_BASE_X_METERS} from '../../data/hostileNpcs';
import {resolveActiveSceneActBackgroundImage} from '../../services/customWorldSceneActRuntime';
import {AnimationState, WorldType} from '../../types';
import {GameCanvasEffectLayer} from './GameCanvasEffectLayer';
import {GameCanvasEntityLayer} from './GameCanvasEntityLayer';
@@ -25,6 +26,8 @@ export function GameCanvasRuntime({
encounter,
currentScenePreset,
worldType,
customWorldProfile = null,
storyEngineMemory = null,
sceneHostileNpcs,
playerX,
playerOffsetY,
@@ -50,7 +53,16 @@ export function GameCanvasRuntime({
const resolvedWorldType = worldType
? resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA
: null;
const backgroundSrc = currentScenePreset?.imageSrc
const activeSceneActBackground =
currentScenePreset?.id
? resolveActiveSceneActBackgroundImage({
profile: customWorldProfile,
sceneId: currentScenePreset.id,
storyEngineMemory,
})
: null;
const backgroundSrc = activeSceneActBackground
|| currentScenePreset?.imageSrc
|| (resolvedWorldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png');
const monsters = resolvedWorldType ? MONSTERS_BY_WORLD[resolvedWorldType] : [];
const groundBottom = '18%';

View File

@@ -9,9 +9,11 @@ import {
CombatActionMode,
CombatVisualEffect,
CompanionRenderState,
CustomWorldProfile,
Encounter,
SceneHostileNpc,
ScenePresetInfo,
StoryEngineMemoryState,
WorldType,
} from '../../types';
import {CharacterAnimator} from '../CharacterAnimator';
@@ -29,6 +31,8 @@ export interface GameCanvasProps {
encounter: Encounter | null;
currentScenePreset: ScenePresetInfo | null;
worldType: WorldType | null;
customWorldProfile?: CustomWorldProfile | null;
storyEngineMemory?: StoryEngineMemoryState | null;
sceneHostileNpcs: SceneHostileNpc[];
playerX: number;
playerOffsetY: number;

View File

@@ -39,6 +39,8 @@ export function GameShellCanvasStage({
encounter={visibleGameState.currentEncounter}
currentScenePreset={visibleGameState.currentScenePreset}
worldType={visibleGameState.worldType}
customWorldProfile={visibleGameState.customWorldProfile}
storyEngineMemory={visibleGameState.storyEngineMemory}
sceneHostileNpcs={visibleGameState.sceneHostileNpcs}
playerX={visibleGameState.playerX}
playerOffsetY={visibleGameState.playerOffsetY}

View File

@@ -703,7 +703,6 @@ export function PlatformHomeView({
saves: Archive,
profile: UserRound,
} as const;
const latestSaveEntry = saveEntries[0] ?? null;
const openUserSurface = () => {
if (authUi?.user) {
authUi.openAccountModal();
@@ -876,41 +875,6 @@ export function PlatformHomeView({
<div className={MOBILE_PAGE_STAGE_CLASS}>
{authUi?.user ? (
<>
<section
className={`${HERO_SURFACE_CLASS} relative overflow-hidden px-[18px] py-4 text-left`}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_30%),radial-gradient(circle_at_right,rgba(255,205,178,0.18),transparent_28%),linear-gradient(135deg,rgba(255,92,120,0.92),rgba(255,139,98,0.9))]" />
<div className="relative z-10 flex min-h-[10.5rem] flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="platform-pill platform-pill--cool">
SAVE ARCHIVE
</span>
<div className="platform-pill platform-pill--neutral px-3 text-[11px] tracking-[0.08em]">
{saveEntries.length > 0 ? `${saveEntries.length} 个存档` : '暂无存档'}
</div>
</div>
<div className="flex min-w-0 items-stretch gap-3 sm:gap-4">
<div className="min-w-0 flex-1">
<div className="line-clamp-2 break-words text-[1.95rem] font-black leading-[1.02] text-white sm:text-3xl">
{latestSaveEntry ? latestSaveEntry.worldName : '存档'}
</div>
<div className="mt-2 text-sm leading-6 text-zinc-200/88">
{latestSaveEntry
? `最近更新于 ${formatSnapshotTime(latestSaveEntry.lastPlayedAt)},点开后可直接继续游玩。`
: '你在平台里留下的最近可恢复存档会显示在这里。'}
</div>
</div>
{latestSaveEntry ? (
<SaveArchivePreview
entry={latestSaveEntry}
label="最近更新"
className="h-[8.8rem] w-[6.1rem] sm:h-[9.4rem] sm:w-[7rem]"
/>
) : null}
</div>
</div>
</section>
{saveError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{saveError}

View File

@@ -641,6 +641,7 @@ test('authenticated users with save archives default into the saves tab', async
expect((await screen.findAllByText('全部存档')).length).toBeGreaterThan(0);
expect((await screen.findAllByText('潮雾列岛')).length).toBeGreaterThan(0);
expect(screen.getAllByText('最近更新时间排序').length).toBeGreaterThan(0);
expect(screen.queryByText('SAVE ARCHIVE')).toBeNull();
});
test('save tab can resume a selected archive directly into the game', async () => {

View File

@@ -85,6 +85,10 @@ export interface GameShellAdventureStatistics {
scenesTraveled: number;
currentSceneName: string;
playerCurrency: number;
playerLevel?: number;
playerCurrentLevelXp?: number;
playerXpToNextLevel?: number;
playerTotalXp?: number;
inventoryItemCount: number;
inventoryStackCount: number;
activeCompanionCount: number;

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { normalizePlayerProgressionState } from '../../data/playerProgression';
import { getLiveGamePlayTimeMs } from '../../data/runtimeStats';
import { getWorldCampScenePreset } from '../../data/scenePresets';
import type {
@@ -41,7 +42,8 @@ export function buildGameShellDialogueIndicator(params: {
return {
showPlayer: true,
showEncounter: true,
activeSpeaker: lastSpeaker === 'player' ? 'player' : lastSpeaker ? 'npc' : null,
activeSpeaker:
lastSpeaker === 'player' ? 'player' : lastSpeaker ? 'npc' : null,
};
}
@@ -62,7 +64,7 @@ export function buildCanvasCompanionRenderStates(params: {
}) {
const activeEncounterNpcId =
params.visibleGameState.currentEncounter?.kind === 'npc'
? params.visibleGameState.currentEncounter.id ?? null
? (params.visibleGameState.currentEncounter.id ?? null)
: null;
if (!activeEncounterNpcId) {
return params.visibleCompanionRenderStates;
@@ -79,6 +81,9 @@ export function buildAdventureStatistics(params: {
livePlayTimeMs: number;
}): GameShellAdventureStatistics {
const { gameState, visibleGameState, livePlayTimeMs } = params;
const playerProgression = normalizePlayerProgressionState(
visibleGameState.playerProgression ?? null,
);
return {
playTimeMs: livePlayTimeMs,
@@ -94,6 +99,10 @@ export function buildAdventureStatistics(params: {
scenesTraveled: gameState.runtimeStats.scenesTraveled,
currentSceneName: visibleGameState.currentScenePreset?.name ?? '当前区域',
playerCurrency: visibleGameState.playerCurrency,
playerLevel: playerProgression.level,
playerCurrentLevelXp: playerProgression.currentLevelXp,
playerXpToNextLevel: playerProgression.xpToNextLevel,
playerTotalXp: playerProgression.totalXp,
inventoryItemCount: visibleGameState.playerInventory.reduce(
(sum, item) => sum + item.quantity,
0,
@@ -104,17 +113,11 @@ export function buildAdventureStatistics(params: {
};
}
export function useGameShellRuntimeViewModel(params: Pick<
GameShellProps,
'session' | 'story' | 'companions'
>) {
export function useGameShellRuntimeViewModel(
params: Pick<GameShellProps, 'session' | 'story' | 'companions'>,
) {
const { session, story, companions } = params;
const {
gameState,
currentStory,
isLoading,
isMapOpen,
} = session;
const { gameState, currentStory, isLoading, isMapOpen } = session;
const { npcUi, characterChatUi, handleChoice } = story;
const { buildCompanionRenderStates } = companions;
@@ -122,7 +125,7 @@ export function useGameShellRuntimeViewModel(params: Pick<
const openingCampSceneId = useMemo(
() =>
gameState.worldType
? getWorldCampScenePreset(gameState.worldType)?.id ?? null
? (getWorldCampScenePreset(gameState.worldType)?.id ?? null)
: null,
[gameState.worldType],
);