Skip to content

Commit 34ed940

Browse files
committed
feat(world): 声景体系、疲劳共情与延时摄影开场(宪法P2收官)
- WorldSoundscape:双世界环境底噪(深海=雨声/穹顶=麦浪风声),音量0.12淡入淡出、后台自动静音、外观设置可开关(默认关) - FatigueEmpathy:连续深潜未真休息达阈值时弹出觉察式上浮建议——休息时长口径判定真休息(skip不重置),零负向语言可忽略 - WorldRecap:每日首次打开展示6秒世界生长摘要(珊瑚/深度/发现日间增量),跨日基线结算,零增长温柔文案 - fatigue.ts纯函数规则层+单测(阈值/真休息比例/无效值守卫) - App全局挂载三组件;宪法进度更新为P2完成
1 parent 5cb8427 commit 34ed940

9 files changed

Lines changed: 392 additions & 2 deletions

File tree

‎client/src/App.tsx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,12 @@ import { assistantEventBus } from '@/features/assistant/lib/eventBus';
2626
import { RETURN_THRESHOLD_MS } from '@/features/assistant/constants';
2727
// 留存机制:初始化 hook(store 加载)+ 深海发现弹窗(全局 portal)
2828
import { useRetentionInit } from '@/features/retention/hooks/useRetentionInit';
29+
import { useWorldSnapshotSync } from '@/features/retention/hooks/useWorldSnapshotSync';
2930
import { DiscoveryReveal } from '@/features/retention/components/DiscoveryReveal';
3031
import { SignatureMoment } from '@/features/retention/components/SignatureMoment';
32+
import { FatigueEmpathy } from '@/features/retention/components/FatigueEmpathy';
33+
import { WorldRecap } from '@/features/retention/components/WorldRecap';
34+
import { WorldSoundscape } from '@/lib/audio/WorldSoundscape';
3135
import '@/stores/useSettingsStore'; // 导入以触发音效设置初始化
3236
import { startPomodoroScheduler } from '@/features/pomodoro/lib/pomodoroScheduler';
3337

