Skip to content

Commit 3165d00

Browse files
committed
feat(world): 签名时刻三幕演出(宪法P1第三批·掌握一个概念)
- SignatureMoment:全产品唯一一级时刻——静默(1.5s)→事件(4.5s星亮+波纹)→余韵(2s),Esc/点击可打断(觉察原则),reduced-motion静帧版,achievement_unlocked音效 - useWorldEvents 扩展 signatureSeq/signatureConcept 事件通道 - useFeynmanAI 双挂载点接线:单次评估 overallScore≥75 与追问评估 understandingScore≥7.5 均视为概念掌握 - App.tsx 全局挂载(与 DiscoveryReveal 同级,仅事件发生时渲染DOM)
1 parent 7057ce0 commit 3165d00

4 files changed

Lines changed: 198 additions & 1 deletion

File tree

‎client/src/App.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { RETURN_THRESHOLD_MS } from '@/features/assistant/constants';
2727
// 留存机制:初始化 hook(store 加载)+ 深海发现弹窗(全局 portal)
2828
import { useRetentionInit } from '@/features/retention/hooks/useRetentionInit';
2929
import { DiscoveryReveal } from '@/features/retention/components/DiscoveryReveal';
30+
import { SignatureMoment } from '@/features/retention/components/SignatureMoment';
3031
import '@/stores/useSettingsStore'; // 导入以触发音效设置初始化
3132
import { startPomodoroScheduler } from '@/features/pomodoro/lib/pomodoroScheduler';
3233

@@ -153,6 +154,9 @@ function App() {
153154
{/* 留存机制:深海发现揭示弹窗(createPortal 到 body,与 AchievementToast 同级)
154155
仅当 discoveryStore 有待展示发现时才渲染实际 DOM */}
155156
<DiscoveryReveal />
157+
{/* 签名时刻(宪法第三条):掌握一个概念时的三幕演出,
158+
由世界事件总线驱动,仅事件发生时渲染 DOM */}
159+
<SignatureMoment />
156160
</ErrorBoundary>
157161
</SyncProvider>
158162
</AuthProvider>

‎client/src/features/feynman/hooks/useFeynmanAI.tsx‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { MessageCircle, Lightbulb, SearchCheck } from 'lucide-react';
1414
import { useToast, type ContextMenuGroup } from '@/components/ui';
1515
import { useContextMenu } from '@/lib/contextMenu';
1616
import { useAIEvaluate, useAIFeynmanQuestion, useAIFeynmanEvaluateAnswers } from '@/lib/ai/useAI';
17+
import { useWorldEvents } from '@/features/retention/store/useWorldEvents';
1718
import { useAIErrorHandler } from '@/lib/ai/hooks/useAIErrorHandler';
1819
import { useStuckTimer } from '@/hooks/useStuckTimer';
1920
import { assistantEventBus } from '@/features/assistant/lib/eventBus';
@@ -155,7 +156,13 @@ export function useFeynmanAI(note: FeynmanNote | null) {
155156
}
156157
setShowAIEval(true);
157158
aiEvaluate(note.concept, note.explanation)
158-
.then((result) => { persistAIResult({ evalResult: result }); })
159+
.then((result) => {
160+
persistAIResult({ evalResult: result });
161+
// 宪法第三条签名时刻挂载点:评估通过(≥75)= 掌握一个概念
162+
if (result && result.overallScore >= 75) {
163+
useWorldEvents.getState().emitSignatureMoment(note.concept);
164+
}
165+
})
159166
.catch(handleEvalError);
160167
}, [note, toast, aiEvaluate, handleEvalError, persistAIResult]);
161168

