@@ -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',
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
scoreAttributeFit,
|
||||
} from './attributeResolver';
|
||||
import {
|
||||
getCharacterAdventureOpening,
|
||||
getCharacterById,
|
||||
getCharacterCombatStats,
|
||||
getCharacterEquipment,
|
||||
@@ -985,6 +986,76 @@ function getFirstContactRelationStance(npcState: NpcPersistentState) {
|
||||
return npcState.relationState?.stance ?? buildRelationState(npcState.affinity).stance;
|
||||
}
|
||||
|
||||
function ensureDialogueSentence(text: string | null | undefined) {
|
||||
const normalized = text?.trim() ?? '';
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return /[。!?!?]$/u.test(normalized) ? normalized : `${normalized}。`;
|
||||
}
|
||||
|
||||
export function buildNpcChatOpeningText(
|
||||
encounter: Encounter,
|
||||
npcState: NpcPersistentState,
|
||||
worldType: WorldType | null,
|
||||
recruitCharacterOverride?: Character | null,
|
||||
) {
|
||||
const recruitCharacter =
|
||||
recruitCharacterOverride ?? resolveEncounterRecruitCharacter(encounter);
|
||||
const opening = recruitCharacter
|
||||
? getCharacterAdventureOpening(recruitCharacter, worldType)
|
||||
: null;
|
||||
const stance = getFirstContactRelationStance(npcState);
|
||||
|
||||
if (isNpcFirstMeaningfulContact(encounter, npcState)) {
|
||||
const greeting =
|
||||
stance === 'guarded' ? '先打个招呼。' : '先和你打个招呼。';
|
||||
const surfaceHook = ensureDialogueSentence(opening?.surfaceHook);
|
||||
const immediateConcern = ensureDialogueSentence(opening?.immediateConcern);
|
||||
const guardedMotive = ensureDialogueSentence(opening?.guardedMotive);
|
||||
const fallbackLine =
|
||||
stance === 'bonded'
|
||||
? '这一步我既然亲自来了,就说明眼前这件事得先和你对齐。'
|
||||
: stance === 'cooperative'
|
||||
? '我先来和你碰个头,眼下这局势最好别各说各话。'
|
||||
: stance === 'neutral'
|
||||
? '我会出现在这里不是没有缘由,不过咱们最好先把眼前情况看清。'
|
||||
: '前面的动静不太对,我想先看看你会怎么开口。';
|
||||
|
||||
if (
|
||||
encounter.specialBehavior === 'camp_companion'
|
||||
|| encounter.specialBehavior === 'initial_companion'
|
||||
) {
|
||||
return [
|
||||
greeting,
|
||||
surfaceHook || immediateConcern || fallbackLine,
|
||||
surfaceHook && immediateConcern && surfaceHook !== immediateConcern
|
||||
? immediateConcern
|
||||
: null,
|
||||
guardedMotive,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
}
|
||||
|
||||
return [greeting, immediateConcern || surfaceHook || fallbackLine]
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
}
|
||||
|
||||
switch (stance) {
|
||||
case 'bonded':
|
||||
return '又见面了。你想先从哪件事接着说?';
|
||||
case 'cooperative':
|
||||
return '你开口吧,我先听听你想聊哪一件。';
|
||||
case 'neutral':
|
||||
return '先说吧,你想从哪里问起?';
|
||||
default:
|
||||
return '说吧,你想先问什么?';
|
||||
}
|
||||
}
|
||||
|
||||
export function getNpcFirstContactTopics(
|
||||
encounter: Encounter,
|
||||
npcState: NpcPersistentState,
|
||||
|
||||
@@ -169,10 +169,14 @@ export function getWorldAttributeSchema(
|
||||
customWorldProfile?: CustomWorldProfile | null,
|
||||
) {
|
||||
if (worldType === WorldType.CUSTOM && customWorldProfile) {
|
||||
return (
|
||||
resolveCustomWorldRuleProfile(customWorldProfile)?.attributeSchema
|
||||
?? customWorldProfile.attributeSchema
|
||||
);
|
||||
try {
|
||||
return (
|
||||
resolveCustomWorldRuleProfile(customWorldProfile)?.attributeSchema
|
||||
?? customWorldProfile.attributeSchema
|
||||
);
|
||||
} catch {
|
||||
return customWorldProfile.attributeSchema;
|
||||
}
|
||||
}
|
||||
|
||||
if (worldType === WorldType.XIANXIA) {
|
||||
|
||||
@@ -164,6 +164,44 @@ function createState(overrides: Partial<GameState> = {}): GameState {
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
function createSceneActProfile(
|
||||
primaryNpcId = 'npc-rival',
|
||||
): NonNullable<GameState['customWorldProfile']> {
|
||||
return {
|
||||
id: 'custom-world-scene-act-test',
|
||||
name: '断桥旧案',
|
||||
summary: '用于测试场景幕主角色聊天规则。',
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'scene-bridge-chapter',
|
||||
sceneId: 'scene-bridge',
|
||||
title: '断桥口',
|
||||
summary: '桥口旧账还没了结。',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: [],
|
||||
acts: [
|
||||
{
|
||||
id: 'scene-bridge-act-1',
|
||||
sceneId: 'scene-bridge',
|
||||
title: '对峙幕',
|
||||
summary: '玩家与断桥客正面碰头。',
|
||||
stageCoverage: ['opening'],
|
||||
backgroundImageSrc: '/bridge-act-1.png',
|
||||
encounterNpcIds: [primaryNpcId, 'npc-bystander'],
|
||||
primaryNpcId,
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '逼近断桥旧案的核心线索。',
|
||||
transitionHook: '桥下藏着还没灭的灯。',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as unknown as NonNullable<GameState['customWorldProfile']>;
|
||||
}
|
||||
|
||||
function createCurrentChatStory(): StoryMoment {
|
||||
return {
|
||||
text: '断桥客:你居然还敢来。\n你:我只是想把话说清楚。',
|
||||
@@ -195,6 +233,42 @@ function createCurrentChatStory(): StoryMoment {
|
||||
};
|
||||
}
|
||||
|
||||
function createLimitedPrimaryNpcChatStory(turnCount: number): StoryMoment {
|
||||
return {
|
||||
text: '断桥客还在压着不肯说完的话。',
|
||||
options: [
|
||||
createOption('npc_chat', '那你至少告诉我接下来该去哪', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'chat',
|
||||
}),
|
||||
],
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: '断桥客',
|
||||
text: '该听见的人还没到。',
|
||||
},
|
||||
{
|
||||
speaker: 'player',
|
||||
text: '你总得让我知道下一步该往哪边走。',
|
||||
},
|
||||
],
|
||||
npcChatState: {
|
||||
npcId: 'npc-rival',
|
||||
npcName: '断桥客',
|
||||
turnCount,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
sceneActId: 'scene-bridge-act-1',
|
||||
turnLimit: 5,
|
||||
remainingTurns: Math.max(0, 5 - turnCount),
|
||||
limitReason: 'negative_affinity',
|
||||
forceExitAfterTurn: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createQuest(id: string, title: string): QuestLogEntry {
|
||||
return {
|
||||
id,
|
||||
@@ -541,6 +615,170 @@ describe('npcEncounterActions', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('opens npc chat without injecting a local preset opening line', () => {
|
||||
const encounter = createEncounter();
|
||||
streamNpcChatTurnMock.mockResolvedValueOnce({
|
||||
affinityDelta: 0,
|
||||
affinityText: '这轮对话暂时没有带来明显关系变化。',
|
||||
npcReply: '先站住。你想从哪一句开始问,我先听听。',
|
||||
suggestions: ['我先问桥上出了什么事'],
|
||||
});
|
||||
const actions = createNpcEncounterActions({
|
||||
gameState: createState({
|
||||
currentEncounter: encounter,
|
||||
npcInteractionActive: false,
|
||||
}),
|
||||
currentStory: {
|
||||
text: '断桥客站在风口,等你先挑明来意。',
|
||||
options: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(actions.enterNpcInteraction(encounter, '与断桥客搭话')).toBe(true);
|
||||
|
||||
const nextStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
|
||||
expect(nextStory.displayMode).toBe('dialogue');
|
||||
expect(nextStory.dialogue ?? []).toEqual([]);
|
||||
expect(nextStory.text).toBe('');
|
||||
expect(nextStory.npcChatState).toMatchObject({
|
||||
npcId: 'npc-rival',
|
||||
npcName: '断桥客',
|
||||
turnCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('streams a model-driven npc-initiated opening on first meaningful contact', async () => {
|
||||
const encounter = createEncounter();
|
||||
streamNpcChatTurnMock.mockResolvedValueOnce({
|
||||
affinityDelta: 0,
|
||||
affinityText: '这轮对话暂时没有带来明显关系变化。',
|
||||
npcReply: '先别急着拔话头。桥上的风向刚变,我得先确认你是来问旧账,还是来救人。',
|
||||
suggestions: ['我先听你说桥上出了什么事', '你先说你在防谁', '我不是来翻旧账的'],
|
||||
});
|
||||
|
||||
const actions = createNpcEncounterActions({
|
||||
gameState: createState({
|
||||
currentEncounter: encounter,
|
||||
npcInteractionActive: false,
|
||||
npcStates: {
|
||||
'npc-rival': {
|
||||
affinity: 8,
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
firstMeaningfulContactResolved: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
currentStory: {
|
||||
text: '断桥客站在风口,等你先挑明来意。',
|
||||
options: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(actions.enterNpcInteraction(encounter, '与断桥客搭话')).toBe(true);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(streamNpcChatTurnMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
id: 'npc-rival',
|
||||
}),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
[],
|
||||
'【NPC 主动开场】',
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
npcInitiatesConversation: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
|
||||
expect(lastStory.streaming).toBe(false);
|
||||
expect(lastStory.dialogue).toEqual([
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: '断桥客',
|
||||
text: '先别急着拔话头。桥上的风向刚变,我得先确认你是来问旧账,还是来救人。',
|
||||
},
|
||||
]);
|
||||
expect(lastStory.npcChatState).toMatchObject({
|
||||
npcId: 'npc-rival',
|
||||
openingSource: 'npc_initiated',
|
||||
turnCount: 0,
|
||||
});
|
||||
expect(lastStory.options.map((option) => option.actionText)).toEqual([
|
||||
'我先听你说桥上出了什么事',
|
||||
'你先说你在防谁',
|
||||
'我不是来翻旧账的',
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes any prefilled local opening line before the first model-driven npc reply', async () => {
|
||||
streamNpcChatTurnMock.mockResolvedValueOnce({
|
||||
affinityDelta: 1,
|
||||
affinityText: '断桥客的语气稍微松了一点。',
|
||||
npcReply: '先打个招呼。你盯着我看了这么久,总得先告诉我你想问哪一层。',
|
||||
suggestions: ['我先问你刚才在防谁'],
|
||||
});
|
||||
const actions = createNpcEncounterActions({
|
||||
currentStory: {
|
||||
text: '先和你打个招呼。前面的风不太对。',
|
||||
options: [
|
||||
createOption('npc_chat', '先问问你刚才在留意什么', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'chat',
|
||||
}),
|
||||
],
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: '断桥客',
|
||||
text: '先和你打个招呼。前面的风不太对。',
|
||||
},
|
||||
],
|
||||
npcChatState: {
|
||||
npcId: 'npc-rival',
|
||||
npcName: '断桥客',
|
||||
turnCount: 0,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
openingSource: 'player_reply',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
actions.handleNpcChatTurn(createEncounter(), '你刚才到底在看什么?'),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(streamNpcChatTurnMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
[],
|
||||
'你刚才到底在看什么?',
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
|
||||
expect(
|
||||
lastStory.dialogue?.some((turn) =>
|
||||
turn.text.includes('先和你打个招呼。前面的风不太对。'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('passes the quest id through to the server runtime resolver for quest turn-in', async () => {
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
||||
hydratedSnapshot: {
|
||||
@@ -729,6 +967,134 @@ describe('npcEncounterActions', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('lets the current act primary npc enter limited chat even with negative affinity', () => {
|
||||
const encounter = createEncounter();
|
||||
streamNpcChatTurnMock.mockResolvedValueOnce({
|
||||
affinityDelta: 0,
|
||||
affinityText: '这轮对话暂时没有带来明显关系变化。',
|
||||
npcReply: '先把来意说清楚,我再决定要不要把后半句给你。',
|
||||
suggestions: ['你先说你到底在防谁'],
|
||||
});
|
||||
const actions = createNpcEncounterActions({
|
||||
gameState: createState({
|
||||
currentEncounter: encounter,
|
||||
customWorldProfile: createSceneActProfile(),
|
||||
npcInteractionActive: false,
|
||||
npcStates: {
|
||||
'npc-rival': {
|
||||
affinity: -8,
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
currentStory: {
|
||||
text: '断桥客停在桥口,像是在等你自己把话说出来。',
|
||||
options: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(actions.enterNpcInteraction(encounter, '与断桥客搭话')).toBe(true);
|
||||
|
||||
const nextStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
|
||||
expect(nextStory.npcChatState).toMatchObject({
|
||||
npcId: 'npc-rival',
|
||||
sceneActId: 'scene-bridge-act-1',
|
||||
turnLimit: 5,
|
||||
remainingTurns: 5,
|
||||
limitReason: 'negative_affinity',
|
||||
});
|
||||
expect(
|
||||
nextStory.options.some((option) => option.functionId === 'npc_fight'),
|
||||
).toBe(false);
|
||||
expect(
|
||||
nextStory.options.some(
|
||||
(option) => option.functionId === 'battle_escape_breakout',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('force exits limited hostile chat on the fifth turn and offers a continue option', async () => {
|
||||
streamNpcChatTurnMock.mockResolvedValueOnce({
|
||||
affinityDelta: 0,
|
||||
affinityText: '这轮对话暂时没有带来明显关系变化。',
|
||||
npcReply: '去城西废桥下找那盏没灭的灯。等你看见它,再来问我剩下那半句。',
|
||||
suggestions: [],
|
||||
chatDirective: {
|
||||
turnLimit: 5,
|
||||
remainingTurns: 0,
|
||||
forceExit: true,
|
||||
closingMode: 'foreshadow_close',
|
||||
},
|
||||
});
|
||||
|
||||
const actions = createNpcEncounterActions({
|
||||
gameState: createState({
|
||||
customWorldProfile: createSceneActProfile(),
|
||||
npcStates: {
|
||||
'npc-rival': {
|
||||
affinity: -12,
|
||||
helpUsed: false,
|
||||
chattedCount: 4,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
currentStory: createLimitedPrimaryNpcChatStory(4),
|
||||
});
|
||||
|
||||
await expect(
|
||||
actions.handleNpcChatTurn(
|
||||
createEncounter(),
|
||||
'那你至少告诉我,接下来该去哪里找答案。',
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(streamNpcChatTurnMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
id: 'npc-rival',
|
||||
}),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
'那你至少告诉我,接下来该去哪里找答案。',
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
questOfferContext: null,
|
||||
chatDirective: expect.objectContaining({
|
||||
sceneActId: 'scene-bridge-act-1',
|
||||
turnLimit: 5,
|
||||
remainingTurns: 0,
|
||||
limitReason: 'negative_affinity',
|
||||
forceExitAfterTurn: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
|
||||
expect(lastStory.npcChatState).toBeUndefined();
|
||||
expect(lastStory.options).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'story_continue_adventure',
|
||||
actionText: '继续',
|
||||
}),
|
||||
]);
|
||||
expect(lastStory.dialogue?.at(-1)).toEqual(
|
||||
expect.objectContaining({
|
||||
speaker: 'system',
|
||||
text: '这轮交谈先在这里收束,对方留下的线索把你推向了下一步。',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('offers a pending quest after enough warmup chat turns with a positive-affinity npc', async () => {
|
||||
const pendingQuest = createQuest('quest-bridge-offer', '断桥口的密信');
|
||||
streamNpcChatTurnMock.mockResolvedValueOnce({
|
||||
|
||||
@@ -21,6 +21,9 @@ import { resolveFunctionOption } from '../../data/stateFunctions';
|
||||
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
||||
import { streamNpcChatTurn } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import {
|
||||
resolveLimitedPrimaryNpcChatState,
|
||||
} from '../../services/customWorldSceneActRuntime';
|
||||
import { generateQuestForNpcEncounter } from '../../services/questDirector';
|
||||
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
@@ -74,6 +77,14 @@ type BuildStoryContextExtras = {
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
};
|
||||
|
||||
type NpcChatDirective = {
|
||||
sceneActId?: string | null;
|
||||
turnLimit?: number | null;
|
||||
remainingTurns?: number | null;
|
||||
limitReason?: 'negative_affinity' | null;
|
||||
forceExitAfterTurn?: boolean;
|
||||
} | null;
|
||||
|
||||
function isNpcEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter {
|
||||
@@ -103,6 +114,7 @@ export function createStoryNpcEncounterActions({
|
||||
generateStoryForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getAvailableOptionsForState,
|
||||
buildContinueAdventureOption,
|
||||
getNpcEncounterKey,
|
||||
getResolvedNpcState,
|
||||
updateNpcState,
|
||||
@@ -587,9 +599,11 @@ export function createStoryNpcEncounterActions({
|
||||
options: StoryOption[];
|
||||
streaming: boolean;
|
||||
turnCount: number;
|
||||
chatDirective?: NpcChatDirective;
|
||||
pendingQuestOffer?: {
|
||||
quest: QuestLogEntry;
|
||||
} | null;
|
||||
openingSource?: 'npc_initiated' | 'player_reply';
|
||||
}): StoryMoment => ({
|
||||
text: params.dialogue.map((turn) => turn.text).join('\n'),
|
||||
options: params.options,
|
||||
@@ -601,6 +615,12 @@ export function createStoryNpcEncounterActions({
|
||||
npcName: params.encounter.npcName,
|
||||
turnCount: params.turnCount,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
openingSource: params.openingSource ?? 'player_reply',
|
||||
sceneActId: params.chatDirective?.sceneActId ?? null,
|
||||
turnLimit: params.chatDirective?.turnLimit ?? null,
|
||||
remainingTurns: params.chatDirective?.remainingTurns ?? null,
|
||||
limitReason: params.chatDirective?.limitReason ?? null,
|
||||
forceExitAfterTurn: params.chatDirective?.forceExitAfterTurn ?? false,
|
||||
pendingQuestOffer: params.pendingQuestOffer ?? null,
|
||||
},
|
||||
});
|
||||
@@ -622,17 +642,51 @@ export function createStoryNpcEncounterActions({
|
||||
});
|
||||
};
|
||||
|
||||
const buildNpcChatOpeningDialogue = (encounter: Encounter) =>
|
||||
const buildLegacyNpcChatOpeningPlaceholder = (encounter: Encounter) =>
|
||||
`${encounter.npcName}看着你,像是在等你把话接下去。`;
|
||||
|
||||
const sanitizeNpcChatDialogueHistory = (
|
||||
encounter: Encounter,
|
||||
dialogue: NonNullable<StoryMoment['dialogue']>,
|
||||
turnCount: number,
|
||||
openingSource?: StoryMoment['npcChatState'] extends infer T
|
||||
? T extends { openingSource?: infer U }
|
||||
? U
|
||||
: never
|
||||
: never,
|
||||
) => {
|
||||
const legacyOpeningText = buildLegacyNpcChatOpeningPlaceholder(encounter);
|
||||
|
||||
return dialogue.filter((turn, index) => {
|
||||
if (index !== 0 || turn.speaker !== 'npc') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (turn.text.trim() === legacyOpeningText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (turnCount === 0 && dialogue.length === 1) {
|
||||
return openingSource === 'npc_initiated';
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const buildNpcChatDialogueHistory = (
|
||||
encounter: Encounter,
|
||||
turnCount: number,
|
||||
) =>
|
||||
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName) &&
|
||||
currentStory.dialogue
|
||||
? [...currentStory.dialogue]
|
||||
: [
|
||||
{
|
||||
speaker: 'npc' as const,
|
||||
speakerName: encounter.npcName,
|
||||
text: `${encounter.npcName}看着你,像是在等你把话接下去。`,
|
||||
},
|
||||
];
|
||||
? sanitizeNpcChatDialogueHistory(
|
||||
encounter,
|
||||
currentStory.dialogue,
|
||||
turnCount,
|
||||
currentStory.npcChatState?.openingSource,
|
||||
)
|
||||
: [];
|
||||
|
||||
const buildHostileNpcDeclarationText = (
|
||||
encounter: Encounter,
|
||||
@@ -744,8 +798,10 @@ export function createStoryNpcEncounterActions({
|
||||
encounter: Encounter,
|
||||
selectedOption: StoryOption,
|
||||
extraOptions: StoryOption[] = [],
|
||||
chatDirective?: NpcChatDirective,
|
||||
openingSource: 'npc_initiated' | 'player_reply' = 'player_reply',
|
||||
) => {
|
||||
const openingDialogue = buildNpcChatOpeningDialogue(encounter);
|
||||
const openingDialogue = buildNpcChatDialogueHistory(encounter, 0);
|
||||
|
||||
setAiError(null);
|
||||
setCurrentStory(
|
||||
@@ -759,11 +815,144 @@ export function createStoryNpcEncounterActions({
|
||||
),
|
||||
streaming: false,
|
||||
turnCount: 0,
|
||||
chatDirective,
|
||||
openingSource,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
const startNpcInitiatedOpening = async (
|
||||
encounter: Encounter,
|
||||
selectedOption: StoryOption,
|
||||
extraOptions: StoryOption[] = [],
|
||||
chatDirective?: NpcChatDirective,
|
||||
) => {
|
||||
const playerCharacter = gameState.playerCharacter;
|
||||
if (!playerCharacter || !gameState.worldType) {
|
||||
return enterNpcChat(
|
||||
encounter,
|
||||
selectedOption,
|
||||
extraOptions,
|
||||
chatDirective,
|
||||
'npc_initiated',
|
||||
);
|
||||
}
|
||||
|
||||
const npcState = getResolvedNpcState(gameState, encounter);
|
||||
const openingCampContext = buildOpeningCampChatContext(
|
||||
gameState,
|
||||
playerCharacter,
|
||||
encounter,
|
||||
);
|
||||
const existingDialogue = buildNpcChatDialogueHistory(encounter, 0);
|
||||
const openingOptions = buildNpcChatEntryOptions(
|
||||
encounter,
|
||||
selectedOption,
|
||||
extraOptions,
|
||||
);
|
||||
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
setCurrentStory(
|
||||
buildNpcChatStoryMoment({
|
||||
encounter,
|
||||
dialogue: existingDialogue,
|
||||
options: [],
|
||||
streaming: true,
|
||||
turnCount: 0,
|
||||
chatDirective,
|
||||
openingSource: 'npc_initiated',
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
const chatTurn = await streamNpcChatTurn(
|
||||
gameState.worldType,
|
||||
playerCharacter,
|
||||
encounter,
|
||||
getStoryGenerationHostileNpcs(gameState),
|
||||
gameState.storyHistory,
|
||||
buildStoryContextFromState(gameState, {
|
||||
lastFunctionId: 'npc_chat',
|
||||
...openingCampContext,
|
||||
encounterNpcStateOverride: npcState,
|
||||
}),
|
||||
existingDialogue,
|
||||
'【NPC 主动开场】',
|
||||
{
|
||||
affinity: npcState.affinity,
|
||||
chattedCount: npcState.chattedCount,
|
||||
recruited: npcState.recruited,
|
||||
},
|
||||
{
|
||||
onReplyUpdate: (text) => {
|
||||
setCurrentStory(
|
||||
buildNpcChatStoryMoment({
|
||||
encounter,
|
||||
dialogue: [
|
||||
...existingDialogue,
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: encounter.npcName,
|
||||
text,
|
||||
},
|
||||
],
|
||||
options: [],
|
||||
streaming: true,
|
||||
turnCount: 0,
|
||||
chatDirective,
|
||||
openingSource: 'npc_initiated',
|
||||
}),
|
||||
);
|
||||
},
|
||||
chatDirective,
|
||||
npcInitiatesConversation: true,
|
||||
},
|
||||
);
|
||||
if (!chatTurn?.npcReply?.trim()) {
|
||||
throw new Error('NPC 主动开场结果为空');
|
||||
}
|
||||
|
||||
setCurrentStory(
|
||||
buildNpcChatStoryMoment({
|
||||
encounter,
|
||||
dialogue: [
|
||||
...existingDialogue,
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: encounter.npcName,
|
||||
text: chatTurn.npcReply,
|
||||
},
|
||||
],
|
||||
options: buildNpcChatTurnOptions(
|
||||
encounter,
|
||||
chatTurn.suggestions.length > 0
|
||||
? chatTurn.suggestions
|
||||
: openingOptions.map((option) => option.actionText),
|
||||
),
|
||||
streaming: false,
|
||||
turnCount: 0,
|
||||
chatDirective,
|
||||
openingSource: 'npc_initiated',
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to start npc initiated opening:', error);
|
||||
setAiError(error instanceof Error ? error.message : 'NPC 主动开场失败');
|
||||
return enterNpcChat(
|
||||
encounter,
|
||||
selectedOption,
|
||||
extraOptions,
|
||||
chatDirective,
|
||||
'npc_initiated',
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNpcChatTurn = async (
|
||||
encounter: Encounter,
|
||||
playerMessage: string,
|
||||
@@ -780,7 +969,12 @@ export function createStoryNpcEncounterActions({
|
||||
: null;
|
||||
const existingDialogue =
|
||||
currentStory?.dialogue && currentNpcChatState
|
||||
? [...currentStory.dialogue]
|
||||
? sanitizeNpcChatDialogueHistory(
|
||||
encounter,
|
||||
currentStory.dialogue,
|
||||
currentNpcChatState.turnCount ?? 0,
|
||||
currentNpcChatState.openingSource,
|
||||
)
|
||||
: [];
|
||||
const dialogueWithPlayer = [
|
||||
...existingDialogue,
|
||||
@@ -790,6 +984,12 @@ export function createStoryNpcEncounterActions({
|
||||
},
|
||||
];
|
||||
const nextTurnCount = (currentNpcChatState?.turnCount ?? 0) + 1;
|
||||
const limitedChatDirective = resolveLimitedPrimaryNpcChatState({
|
||||
state: gameState,
|
||||
npcId: encounter.id ?? encounter.npcName,
|
||||
affinity: npcState.affinity,
|
||||
nextTurnCount,
|
||||
});
|
||||
const openingCampContext = buildOpeningCampChatContext(
|
||||
gameState,
|
||||
playerCharacter,
|
||||
@@ -805,6 +1005,7 @@ export function createStoryNpcEncounterActions({
|
||||
options: [],
|
||||
streaming: true,
|
||||
turnCount: nextTurnCount,
|
||||
chatDirective: limitedChatDirective,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -843,13 +1044,17 @@ export function createStoryNpcEncounterActions({
|
||||
options: [],
|
||||
streaming: true,
|
||||
turnCount: nextTurnCount,
|
||||
chatDirective: limitedChatDirective,
|
||||
}),
|
||||
);
|
||||
},
|
||||
questOfferContext: {
|
||||
state: gameState,
|
||||
turnCount: nextTurnCount,
|
||||
},
|
||||
questOfferContext: limitedChatDirective
|
||||
? null
|
||||
: {
|
||||
state: gameState,
|
||||
turnCount: nextTurnCount,
|
||||
},
|
||||
chatDirective: limitedChatDirective,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -912,8 +1117,45 @@ export function createStoryNpcEncounterActions({
|
||||
const pendingQuest =
|
||||
(chatTurn.pendingQuestOffer?.quest as QuestLogEntry | undefined) ??
|
||||
null;
|
||||
const resolvedChatDirective = limitedChatDirective
|
||||
? {
|
||||
sceneActId: limitedChatDirective.sceneActId ?? null,
|
||||
turnLimit:
|
||||
chatTurn.chatDirective?.turnLimit ??
|
||||
limitedChatDirective.turnLimit ??
|
||||
null,
|
||||
remainingTurns:
|
||||
chatTurn.chatDirective?.remainingTurns ??
|
||||
limitedChatDirective.remainingTurns ??
|
||||
null,
|
||||
limitReason: limitedChatDirective.limitReason ?? null,
|
||||
forceExitAfterTurn:
|
||||
chatTurn.chatDirective?.forceExit ??
|
||||
limitedChatDirective.forceExitAfterTurn ??
|
||||
false,
|
||||
}
|
||||
: null;
|
||||
const shouldForceExitAfterTurn =
|
||||
resolvedChatDirective?.forceExitAfterTurn === true;
|
||||
const pendingQuestIntroText =
|
||||
chatTurn.pendingQuestOffer?.introText?.trim() || '';
|
||||
if (shouldForceExitAfterTurn) {
|
||||
const closingDialogue = [
|
||||
...nextDialogue,
|
||||
{
|
||||
speaker: 'system' as const,
|
||||
text: '这轮交谈先在这里收束,对方留下的线索把你推向了下一步。',
|
||||
},
|
||||
];
|
||||
setCurrentStory({
|
||||
text: closingDialogue.map((turn) => turn.text).join('\n'),
|
||||
options: [buildContinueAdventureOption()],
|
||||
displayMode: 'dialogue',
|
||||
dialogue: closingDialogue,
|
||||
streaming: false,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (pendingQuest) {
|
||||
setCurrentStory(
|
||||
buildNpcChatStoryMoment({
|
||||
@@ -931,6 +1173,7 @@ export function createStoryNpcEncounterActions({
|
||||
options: buildPendingQuestOfferOptions(encounter),
|
||||
streaming: false,
|
||||
turnCount: nextTurnCount,
|
||||
chatDirective: resolvedChatDirective,
|
||||
pendingQuestOffer: {
|
||||
quest: pendingQuest,
|
||||
},
|
||||
@@ -951,6 +1194,7 @@ export function createStoryNpcEncounterActions({
|
||||
),
|
||||
streaming: false,
|
||||
turnCount: nextTurnCount,
|
||||
chatDirective: resolvedChatDirective,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
@@ -967,6 +1211,7 @@ export function createStoryNpcEncounterActions({
|
||||
),
|
||||
streaming: false,
|
||||
turnCount: nextTurnCount,
|
||||
chatDirective: limitedChatDirective,
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
@@ -1041,7 +1286,14 @@ export function createStoryNpcEncounterActions({
|
||||
setGameState(nextState);
|
||||
setAiError(null);
|
||||
|
||||
if (npcState.affinity < 0 || encounter.hostile) {
|
||||
const limitedChatDirective = resolveLimitedPrimaryNpcChatState({
|
||||
state: nextState,
|
||||
npcId: encounter.id ?? encounter.npcName,
|
||||
affinity: npcState.affinity,
|
||||
nextTurnCount: 0,
|
||||
});
|
||||
|
||||
if ((npcState.affinity < 0 || encounter.hostile) && !limitedChatDirective) {
|
||||
setCurrentStory(
|
||||
buildHostileNpcStoryMoment(
|
||||
encounter,
|
||||
@@ -1079,7 +1331,22 @@ export function createStoryNpcEncounterActions({
|
||||
},
|
||||
} satisfies StoryOption);
|
||||
|
||||
return enterNpcChat(encounter, seedChatOption, chatOptions.slice(1));
|
||||
if (!currentStory?.npcChatState && !npcState.firstMeaningfulContactResolved) {
|
||||
void startNpcInitiatedOpening(
|
||||
encounter,
|
||||
seedChatOption,
|
||||
chatOptions.slice(1),
|
||||
limitedChatDirective,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return enterNpcChat(
|
||||
encounter,
|
||||
seedChatOption,
|
||||
chatOptions.slice(1),
|
||||
limitedChatDirective,
|
||||
);
|
||||
};
|
||||
|
||||
const resolveServerNpcStoryAction = async (params: {
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
import { appendChronicleEntries } from '../../services/storyEngine/storyChronicle';
|
||||
import { buildThemePackFromWorldProfile } from '../../services/storyEngine/themePack';
|
||||
import { buildThreadContractsFromProfile } from '../../services/storyEngine/threadContract';
|
||||
import { buildInitialSceneActRuntimeState } from '../../services/customWorldSceneActRuntime';
|
||||
import {
|
||||
collectStorySignals,
|
||||
resolveSignalsToThreadUpdates,
|
||||
@@ -216,6 +217,12 @@ function ensureSceneChapterQuestState(params: {
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
openedSceneChapterIds,
|
||||
currentSceneActState:
|
||||
buildInitialSceneActRuntimeState({
|
||||
profile: params.nextState.customWorldProfile,
|
||||
sceneId: scene.id,
|
||||
storyEngineMemory,
|
||||
}) ?? storyEngineMemory.currentSceneActState ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -223,6 +230,12 @@ function ensureSceneChapterQuestState(params: {
|
||||
const nextMemory = {
|
||||
...storyEngineMemory,
|
||||
openedSceneChapterIds: [...openedSceneChapterIds, scene.id],
|
||||
currentSceneActState:
|
||||
buildInitialSceneActRuntimeState({
|
||||
profile: params.nextState.customWorldProfile,
|
||||
sceneId: scene.id,
|
||||
storyEngineMemory,
|
||||
}) ?? storyEngineMemory.currentSceneActState ?? null,
|
||||
};
|
||||
const existingChapterQuest = getChapterQuestForScene(
|
||||
params.nextState.quests,
|
||||
|
||||
@@ -73,6 +73,7 @@ function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
|
||||
return {
|
||||
id: 'camp-companion',
|
||||
kind: 'npc',
|
||||
characterId: 'sword-princess',
|
||||
npcName: '沈砺',
|
||||
npcDescription: '正靠在营地灯火旁观察风向。',
|
||||
npcAvatar: '/npc.png',
|
||||
@@ -152,9 +153,11 @@ describe('storyCampCompanion', () => {
|
||||
WorldType.WUXIA,
|
||||
);
|
||||
|
||||
expect(text).toContain('先和你打个招呼。');
|
||||
expect(text).toContain('我来这里,是为了确认旧路尽头到底出了什么事。');
|
||||
expect(text).toContain('沈砺:那就不要说得太快太多。');
|
||||
expect(text).toContain('眼下的风向不对,我们不能直接把底牌亮出来。');
|
||||
expect(text).toContain('我真正要找的东西,还不能让更多人知道。');
|
||||
expect(text).not.toContain('像是在等你把话接下去');
|
||||
});
|
||||
|
||||
it('summarizes the camp opening result with the current concern', () => {
|
||||
@@ -168,7 +171,7 @@ describe('storyCampCompanion', () => {
|
||||
expect(text).toContain('眼下的风向不对');
|
||||
});
|
||||
|
||||
it('keeps chat and recruit options while appending the travel action for camp openings', () => {
|
||||
it('keeps the opening camp options focused on继续交谈', () => {
|
||||
const buildNpcStory = vi.fn(() =>
|
||||
createStory('营地开场', [
|
||||
createOption('npc_chat', '继续交谈'),
|
||||
@@ -190,11 +193,7 @@ describe('storyCampCompanion', () => {
|
||||
createEncounter(),
|
||||
);
|
||||
|
||||
expect(options.map((option) => option.functionId)).toEqual([
|
||||
'npc_chat',
|
||||
'npc_recruit',
|
||||
'camp_travel_home_scene',
|
||||
]);
|
||||
expect(options.map((option) => option.functionId)).toEqual(['npc_chat']);
|
||||
});
|
||||
|
||||
it('uses AI follow-up options when the camp follow-up request succeeds and falls back on errors', async () => {
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
NPC_CHAT_FUNCTION,
|
||||
NPC_FIGHT_FUNCTION,
|
||||
NPC_LEAVE_FUNCTION,
|
||||
NPC_RECRUIT_FUNCTION,
|
||||
} from '../../data/functionCatalog';
|
||||
import { buildInitialNpcState } from '../../data/npcInteractions';
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
buildNpcChatOpeningText,
|
||||
} from '../../data/npcInteractions';
|
||||
import {
|
||||
getForwardScenePreset,
|
||||
getScenePresetById,
|
||||
@@ -57,15 +59,20 @@ export function buildInitialCompanionDialogueText(
|
||||
encounter: Encounter,
|
||||
worldType: WorldType | null,
|
||||
) {
|
||||
const opening = getCharacterAdventureOpening(character, worldType);
|
||||
const surfaceHook =
|
||||
opening?.surfaceHook ?? '这个地方与我来此的目的息息相关。';
|
||||
const immediateConcern =
|
||||
opening?.immediateConcern ?? '前方似乎有些不对劲,我们不能贸然前进。';
|
||||
const guardedMotive =
|
||||
opening?.guardedMotive ?? '我并非偶然来到这里,但我还不准备全盘托出。';
|
||||
|
||||
return `${encounter.npcName}看着你,先压低声音开口:“${immediateConcern}。${guardedMotive}”`;
|
||||
const resolvedEncounter =
|
||||
encounter.characterId === character.id
|
||||
? encounter
|
||||
: {
|
||||
...encounter,
|
||||
characterId: encounter.characterId ?? character.id,
|
||||
};
|
||||
const initialNpcState = buildInitialNpcState(resolvedEncounter, worldType);
|
||||
return buildNpcChatOpeningText(
|
||||
resolvedEncounter,
|
||||
initialNpcState,
|
||||
worldType,
|
||||
character,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCampCompanionOpeningResultText(
|
||||
|
||||
1353
src/index.css
1353
src/index.css
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,21 @@
|
||||
/**
|
||||
* 自定义世界角色资产工坊的“默认描述文本种子”主源。
|
||||
*
|
||||
* 这份脚本只负责一件事:
|
||||
* - 从当前角色对象已有字段里挑出最合适的文本,
|
||||
* 作为资产工坊输入框的初始默认值
|
||||
*
|
||||
* 它不负责:
|
||||
* - 直接调用 LLM 重新编译默认描述
|
||||
* - 直接生成图像模型 prompt
|
||||
* - 直接生成动作模型 prompt
|
||||
*
|
||||
* 当前真实调用状态:
|
||||
* - CustomWorldRoleAssetStudioModal 的初始默认值主链,来自本文件
|
||||
* - 也就是说,资产工坊页面打开时看到的“形象描述 / 动作描述”
|
||||
* 当前优先取这里的本地字段映射,而不是后端
|
||||
* /api/assets/character-prompts/generate 接口
|
||||
*/
|
||||
export type PromptDefaultRole = {
|
||||
name: string;
|
||||
title: string;
|
||||
@@ -19,10 +37,18 @@ export type CustomWorldRolePromptBundle = {
|
||||
scenePromptText: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 对角色字段做轻量清洗,确保作为输入框默认值时不会带多余空白。
|
||||
*/
|
||||
function cleanSeedText(value: string | undefined, maxLength: number) {
|
||||
return (value ?? '').replace(/\s+/gu, ' ').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按优先级选择第一条可用文本。
|
||||
*
|
||||
* 这里是非常轻量的本地回退逻辑,不做任何“重新创作”或 prompt 扩写。
|
||||
*/
|
||||
function pickFirstDescription(
|
||||
values: Array<string | undefined>,
|
||||
maxLength: number,
|
||||
@@ -37,6 +63,18 @@ function pickFirstDescription(
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 资产工坊默认文本映射规则。
|
||||
*
|
||||
* 规则分层:
|
||||
* - visualPromptText: 优先使用角色 visualDescription,其次 description
|
||||
* - animationPromptText: 优先使用 actionDescription,其次 combatStyle
|
||||
* - scenePromptText: 优先使用 sceneVisualDescription,其次 backstory
|
||||
*
|
||||
* 注意:
|
||||
* - 返回值只是“输入框默认文案”
|
||||
* - 正式图像 / 动作模型 prompt 还会在后端继续编译
|
||||
*/
|
||||
export function buildDefaultRolePromptBundle(
|
||||
role: PromptDefaultRole,
|
||||
): CustomWorldRolePromptBundle {
|
||||
|
||||
@@ -30,6 +30,11 @@ import {
|
||||
getFunctionById,
|
||||
getFunctionPromptDescription,
|
||||
} from '../data/stateFunctions';
|
||||
import type { StoryGenerationContext } from '../services/aiTypes';
|
||||
import { buildCustomWorldReferenceText } from '../services/customWorld';
|
||||
import { sanitizePromptNarrativeText } from '../services/narrativeLanguage';
|
||||
import { describeGoalStackForPrompt } from '../services/storyEngine/goalDirector';
|
||||
import { buildStoryPromptHistory } from '../services/storyHistory';
|
||||
import {
|
||||
Character,
|
||||
CharacterGender,
|
||||
@@ -40,11 +45,6 @@ import {
|
||||
StoryOption,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import type { StoryGenerationContext } from '../services/aiTypes';
|
||||
import { buildCustomWorldReferenceText } from '../services/customWorld';
|
||||
import { sanitizePromptNarrativeText } from '../services/narrativeLanguage';
|
||||
import { describeGoalStackForPrompt } from '../services/storyEngine/goalDirector';
|
||||
import { buildStoryPromptHistory } from '../services/storyHistory';
|
||||
|
||||
export const SYSTEM_PROMPT = `你是角色扮演 RPG 的剧情推进者,只能返回 JSON 对象,不能输出解释、markdown 或代码块。
|
||||
输出格式必须严格符合:
|
||||
@@ -1817,8 +1817,11 @@ export function buildStrictNpcChatDialoguePrompt(
|
||||
'不要让对方在聊天里推进交易、招募、切磋、战斗、送礼、求助、离开、继续前进、切换场景等其他 function。',
|
||||
'不要替玩家做选择,不要用建议句、命令句或诱导句把聊天写成别的行为入口。',
|
||||
'低揭示阶段时,宁可留钩子、先谈眼前局势,也不要把完整来历和目标一次说完。',
|
||||
context.isFirstMeaningfulContact
|
||||
? '如果这是第一次真正接触,对方第一次开口必须先用一句自然招呼或开场判断起手,不能写成“某人看着你,像是在等你把话接下去”这类第三人称占位旁白。'
|
||||
: null,
|
||||
'如果当前情景是初见或刚打完一轮冲突,优先写短句、观察句和试探句,不要写成正式自我介绍。',
|
||||
].join('\n\n');
|
||||
].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
export function buildNpcRecruitDialoguePrompt(
|
||||
|
||||
@@ -979,6 +979,7 @@ export async function streamNpcChatTurn(
|
||||
turnCount: number;
|
||||
} | null;
|
||||
chatDirective?: NpcChatTurnDirective | null;
|
||||
npcInitiatesConversation?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const payload = {
|
||||
@@ -993,6 +994,7 @@ export async function streamNpcChatTurn(
|
||||
dialogue: conversationHistory ?? [],
|
||||
playerMessage,
|
||||
npcState,
|
||||
npcInitiatesConversation: options.npcInitiatesConversation ?? false,
|
||||
questOfferContext: options.questOfferContext
|
||||
? {
|
||||
state: options.questOfferContext.state,
|
||||
|
||||
@@ -116,6 +116,7 @@ export interface StoryNpcChatState {
|
||||
npcName: string;
|
||||
turnCount: number;
|
||||
customInputPlaceholder?: string;
|
||||
openingSource?: 'npc_initiated' | 'player_reply';
|
||||
sceneActId?: string | null;
|
||||
turnLimit?: number | null;
|
||||
remainingTurns?: number | null;
|
||||
|
||||
Reference in New Issue
Block a user