@@ -53,6 +57,8 @@ function App() {
5357
// 留存机制:在 Provider 内、路由外初始化所有留存 store
5458
// 放在此处确保无论用户从哪个路由进入,数据均已就绪
5559
useRetentionInit();
60+
// 世界状态快照同步:retention 数据→sqlite→MCP 记忆服务器(单向桥,失败静默)
61+
useWorldSnapshotSync();
5662

5763
// ── 启动缓冲带:接管 HTML 内联 splash,首次渲染完成后淡出 ──
5864
useLayoutEffect(() => {
@@ -157,6 +163,11 @@ function App() {
157163
{/* 签名时刻(宪法第三条):掌握一个概念时的三幕演出,
158164
由世界事件总线驱动,仅事件发生时渲染 DOM */}
159165
<SignatureMoment />
166+
{/* 宪法 P2:疲劳共情(觉察式建议)+ 延时摄影开场(每日生长摘要)
167+
+ 世界声景(双世界环境底噪,默认关闭可开启) */}
168+
<FatigueEmpathy />
169+
<WorldRecap />
170+
<WorldSoundscape />
160171
</ErrorBoundary>
161172
</SyncProvider>
162173
</AuthProvider>
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* FatigueEmpathy — 疲劳共情(宪法 P2 · 觉察而非管控)
3+
* FatigueEmpathy — gentle surfacing suggestion (awareness, not control)
4+
*
5+
* @ai-context: 监听番茄相位迁移:连续完成 2 个工作会话而未经历真实休息
6+
* (跳过休息不重置计数)时,弹一次可忽略的共情建议;真实休息即恢复。
7+
* 无阻断、无负向语言、toast 天然可关闭。
8+
*
9+
* @ai-context: Watches pomodoro phase transitions; suggests surfacing after
10+
* consecutive work sessions without a real break. Fully dismissible.
11+
*/
12+
import { useEffect, useRef } from 'react';
13+
import { usePomodoroStore } from '@/features/pomodoro/store/usePomodoroStore';
14+
import { useToast } from '@/components/ui/Toast';
15+
import { shouldSuggestSurfacing, wasRealBreak, SURFACE_SUGGESTION_TEXT } from '../lib/fatigue';
16+
17+
export function FatigueEmpathy() {
18+
const { toast } = useToast();
19+
const workCount = useRef(0);
20+
const breakEnteredAt = useRef<number | null>(null);
21+
22+
useEffect(() => {
23+
let prevPhase = usePomodoroStore.getState().phase;
24+
// 订阅相位迁移而非每帧状态,避免高频回调
25+
const unsub = usePomodoroStore.subscribe((s) => {
26+
if (s.phase === prevPhase) return;
27+
const from = prevPhase;
28+
prevPhase = s.phase;
29+
30+
// 工作→休息:累计一次连续深潜,记录休息入口时刻
31+
if (from === 'work' && (s.phase === 'short_break' || s.phase === 'long_break')) {
32+
workCount.current += 1;
33+
breakEnteredAt.current = Date.now();
34+
if (shouldSuggestSurfacing(workCount.current)) {
35+
toast({ type: 'info', message: SURFACE_SUGGESTION_TEXT });
36+
workCount.current = 0; // 建议后重新计数,避免唠叨
37+
}
38+
return;
39+
}
40+
// 休息→工作:停留时长达标=真休息(重置);跳过/提前返回不重置
41+
if ((from === 'short_break' || from === 'long_break') && s.phase === 'work') {
42+
const planned = from === 'short_break' ? s.settings.shortBreakDuration : s.settings.longBreakDuration;
43+
const elapsed = breakEnteredAt.current ? Date.now() - breakEnteredAt.current : 0;
44+
if (wasRealBreak(elapsed, planned)) workCount.current = 0;
45+
breakEnteredAt.current = null;
46+
}
47+
});
48+
return unsub;
49+
}, [toast]);
50+
51+
return null;
52+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* WorldRecap — 延时摄影开场(宪法 P2 · 每次打开看见生长)
3+
* WorldRecap — daily "the world grew while you were away" recap
4+
*
5+
* @ai-context: 每日首次打开时展示 6 秒的世界生长摘要(珊瑚/深度/发现
6+
* 的日间增量),把"变化"直接递到眼前——留存钩子 4(意外+生长)。
7+
* 基线存 localStorage:日期变化才结算,同日多次打开不重复打扰。
8+
* 零增长时只留一句温柔文案,无负向表达。
9+
*
10+
* @ai-context: Once per day, shows a 6-second growth recap (coral/depth/
11+
* discovery deltas). Baseline settles only on date change.
12+
*/
13+
import { useEffect, useState } from 'react';
14+
import { createPortal } from 'react-dom';
15+
import { AnimatePresence, motion } from 'framer-motion';
16+
import { useEcosystemStore } from '../store/useEcosystemStore';
17+
import { useDiscoveryStore } from '../store/useDiscoveryStore';
18+
19+
const BASELINE_KEY = 'ed-recap-baseline';
20+
const RECAP_MS = 6000;
21+
22+
interface RecapBaseline {
23+
date: string;
24+
corals: number;
25+
totalDepth: number;
26+
discoveries: number;
27+
}
28+
29+
interface RecapDeltas {
30+
corals: number;
31+
depth: number;
32+
discoveries: number;
33+
}
34+
35+
function today(): string {
36+
return new Date().toISOString().split('T')[0];
37+
}
38+
39+
function readBaseline(): RecapBaseline | null {
40+
try {
41+
const raw = localStorage.getItem(BASELINE_KEY);
42+
return raw ? (JSON.parse(raw) as RecapBaseline) : null;
43+
} catch {
44+
return null;
45+
}
46+
}
47+
48+
export function WorldRecap() {
49+
const initialized = useEcosystemStore((s) => s.initialized);
50+
const corals = useEcosystemStore((s) => s.corals);
51+
const totalDepth = useEcosystemStore((s) => s.totalDepth);
52+
const discoveriesCount = useDiscoveryStore((s) => s.totalCount);
53+
const [deltas, setDeltas] = useState<RecapDeltas | null>(null);
54+
55+
// 结算逻辑:数据就绪后比较基线;跨日才展示,任何情况下都把今天写回基线
56+
useEffect(() => {
57+
if (!initialized) return;
58+
59+
const prev = readBaseline();
60+
const now: RecapBaseline = {
61+
date: today(),
62+
corals: corals.length,
63+
totalDepth,
64+
discoveries: discoveriesCount,
65+
};
66+
67+
if (prev && prev.date !== now.date) {
68+
setDeltas({
69+
corals: Math.max(0, now.corals - prev.corals),
70+
depth: Math.max(0, now.totalDepth - prev.totalDepth),
71+
discoveries: Math.max(0, now.discoveries - prev.discoveries),
72+
});
73+
}
74+
75+
try {
76+
localStorage.setItem(BASELINE_KEY, JSON.stringify(now));
77+
} catch { /* ignore */ }
78+
// eslint-disable-next-line react-hooks/exhaustive-deps
79+
}, [initialized]);
80+
81+
// 自动消散
82+
useEffect(() => {
83+
if (!deltas) return;
84+
const t = setTimeout(() => setDeltas(null), RECAP_MS);
85+
return () => clearTimeout(t);
86+
}, [deltas]);
87+
88+
if (!deltas) return null;
89+
90+
const lines: string[] = [];
91+
if (deltas.corals > 0) lines.push(`新种下 ${deltas.corals} 株珊瑚`);
92+
if (deltas.depth > 0) lines.push(`潜航加深 ${deltas.depth} 米`);
93+
if (deltas.discoveries > 0) lines.push(`新增 ${deltas.discoveries} 次深海发现`);
94+
95+
return createPortal(
96+
<AnimatePresence>
97+
<motion.div
98+
key={`recap-${deltas.depth}-${deltas.corals}`}
99+
initial={{ opacity: 0, y: 16 }}
100+
animate={{ opacity: 1, y: 0 }}
101+
exit={{ opacity: 0, transition: { duration: 0.3 } }}
102+
transition={{ type: 'spring', stiffness: 200, damping: 26 }}
103+
onClick={() => setDeltas(null)}
104+
style={{
105+
position: 'fixed', left: 24, bottom: 24, zIndex: 55, cursor: 'pointer',
106+
maxWidth: 320, padding: '16px 20px', borderRadius: '16px 10px 14px 12px',
107+
background: 'rgba(13,24,44,0.78)', border: '1px solid rgba(129,140,248,0.25)',
108+
backdropFilter: 'blur(14px)', boxShadow: '0 8px 30px rgba(4,10,20,0.5)',
109+
}}
110+
>
111+
<div style={{ fontFamily: "'LXGW WenKai Lite','Noto Serif SC',serif", fontSize: 15, fontWeight: 700, letterSpacing: 2, color: '#E0E6F0' }}>
112+
你不在的时候,世界在生长
113+
</div>
114+
{lines.length > 0 ? (
115+
<ul style={{ margin: '10px 0 0', padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 5 }}>
116+
{lines.map((l) => (
117+
<li key={l} style={{ fontSize: 12.5, color: '#90A0B8', display: 'flex', alignItems: 'center', gap: 8 }}>
118+
<span style={{ width: 5, height: 5, borderRadius: '50%', background: '#22D3EE', boxShadow: '0 0 6px #22D3EE' }} />
119+
{l}
120+
</li>
121+
))}
122+
</ul>
123+
) : (
124+
<div style={{ marginTop: 10, fontSize: 12.5, color: '#90A0B8' }}>
125+
海面平静如昨——今天下去看看?
126+
</div>
127+
)}
128+
</motion.div>
129+
</AnimatePresence>,
130+
document.body,
131+
);
132+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* 疲劳共情规则单元测试
3+
* Unit tests for fatigue empathy rules
4+
*/
5+
import { describe, it, expect } from 'vitest';
6+
import {
7+
shouldSuggestSurfacing,
8+
wasRealBreak,
9+
SURFACE_SUGGESTION_THRESHOLD,
10+
REAL_BREAK_RATIO,
11+
} from './fatigue';
12+
13+
describe('shouldSuggestSurfacing', () => {
14+
it('should suggest after threshold consecutive work sessions', () => {
15+
expect(shouldSuggestSurfacing(SURFACE_SUGGESTION_THRESHOLD)).toBe(true);
16+
expect(shouldSuggestSurfacing(SURFACE_SUGGESTION_THRESHOLD + 3)).toBe(true);
17+
});
18+
19+
it('should stay silent below threshold (觉察不唠叨)', () => {
20+
expect(shouldSuggestSurfacing(0)).toBe(false);
21+
expect(shouldSuggestSurfacing(SURFACE_SUGGESTION_THRESHOLD - 1)).toBe(false);
22+
});
23+
});
24+
25+
describe('wasRealBreak', () => {
26+
it('should accept rest reaching the ratio of planned duration', () => {
27+
// 计划 5 分钟休息,停留 60% 即算真休息
28+
const planned = 300;
29+
expect(wasRealBreak(planned * 1000 * REAL_BREAK_RATIO, planned)).toBe(true);
30+
expect(wasRealBreak(planned * 1000, planned)).toBe(true);
31+
});
32+
33+
it('should reject skipped/shortened breaks', () => {
34+
const planned = 300;
35+
expect(wasRealBreak(planned * 1000 * (REAL_BREAK_RATIO - 0.1), planned)).toBe(false);
36+
expect(wasRealBreak(0, planned)).toBe(false);
37+
});
38+
39+
it('should guard invalid planned duration', () => {
40+
expect(wasRealBreak(999999, 0)).toBe(false);
41+
expect(wasRealBreak(999999, -5)).toBe(false);
42+
});
43+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* 疲劳共情判定(宪法 P2 · 觉察原则)
3+
* Fatigue empathy rules (constitution P2 · awareness principle)
4+
*
5+
* @ai-context: 纯函数规则层:连续深潜而不上浮休息达到阈值时建议一次呼吸。
6+
* 觉察而非管控——建议永远可忽略、无惩罚、无阻断(宪法第二/四原则)。
7+
*
8+
* @ai-context: Pure rules: suggest surfacing after N consecutive work
9+
* sessions without a real break. Suggestions are always dismissible.
10+
*/
11+
12+
/** 连续完成的工作会话数阈值(未经历真实休息)→ 触发一次共情建议 */
13+
export const SURFACE_SUGGESTION_THRESHOLD = 2;
14+
15+
/** 休息时长达到计划值的此比例即视为"真实休息"(跳过/提前返回不算) */
16+
export const REAL_BREAK_RATIO = 0.6;
17+
18+
export function shouldSuggestSurfacing(workSessionsWithoutBreak: number): boolean {
19+
return workSessionsWithoutBreak >= SURFACE_SUGGESTION_THRESHOLD;
20+
}
21+
22+
/**
23+
* 判定一次休息是否为真实休息(时长口径,不依赖动作标记)
24+
* @ai-context skip 不写 lastAction,故用停留时长与计划时长比较判定
25+
*/
26+
export function wasRealBreak(elapsedMs: number, plannedSeconds: number): boolean {
27+
if (plannedSeconds <= 0) return false;
28+
return elapsedMs >= plannedSeconds * 1000 * REAL_BREAK_RATIO;
29+
}
30+
31+
/** 共情文案(零负向语言:是邀请,不是警告) */
32+
export const SURFACE_SUGGESTION_TEXT =
33+
'已连续深潜一段时间了,要不要上浮透口气?忽略也没关系,海一直在。';
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* WorldSoundscape — 世界声景(宪法 P2 · 声景体系第一步)
3+
* WorldSoundscape — ambient world audio per theme (opt-in)
4+
*
5+
* @ai-context: 双世界的环境底噪:深海=雨声(隔绝感),穹顶=麦浪风声(通透感),
6+
* 音量克制(0.12)淡入淡出;默认关闭,外观设置可开启(觉察原则:可关闭)。
7+
* 页面隐藏时暂停,避免后台空放。
8+
*
9+
* @ai-context: Theme-bound ambient loop (rain for deep-sea, wind for aurora),
10+
* low volume with fades, opt-in, paused when the window is hidden.
11+
*/
12+
import { useEffect, useState } from 'react';
13+
import { useSceneTheme } from '@/lib/3d/hooks/useSceneTheme';
14+
import { useAudioPlayer } from '@/lib/audio/useAudioPlayer';
15+
import {
16+
getWorldSoundscapeEnabled,
17+
WORLD_SOUNDSCAPE_CHANGE_EVENT,
18+
} from './worldSoundscapeConfig';
19+
20+
/** 主题 → 环境音源映射 */
21+
const THEME_SOUND: Record<'deep-sea' | 'aurora-dome', string> = {
22+
'deep-sea': '/audio/rain.mp3',
23+
'aurora-dome': '/audio/wind-wheat.mp3',
24+
};
25+
26+
export function WorldSoundscape() {
27+
const theme = useSceneTheme();
28+
const [enabled, setEnabled] = useState(getWorldSoundscapeEnabled);
29+
const [visible, setVisible] = useState(() => document.visibilityState === 'visible');
30+
31+
// 设置页开关变更广播
32+
useEffect(() => {
33+
const onChange = (e: Event) => setEnabled((e as CustomEvent<boolean>).detail === true);
34+
window.addEventListener(WORLD_SOUNDSCAPE_CHANGE_EVENT, onChange);
35+
return () => window.removeEventListener(WORLD_SOUNDSCAPE_CHANGE_EVENT, onChange);
36+
}, []);
37+
38+
// 后台静音
39+
useEffect(() => {
40+
const onVis = () => setVisible(document.visibilityState === 'visible');
41+
document.addEventListener('visibilitychange', onVis);
42+
return () => document.removeEventListener('visibilitychange', onVis);
43+
}, []);
44+
45+
const src = THEME_SOUND[theme];
46+
const { play, pause, isPlaying } = useAudioPlayer({
47+
src, volume: 0.12, loop: true, fadeInMs: 1800, fadeOutMs: 1200,
48+
});
49+
50+
const shouldPlay = enabled && visible;
51+
useEffect(() => {
52+
if (shouldPlay && !isPlaying) play();
53+
else if (!shouldPlay && isPlaying) pause();
54+
}, [shouldPlay, isPlaying, play, pause]);
55+
56+
return null;
57+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* 世界声景开关配置(与组件分离,保证 fast-refresh 纯组件文件)
3+
* World soundscape toggle helpers (separated from the component file)
4+
*
5+
* @ai-context: localStorage 持久化 + 跨组件变更广播事件。
6+
*/
7+
8+
/** 声景开关 localStorage 键 */
9+
export const WORLD_SOUNDSCAPE_KEY = 'ed-world-soundscape';
10+
/** 开关变更广播事件名 */
11+
export const WORLD_SOUNDSCAPE_CHANGE_EVENT = 'ed-world-soundscape-change';
12+
13+
export function getWorldSoundscapeEnabled(): boolean {
14+
try {
15+
return localStorage.getItem(WORLD_SOUNDSCAPE_KEY) === '1';
16+
} catch {
17+
return false;
18+
}
19+
}
20+
21+
export function setWorldSoundscapeEnabled(on: boolean): void {
22+
try {
23+
localStorage.setItem(WORLD_SOUNDSCAPE_KEY, on ? '1' : '0');
24+
} catch { /* ignore */ }
25+
window.dispatchEvent(new CustomEvent(WORLD_SOUNDSCAPE_CHANGE_EVENT, { detail: on }));
26+
}

0 commit comments

Comments
 (0)