@@ -44,6 +44,7 @@ import {
|
||||
createNpcBattleMonster,
|
||||
normalizeNpcPersistentState,
|
||||
} from '../data/npcInteractions';
|
||||
import { normalizePlayerProgressionState } from '../data/playerProgression';
|
||||
import { getSceneHostileNpcPresetIds } from '../data/scenePresets';
|
||||
import type { CharacterChatTarget } from '../hooks/useStoryGeneration';
|
||||
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
|
||||
@@ -64,6 +65,7 @@ import {
|
||||
} from './BackstoryArchive';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import {
|
||||
buildCharacterSkillRenderId,
|
||||
getCharacterDetailSpriteStyle,
|
||||
getContributionVisualStyle,
|
||||
getSkillDeliveryLabel,
|
||||
@@ -71,8 +73,10 @@ import {
|
||||
} from './CharacterInfoHelpers';
|
||||
import {
|
||||
CharacterAttributeGrid,
|
||||
CharacterIdentityBadges,
|
||||
CharacterSkillsList,
|
||||
MultiplierContributionList,
|
||||
PlayerLevelProgress,
|
||||
StatusRow,
|
||||
} from './CharacterInfoShared';
|
||||
import { GENERIC_NPC_SCENE_SCALE } from './game-canvas/GameCanvasShared';
|
||||
@@ -137,7 +141,8 @@ function resolveSkillPreviewMonsterId(gameState: GameState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sceneMonsterId = getSceneHostileNpcPresetIds(gameState.currentScenePreset)[0] ?? null;
|
||||
const sceneMonsterId =
|
||||
getSceneHostileNpcPresetIds(gameState.currentScenePreset)[0] ?? null;
|
||||
if (sceneMonsterId) {
|
||||
return sceneMonsterId;
|
||||
}
|
||||
@@ -469,6 +474,45 @@ export function AdventureEntityModal({
|
||||
privateChatUnlockAffinity != null &&
|
||||
companionNpcState.affinity >= privateChatUnlockAffinity,
|
||||
);
|
||||
const normalizedPlayerProgression = normalizePlayerProgressionState(
|
||||
gameState.playerProgression ?? null,
|
||||
);
|
||||
const selectedNpcLevel =
|
||||
npcEncounter?.levelProfile?.level ??
|
||||
npcBattleState?.levelProfile?.level ??
|
||||
null;
|
||||
const selectionRoleLabel =
|
||||
selection?.kind === 'player'
|
||||
? '队长'
|
||||
: selection?.kind === 'companion'
|
||||
? '同行'
|
||||
: selection?.kind === 'npc' && npcEncounter && npcState
|
||||
? getNpcBadge(
|
||||
npcEncounter,
|
||||
npcState.affinity,
|
||||
Boolean(npcBattleState),
|
||||
)
|
||||
: null;
|
||||
const selectionLevelText =
|
||||
selection?.kind === 'player'
|
||||
? `Lv.${normalizedPlayerProgression.level}`
|
||||
: selection?.kind === 'companion'
|
||||
? `参考 Lv.${normalizedPlayerProgression.level}`
|
||||
: typeof selectedNpcLevel === 'number'
|
||||
? `Lv.${selectedNpcLevel}`
|
||||
: null;
|
||||
const selectionRoleTone: 'amber' | 'sky' | 'rose' | 'emerald' | 'zinc' =
|
||||
selection?.kind === 'player'
|
||||
? 'amber'
|
||||
: selection?.kind === 'companion'
|
||||
? 'sky'
|
||||
: selection?.kind === 'npc' && npcEncounter && npcState
|
||||
? npcEncounter.hostile ||
|
||||
Boolean(npcBattleState) ||
|
||||
npcState.affinity < 0
|
||||
? 'rose'
|
||||
: 'emerald'
|
||||
: 'zinc';
|
||||
|
||||
const title =
|
||||
selection?.kind === 'player'
|
||||
@@ -673,7 +717,10 @@ export function AdventureEntityModal({
|
||||
)
|
||||
: [];
|
||||
const selectedSkill =
|
||||
displayedSkills.find((skill) => skill.id === selectedSkillId) ?? null;
|
||||
displayedSkills.find(
|
||||
(skill, index) =>
|
||||
buildCharacterSkillRenderId(skill, index) === selectedSkillId,
|
||||
) ?? null;
|
||||
const selectedSkillPreviewWorldType = gameState.worldType ?? null;
|
||||
const selectedSkillPreviewMonsterId = selectedSkillPreviewWorldType
|
||||
? resolveSkillPreviewMonsterId(gameState)
|
||||
@@ -686,23 +733,30 @@ export function AdventureEntityModal({
|
||||
inventory.find((item) => item.id === selectedItemId) ?? null;
|
||||
const selectedSkillOwnerName =
|
||||
detailCharacter?.name ?? npcEncounter?.npcName ?? title;
|
||||
const recentChronicleEntries = gameState.storyEngineMemory?.chronicle?.slice(-3) ?? [];
|
||||
const recentCarrierEchoes = (gameState.storyEngineMemory?.recentCarrierIds ?? [])
|
||||
.map((carrierId) =>
|
||||
gameState.playerInventory.find((item) => item.id === carrierId)?.runtimeMetadata?.storyFingerprint?.visibleClue
|
||||
?? gameState.playerInventory.find((item) => item.id === carrierId)?.name
|
||||
?? '',
|
||||
const recentChronicleEntries =
|
||||
gameState.storyEngineMemory?.chronicle?.slice(-3) ?? [];
|
||||
const recentCarrierEchoes = (
|
||||
gameState.storyEngineMemory?.recentCarrierIds ?? []
|
||||
)
|
||||
.map(
|
||||
(carrierId) =>
|
||||
gameState.playerInventory.find((item) => item.id === carrierId)
|
||||
?.runtimeMetadata?.storyFingerprint?.visibleClue ??
|
||||
gameState.playerInventory.find((item) => item.id === carrierId)?.name ??
|
||||
'',
|
||||
)
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
const sceneResidues = gameState.currentScenePreset?.narrativeResidues?.slice(0, 3) ?? [];
|
||||
const selectedCompanionResolution =
|
||||
detailCharacter
|
||||
? gameState.storyEngineMemory?.companionResolutions?.find(
|
||||
(resolution) => resolution.characterId === detailCharacter.id,
|
||||
) ?? null
|
||||
: null;
|
||||
const relatedConsequences = (gameState.storyEngineMemory?.consequenceLedger ?? [])
|
||||
const sceneResidues =
|
||||
gameState.currentScenePreset?.narrativeResidues?.slice(0, 3) ?? [];
|
||||
const selectedCompanionResolution = detailCharacter
|
||||
? (gameState.storyEngineMemory?.companionResolutions?.find(
|
||||
(resolution) => resolution.characterId === detailCharacter.id,
|
||||
) ?? null)
|
||||
: null;
|
||||
const relatedConsequences = (
|
||||
gameState.storyEngineMemory?.consequenceLedger ?? []
|
||||
)
|
||||
.filter((record) =>
|
||||
detailCharacter
|
||||
? record.relatedIds.includes(detailCharacter.id)
|
||||
@@ -761,6 +815,14 @@ export function AdventureEntityModal({
|
||||
{title}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-zinc-400">{subtitle}</div>
|
||||
{selectionRoleLabel ? (
|
||||
<CharacterIdentityBadges
|
||||
roleLabel={selectionRoleLabel}
|
||||
levelText={selectionLevelText}
|
||||
roleTone={selectionRoleTone}
|
||||
className="mt-2"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -780,7 +842,9 @@ export function AdventureEntityModal({
|
||||
{selection.kind === 'player' && playerCharacter ? (
|
||||
playerCharacter.visual ? (
|
||||
<MedievalNpcAnimator
|
||||
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(playerCharacter.visual)}
|
||||
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
|
||||
playerCharacter.visual,
|
||||
)}
|
||||
scale={2.08}
|
||||
/>
|
||||
) : (
|
||||
@@ -798,7 +862,9 @@ export function AdventureEntityModal({
|
||||
companionCharacter ? (
|
||||
companionCharacter.visual ? (
|
||||
<MedievalNpcAnimator
|
||||
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(companionCharacter.visual)}
|
||||
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
|
||||
companionCharacter.visual,
|
||||
)}
|
||||
scale={2.08}
|
||||
/>
|
||||
) : (
|
||||
@@ -815,7 +881,9 @@ export function AdventureEntityModal({
|
||||
) : npcCharacter ? (
|
||||
npcCharacter.visual ? (
|
||||
<MedievalNpcAnimator
|
||||
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(npcCharacter.visual)}
|
||||
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
|
||||
npcCharacter.visual,
|
||||
)}
|
||||
scale={2.08}
|
||||
/>
|
||||
) : (
|
||||
@@ -824,7 +892,9 @@ export function AdventureEntityModal({
|
||||
character={npcCharacter}
|
||||
className="h-full w-full"
|
||||
imageClassName="object-bottom"
|
||||
style={getCharacterDetailSpriteStyle(npcCharacter)}
|
||||
style={getCharacterDetailSpriteStyle(
|
||||
npcCharacter,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
) : hostileNpcPreset ? (
|
||||
@@ -842,15 +912,6 @@ export function AdventureEntityModal({
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{selection.kind === 'npc' && npcEncounter && npcState && (
|
||||
<div className="mt-3 rounded-full border border-rose-400/25 bg-rose-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-rose-100">
|
||||
{getNpcBadge(
|
||||
npcEncounter,
|
||||
npcState.affinity,
|
||||
Boolean(npcBattleState),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-3 text-sm leading-relaxed text-zinc-300">
|
||||
{description}
|
||||
</p>
|
||||
@@ -942,17 +1003,24 @@ export function AdventureEntityModal({
|
||||
<div className="space-y-3">
|
||||
{selectedCompanionResolution && (
|
||||
<div className="rounded-xl border border-emerald-400/18 bg-emerald-500/8 px-3 py-2 text-xs text-emerald-100/85">
|
||||
队友收束:{selectedCompanionResolution.resolutionType} · {selectedCompanionResolution.summary}
|
||||
队友收束:
|
||||
{selectedCompanionResolution.resolutionType} ·{' '}
|
||||
{selectedCompanionResolution.summary}
|
||||
</div>
|
||||
)}
|
||||
{relatedConsequences.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{relatedConsequences.map((record, index) => (
|
||||
<div
|
||||
key={record.id || `consequence-${record.title}-${index}`}
|
||||
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>
|
||||
<span className="text-white">
|
||||
{record.title}
|
||||
</span>
|
||||
{':'}
|
||||
{record.summary}
|
||||
</div>
|
||||
@@ -963,7 +1031,10 @@ export function AdventureEntityModal({
|
||||
<div className="space-y-1">
|
||||
{recentChronicleEntries.map((entry, index) => (
|
||||
<div
|
||||
key={entry.id || `chronicle-${entry.title}-${index}`}
|
||||
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">
|
||||
@@ -985,10 +1056,15 @@ export function AdventureEntityModal({
|
||||
<div className="space-y-1">
|
||||
{sceneResidues.map((residue, index) => (
|
||||
<div
|
||||
key={residue.id || `residue-${residue.title}-${index}`}
|
||||
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>
|
||||
<span className="text-white">
|
||||
{residue.title}
|
||||
</span>
|
||||
{':'}
|
||||
{residue.visibleClue}
|
||||
</div>
|
||||
@@ -1001,6 +1077,22 @@ export function AdventureEntityModal({
|
||||
|
||||
<Section title="属性">
|
||||
<div className="space-y-4">
|
||||
{selection.kind === 'player' ? (
|
||||
<div className="rounded-xl border border-amber-300/18 bg-amber-500/8 px-3 py-3">
|
||||
<div className="mb-2 text-[10px] uppercase tracking-[0.18em] text-amber-100/75">
|
||||
等级
|
||||
</div>
|
||||
<PlayerLevelProgress
|
||||
level={normalizedPlayerProgression.level}
|
||||
currentLevelXp={
|
||||
normalizedPlayerProgression.currentLevelXp
|
||||
}
|
||||
xpToNextLevel={
|
||||
normalizedPlayerProgression.xpToNextLevel
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
<StatusRow
|
||||
label={resourceLabels.hp}
|
||||
|
||||
@@ -105,3 +105,101 @@ test('adventure panel treats negative affinity updates as relationship change sy
|
||||
expect(html).toContain('关系变化');
|
||||
expect(html).toContain('关系转冷 好感 -2');
|
||||
});
|
||||
|
||||
test('adventure panel shows current act label and remaining turns for limited hostile npc chat', () => {
|
||||
const currentStory: StoryMoment = {
|
||||
text: '断桥客仍在压着最后那半句真相。',
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{ speaker: 'player', text: '你到底还在替谁守着这座桥?' },
|
||||
{ speaker: 'npc', speakerName: '断桥客', text: '你还没资格知道全名。' },
|
||||
],
|
||||
options: [],
|
||||
npcChatState: {
|
||||
npcId: 'npc-rival',
|
||||
npcName: '断桥客',
|
||||
turnCount: 3,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
sceneActId: 'scene-bridge-act-1',
|
||||
turnLimit: 5,
|
||||
remainingTurns: 2,
|
||||
limitReason: 'negative_affinity',
|
||||
forceExitAfterTurn: false,
|
||||
},
|
||||
};
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<AdventurePanel
|
||||
aiError={null}
|
||||
currentStory={currentStory}
|
||||
isLoading={false}
|
||||
displayedOptions={[]}
|
||||
hideOptions={false}
|
||||
canRefreshOptions={false}
|
||||
onRefreshOptions={() => undefined}
|
||||
onChoice={() => undefined}
|
||||
onSubmitNpcChatInput={() => true}
|
||||
onExitNpcChat={() => true}
|
||||
onOpenCharacter={() => undefined}
|
||||
onOpenInventory={() => undefined}
|
||||
playerCharacter={createCharacter()}
|
||||
worldType={WorldType.WUXIA}
|
||||
quests={[]}
|
||||
questUi={{
|
||||
acknowledgeQuestCompletion: () => undefined,
|
||||
claimQuestReward: () => null,
|
||||
}}
|
||||
npcChatQuestOfferUi={{
|
||||
replacePendingOffer: async () => false,
|
||||
abandonPendingOffer: () => false,
|
||||
acceptPendingOffer: () => null,
|
||||
}}
|
||||
goalStack={{
|
||||
northStarGoal: null,
|
||||
activeGoal: null,
|
||||
immediateStepGoal: null,
|
||||
supportGoals: [],
|
||||
}}
|
||||
goalPulse={null}
|
||||
onDismissGoalPulse={() => undefined}
|
||||
battleRewardUi={{
|
||||
reward: null,
|
||||
dismiss: () => undefined,
|
||||
}}
|
||||
playerHp={100}
|
||||
playerMaxHp={100}
|
||||
playerMana={20}
|
||||
playerMaxMana={20}
|
||||
playerSkillCooldowns={{}}
|
||||
inBattle={false}
|
||||
currentNpcBattleMode={null}
|
||||
statistics={{
|
||||
playTimeMs: 0,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
questsCompleted: 0,
|
||||
questsTurnedIn: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
currentSceneName: '断桥口',
|
||||
playerCurrency: 0,
|
||||
inventoryItemCount: 0,
|
||||
inventoryStackCount: 0,
|
||||
activeCompanionCount: 0,
|
||||
rosterCompanionCount: 0,
|
||||
}}
|
||||
musicVolume={0.6}
|
||||
onMusicVolumeChange={() => undefined}
|
||||
onSaveAndExit={() => undefined}
|
||||
currentSceneActTitle="断桥口 · 对峙幕"
|
||||
currentSceneActIndex={1}
|
||||
currentSceneActCount={3}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('当前幕');
|
||||
expect(html).toContain('断桥口 · 对峙幕');
|
||||
expect(html).toContain('1/3');
|
||||
expect(html).toContain('剩余交谈');
|
||||
expect(html).toContain('2 轮');
|
||||
});
|
||||
|
||||
@@ -110,6 +110,9 @@ interface AdventurePanelProps {
|
||||
onSaveAndExit: () => void;
|
||||
chapterState?: ChapterState | null;
|
||||
journeyBeat?: JourneyBeat | null;
|
||||
currentSceneActTitle?: string | null;
|
||||
currentSceneActIndex?: number | null;
|
||||
currentSceneActCount?: number | null;
|
||||
}
|
||||
|
||||
const AdventurePanelOverlays = lazy(async () => {
|
||||
@@ -280,19 +283,6 @@ 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':
|
||||
@@ -675,6 +665,9 @@ export function AdventurePanel({
|
||||
onSaveAndExit,
|
||||
chapterState = null,
|
||||
journeyBeat = null,
|
||||
currentSceneActTitle = null,
|
||||
currentSceneActIndex = null,
|
||||
currentSceneActCount = null,
|
||||
}: AdventurePanelProps) {
|
||||
const isDialogueStory = currentStory.displayMode === 'dialogue';
|
||||
const dialogueTurns = currentStory.dialogue ?? [];
|
||||
@@ -931,13 +924,10 @@ 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 limitedNpcChatRemainingTurns =
|
||||
npcChatState?.turnLimit && npcChatState.limitReason === 'negative_affinity'
|
||||
? Math.max(0, npcChatState.remainingTurns ?? 0)
|
||||
: null;
|
||||
const shouldMountAdventureOverlays =
|
||||
isGoalPanelOpen ||
|
||||
isSettingsPanelOpen ||
|
||||
@@ -1059,9 +1049,32 @@ export function AdventurePanel({
|
||||
|
||||
<div
|
||||
ref={storyScrollContainerRef}
|
||||
className="pixel-nine-slice pixel-panel mb-3 min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide"
|
||||
className="pixel-nine-slice pixel-panel mb-2 min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide"
|
||||
style={getNineSliceStyle(UI_CHROME.storyPanel)}
|
||||
>
|
||||
{(currentSceneActTitle || limitedNpcChatRemainingTurns !== null) && (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 px-1">
|
||||
{currentSceneActTitle ? (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-sky-300/18 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.16em] text-sky-100">
|
||||
<span>当前幕</span>
|
||||
<span className="text-white/90">{currentSceneActTitle}</span>
|
||||
{currentSceneActIndex && currentSceneActCount ? (
|
||||
<span className="text-sky-100/65">
|
||||
{currentSceneActIndex}/{currentSceneActCount}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{limitedNpcChatRemainingTurns !== null ? (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-rose-300/18 bg-rose-500/10 px-3 py-1 text-[10px] tracking-[0.16em] text-rose-100">
|
||||
<span>剩余交谈</span>
|
||||
<span className="text-white/90">
|
||||
{limitedNpcChatRemainingTurns} 轮
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{isDialogueStory ? (
|
||||
<div className="space-y-3">
|
||||
{dialogueTurns.length > 0 ? (
|
||||
@@ -1083,6 +1096,8 @@ export function AdventurePanel({
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : isNpcChatMode && !isStoryStreaming ? (
|
||||
<div className="h-1" aria-hidden="true" />
|
||||
) : (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-3 py-2 text-sm text-zinc-400">
|
||||
@@ -1098,29 +1113,8 @@ 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="mt-auto shrink-0 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)]">
|
||||
<div className="mb-1.5 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1167,7 +1161,7 @@ export function AdventurePanel({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
{isLoading && !isStoryStreaming ? (
|
||||
<div className="flex items-center justify-center space-x-2 p-4 text-zinc-600">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -1275,7 +1269,7 @@ export function AdventurePanel({
|
||||
);
|
||||
})}
|
||||
{isNpcChatMode && !isNpcQuestOfferMode ? (
|
||||
<div className="pixel-nine-slice pixel-panel mt-0.5 border border-white/10 bg-black/25 p-1.5">
|
||||
<div className="pixel-nine-slice pixel-panel border border-white/10 bg-black/25 p-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<input
|
||||
value={npcChatDraft}
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('CharacterAnimator portrait fallbacks', () => {
|
||||
expect(image.style.animation).toContain(
|
||||
'character-animator-portrait-death-fall',
|
||||
);
|
||||
expect(image.style.transform).toContain('rotate(90deg)');
|
||||
expect(image.style.transform).toContain('rotate(-90deg)');
|
||||
expect(image.style.transform).toContain('scaleX(-1)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ const PORTRAIT_FALLBACK_ANIMATION: CharacterAnimationConfig = {
|
||||
|
||||
const FALLEN_PORTRAIT_STYLE: React.CSSProperties = {
|
||||
imageRendering: 'pixelated',
|
||||
transform: 'translateY(16%) rotate(90deg) scaleX(-1) scale(0.82)',
|
||||
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',
|
||||
|
||||
@@ -43,6 +43,19 @@ export function getSkillStyleLabel(skill: Character['skills'][number]) {
|
||||
return SKILL_STYLE_LABELS[skill.style];
|
||||
}
|
||||
|
||||
export function buildCharacterSkillRenderId(
|
||||
skill: Character['skills'][number],
|
||||
index: number,
|
||||
) {
|
||||
const normalizedId = skill.id.trim();
|
||||
if (normalizedId) {
|
||||
return normalizedId;
|
||||
}
|
||||
|
||||
const fallbackSeed = skill.name.trim() || getSkillStyleLabel(skill) || 'skill';
|
||||
return `skill-${fallbackSeed}-${index}`;
|
||||
}
|
||||
|
||||
function getContributionHeatRatio(value: number) {
|
||||
return getBuildContributionQualityRatio(value);
|
||||
}
|
||||
|
||||
89
src/components/CharacterInfoShared.test.tsx
Normal file
89
src/components/CharacterInfoShared.test.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { AnimationState, type Character } from '../types';
|
||||
import {
|
||||
CharacterIdentityBadges,
|
||||
CharacterSkillsList,
|
||||
PlayerLevelProgress,
|
||||
} from './CharacterInfoShared';
|
||||
|
||||
function createSkill(
|
||||
name: string,
|
||||
style: Character['skills'][number]['style'],
|
||||
): Character['skills'][number] {
|
||||
return {
|
||||
id: '',
|
||||
name,
|
||||
animation: AnimationState.IDLE,
|
||||
damage: 12,
|
||||
manaCost: 4,
|
||||
cooldownTurns: 2,
|
||||
range: 1,
|
||||
style,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('CharacterSkillsList falls back to stable render ids when skill ids are empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleSelectSkill = vi.fn();
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
render(
|
||||
<CharacterSkillsList
|
||||
skills={[
|
||||
createSkill('潮刃突进', 'burst'),
|
||||
createSkill('雾行转位', 'mobility'),
|
||||
]}
|
||||
onSelectSkill={handleSelectSkill}
|
||||
/>,
|
||||
);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
await user.click(buttons[0]!);
|
||||
await user.click(buttons[1]!);
|
||||
|
||||
expect(handleSelectSkill).toHaveBeenNthCalledWith(1, 'skill-潮刃突进-0');
|
||||
expect(handleSelectSkill).toHaveBeenNthCalledWith(2, 'skill-雾行转位-1');
|
||||
|
||||
const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) =>
|
||||
call.some(
|
||||
(arg) =>
|
||||
typeof arg === 'string' &&
|
||||
arg.includes('Encountered two children with the same key'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(duplicateKeyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('CharacterIdentityBadges renders role and level chips together', () => {
|
||||
render(
|
||||
<CharacterIdentityBadges
|
||||
roleLabel="队长"
|
||||
roleTone="amber"
|
||||
levelText="Lv.7"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('队长')).toBeTruthy();
|
||||
expect(screen.getByText('Lv.7')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('PlayerLevelProgress renders xp progress details', () => {
|
||||
render(
|
||||
<PlayerLevelProgress level={6} currentLevelXp={72} xpToNextLevel={120} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Lv.6')).toBeTruthy();
|
||||
expect(screen.getByText('72/120')).toBeTruthy();
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
WorldAttributeSchema,
|
||||
} from '../types';
|
||||
import {
|
||||
buildCharacterSkillRenderId,
|
||||
type ContributionRow,
|
||||
formatAttributeMetricValue,
|
||||
getAttributeBonusPillClassName,
|
||||
@@ -56,6 +57,88 @@ export function StatusRow({
|
||||
);
|
||||
}
|
||||
|
||||
export function CharacterIdentityBadges({
|
||||
roleLabel,
|
||||
levelText = null,
|
||||
roleTone = 'sky',
|
||||
className = '',
|
||||
}: {
|
||||
roleLabel: string;
|
||||
levelText?: string | null;
|
||||
roleTone?: 'amber' | 'sky' | 'rose' | 'emerald' | 'zinc';
|
||||
className?: string;
|
||||
}) {
|
||||
const roleClass =
|
||||
roleTone === 'amber'
|
||||
? 'border-amber-300/20 bg-amber-500/10 text-amber-100'
|
||||
: roleTone === 'rose'
|
||||
? 'border-rose-300/20 bg-rose-500/10 text-rose-100'
|
||||
: roleTone === 'emerald'
|
||||
? 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100'
|
||||
: roleTone === 'zinc'
|
||||
? 'border-white/10 bg-black/20 text-zinc-200'
|
||||
: 'border-sky-300/20 bg-sky-500/10 text-sky-100';
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-2 ${className}`.trim()}>
|
||||
<span
|
||||
className={`rounded-full border px-2.5 py-1 text-[10px] tracking-[0.16em] ${roleClass}`}
|
||||
>
|
||||
{roleLabel}
|
||||
</span>
|
||||
{levelText ? (
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] tracking-[0.16em] text-zinc-200">
|
||||
{levelText}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlayerLevelProgress({
|
||||
level,
|
||||
currentLevelXp,
|
||||
xpToNextLevel,
|
||||
className = '',
|
||||
}: {
|
||||
level: number;
|
||||
currentLevelXp: number;
|
||||
xpToNextLevel: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const safeLevel = Math.max(1, Math.round(level));
|
||||
const safeCurrentLevelXp = Math.max(0, Math.round(currentLevelXp));
|
||||
const safeXpToNextLevel = Math.max(0, Math.round(xpToNextLevel));
|
||||
const ratio =
|
||||
safeXpToNextLevel <= 0
|
||||
? 1
|
||||
: Math.max(
|
||||
0,
|
||||
Math.min(1, safeCurrentLevelXp / safeXpToNextLevel),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex items-center justify-between gap-3 text-[11px]">
|
||||
<div className="font-semibold text-amber-50">Lv.{safeLevel}</div>
|
||||
<div className="text-zinc-400">
|
||||
{safeXpToNextLevel > 0
|
||||
? `${safeCurrentLevelXp}/${safeXpToNextLevel}`
|
||||
: '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: ratio <= 0 ? '0%' : `${Math.max(6, ratio * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CharacterSkillsList({
|
||||
skills,
|
||||
onSelectSkill,
|
||||
@@ -75,7 +158,8 @@ export function CharacterSkillsList({
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{skills.map((skill) => {
|
||||
{skills.map((skill, index) => {
|
||||
const skillRenderId = buildCharacterSkillRenderId(skill, index);
|
||||
const content = (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -99,9 +183,9 @@ export function CharacterSkillsList({
|
||||
if (onSelectSkill) {
|
||||
return (
|
||||
<button
|
||||
key={skill.id}
|
||||
key={skillRenderId}
|
||||
type="button"
|
||||
onClick={() => onSelectSkill(skill.id)}
|
||||
onClick={() => onSelectSkill(skillRenderId)}
|
||||
className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 text-left text-sm text-zinc-300 transition-colors hover:border-sky-300/25 hover:bg-sky-500/8"
|
||||
>
|
||||
{content}
|
||||
@@ -111,7 +195,7 @@ export function CharacterSkillsList({
|
||||
|
||||
return (
|
||||
<div
|
||||
key={skill.id}
|
||||
key={skillRenderId}
|
||||
className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-300"
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { normalizePlayerProgressionState } from '../data/playerProgression';
|
||||
import {
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
@@ -57,8 +58,10 @@ import {
|
||||
} from './CharacterInfoHelpers';
|
||||
import {
|
||||
CharacterAttributeGrid,
|
||||
CharacterIdentityBadges,
|
||||
CharacterSkillsList,
|
||||
MultiplierContributionList,
|
||||
PlayerLevelProgress,
|
||||
StatusRow,
|
||||
} from './CharacterInfoShared';
|
||||
import type { GameCanvasEntitySelection } from './GameCanvas';
|
||||
@@ -69,6 +72,7 @@ interface CharacterPanelProps {
|
||||
worldType: WorldType | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
playerCharacter: Character;
|
||||
playerProgression?: GameState['playerProgression'] | null;
|
||||
playerHp: number;
|
||||
playerMaxHp: number;
|
||||
playerMana: number;
|
||||
@@ -97,6 +101,7 @@ type PartyMember = {
|
||||
mana: number;
|
||||
maxMana: number;
|
||||
isLeader: boolean;
|
||||
levelText: string | null;
|
||||
};
|
||||
|
||||
type EquipmentRow = {
|
||||
@@ -140,6 +145,7 @@ export function CharacterPanel({
|
||||
worldType,
|
||||
customWorldProfile = null,
|
||||
playerCharacter,
|
||||
playerProgression = null,
|
||||
playerHp,
|
||||
playerMaxHp,
|
||||
playerMana,
|
||||
@@ -157,6 +163,10 @@ export function CharacterPanel({
|
||||
const [selectedContributionLabel, setSelectedContributionLabel] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const normalizedPlayerProgression =
|
||||
normalizePlayerProgressionState(playerProgression);
|
||||
const leaderLevelText = `Lv.${normalizedPlayerProgression.level}`;
|
||||
const companionReferenceLevelText = `参考 Lv.${normalizedPlayerProgression.level}`;
|
||||
|
||||
const partyMembers = useMemo<PartyMember[]>(
|
||||
() => [
|
||||
@@ -165,28 +175,32 @@ export function CharacterPanel({
|
||||
npcId: null,
|
||||
renderState: null,
|
||||
character: playerCharacter,
|
||||
roleLabel: '闃熼暱',
|
||||
roleLabel: '\u961f\u957f',
|
||||
hp: playerHp,
|
||||
maxHp: playerMaxHp,
|
||||
mana: playerMana,
|
||||
maxMana: playerMaxMana,
|
||||
isLeader: true,
|
||||
levelText: leaderLevelText,
|
||||
},
|
||||
...companionRenderStates.map((companion) => ({
|
||||
id: companion.npcId,
|
||||
npcId: companion.npcId,
|
||||
renderState: companion,
|
||||
character: companion.character,
|
||||
roleLabel: '鍚岃',
|
||||
roleLabel: '\u540c\u884c',
|
||||
hp: companion.hp,
|
||||
maxHp: companion.maxHp,
|
||||
mana: companion.mana,
|
||||
maxMana: companion.maxMana,
|
||||
isLeader: false,
|
||||
levelText: companionReferenceLevelText,
|
||||
})),
|
||||
],
|
||||
[
|
||||
companionReferenceLevelText,
|
||||
companionRenderStates,
|
||||
leaderLevelText,
|
||||
playerCharacter,
|
||||
playerHp,
|
||||
playerMaxHp,
|
||||
@@ -257,15 +271,16 @@ export function CharacterPanel({
|
||||
: null;
|
||||
const selectedMemberArcState =
|
||||
selectedMember && !selectedMember.isLeader
|
||||
? companionArcStates.find(
|
||||
? (companionArcStates.find(
|
||||
(arcState) => arcState.characterId === selectedMember.character.id,
|
||||
) ?? null
|
||||
) ?? null)
|
||||
: null;
|
||||
const selectedMemberResolution =
|
||||
selectedMember && !selectedMember.isLeader
|
||||
? companionResolutions.find(
|
||||
(resolution) => resolution.characterId === selectedMember.character.id,
|
||||
) ?? null
|
||||
? (companionResolutions.find(
|
||||
(resolution) =>
|
||||
resolution.characterId === selectedMember.character.id,
|
||||
) ?? null)
|
||||
: null;
|
||||
const selectedMemberPublicBackstory =
|
||||
selectedMember && !selectedMember.isLeader && selectedMemberAffinity != null
|
||||
@@ -410,11 +425,12 @@ export function CharacterPanel({
|
||||
{member.character.title}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] ${member.isLeader ? 'bg-amber-500/10 text-amber-100' : 'bg-sky-500/10 text-sky-100'}`}
|
||||
>
|
||||
{member.roleLabel}
|
||||
</span>
|
||||
<CharacterIdentityBadges
|
||||
roleLabel={member.roleLabel}
|
||||
levelText={member.levelText}
|
||||
roleTone={member.isLeader ? 'amber' : 'sky'}
|
||||
className="shrink-0 justify-end"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2.5 space-y-2.5">
|
||||
<StatusRow
|
||||
@@ -591,8 +607,15 @@ export function CharacterPanel({
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">
|
||||
{selectedMember.character.name}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[10px] tracking-[0.2em] text-zinc-500">
|
||||
<span>{selectedMember.character.title}</span>
|
||||
<div className="mt-1 text-[10px] tracking-[0.2em] text-zinc-500">
|
||||
{selectedMember.character.title}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<CharacterIdentityBadges
|
||||
roleLabel={selectedMember.roleLabel}
|
||||
levelText={selectedMember.levelText}
|
||||
roleTone={selectedMember.isLeader ? 'amber' : 'sky'}
|
||||
/>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200">
|
||||
{getGenderLabel(selectedMember.character.gender)}
|
||||
</span>
|
||||
@@ -617,7 +640,9 @@ export function CharacterPanel({
|
||||
<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">
|
||||
{selectedMember.character.visual ? (
|
||||
<MedievalNpcAnimator
|
||||
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(selectedMember.character.visual)}
|
||||
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
|
||||
selectedMember.character.visual,
|
||||
)}
|
||||
scale={2.08}
|
||||
/>
|
||||
) : (
|
||||
@@ -652,6 +677,22 @@ export function CharacterPanel({
|
||||
状态
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{selectedMember.isLeader && (
|
||||
<div className="rounded-xl border border-amber-300/18 bg-amber-500/8 px-3 py-3">
|
||||
<div className="mb-2 text-[10px] uppercase tracking-[0.18em] text-amber-100/75">
|
||||
等级
|
||||
</div>
|
||||
<PlayerLevelProgress
|
||||
level={normalizedPlayerProgression.level}
|
||||
currentLevelXp={
|
||||
normalizedPlayerProgression.currentLevelXp
|
||||
}
|
||||
xpToNextLevel={
|
||||
normalizedPlayerProgression.xpToNextLevel
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<StatusRow
|
||||
label={resourceLabels.hp}
|
||||
current={selectedMember.hp}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
@@ -12,8 +12,8 @@ import type {
|
||||
} from '../types';
|
||||
import { CustomWorldEntityCatalog } from './CustomWorldEntityCatalog';
|
||||
import {
|
||||
CustomWorldEntityEditorModal,
|
||||
type CustomWorldEditorTarget,
|
||||
CustomWorldEntityEditorModal,
|
||||
} from './CustomWorldEntityEditorModal';
|
||||
|
||||
vi.mock('../data/characterPresets', async () => {
|
||||
@@ -24,6 +24,11 @@ vi.mock('../data/characterPresets', async () => {
|
||||
return {
|
||||
...actual,
|
||||
buildCustomWorldPlayableCharacters: vi.fn(() => []),
|
||||
buildCustomWorldRuntimeCharacters: vi.fn(() => []),
|
||||
createCharacterSkillCooldowns: vi.fn(() => ({})),
|
||||
getCharacterMaxHp: vi.fn(() => 180),
|
||||
getCharacterMaxMana: vi.fn(() => 60),
|
||||
setRuntimeCharacterOverrides: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -34,6 +39,8 @@ vi.mock('./CharacterAnimator', () => ({
|
||||
vi.mock('../services/aiService', () => ({
|
||||
generateCustomWorldSceneImage: vi.fn(),
|
||||
generateCustomWorldSceneNpc: vi.fn(),
|
||||
generateInitialStory: vi.fn(),
|
||||
generateNextStep: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./CustomWorldNpcVisualEditor', () => ({
|
||||
@@ -43,6 +50,19 @@ vi.mock('./CustomWorldNpcVisualEditor', () => ({
|
||||
CustomWorldNpcVisualEditor: () => <div>预设形象编辑器</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./game-shell/GameShellRuntime', () => ({
|
||||
GameShellRuntime: ({
|
||||
session,
|
||||
}: {
|
||||
session: { gameState: { currentScenePreset?: { name?: string } | null } };
|
||||
}) => (
|
||||
<div>
|
||||
<div>幕预览运行时</div>
|
||||
<div>{session.gameState.currentScenePreset?.name ?? '未进入场景'}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({
|
||||
fetchCharacterWorkflowCache: vi.fn().mockResolvedValue({ cache: null }),
|
||||
generateCharacterPromptBundle: vi.fn().mockResolvedValue({
|
||||
@@ -138,7 +158,19 @@ function createProfile(): CustomWorldProfile {
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: ['守潮盟', '沉钟会'],
|
||||
coreConflicts: ['旧航道归属', '沉钟遗产争夺'],
|
||||
attributeSchema: {},
|
||||
attributeSchema: {
|
||||
id: 'schema-1',
|
||||
worldId: 'world-1',
|
||||
schemaVersion: 1,
|
||||
generatedFrom: {
|
||||
worldType: 'WUXIA',
|
||||
worldName: '潮雾群岛',
|
||||
settingSummary: '潮雾群岛上的禁制与旧航道正在一起失衡。',
|
||||
tone: '压抑、潮湿、带着未解旧伤。',
|
||||
conflictCore: '旧航道归属',
|
||||
},
|
||||
slots: [],
|
||||
},
|
||||
playableNpcs: [createPlayableRole('playable-1', '沈砺')],
|
||||
storyNpcs: [createStoryRole('story-1', '顾潮音')],
|
||||
items: [],
|
||||
@@ -189,6 +221,9 @@ function LandmarkEditorFlowHarness() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<pre data-testid="landmark-profile-json" className="hidden">
|
||||
{JSON.stringify(profile)}
|
||||
</pre>
|
||||
<CustomWorldEntityCatalog
|
||||
profile={profile}
|
||||
previewCharacters={[]}
|
||||
@@ -209,6 +244,19 @@ function LandmarkEditorFlowHarness() {
|
||||
);
|
||||
}
|
||||
|
||||
function readLandmarkHarnessProfile() {
|
||||
const content = screen.getByTestId('landmark-profile-json').textContent;
|
||||
return JSON.parse(content || '{}') as CustomWorldProfile;
|
||||
}
|
||||
|
||||
function getSceneActCard(index: number) {
|
||||
const card = screen.getAllByTestId('scene-act-card')[index];
|
||||
if (!card) {
|
||||
throw new Error(`未找到第 ${index + 1} 个幕卡片`);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
function CampEditorFlowHarness() {
|
||||
const [profile, setProfile] = useState<CustomWorldProfile>({
|
||||
...createProfileWithLandmark(),
|
||||
@@ -548,3 +596,128 @@ test('开局场景图片保存后会同步更新编辑页和场景列表', async
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('场景编辑器会在场景内展示槽位化多幕配置并保存', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<LandmarkEditorFlowHarness />);
|
||||
|
||||
expect(screen.getByText('多幕配置')).toBeTruthy();
|
||||
expect(screen.getAllByTestId('scene-act-card')).toHaveLength(3);
|
||||
expect(screen.queryByText('幕标题')).toBeNull();
|
||||
expect(screen.queryByText('幕摘要')).toBeNull();
|
||||
expect(screen.queryByText('幕目标')).toBeNull();
|
||||
expect(screen.queryByText('过渡铺垫')).toBeNull();
|
||||
|
||||
const firstActCard = getSceneActCard(0);
|
||||
expect(within(firstActCard).getAllByTestId('scene-act-slot-button')).toHaveLength(3);
|
||||
|
||||
await user.click(within(firstActCard).getByRole('button', { name: '配置背景' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('配置幕背景:第1幕')).toBeTruthy();
|
||||
});
|
||||
|
||||
const presetImage = screen.getByRole('img', { name: '幕背景预设 1' });
|
||||
const presetSrc = presetImage.getAttribute('src');
|
||||
const presetButton = presetImage.closest('button');
|
||||
expect(presetButton).toBeTruthy();
|
||||
if (!presetButton) {
|
||||
throw new Error('未找到幕背景预设按钮');
|
||||
}
|
||||
await user.click(presetButton);
|
||||
await user.click(screen.getByRole('button', { name: '保存背景' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('配置幕背景:第1幕')).toBeNull();
|
||||
});
|
||||
|
||||
await user.click(within(getSceneActCard(0)).getAllByTestId('scene-act-slot-button')[0]!);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('配置角色:第1幕 · 主角色槽位')).toBeTruthy();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /闻雪汀[\s\S]*选择/u }));
|
||||
await user.click(screen.getByRole('button', { name: '保存角色' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('配置角色:第1幕 · 主角色槽位')).toBeNull();
|
||||
});
|
||||
|
||||
expect(
|
||||
within(getSceneActCard(0)).getByRole('button', {
|
||||
name: '配置第1个角色:闻雪汀',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /保存修改/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('编辑场景:沉钟栈桥')).toBeNull();
|
||||
});
|
||||
|
||||
const savedProfile = readLandmarkHarnessProfile();
|
||||
const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find(
|
||||
(entry) => entry.sceneId === 'landmark-1',
|
||||
);
|
||||
|
||||
expect(savedSceneChapter).toBeTruthy();
|
||||
expect(savedSceneChapter?.acts[0]?.backgroundImageSrc).toBe(presetSrc);
|
||||
expect(savedSceneChapter?.acts[0]?.encounterNpcIds[0]).toBe('story-2');
|
||||
expect(savedSceneChapter?.acts[0]?.primaryNpcId).toBe('story-2');
|
||||
});
|
||||
|
||||
test('场景多幕支持新增删除和调序', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<LandmarkEditorFlowHarness />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '新增一幕' }));
|
||||
expect(screen.getAllByTestId('scene-act-card')).toHaveLength(4);
|
||||
|
||||
const secondActCard = getSceneActCard(1);
|
||||
await user.click(within(secondActCard).getAllByTestId('scene-act-slot-button')[0]!);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('配置角色:第2幕 · 主角色槽位')).toBeTruthy();
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: /谢孤灯[\s\S]*选择/u }));
|
||||
await user.click(screen.getByRole('button', { name: '保存角色' }));
|
||||
await user.click(within(secondActCard).getByRole('button', { name: '下移' }));
|
||||
|
||||
const fourthActCard = getSceneActCard(3);
|
||||
await user.click(within(fourthActCard).getByRole('button', { name: '删除' }));
|
||||
expect(screen.getAllByTestId('scene-act-card')).toHaveLength(3);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /保存修改/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('编辑场景:沉钟栈桥')).toBeNull();
|
||||
});
|
||||
|
||||
const savedProfile = readLandmarkHarnessProfile();
|
||||
const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find(
|
||||
(entry) => entry.sceneId === 'landmark-1',
|
||||
);
|
||||
|
||||
expect(savedSceneChapter?.acts).toHaveLength(3);
|
||||
expect(savedSceneChapter?.acts[2]?.primaryNpcId).toBe('story-3');
|
||||
});
|
||||
|
||||
test('场景幕预览会打开当前幕运行时面板', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<LandmarkEditorFlowHarness />);
|
||||
|
||||
await user.click(within(getSceneActCard(0)).getByRole('button', { name: '幕预览' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('幕预览运行时')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.getAllByText('沉钟栈桥').length).toBeGreaterThan(0);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '关闭预览' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('幕预览运行时')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import {getLiveGamePlayTimeMs} from '../data/runtimeStats';
|
||||
import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes';
|
||||
import {getWorldCampScenePreset} from '../data/scenePresets';
|
||||
import {BottomTab} from '../hooks/useGameFlow';
|
||||
import {resolveActiveSceneActBlueprint} from '../services/customWorldSceneActRuntime';
|
||||
import {
|
||||
type BattleRewardUi,
|
||||
type CharacterChatUi,
|
||||
@@ -321,6 +322,33 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
() => getLiveGamePlayTimeMs(gameState.runtimeStats, clockNow),
|
||||
[clockNow, gameState.runtimeStats],
|
||||
);
|
||||
const activeSceneAct = useMemo(
|
||||
() => resolveActiveSceneActBlueprint({
|
||||
profile: visibleGameState.customWorldProfile,
|
||||
sceneId: visibleGameState.currentScenePreset?.id ?? null,
|
||||
storyEngineMemory: visibleGameState.storyEngineMemory,
|
||||
}),
|
||||
[
|
||||
visibleGameState.currentScenePreset?.id,
|
||||
visibleGameState.customWorldProfile,
|
||||
visibleGameState.storyEngineMemory,
|
||||
],
|
||||
);
|
||||
const activeSceneChapter = useMemo(() => {
|
||||
if (!visibleGameState.customWorldProfile || !visibleGameState.currentScenePreset?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
visibleGameState.customWorldProfile.sceneChapterBlueprints?.find(
|
||||
entry => entry.sceneId === visibleGameState.currentScenePreset?.id
|
||||
|| entry.linkedLandmarkIds.includes(visibleGameState.currentScenePreset?.id ?? ''),
|
||||
) ?? null
|
||||
);
|
||||
}, [
|
||||
visibleGameState.currentScenePreset?.id,
|
||||
visibleGameState.customWorldProfile,
|
||||
]);
|
||||
|
||||
const adventureStatistics = useMemo(
|
||||
() => ({
|
||||
@@ -415,8 +443,9 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
<div
|
||||
className={`pixel-app-shell flex min-h-0 flex-1 flex-col ${isCharacterSelectionStage ? 'justify-center p-4 sm:p-5' : 'p-3 sm:p-4'}`}
|
||||
style={{
|
||||
background: isCharacterSelectionStage
|
||||
? '#0d1016'
|
||||
backgroundColor: isCharacterSelectionStage ? '#0d1016' : undefined,
|
||||
backgroundImage: isCharacterSelectionStage
|
||||
? undefined
|
||||
: `linear-gradient(rgba(10, 12, 18, 0.55), rgba(10, 12, 18, 0.55)), url("${UI_CHROME.appBackground}")`,
|
||||
backgroundPosition: isCharacterSelectionStage ? undefined : 'center',
|
||||
backgroundRepeat: isCharacterSelectionStage ? undefined : 'repeat',
|
||||
@@ -564,6 +593,18 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
journeyBeat={
|
||||
visibleGameState.storyEngineMemory?.currentJourneyBeat ?? null
|
||||
}
|
||||
currentSceneActTitle={activeSceneAct?.title ?? null}
|
||||
currentSceneActIndex={
|
||||
activeSceneChapter && activeSceneAct
|
||||
? (() => {
|
||||
const actIndex = activeSceneChapter.acts.findIndex(
|
||||
act => act.id === activeSceneAct.id,
|
||||
);
|
||||
return actIndex >= 0 ? actIndex + 1 : null;
|
||||
})()
|
||||
: null
|
||||
}
|
||||
currentSceneActCount={activeSceneChapter?.acts.length ?? null}
|
||||
statistics={adventureStatistics}
|
||||
musicVolume={musicVolume}
|
||||
onMusicVolumeChange={onMusicVolumeChange}
|
||||
|
||||
@@ -83,6 +83,9 @@ test('settings header uses a generic title instead of the phone number', () => {
|
||||
expect(screen.queryByText(/^登录设备$/)).toBeNull();
|
||||
expect(screen.queryByText(/^操作记录$/)).toBeNull();
|
||||
expect(screen.queryByText('当前账号状态')).toBeNull();
|
||||
expect(screen.queryByText('当前主题')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '退出登录' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '退出全部设备' })).toBeNull();
|
||||
});
|
||||
|
||||
test('account actions open in independent panels instead of inline expansion', async () => {
|
||||
@@ -121,9 +124,13 @@ test('nested settings panels keep back navigation without an extra close action'
|
||||
await user.click(screen.getByRole('button', { name: /账号信息/ }));
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
const accountHeader = accountDialog.firstElementChild as HTMLElement | null;
|
||||
expect(
|
||||
within(accountDialog).getByRole('button', { name: '返回' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
accountHeader?.lastElementChild?.textContent?.includes('返回'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
within(accountDialog).queryByRole('button', { name: '关闭' }),
|
||||
).toBeNull();
|
||||
@@ -135,9 +142,14 @@ test('nested settings panels keep back navigation without an extra close action'
|
||||
const changePhoneDialog = screen.getByRole('dialog', {
|
||||
name: '绑定新手机号',
|
||||
});
|
||||
const changePhoneHeader =
|
||||
changePhoneDialog.firstElementChild as HTMLElement | null;
|
||||
expect(
|
||||
within(changePhoneDialog).getByRole('button', { name: '返回' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
changePhoneHeader?.lastElementChild?.textContent?.includes('返回'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
within(changePhoneDialog).queryByRole('button', { name: '关闭' }),
|
||||
).toBeNull();
|
||||
@@ -234,6 +246,12 @@ test('account panel includes merged security devices and audit sections', async
|
||||
expect(within(accountDialog).getByText('手机号保护')).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('iPhone 15 Pro')).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('登录成功')).toBeTruthy();
|
||||
expect(
|
||||
within(accountDialog).getByRole('button', { name: '退出登录' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(accountDialog).getByRole('button', { name: '退出全部设备' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('legacy nested section requests now open the merged account panel', () => {
|
||||
|
||||
@@ -173,7 +173,7 @@ function OverlayPanel({
|
||||
onClick={onBack ?? onClose}
|
||||
>
|
||||
<div
|
||||
className="platform-auth-card flex w-full flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6"
|
||||
className="platform-auth-card flex max-h-full w-full min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
@@ -182,20 +182,8 @@ function OverlayPanel({
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{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}
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
) : null}
|
||||
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
|
||||
{eyebrow}
|
||||
</div>
|
||||
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
|
||||
{eyebrow}
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-[var(--platform-text-strong)]">
|
||||
{title}
|
||||
@@ -208,7 +196,16 @@ function OverlayPanel({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{action}
|
||||
{onBack ? null : (
|
||||
{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}
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
|
||||
@@ -420,7 +417,7 @@ export function AccountModal({
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="platform-auth-card relative flex w-full max-w-5xl flex-col overflow-hidden rounded-[28px] p-5 sm:p-6"
|
||||
className="platform-auth-card relative flex h-[min(100%,calc(100vh-2rem))] w-full max-w-5xl min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:p-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="设置与账号安全"
|
||||
@@ -443,7 +440,7 @@ export function AccountModal({
|
||||
</div>
|
||||
|
||||
<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 ref={settingsHomeRef} className="flex min-h-0 flex-col gap-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{SETTINGS_SECTIONS.map((section) => (
|
||||
<SettingsEntryCard
|
||||
@@ -459,39 +456,6 @@ export function AccountModal({
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
|
||||
@@ -503,7 +467,7 @@ export function AccountModal({
|
||||
onBack={closeSectionPanel}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex min-h-0 flex-col gap-4">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<ThemeOptionCard
|
||||
active={platformTheme === 'light'}
|
||||
@@ -548,7 +512,7 @@ export function AccountModal({
|
||||
onBack={closeSectionPanel}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex min-h-0 flex-col gap-4">
|
||||
{accountNotice ? (
|
||||
<div className="platform-banner platform-banner--success text-sm">
|
||||
{accountNotice}
|
||||
@@ -571,6 +535,27 @@ export function AccountModal({
|
||||
))}
|
||||
</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 className="platform-subpanel rounded-2xl px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
|
||||
@@ -120,7 +120,7 @@ test('auth gate keeps platform content visible when phone login is available', a
|
||||
);
|
||||
|
||||
expect(await screen.findByText('应用内容')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '登录' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '登录' })).toBeNull();
|
||||
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
|
||||
expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -76,7 +76,6 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
||||
const [initialSettingsSection, setInitialSettingsSection] =
|
||||
useState<PlatformSettingsSection | null>(null);
|
||||
const [showGlobalAccountActions, setShowGlobalAccountActions] = useState(true);
|
||||
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
const [auditLogs, setAuditLogs] = useState<AuthAuditLogEntry[]>([]);
|
||||
@@ -389,7 +388,6 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
await logoutAuthUser();
|
||||
setShowSettingsModal(false);
|
||||
},
|
||||
setGlobalAccountActionsVisible: setShowGlobalAccountActions,
|
||||
musicVolume: settings.musicVolume,
|
||||
setMusicVolume: settings.setMusicVolume,
|
||||
platformTheme: settings.platformTheme,
|
||||
@@ -516,38 +514,6 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
<AuthUiContext.Provider value={authUiValue}>
|
||||
<div className="relative">
|
||||
<div className={`platform-theme ${platformThemeClass}`}>
|
||||
{showGlobalAccountActions ? (
|
||||
<div className="pointer-events-none fixed right-3 top-3 z-50 flex justify-end">
|
||||
{readyUser ? (
|
||||
<div className="platform-auth-card pointer-events-auto flex items-center gap-2 rounded-full px-3 py-2 text-xs text-[var(--platform-text-base)]">
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--secondary min-h-0 rounded-full px-2.5 py-1 text-[11px]"
|
||||
onClick={() => openAccountModal()}
|
||||
>
|
||||
{readyUser.displayName}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-2.5 py-1 text-[11px]"
|
||||
onClick={() => {
|
||||
void logoutAuthUser();
|
||||
}}
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="platform-auth-card pointer-events-auto rounded-full px-3 py-2 text-xs font-medium text-[var(--platform-text-strong)] transition hover:-translate-y-px"
|
||||
onClick={() => openLoginModal()}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{readyUser ? (
|
||||
<AccountModal
|
||||
user={readyUser}
|
||||
|
||||
@@ -17,7 +17,6 @@ type AuthUiContextValue = {
|
||||
openSettingsModal: (section?: PlatformSettingsSection) => void;
|
||||
openAccountModal: () => void;
|
||||
logout: () => Promise<void>;
|
||||
setGlobalAccountActionsVisible: (visible: boolean) => void;
|
||||
musicVolume: number;
|
||||
setMusicVolume: (value: number) => void;
|
||||
platformTheme: PlatformTheme;
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
RoleCharacterSprite,
|
||||
SCENE_TRANSITION_LOWER_COMPANION_DELAY_S,
|
||||
SCENE_TRANSITION_UPPER_COMPANION_DELAY_S,
|
||||
SceneEncounterNpcSprite,
|
||||
SceneEntityButton,
|
||||
} from './GameCanvasShared';
|
||||
|
||||
@@ -403,7 +404,9 @@ export function GameCanvasEntityLayer({
|
||||
style={{imageRendering: 'pixelated'}}
|
||||
/>
|
||||
</div>
|
||||
) : peacefulResolvedCharacter ? (
|
||||
) : peacefulResolvedCharacter &&
|
||||
!encounter.visual &&
|
||||
!encounter.imageSrc?.trim() ? (
|
||||
<RoleCharacterSprite
|
||||
state={AnimationState.IDLE}
|
||||
character={peacefulResolvedCharacter}
|
||||
@@ -417,11 +420,11 @@ export function GameCanvasEntityLayer({
|
||||
className="scale-[1.82] origin-bottom"
|
||||
/>
|
||||
) : (
|
||||
<MedievalNpcAnimator
|
||||
<SceneEncounterNpcSprite
|
||||
encounter={encounter}
|
||||
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.38)]"
|
||||
state={AnimationState.IDLE}
|
||||
facing={peacefulNpcSpriteFacing}
|
||||
scale={GENERIC_NPC_SCENE_SCALE}
|
||||
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.38)]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,10 @@ import React, {useEffect, useState} from 'react';
|
||||
|
||||
import {getCharacterById} from '../../data/characterPresets';
|
||||
import {METERS_TO_PIXELS} from '../../data/hostileNpcs';
|
||||
import {buildMedievalNpcVisualFromCustomWorldVisual} from '../../data/medievalNpcVisuals';
|
||||
import {
|
||||
buildMedievalNpcVisual,
|
||||
buildMedievalNpcVisualFromCustomWorldVisual,
|
||||
} from '../../data/medievalNpcVisuals';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
@@ -246,6 +249,87 @@ export function RoleCharacterSprite({
|
||||
);
|
||||
}
|
||||
|
||||
export function SceneEncounterNpcSprite({
|
||||
encounter,
|
||||
state,
|
||||
facing,
|
||||
className,
|
||||
}: {
|
||||
encounter: Encounter;
|
||||
state: AnimationState;
|
||||
facing: 'left' | 'right';
|
||||
className?: string;
|
||||
}) {
|
||||
if (encounter.visual) {
|
||||
return (
|
||||
<MedievalNpcAnimator
|
||||
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(encounter.visual)}
|
||||
className={`origin-bottom ${className ?? ''}`.trim()}
|
||||
scale={1.36}
|
||||
facing={facing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (encounter.imageSrc?.trim()) {
|
||||
return (
|
||||
<img
|
||||
src={encounter.imageSrc.trim()}
|
||||
alt={encounter.npcName}
|
||||
className={`h-full w-full object-contain ${className ?? ''}`.trim()}
|
||||
style={{
|
||||
...DEFAULT_IMAGE_STYLE,
|
||||
transform: facing === 'left' ? 'scaleX(-1)' : undefined,
|
||||
transformOrigin: 'bottom center',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const runtimeCustomWorldCharacter =
|
||||
encounter.characterId ? getCharacterById(encounter.characterId) : null;
|
||||
if (runtimeCustomWorldCharacter?.visual) {
|
||||
return (
|
||||
<MedievalNpcAnimator
|
||||
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(runtimeCustomWorldCharacter.visual)}
|
||||
className={`origin-bottom ${className ?? ''}`.trim()}
|
||||
scale={1.36}
|
||||
facing={facing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (runtimeCustomWorldCharacter) {
|
||||
return (
|
||||
<div
|
||||
className="h-full w-full"
|
||||
style={{transform: facing === 'left' ? 'scaleX(-1)' : undefined}}
|
||||
>
|
||||
<CharacterAnimator
|
||||
state={state}
|
||||
character={runtimeCustomWorldCharacter}
|
||||
className={ROLE_CHARACTER_SPRITE_CLASS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MedievalNpcAnimator
|
||||
visualSpec={buildMedievalNpcVisual({
|
||||
id: encounter.id ?? encounter.npcName,
|
||||
npcName: encounter.npcName,
|
||||
npcDescription: encounter.npcDescription,
|
||||
npcAvatar: encounter.npcAvatar,
|
||||
context: encounter.context,
|
||||
} as Encounter)}
|
||||
className={`origin-bottom ${className ?? ''}`.trim()}
|
||||
scale={1.36}
|
||||
facing={facing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DialogueBubbleIcon({
|
||||
active = false,
|
||||
flip = false,
|
||||
|
||||
@@ -117,12 +117,90 @@ test('custom world character selection stays stable when character ids are empty
|
||||
render(
|
||||
<CharacterSelectionFlow
|
||||
worldType={WorldType.CUSTOM}
|
||||
customWorldProfile={{} as CustomWorldProfile}
|
||||
customWorldProfile={{
|
||||
attributeSchema: {
|
||||
id: 'schema:custom:test',
|
||||
worldId: 'custom:test',
|
||||
schemaVersion: 1,
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: '潮城',
|
||||
settingSummary: '潮水与迷雾交织的港城。',
|
||||
tone: '潮湿、危险、带着试探。',
|
||||
conflictCore: '在涨落之间抢先一步。',
|
||||
},
|
||||
slots: [
|
||||
{
|
||||
slotId: 'axis_a',
|
||||
name: '潮骨',
|
||||
definition: '扛住潮压与正面冲击的底子。',
|
||||
positiveSignals: [],
|
||||
negativeSignals: [],
|
||||
combatUseText: '顶住正面浪涌。',
|
||||
socialUseText: '给人能扛事的可靠感。',
|
||||
explorationUseText: '在风浪里稳住自己。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_b',
|
||||
name: '浪步',
|
||||
definition: '顺潮借势、换位穿行的能力。',
|
||||
positiveSignals: [],
|
||||
negativeSignals: [],
|
||||
combatUseText: '借势切线。',
|
||||
socialUseText: '谈吐灵活。',
|
||||
explorationUseText: '穿越复杂地形。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_c',
|
||||
name: '舟识',
|
||||
definition: '辨流向、识潮眼的能力。',
|
||||
positiveSignals: [],
|
||||
negativeSignals: [],
|
||||
combatUseText: '抓住变化时机。',
|
||||
socialUseText: '看懂局势留白。',
|
||||
explorationUseText: '辨认水路与遗痕。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_d',
|
||||
name: '潮魄',
|
||||
definition: '在剧烈变化中仍敢推进的胆气。',
|
||||
positiveSignals: [],
|
||||
negativeSignals: [],
|
||||
combatUseText: '顶着压力推进。',
|
||||
socialUseText: '在冲突里压住场子。',
|
||||
explorationUseText: '面对异变继续前探。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_e',
|
||||
name: '契汐',
|
||||
definition: '与人和约定形成牵引的能力。',
|
||||
positiveSignals: [],
|
||||
negativeSignals: [],
|
||||
combatUseText: '借协同形成连锁。',
|
||||
socialUseText: '结盟、安抚与交换。',
|
||||
explorationUseText: '从旧约中打开局面。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_f',
|
||||
name: '回澜',
|
||||
definition: '在漫长消耗中回稳状态的能力。',
|
||||
positiveSignals: [],
|
||||
negativeSignals: [],
|
||||
combatUseText: '久战不乱。',
|
||||
socialUseText: '遇事沉静。',
|
||||
explorationUseText: '在恶劣天气里保有余力。',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as CustomWorldProfile}
|
||||
onBack={() => {}}
|
||||
onConfirm={handleConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/潮骨:/u)).toBeTruthy();
|
||||
expect(screen.queryByText(/力量:/u)).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /闻潮/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
import {
|
||||
buildCharacterAttributeProfile,
|
||||
} from '../../data/attributeProfileGenerator';
|
||||
import {
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
} from '../../data/attributeResolver';
|
||||
import {
|
||||
buildCustomWorldPlayableCharacters,
|
||||
ROLE_TEMPLATE_CHARACTERS,
|
||||
@@ -32,13 +39,6 @@ const CHARACTER_DISPLAY: Record<string, {name: string; title: string; role: stri
|
||||
'fighter-4': {name: '装甲长矛手', title: '重装先锋', role: '前线', tags: ['守护', '稳定', '突破']},
|
||||
};
|
||||
|
||||
const ATTRIBUTE_LABELS: Record<keyof Character['attributes'], string> = {
|
||||
strength: '力量',
|
||||
agility: '敏捷',
|
||||
intelligence: '智力',
|
||||
spirit: '精神',
|
||||
};
|
||||
|
||||
function getGenderLabel(gender: Character['gender']) {
|
||||
if (gender === 'female') return '女性';
|
||||
if (gender === 'male') return '男性';
|
||||
@@ -211,6 +211,22 @@ export function CharacterSelectionFlow({
|
||||
const selectedCharacterMeta = selectedCharacter
|
||||
? getCharacterMeta(selectedCharacter, {name: selectedCharacterDraft?.name})
|
||||
: null;
|
||||
const attributeSchema = useMemo(
|
||||
() => resolveAttributeSchema(worldType, customWorldProfile),
|
||||
[customWorldProfile, worldType],
|
||||
);
|
||||
const selectedAttributeProfile = useMemo(
|
||||
() =>
|
||||
selectedCharacter
|
||||
? resolveCharacterAttributeProfile(
|
||||
selectedCharacter,
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
)
|
||||
?? buildCharacterAttributeProfile(selectedCharacter, attributeSchema)
|
||||
: null,
|
||||
[attributeSchema, customWorldProfile, selectedCharacter, worldType],
|
||||
);
|
||||
const selectedCharacterPersonalityTags = useMemo(
|
||||
() => (selectedCharacterPreview ? getPersonalityTags(selectedCharacterPreview.personality) : []),
|
||||
[selectedCharacterPreview],
|
||||
@@ -363,10 +379,10 @@ export function CharacterSelectionFlow({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1 text-[11px] text-zinc-300 sm:gap-1.5 sm:text-[13px]">
|
||||
{Object.entries(selectedCharacter.attributes).map(([key, value]) => (
|
||||
<div key={key} className="rounded-lg border border-white/6 bg-black/20 px-2 py-1.5 text-center sm:px-2.5">
|
||||
{ATTRIBUTE_LABELS[key as keyof Character['attributes']]}: {value}
|
||||
<div className="grid grid-cols-2 gap-1 text-[11px] text-zinc-300 sm:grid-cols-3 sm:gap-1.5 sm:text-[13px]">
|
||||
{attributeSchema.slots.map((slot) => (
|
||||
<div key={slot.slotId} className="rounded-lg border border-white/6 bg-black/20 px-2 py-1.5 text-center sm:px-2.5">
|
||||
{slot.name}: {selectedAttributeProfile?.values?.[slot.slotId] ?? 0}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -141,11 +141,15 @@ export function GameShellMainContent({
|
||||
<div
|
||||
className={`${isPlatformShell ? 'platform-main-shell' : 'pixel-app-shell'} flex min-h-0 flex-1 flex-col ${isCharacterSelectionStage ? 'justify-center p-4 sm:p-5' : 'p-3 sm:p-4'}`}
|
||||
style={{
|
||||
background: isPlatformShell
|
||||
backgroundColor: isPlatformShell
|
||||
? 'transparent'
|
||||
: isCharacterSelectionStage
|
||||
? '#0d1016'
|
||||
: `linear-gradient(rgba(10, 12, 18, 0.55), rgba(10, 12, 18, 0.55)), url("${UI_CHROME.appBackground}")`,
|
||||
? '#0d1016'
|
||||
: undefined,
|
||||
backgroundImage:
|
||||
isPlatformShell || isCharacterSelectionStage
|
||||
? undefined
|
||||
: `linear-gradient(rgba(10, 12, 18, 0.55), rgba(10, 12, 18, 0.55)), url("${UI_CHROME.appBackground}")`,
|
||||
backgroundPosition:
|
||||
isPlatformShell || isCharacterSelectionStage ? undefined : 'center',
|
||||
backgroundRepeat:
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { lazy, Suspense } from 'react';
|
||||
|
||||
import type { CharacterChatUi, InventoryFlowUi, StoryGenerationNpcUi } from '../../hooks/useStoryGeneration';
|
||||
import type {
|
||||
CharacterChatUi,
|
||||
InventoryFlowUi,
|
||||
StoryGenerationNpcUi,
|
||||
} from '../../hooks/useStoryGeneration';
|
||||
import type { CompanionRenderState, GameState } from '../../types';
|
||||
import { CHROME_ICONS, getNineSliceStyle,UI_CHROME } from '../../uiAssets';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
import type { GameCanvasEntitySelection } from '../GameCanvas';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
import { ModalLoadingFallback, PanelLoadingFallback } from './GameShellLoaders';
|
||||
@@ -120,7 +124,14 @@ export function GameShellOverlays({
|
||||
return (
|
||||
<>
|
||||
{shouldMountAdventureEntityModal && (
|
||||
<Suspense fallback={<ModalLoadingFallback label="正在加载冒险详情..." onClose={closeAdventureEntityModal} />}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<ModalLoadingFallback
|
||||
label="正在加载冒险详情..."
|
||||
onClose={closeAdventureEntityModal}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AdventureEntityModal
|
||||
selection={selectedSceneEntity}
|
||||
gameState={gameState}
|
||||
@@ -146,10 +157,12 @@ export function GameShellOverlays({
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,60rem)] w-full max-w-5xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
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 text-sm font-semibold text-white">{overlayPanel === 'character' ? '队伍' : '背包'}</div>
|
||||
<div className="min-w-0 pr-10 text-sm font-semibold text-white">
|
||||
{overlayPanel === 'character' ? '队伍' : '背包'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeOverlayPanel}
|
||||
@@ -160,11 +173,14 @@ export function GameShellOverlays({
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 p-5">
|
||||
{overlayPanel === 'character' ? (
|
||||
<Suspense fallback={<PanelLoadingFallback label="正在加载队伍面板" />}>
|
||||
<Suspense
|
||||
fallback={<PanelLoadingFallback label="正在加载队伍面板" />}
|
||||
>
|
||||
<CharacterPanel
|
||||
worldType={gameState.worldType}
|
||||
customWorldProfile={gameState.customWorldProfile}
|
||||
playerCharacter={gameState.playerCharacter}
|
||||
playerProgression={gameState.playerProgression ?? null}
|
||||
playerHp={gameState.playerHp}
|
||||
playerMaxHp={gameState.playerMaxHp}
|
||||
playerMana={gameState.playerMana}
|
||||
@@ -178,7 +194,7 @@ export function GameShellOverlays({
|
||||
closeOverlayPanel();
|
||||
openCampModal();
|
||||
}}
|
||||
onOpenCharacterChat={target => {
|
||||
onOpenCharacterChat={(target) => {
|
||||
closeOverlayPanel();
|
||||
characterChatUi.openChat(target);
|
||||
}}
|
||||
@@ -187,7 +203,9 @@ export function GameShellOverlays({
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<Suspense fallback={<PanelLoadingFallback label="正在加载背包面板" />}>
|
||||
<Suspense
|
||||
fallback={<PanelLoadingFallback label="正在加载背包面板" />}
|
||||
>
|
||||
<InventoryPanel
|
||||
playerCharacter={gameState.playerCharacter}
|
||||
worldType={gameState.worldType}
|
||||
@@ -214,7 +232,14 @@ export function GameShellOverlays({
|
||||
</AnimatePresence>
|
||||
|
||||
{shouldMountCampModal && (
|
||||
<Suspense fallback={<ModalLoadingFallback label="正在加载队伍营地..." onClose={closeCampModal} />}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<ModalLoadingFallback
|
||||
label="正在加载队伍营地..."
|
||||
onClose={closeCampModal}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CompanionCampModal
|
||||
isOpen={showTeamModal}
|
||||
playerCharacter={gameState.playerCharacter}
|
||||
@@ -229,13 +254,20 @@ export function GameShellOverlays({
|
||||
)}
|
||||
|
||||
{shouldMountMapModal && (
|
||||
<Suspense fallback={<ModalLoadingFallback label="正在加载地图..." onClose={() => setIsMapOpen(false)} />}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<ModalLoadingFallback
|
||||
label="正在加载地图..."
|
||||
onClose={() => setIsMapOpen(false)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MapModal
|
||||
isOpen={isMapOpen}
|
||||
currentScenePreset={gameState.currentScenePreset}
|
||||
worldType={gameState.worldType}
|
||||
canTravel={!gameState.inBattle && !isLoading}
|
||||
onTravelToScene={scene => {
|
||||
onTravelToScene={(scene) => {
|
||||
const triggered = handleMapTravelToScene(scene.id);
|
||||
if (triggered) {
|
||||
setIsMapOpen(false);
|
||||
@@ -248,7 +280,14 @@ export function GameShellOverlays({
|
||||
)}
|
||||
|
||||
{shouldMountCharacterChatModal && (
|
||||
<Suspense fallback={<ModalLoadingFallback label="正在加载角色聊天..." onClose={characterChatUi.closeChat} />}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<ModalLoadingFallback
|
||||
label="正在加载角色聊天..."
|
||||
onClose={characterChatUi.closeChat}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CharacterChatModal
|
||||
modal={characterChatUi.modal}
|
||||
onClose={characterChatUi.closeChat}
|
||||
@@ -261,7 +300,9 @@ export function GameShellOverlays({
|
||||
)}
|
||||
|
||||
{shouldMountNpcModals && (
|
||||
<Suspense fallback={<ModalLoadingFallback label="正在加载角色交互..." />}>
|
||||
<Suspense
|
||||
fallback={<ModalLoadingFallback label="正在加载角色交互..." />}
|
||||
>
|
||||
<NpcModals gameState={gameState} npcUi={npcUi} />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { lazy, Suspense, useEffect } from 'react';
|
||||
import { lazy, Suspense } from 'react';
|
||||
|
||||
import { normalizePlayerProgressionState } from '../../data/playerProgression';
|
||||
import { UI_CHROME } from '../../uiAssets';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { GameShellMainContent } from './GameShellMainContent';
|
||||
@@ -19,7 +20,13 @@ const GameShellCanvasStage = lazy(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
export function GameShellRuntime({session, story, entry, companions, audio}: GameShellProps) {
|
||||
export function GameShellRuntime({
|
||||
session,
|
||||
story,
|
||||
entry,
|
||||
companions,
|
||||
audio,
|
||||
}: GameShellProps) {
|
||||
const authUi = useAuthUi();
|
||||
const isPlatformShell = !session.gameState.worldType;
|
||||
const platformThemeClass =
|
||||
@@ -60,12 +67,9 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
handleBackToWorldSelect,
|
||||
handleCharacterSelect,
|
||||
} = entry;
|
||||
const {
|
||||
companionRenderStates,
|
||||
onBenchCompanion,
|
||||
onActivateRosterCompanion,
|
||||
} = companions;
|
||||
const {musicVolume, onMusicVolumeChange} = audio;
|
||||
const { companionRenderStates, onBenchCompanion, onActivateRosterCompanion } =
|
||||
companions;
|
||||
const { musicVolume, onMusicVolumeChange } = audio;
|
||||
const {
|
||||
selectionStage,
|
||||
setSelectionStage,
|
||||
@@ -103,24 +107,26 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
story,
|
||||
companions,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
authUi?.setGlobalAccountActionsVisible(false);
|
||||
|
||||
return () => {
|
||||
authUi?.setGlobalAccountActionsVisible(true);
|
||||
};
|
||||
}, [authUi]);
|
||||
const playerProgression = normalizePlayerProgressionState(
|
||||
visibleGameState.playerProgression ?? null,
|
||||
);
|
||||
const playerProgressionRatio =
|
||||
playerProgression.xpToNextLevel <= 0
|
||||
? 1
|
||||
: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
playerProgression.currentLevelXp / playerProgression.xpToNextLevel,
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${isPlatformShell ? `platform-ui-shell platform-theme ${platformThemeClass} text-[var(--platform-text-strong)]` : 'fusion-pixel-app pixel-root-shell text-zinc-100'} flex h-screen max-h-screen flex-col overflow-hidden font-sans`}
|
||||
style={{
|
||||
background: isPlatformShell
|
||||
? 'var(--platform-body-fill)'
|
||||
: undefined,
|
||||
backgroundImage: isPlatformShell
|
||||
? undefined
|
||||
? 'var(--platform-body-fill)'
|
||||
: `linear-gradient(rgba(8, 10, 14, 0.82), rgba(8, 10, 14, 0.82)), url("${UI_CHROME.appBackground}")`,
|
||||
backgroundPosition: isPlatformShell ? undefined : 'center',
|
||||
backgroundRepeat: isPlatformShell ? undefined : 'repeat',
|
||||
@@ -141,6 +147,36 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
{visibleGameState.playerCharacter && (
|
||||
<div
|
||||
className="pointer-events-none fixed z-[26] w-[4.5rem] drop-shadow-[0_2px_8px_rgba(0,0,0,0.75)]"
|
||||
style={{
|
||||
top: 'calc(env(safe-area-inset-top, 0px) + 0.65rem)',
|
||||
left: 'calc(env(safe-area-inset-left, 0px) + 0.7rem)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-end gap-1.5 text-amber-50">
|
||||
<span className="text-[10px] font-semibold uppercase leading-none tracking-[0.14em] text-amber-100/80">
|
||||
Lv
|
||||
</span>
|
||||
<span className="text-2xl font-black leading-none tracking-[-0.08em] text-white">
|
||||
{playerProgression.level}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1 overflow-hidden rounded-full bg-black/45">
|
||||
<div
|
||||
className="h-full rounded-full bg-[linear-gradient(90deg,rgba(251,191,36,0.65),rgba(254,240,138,0.95))]"
|
||||
style={{
|
||||
width:
|
||||
playerProgressionRatio <= 0
|
||||
? '0%'
|
||||
: `${Math.max(8, playerProgressionRatio * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<GameShellMainContent
|
||||
gameState={gameState}
|
||||
visibleGameState={visibleGameState}
|
||||
|
||||
@@ -9,8 +9,13 @@ import type {
|
||||
NpcChatQuestOfferUi,
|
||||
QuestFlowUi,
|
||||
} from '../../hooks/useStoryGeneration';
|
||||
import type { CompanionRenderState, GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import { getNineSliceStyle,TAB_ICONS, UI_CHROME } from '../../uiAssets';
|
||||
import type {
|
||||
CompanionRenderState,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import { getNineSliceStyle, TAB_ICONS, UI_CHROME } from '../../uiAssets';
|
||||
import type { GameCanvasEntitySelection } from '../GameCanvas';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
import { PanelLoadingFallback } from './GameShellLoaders';
|
||||
@@ -110,11 +115,20 @@ export function GameShellStoryPanels({
|
||||
<button
|
||||
onClick={() => setBottomTab('character')}
|
||||
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'character' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
|
||||
style={getNineSliceStyle(bottomTab === 'character' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, { paddingX: 10, paddingY: 8 })}
|
||||
style={getNineSliceStyle(
|
||||
bottomTab === 'character'
|
||||
? UI_CHROME.tabActive
|
||||
: UI_CHROME.tabInactive,
|
||||
{ paddingX: 10, paddingY: 8 },
|
||||
)}
|
||||
>
|
||||
<span className="pixel-tab-button__inner">
|
||||
<PixelIcon
|
||||
src={bottomTab === 'character' ? TAB_ICONS.character.active : TAB_ICONS.character.inactive}
|
||||
src={
|
||||
bottomTab === 'character'
|
||||
? TAB_ICONS.character.active
|
||||
: TAB_ICONS.character.inactive
|
||||
}
|
||||
className={`pixel-tab-button__icon ${bottomTab === 'character' ? 'opacity-100' : 'opacity-70'}`}
|
||||
/>
|
||||
<span className="pixel-tab-button__label">角色</span>
|
||||
@@ -123,11 +137,20 @@ export function GameShellStoryPanels({
|
||||
<button
|
||||
onClick={() => setBottomTab('adventure')}
|
||||
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'adventure' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
|
||||
style={getNineSliceStyle(bottomTab === 'adventure' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, { paddingX: 10, paddingY: 8 })}
|
||||
style={getNineSliceStyle(
|
||||
bottomTab === 'adventure'
|
||||
? UI_CHROME.tabActive
|
||||
: UI_CHROME.tabInactive,
|
||||
{ paddingX: 10, paddingY: 8 },
|
||||
)}
|
||||
>
|
||||
<span className="pixel-tab-button__inner">
|
||||
<PixelIcon
|
||||
src={bottomTab === 'adventure' ? TAB_ICONS.adventure.active : TAB_ICONS.adventure.inactive}
|
||||
src={
|
||||
bottomTab === 'adventure'
|
||||
? TAB_ICONS.adventure.active
|
||||
: TAB_ICONS.adventure.inactive
|
||||
}
|
||||
className={`pixel-tab-button__icon ${bottomTab === 'adventure' ? 'opacity-100' : 'opacity-70'}`}
|
||||
/>
|
||||
<span className="pixel-tab-button__label">冒险</span>
|
||||
@@ -136,11 +159,20 @@ export function GameShellStoryPanels({
|
||||
<button
|
||||
onClick={() => setBottomTab('inventory')}
|
||||
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'inventory' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
|
||||
style={getNineSliceStyle(bottomTab === 'inventory' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, { paddingX: 10, paddingY: 8 })}
|
||||
style={getNineSliceStyle(
|
||||
bottomTab === 'inventory'
|
||||
? UI_CHROME.tabActive
|
||||
: UI_CHROME.tabInactive,
|
||||
{ paddingX: 10, paddingY: 8 },
|
||||
)}
|
||||
>
|
||||
<span className="pixel-tab-button__inner">
|
||||
<PixelIcon
|
||||
src={bottomTab === 'inventory' ? TAB_ICONS.inventory.active : TAB_ICONS.inventory.inactive}
|
||||
src={
|
||||
bottomTab === 'inventory'
|
||||
? TAB_ICONS.inventory.active
|
||||
: TAB_ICONS.inventory.inactive
|
||||
}
|
||||
className={`pixel-tab-button__icon ${bottomTab === 'inventory' ? 'opacity-100' : 'opacity-70'}`}
|
||||
/>
|
||||
<span className="pixel-tab-button__label">背包</span>
|
||||
@@ -154,6 +186,7 @@ export function GameShellStoryPanels({
|
||||
worldType={visibleGameState.worldType}
|
||||
customWorldProfile={visibleGameState.customWorldProfile}
|
||||
playerCharacter={playerCharacter}
|
||||
playerProgression={visibleGameState.playerProgression ?? null}
|
||||
playerHp={visibleGameState.playerHp}
|
||||
playerMaxHp={visibleGameState.playerMaxHp}
|
||||
playerMana={visibleGameState.playerMana}
|
||||
|
||||
@@ -48,8 +48,10 @@ export type PlatformHomeTab = 'home' | 'create' | 'saves' | 'profile';
|
||||
const PANEL_SURFACE_CLASS = 'platform-surface platform-surface--soft';
|
||||
const HERO_SURFACE_CLASS =
|
||||
'platform-surface platform-surface--hero platform-interactive-card';
|
||||
const MOBILE_PAGE_STAGE_CLASS = 'platform-page-stage space-y-4 pb-2';
|
||||
const DESKTOP_PAGE_STAGE_CLASS = 'platform-page-stage space-y-5 pb-4';
|
||||
const MOBILE_PAGE_STAGE_CLASS =
|
||||
'platform-page-stage platform-remap-surface space-y-4 pb-2';
|
||||
const DESKTOP_PAGE_STAGE_CLASS =
|
||||
'platform-page-stage platform-remap-surface space-y-5 pb-4';
|
||||
|
||||
function SectionHeader({ title, detail }: { title: string; detail: string }) {
|
||||
return (
|
||||
@@ -57,14 +59,18 @@ function SectionHeader({ title, detail }: { title: string; detail: string }) {
|
||||
<div className="text-[10px] font-semibold tracking-[0.26em] text-zinc-500">
|
||||
{detail}
|
||||
</div>
|
||||
<div className="mt-1 text-base font-semibold text-white">{title}</div>
|
||||
<div className="mt-1 text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyShelf({ text }: { text: string }) {
|
||||
return (
|
||||
<div className={`${PANEL_SURFACE_CLASS} rounded-[1.35rem] px-4 py-3 text-sm leading-6 text-zinc-300`}>
|
||||
<div
|
||||
className={`${PANEL_SURFACE_CLASS} rounded-[1.35rem] px-4 py-3 text-sm leading-6 text-zinc-300`}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
@@ -82,7 +88,7 @@ function SaveArchivePreview({
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`relative shrink-0 overflow-hidden rounded-[1.35rem] border border-white/12 bg-black/18 shadow-[0_16px_36px_rgba(15,23,42,0.18)] ${className}`}
|
||||
className={`platform-remap-surface relative shrink-0 overflow-hidden rounded-[1.35rem] border border-white/12 bg-black/18 shadow-[var(--platform-desktop-hover-shadow)] ${className}`}
|
||||
>
|
||||
{entry.coverImageSrc ? (
|
||||
<img
|
||||
@@ -93,7 +99,7 @@ function SaveArchivePreview({
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_34%),linear-gradient(145deg,rgba(255,94,125,0.92),rgba(255,150,116,0.88))]" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.06),rgba(8,10,14,0.74))]" />
|
||||
<div className="absolute inset-0 bg-[var(--platform-card-overlay-soft)]" />
|
||||
<div className="absolute inset-x-0 bottom-0 px-2.5 py-2">
|
||||
<span className="inline-flex max-w-full items-center rounded-full border border-white/15 bg-black/24 px-2.5 py-1 text-[9px] font-semibold tracking-[0.14em] text-white/88">
|
||||
{label}
|
||||
@@ -147,12 +153,10 @@ function WorldCard({
|
||||
className="absolute bottom-2 right-2 h-24 w-24 object-contain opacity-25"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.14),rgba(8,10,14,0.9))]" />
|
||||
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
|
||||
<div className="relative z-10 flex h-full flex-col">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="platform-pill platform-pill--warm">
|
||||
{badge}
|
||||
</span>
|
||||
<span className="platform-pill platform-pill--warm">{badge}</span>
|
||||
<span className="platform-pill platform-pill--neutral px-2.5">
|
||||
{metaLabel}
|
||||
</span>
|
||||
@@ -233,7 +237,7 @@ function CreationLibraryCard({
|
||||
className="absolute bottom-1.5 right-1.5 h-16 w-16 object-contain opacity-24 sm:h-20 sm:w-20"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.1),rgba(8,10,14,0.92))]" />
|
||||
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
|
||||
<div className="relative z-10 flex h-full min-w-0 flex-col">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
@@ -267,7 +271,9 @@ function CreationLibraryCard({
|
||||
<span className="truncate">{primaryTag}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-zinc-200">
|
||||
<span>{entry.visibility === 'published' ? '进入世界' : '继续创作'}</span>
|
||||
<span>
|
||||
{entry.visibility === 'published' ? '进入世界' : '继续创作'}
|
||||
</span>
|
||||
<ArrowRight className="h-3.5 w-3.5 shrink-0" />
|
||||
</span>
|
||||
</div>
|
||||
@@ -286,7 +292,8 @@ function SaveArchiveCard({
|
||||
onClick: () => void;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const summaryText = entry.summaryText || entry.subtitle || '继续推进上一次保存的故事。';
|
||||
const summaryText =
|
||||
entry.summaryText || entry.subtitle || '继续推进上一次保存的故事。';
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -295,7 +302,7 @@ function SaveArchiveCard({
|
||||
disabled={loading}
|
||||
className={`platform-surface platform-surface--soft platform-interactive-card relative flex min-h-[13rem] w-full overflow-hidden p-3.5 text-left sm:min-h-[12.5rem] sm:p-4 ${loading ? 'opacity-80' : ''}`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.14),transparent_30%),radial-gradient(circle_at_right,rgba(255,205,178,0.14),transparent_28%),linear-gradient(180deg,rgba(8,10,14,0.22),rgba(8,10,14,0.9))]" />
|
||||
<div className="absolute inset-0 bg-[var(--platform-card-overlay-deep)]" />
|
||||
<div className="relative z-10 flex h-full w-full flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="platform-pill platform-pill--cool">ARCHIVE</span>
|
||||
@@ -422,7 +429,7 @@ function DesktopTrendingItem({
|
||||
<span>{`${rank}`.padStart(2, '0')}</span>
|
||||
<span>{formatPlatformWorldTime(entry.publishedAt)}</span>
|
||||
</div>
|
||||
<div className="mt-2 line-clamp-1 text-lg font-semibold text-white">
|
||||
<div className="mt-2 line-clamp-1 text-lg font-semibold text-[var(--platform-text-strong)]">
|
||||
{entry.worldName}
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-sm leading-6 text-zinc-300/86">
|
||||
@@ -581,7 +588,9 @@ function ProfileStatCard({
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="text-[11px] tracking-[0.16em]">{label}</span>
|
||||
</div>
|
||||
<div className="mt-3 text-lg font-black text-white">{value}</div>
|
||||
<div className="mt-3 text-lg font-black text-[var(--platform-text-strong)]">
|
||||
{value}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -615,7 +624,9 @@ function ProfileShortcutButton({
|
||||
<div className="platform-profile-chip flex h-10 w-10 items-center justify-center rounded-full">
|
||||
<Icon className="h-[1.125rem] w-[1.125rem]" />
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-white">{label}</div>
|
||||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{label}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -756,7 +767,7 @@ export function PlatformHomeView({
|
||||
? `${snapshotCharacterName} 的进度已保存,点这里回到上一次停下来的故事节点。`
|
||||
: '从设定、角色到场景网络,先生成一版可玩的世界底稿,再继续精修和发布。'}
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2 text-sm font-semibold text-violet-100">
|
||||
<div className="mt-4 flex items-center gap-2 text-sm font-semibold text-white/90">
|
||||
<span>{hasSavedGame ? '继续推进故事' : '进入创作工作台'}</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</div>
|
||||
@@ -832,7 +843,7 @@ export function PlatformHomeView({
|
||||
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-200/88">
|
||||
先选择游戏类型,再进入对应的创作工作台继续推进。
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2 text-sm font-semibold text-cyan-100">
|
||||
<div className="mt-4 flex items-center gap-2 text-sm font-semibold text-white/90">
|
||||
<span>选择类型并继续</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</div>
|
||||
@@ -904,7 +915,9 @@ export function PlatformHomeView({
|
||||
) : (
|
||||
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
|
||||
<div className="platform-subpanel rounded-[1.35rem] px-4 py-4">
|
||||
<div className="text-base font-semibold text-white">尚未登录</div>
|
||||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
尚未登录
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi?.openLoginModal()}
|
||||
@@ -1074,7 +1087,9 @@ export function PlatformHomeView({
|
||||
<Settings className="h-[1.125rem] w-[1.125rem]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">设置</div>
|
||||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
设置
|
||||
</div>
|
||||
<div className="text-xs text-zinc-400">主题与账号</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1085,7 +1100,9 @@ export function PlatformHomeView({
|
||||
) : (
|
||||
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
|
||||
<div className="platform-subpanel rounded-[1.35rem] px-4 py-4">
|
||||
<div className="text-base font-semibold text-white">尚未登录</div>
|
||||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
尚未登录
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi?.openLoginModal()}
|
||||
@@ -1144,15 +1161,19 @@ export function PlatformHomeView({
|
||||
|
||||
<div className="max-w-[35rem]">
|
||||
<div className="text-5xl font-semibold leading-[1.08] text-white">
|
||||
{hasSavedGame ? snapshotWorldName : '把你的世界观直接变成可游玩的舞台'}
|
||||
{hasSavedGame
|
||||
? snapshotWorldName
|
||||
: '把你的世界观直接变成可游玩的舞台'}
|
||||
</div>
|
||||
<div className="mt-4 text-base leading-8 text-zinc-200/86">
|
||||
{hasSavedGame
|
||||
? `${snapshotCharacterName} 的进度已经保存,桌面端可以直接从这里回到上一次停下来的关键节点。`
|
||||
: '从设定、角色、世界结构到可玩流程,一次生成创作底稿,再继续精修并发布到平台广场。'}
|
||||
</div>
|
||||
<div className="mt-5 inline-flex items-center gap-2 rounded-full border border-violet-300/18 bg-violet-500/14 px-4 py-2 text-sm font-semibold text-violet-100">
|
||||
<span>{hasSavedGame ? '继续推进故事' : '进入创作工作台'}</span>
|
||||
<div className="mt-5 inline-flex items-center gap-2 rounded-full border border-white/18 bg-white/18 px-4 py-2 text-sm font-semibold text-white/92">
|
||||
<span>
|
||||
{hasSavedGame ? '继续推进故事' : '进入创作工作台'}
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1181,7 +1202,9 @@ export function PlatformHomeView({
|
||||
<span className="text-zinc-500">
|
||||
{`${index + 1}`.padStart(2, '0')}
|
||||
</span>
|
||||
<span className="line-clamp-1">{entry.worldName}</span>
|
||||
<span className="line-clamp-1">
|
||||
{entry.worldName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1405,8 +1428,11 @@ export function PlatformHomeView({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-4 border-t border-white/5 pt-3"
|
||||
style={{ paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)' }}
|
||||
className="mt-4 border-t pt-3"
|
||||
style={{
|
||||
borderColor: 'var(--platform-line-soft)',
|
||||
paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)',
|
||||
}}
|
||||
>
|
||||
<div className="platform-bottom-nav grid h-14 grid-cols-4 gap-1 rounded-[1.2rem] px-1 py-1">
|
||||
<PlatformTabButton
|
||||
@@ -1441,10 +1467,7 @@ export function PlatformHomeView({
|
||||
<div className="platform-desktop-shell flex h-full min-h-0 flex-col p-5 xl:p-6">
|
||||
<div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-5">
|
||||
<PlatformBrandLogo
|
||||
className="shrink-0"
|
||||
decorative
|
||||
/>
|
||||
<PlatformBrandLogo className="shrink-0" decorative />
|
||||
<div className="platform-desktop-search flex min-w-0 max-w-[34rem] flex-1 items-center gap-3 px-4 py-3 text-zinc-400">
|
||||
<Search className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate text-sm">
|
||||
@@ -1467,11 +1490,17 @@ export function PlatformHomeView({
|
||||
onClick={openUserSurface}
|
||||
className="platform-desktop-search flex items-center gap-3 px-3 py-2.5 text-left"
|
||||
>
|
||||
<span className="flex h-11 w-11 items-center justify-center rounded-full bg-[linear-gradient(135deg,rgba(91,108,255,0.9),rgba(61,217,255,0.82))] text-base font-black text-white shadow-[0_10px_22px_rgba(91,108,255,0.24)]">
|
||||
<span
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full text-base font-black text-white"
|
||||
style={{
|
||||
background: 'var(--platform-profile-avatar-fill)',
|
||||
boxShadow: 'var(--platform-profile-avatar-shadow)',
|
||||
}}
|
||||
>
|
||||
{avatarLabel}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-semibold text-white">
|
||||
<span className="block truncate text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{authUi?.user?.displayName || '进入账户'}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-zinc-400">
|
||||
|
||||
@@ -211,7 +211,6 @@ type TestAuthValue = {
|
||||
openSettingsModal: (section?: PlatformSettingsSection) => void;
|
||||
openAccountModal: () => void;
|
||||
logout: () => Promise<void>;
|
||||
setGlobalAccountActionsVisible: (visible: boolean) => void;
|
||||
musicVolume: number;
|
||||
setMusicVolume: (value: number) => void;
|
||||
platformTheme: 'light' | 'dark';
|
||||
@@ -229,7 +228,6 @@ function createAuthValue(overrides: Partial<TestAuthValue> = {}): TestAuthValue
|
||||
openSettingsModal: () => {},
|
||||
openAccountModal: () => {},
|
||||
logout: async () => {},
|
||||
setGlobalAccountActionsVisible: () => {},
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: () => {},
|
||||
platformTheme: 'light',
|
||||
|
||||
Reference in New Issue
Block a user