@@ -182,6 +189,10 @@ export function useFeynmanAI(note: FeynmanNote | null) {
182189
const evalResult = await aiEvaluateAnswers(note.concept, questions, answers).catch(handleEvalError);
183190
if (evalResult) {
184191
persistAIResult({ answers, answerEvalData: evalResult });
192+
// 追问后评估通过同样视为概念掌握(签名时刻挂载点;0-10 制,7.5≈百分制 75)
193+
if (evalResult.understandingScore >= 7.5) {
194+
useWorldEvents.getState().emitSignatureMoment(note.concept);
195+
}
185196
}
186197
}, [note, aiQuestionData, localAnswers, toast, aiEvaluateAnswers, handleEvalError, persistAIResult]);
187198

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/**
2+
* SignatureMoment — 签名时刻(宪法第三条:全产品唯一一级时刻)
3+
* SignatureMoment — the one signature moment (constitution §3)
4+
*
5+
* @ai-context: 挂载点=掌握一个概念(费曼评估通过/卡片牢固)。三幕结构:
6+
* 第一幕·静默(1.5s 世界安静下来)→ 第二幕·事件(4.5s 星亮起+波纹+连线)
7+
* → 第三幕·余韵(2s 自动消散,痕迹已由 OrderRipples/星图永久保留)。
8+
* 约束:Esc/点击可打断(觉察原则);reduced-motion 降级为 3s 静帧卡;
9+
* 声音占一半(achievement_unlocked);同屏仅一次(新事件重置计时)。
10+
*
11+
* @ai-context: Three-act overlay driven by useWorldEvents.signatureSeq.
12+
* Interruptible (Esc/click), reduced-motion static fallback, sound included.
13+
*/
14+
import { useEffect, useMemo, useRef, useState } from 'react';
15+
import { createPortal } from 'react-dom';
16+
import { AnimatePresence, motion } from 'framer-motion';
17+
import { useWorldEvents } from '../store/useWorldEvents';
18+
import { soundPlayer } from '@/lib/audio/SoundPlayer';
19+
20+
/** 三幕时长(毫秒)/ Act durations */
21+
const ACT1_MS = 1500;
22+
const ACT2_MS = 4500;
23+
const ACT3_MS = 2000;
24+
/** reduced-motion 静帧版展示时长 / Static fallback duration */
25+
const STATIC_MS = 3000;
26+
27+
type Act = 'silence' | 'event' | 'afterglow';
28+
29+
export function SignatureMoment() {
30+
const signatureSeq = useWorldEvents((s) => s.signatureSeq);
31+
const signatureConcept = useWorldEvents((s) => s.signatureConcept);
32+
const [active, setActive] = useState(false);
33+
const [act, setAct] = useState<Act>('silence');
34+
const [concept, setConcept] = useState('');
35+
const timers = useRef<Array<ReturnType<typeof setTimeout>>>([]);
36+
const reduced = useMemo(
37+
() => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches,
38+
[],
39+
);
40+
41+
const clearTimers = () => {
42+
timers.current.forEach(clearTimeout);
43+
timers.current = [];
44+
};
45+
46+
const dismiss = () => {
47+
clearTimers();
48+
setActive(false);
49+
setAct('silence');
50+
};
51+
52+
// 序列号驱动:新事件重置计时(可变重奏的入口,变体库 P2 扩展)
53+
useEffect(() => {
54+
if (signatureSeq === 0) return;
55+
clearTimers();
56+
setConcept(signatureConcept);
57+
setActive(true);
58+
59+
if (reduced) {
60+
// 静帧版:跳过动效,直达事件文案,3s 后消散
61+
setAct('event');
62+
soundPlayer.play('achievement_unlocked');
63+
timers.current.push(setTimeout(dismiss, STATIC_MS));
64+
return;
65+
}
66+
67+
setAct('silence');
68+
soundPlayer.play('achievement_unlocked');
69+
timers.current.push(setTimeout(() => setAct('event'), ACT1_MS));
70+
timers.current.push(setTimeout(() => setAct('afterglow'), ACT1_MS + ACT2_MS));
71+
timers.current.push(setTimeout(dismiss, ACT1_MS + ACT2_MS + ACT3_MS));
72+
73+
return clearTimers;
74+
// eslint-disable-next-line react-hooks/exhaustive-deps
75+
}, [signatureSeq]);
76+
77+
// Esc 打断(觉察原则:仪式服务人,不绑架人)
78+
useEffect(() => {
79+
if (!active) return;
80+
const onKey = (e: KeyboardEvent) => {
81+
if (e.key === 'Escape') dismiss();
82+
};
83+
window.addEventListener('keydown', onKey);
84+
return () => window.removeEventListener('keydown', onKey);
85+
// eslint-disable-next-line react-hooks/exhaustive-deps
86+
}, [active]);
87+
88+
if (!active) return null;
89+
90+
return createPortal(
91+
<AnimatePresence>
92+
<motion.div
93+
key={`signature-${signatureSeq}`}
94+
onClick={dismiss}
95+
initial={{ opacity: 0 }}
96+
animate={{ opacity: 1 }}
97+
exit={{ opacity: 0, transition: { duration: 0.2 } }}
98+
style={{
99+
position: 'fixed', inset: 0, zIndex: 60, cursor: 'pointer',
100+
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
101+
background: 'radial-gradient(circle at 50% 42%, rgba(10,16,30,0.55), rgba(6,10,20,0.82) 75%)',
102+
backdropFilter: 'blur(3px)',
103+
}}
104+
>
105+
{/* 第一幕提示:世界安静下来 */}
106+
<AnimatePresence mode="wait">
107+
{act === 'silence' && !reduced && (
108+
<motion.div
109+
key="silence"
110+
initial={{ opacity: 0 }}
111+
animate={{ opacity: 1 }}
112+
exit={{ opacity: 0 }}
113+
style={{ fontSize: 13, letterSpacing: 6, color: '#90A0B8' }}
114+
>
115+
世 界 安 静 下 来
116+
</motion.div>
117+
)}
118+
119+
{(act === 'event' || act === 'afterglow') && (
120+
<motion.div
121+
key="event"
122+
initial={{ opacity: 0, scale: 0.94 }}
123+
animate={{ opacity: act === 'afterglow' ? 0.85 : 1, scale: 1 }}
124+
transition={{ type: 'spring', stiffness: 200, damping: 26 }}
125+
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 26, textAlign: 'center', padding: '0 32px' }}
126+
>
127+
{/* 亮起的星 + 秩序波纹(痕迹由场景层永久保留,此处是演出) */}
128+
<div style={{ position: 'relative', width: 120, height: 120 }}>
129+
{!reduced && [0, 0.5, 1].map((d) => (
130+
<motion.span
131+
key={d}
132+
initial={{ scale: 0.2, opacity: 0.8 }}
133+
animate={{ scale: 2.6, opacity: 0 }}
134+
transition={{ duration: 2, delay: d, ease: 'easeOut', repeat: Infinity }}
135+
style={{ position: 'absolute', inset: 0, borderRadius: '50%', border: '1px solid #22D3EE' }}
136+
/>
137+
))}
138+
<div style={{
139+
position: 'absolute', inset: 44, borderRadius: '50%',
140+
background: 'radial-gradient(circle at 40% 36%, #E0F7FF, #67E8F9 60%, #0891B2)',
141+
boxShadow: '0 0 34px #22D3EE, 0 0 90px rgba(34,211,238,0.4)',
142+
}} />
143+
</div>
144+
145+
<div>
146+
<div style={{
147+
fontFamily: "'LXGW WenKai Lite','Noto Serif SC',serif",
148+
fontSize: 30, fontWeight: 700, letterSpacing: 2, color: '#E0E6F0',
149+
}}>
150+
「{concept || '这个概念'}」
151+
</div>
152+
<div style={{ fontSize: 14, color: '#90A0B8', marginTop: 12, letterSpacing: 3 }}>
153+
已成为你秩序的一部分
154+
</div>
155+
</div>
156+
157+
{act === 'afterglow' && !reduced && (
158+
<div style={{ fontSize: 11, color: '#607088', letterSpacing: 2 }}>
159+
痕迹已留在世界里 · 点击任意处返回
160+
</div>
161+
)}
162+
</motion.div>
163+
)}
164+
</AnimatePresence>
165+
</motion.div>
166+
</AnimatePresence>,
167+
document.body,
168+
);
169+
}

‎client/src/features/retention/store/useWorldEvents.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,28 @@ interface WorldEventsState {
2222
/** 最近一次波纹起源 / Latest ripple origin */
2323
rippleOrigin: RippleOrigin;
2424

25+
/** 签名时刻序列号(宪法第三条:掌握一个概念) / Signature moment counter */
26+
signatureSeq: number;
27+
/** 被掌握的概念名(签名时刻文案) / Mastered concept name */
28+
signatureConcept: string;
29+
2530
/** 发射秩序波纹(复习/掌握的即时正反馈) / Emit an order ripple */
2631
emitOrderRipple: (origin?: RippleOrigin) => void;
32+
/** 触发签名时刻(概念掌握:费曼评估通过/卡片牢固) / Trigger signature moment */
33+
emitSignatureMoment: (concept: string) => void;
2734
}
2835

2936
export const useWorldEvents = create<WorldEventsState>((set) => ({
3037
rippleSeq: 0,
3138
rippleOrigin: 'center',
39+
signatureSeq: 0,
40+
signatureConcept: '',
3241

3342
emitOrderRipple: (origin = 'center') => {
3443
set((s) => ({ rippleSeq: s.rippleSeq + 1, rippleOrigin: origin }));
3544
},
45+
46+
emitSignatureMoment: (concept) => {
47+
set((s) => ({ signatureSeq: s.signatureSeq + 1, signatureConcept: concept }));
48+
},
3649
}));

0 commit comments

Comments
 (0)