Skip to content

Commit 7057ce0

Browse files
committed
feat(world): 混沌雾与秩序波纹(宪法P1第二批·熵可视化层)
- useWorldEvents:世界事件总线,复习/掌握时刻发射秩序波纹,业务层与世界层解耦 - ChaosMist:遗忘可视化——雾强度由 mist 信号驱动(白化珊瑚占比,封顶0.4),程序化径向渐变纹理,high/medium漂移、low静态,关闭留存自动隐去 - OrderRipples:复习即时正反馈——起源锚点扩散光环,同屏上限3道,reduced-motion降级静帧闪烁 - DeepSeaWorld 接入熵可视化层;useWorldSignals 增加 useShallow 细粒度选择器变体 - useStudySessionStore.rateCard 接线:任何评分都是一次对混沌的推退(含Again,零负向语义)
1 parent 795565b commit 7057ce0

6 files changed

Lines changed: 257 additions & 1 deletion

File tree

‎client/src/features/flashcards/store/useStudySessionStore.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { Flashcard, FlashcardReview, Confidence, GoldenError } from '@/type
1313
import { useFlashcardStore } from './useFlashcardStore';
1414
import { generateId } from '@/lib/utils/uuid';
1515
import { soundPlayer } from '@/lib/audio/SoundPlayer';
16+
import { useWorldEvents } from '@/features/retention/store/useWorldEvents';
1617

1718
// ---------------------------------------------------------------------------
1819
// 常量
@@ -220,6 +221,10 @@ export const useStudySessionStore = create<StudySessionState>((set, get) => {
220221
const card = sessionCards[currentIndex];
221222
if (!card || card.id === undefined) return;
222223

224+
// 宪法第一条:复习行为=秩序波纹。任何评分都是一次对混沌的推退
225+
// (包括 Again——唤醒本身即正向,零负向语义),经世界事件总线驱动深海场景
226+
useWorldEvents.getState().emitOrderRipple('flashcards');
227+
223228
// v0.9.0: goldenError 判定 — 高自信答错(Again)
224229
const isWrong = rating === Rating.Again;
225230
const isGoldenError = confidence === 'high' && isWrong;

‎client/src/features/retention/hooks/useWorldSignals.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
* for the 3D scene layer. Streak wiring lands with the tide batch.
1111
*/
1212
import { useMemo } from 'react';
13+
import { useShallow } from 'zustand/react/shallow';
1314
import { useEcosystemStore } from '../store/useEcosystemStore';
1415
import { useDiscoveryStore } from '../store/useDiscoveryStore';
1516
import { useRetentionSettings } from '../store/useRetentionSettings';
@@ -41,3 +42,16 @@ export function useWorldSignals(): WorldSignalsWithGlow {
4142
return { ...signals, glowScale: vitalityToGlowScale(signals.vitality) };
4243
}, [corals, totalDepth, discoveriesCount, enabled]);
4344
}
45+
46+
/**
47+
* 细粒度选择器版本(3D 逐帧层专用) / Selector variant for frame-critical layers
48+
*
49+
* @ai-context 内部复用 useWorldSignals 派生结果,再用 useShallow 选取子集:
50+
* 仅当所选字段变化时才触发组件重渲染(如 ChaosMist 只选 mist)。
51+
*/
52+
export function useWorldSignalsSelect<T extends Partial<WorldSignalsWithGlow>>(
53+
selector: (s: WorldSignalsWithGlow) => T,
54+
): T {
55+
const signals = useWorldSignals();
56+
return useShallow(selector)(signals);
57+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* 世界事件 Store(熵可视化 · 秩序波纹驱动)
3+
* World events store (entropy visualization · order ripple driver)
4+
*
5+
* @ai-context: 宪法第一条:复习行为→秩序波纹。本 store 是事件总线:
6+
* 学习流程在「掌握/复习」时刻调用 emitOrderRipple(),3D 场景层订阅
7+
* rippleSeq 生成扩散波纹。模块间零直接依赖,保持世界层与业务层解耦。
8+
* 仅保留最近一次事件(波纹是即时反馈,无需队列积压)。
9+
*
10+
* @ai-context: Event bus between learning flows and the 3D world layer.
11+
* Flows call emitOrderRipple() at mastery/review moments; the scene subscribes
12+
* to rippleSeq to spawn expanding ripples. Only the latest event is kept.
13+
*/
14+
import { create } from 'zustand';
15+
16+
/** 波纹起源锚点:模块实体 ID(场景内定位)或 'center' 场景中心 / Ripple origin */
17+
export type RippleOrigin = 'center' | 'pomodoro' | 'notes' | 'flashcards' | 'feynman' | 'inspiration';
18+
19+
interface WorldEventsState {
20+
/** 波纹序列号:每次 emit 自增,场景层据此生成新波纹 / Ripple sequence counter */
21+
rippleSeq: number;
22+
/** 最近一次波纹起源 / Latest ripple origin */
23+
rippleOrigin: RippleOrigin;
24+
25+
/** 发射秩序波纹(复习/掌握的即时正反馈) / Emit an order ripple */
26+
emitOrderRipple: (origin?: RippleOrigin) => void;
27+
}
28+
29+
export const useWorldEvents = create<WorldEventsState>((set) => ({
30+
rippleSeq: 0,
31+
rippleOrigin: 'center',
32+
33+
emitOrderRipple: (origin = 'center') => {
34+
set((s) => ({ rippleSeq: s.rippleSeq + 1, rippleOrigin: origin }));
35+
},
36+
}));
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* ChaosMist — 混沌雾(宪法第一条:遗忘=雾)
3+
* ChaosMist — the entropy fog (constitution §1: forgetting = mist)
4+
*
5+
* @ai-context: 深海场景的遗忘可视化:雾强度由世界信号 mist 驱动
6+
* (白化珊瑚占比,宪法锁定 ≤0.4)。语义是"朦胧可拨开"——复习恢复
7+
* 珊瑚健康后雾自然消退,全程零负向表达。
8+
* 性能预算(宪法第四条):high/medium=径向渐变 sprite 缓慢漂移,
9+
* low=静态单团、无逐帧动画。
10+
*
11+
* @ai-context: Fog sprites whose opacity tracks the mist world signal.
12+
* Budget: drifting gradient sprites on high/medium, static on low tier.
13+
*/
14+
import { useMemo, useRef } from 'react';
15+
import * as THREE from 'three';
16+
import { useFrame } from '@react-three/fiber';
17+
import { useWorldSignalsSelect } from '@/features/retention/hooks/useWorldSignals';
18+
import { useEffectiveTier } from '@/lib/performance/usePerformanceMode';
19+
20+
/** 雾团落点与尺寸(场景边缘,不遮挡中心导航) / Blob anchors at scene edges */
21+
const MIST_BLOBS: Array<{ pos: [number, number, number]; scale: number; speed: number }> = [
22+
{ pos: [-7, -1.5, -6], scale: 16, speed: 0.05 },
23+
{ pos: [6.5, 2, -8], scale: 13, speed: 0.07 },
24+
];
25+
26+
/** 程序化径向渐变雾纹理(无外部素材依赖) / Procedural radial mist texture */
27+
function createMistTexture(): THREE.CanvasTexture {
28+
const size = 256;
29+
const canvas = document.createElement('canvas');
30+
canvas.width = size;
31+
canvas.height = size;
32+
const ctx = canvas.getContext('2d');
33+
if (ctx) {
34+
const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
35+
g.addColorStop(0, 'rgba(7,12,24,0.9)');
36+
g.addColorStop(0.55, 'rgba(7,12,24,0.45)');
37+
g.addColorStop(1, 'rgba(7,12,24,0)');
38+
ctx.fillStyle = g;
39+
ctx.fillRect(0, 0, size, size);
40+
}
41+
return new THREE.CanvasTexture(canvas);
42+
}
43+
44+
export function ChaosMist() {
45+
const tier = useEffectiveTier();
46+
const { mist } = useWorldSignalsSelect((s) => ({ mist: s.mist }));
47+
const texture = useMemo(createMistTexture, []);
48+
const refs = useRef<Array<THREE.Sprite | null>>([]);
49+
const elapsed = useRef(0);
50+
51+
// low 档:静态呈现语义(雾仍在),不跑逐帧动画(宪法第四条)
52+
useFrame((_, delta) => {
53+
if (tier === 'low' || mist <= 0.001) return;
54+
elapsed.current += Math.min(delta, 0.1);
55+
refs.current.forEach((sprite, i) => {
56+
if (!sprite) return;
57+
const blob = MIST_BLOBS[i];
58+
// 极慢漂移:混沌在渗透,但永不吞噬(可拨开的朦胧)
59+
sprite.position.x = blob.pos[0] + Math.sin(elapsed.current * blob.speed) * 0.8;
60+
sprite.position.y = blob.pos[1] + Math.cos(elapsed.current * blob.speed * 0.8) * 0.5;
61+
});
62+
});
63+
64+
// 雾强度直接取自信号(派生层已封顶 0.4);关闭留存时 mist=0,雾自然隐去
65+
if (mist <= 0.001) return null;
66+
const blobCount = tier === 'low' ? 1 : MIST_BLOBS.length;
67+
68+
return (
69+
<group>
70+
{MIST_BLOBS.slice(0, blobCount).map((blob, i) => (
71+
<sprite
72+
key={i}
73+
ref={(el) => { refs.current[i] = el; }}
74+
position={blob.pos}
75+
scale={[blob.scale, blob.scale, 1]}
76+
>
77+
<spriteMaterial
78+
map={texture}
79+
transparent
80+
opacity={mist}
81+
depthWrite={false}
82+
color="#8FA3C8"
83+
/>
84+
</sprite>
85+
))}
86+
</group>
87+
);
88+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* OrderRipples — 秩序波纹(宪法第一条:复习=波纹)
3+
* OrderRipples — order ripples (constitution §1: review = ripple)
4+
*
5+
* @ai-context: 订阅世界事件总线(useWorldEvents.rippleSeq),在波纹起源
6+
* 位置生成向外扩散的发光圆环并自然消散——复习推退混沌的即时正反馈。
7+
* 同屏最多 3 道波纹(性能预算);reduced-motion 降级为原位柔和闪烁。
8+
*
9+
* @ai-context: Spawns expanding glow rings at the ripple origin whenever the
10+
* world event bus emits. Max 3 concurrent; reduced-motion shows a soft flash.
11+
*/
12+
import { useEffect, useMemo, useRef, useState } from 'react';
13+
import * as THREE from 'three';
14+
import { useFrame } from '@react-three/fiber';
15+
import { Billboard } from '@react-three/drei';
16+
import { MODULE_POSITIONS } from '../navigation/OrbitalStore';
17+
import { useWorldEvents, type RippleOrigin } from '@/features/retention/store/useWorldEvents';
18+
19+
/** 波纹生命周期(秒)与同屏上限 / Ripple lifetime and concurrent cap */
20+
const RIPPLE_LIFE = 2.2;
21+
const MAX_CONCURRENT = 3;
22+
const RIPPLE_COLOR = '#22D3EE';
23+
24+
interface RippleInstance {
25+
id: number;
26+
origin: RippleOrigin;
27+
}
28+
29+
/** 起源锚点 → 场景坐标 / Origin anchor → scene position */
30+
function originPosition(origin: RippleOrigin): [number, number, number] {
31+
if (origin === 'center') return [0, 0, 0];
32+
return MODULE_POSITIONS.find((m) => m.id === origin)?.position ?? [0, 0, 0];
33+
}
34+
35+
function Ripple({ pos, reduced, onDone }: {
36+
pos: [number, number, number];
37+
reduced: boolean;
38+
onDone: () => void;
39+
}) {
40+
const meshRef = useRef<THREE.Mesh>(null);
41+
const matRef = useRef<THREE.MeshBasicMaterial>(null);
42+
const age = useRef(0);
43+
44+
useFrame((_, delta) => {
45+
age.current += Math.min(delta, 0.1);
46+
const t = Math.min(1, age.current / RIPPLE_LIFE);
47+
if (t >= 1) {
48+
onDone();
49+
return;
50+
}
51+
// reduced-motion:不扩散,仅原位柔和闪烁(宪法第三条约束的静帧语义)
52+
if (meshRef.current) {
53+
meshRef.current.scale.setScalar(reduced ? 2.2 : 0.6 + t * 6.4);
54+
}
55+
if (matRef.current) {
56+
matRef.current.opacity = (reduced ? 0.45 : 0.8) * (1 - t);
57+
}
58+
});
59+
60+
return (
61+
<Billboard position={pos}>
62+
<mesh ref={meshRef}>
63+
<ringGeometry args={[0.92, 1, 48]} />
64+
<meshBasicMaterial
65+
ref={matRef}
66+
color={RIPPLE_COLOR}
67+
transparent
68+
opacity={0.8}
69+
side={THREE.DoubleSide}
70+
depthWrite={false}
71+
/>
72+
</mesh>
73+
</Billboard>
74+
);
75+
}
76+
77+
export function OrderRipples() {
78+
const rippleSeq = useWorldEvents((s) => s.rippleSeq);
79+
const rippleOrigin = useWorldEvents((s) => s.rippleOrigin);
80+
const [ripples, setRipples] = useState<RippleInstance[]>([]);
81+
const reduced = useMemo(
82+
() => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches,
83+
[],
84+
);
85+
86+
// 序列号驱动生成:seq=0 为初始态不生成
87+
useEffect(() => {
88+
if (rippleSeq === 0) return;
89+
setRipples((prev) => [...prev.slice(-(MAX_CONCURRENT - 1)), { id: rippleSeq, origin: rippleOrigin }]);
90+
}, [rippleSeq, rippleOrigin]);
91+
92+
if (ripples.length === 0) return null;
93+
return (
94+
<group>
95+
{ripples.map((r) => (
96+
<Ripple
97+
key={r.id}
98+
pos={originPosition(r.origin)}
99+
reduced={reduced}
100+
onDone={() => setRipples((prev) => prev.filter((x) => x.id !== r.id))}
101+
/>
102+
))}
103+
</group>
104+
);
105+
}

‎client/src/lib/3d/scenes/DeepSeaWorld.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22
* DeepSeaWorld — 深色模式「深海」3D场景
33
* 深海生态系统:生物发光、海底粒子、有机暗流
44
*
5-
* @ai-context: 3D 场景:DeepSeaWorld。
5+
* @ai-context: 3D 场景:DeepSeaWorld。宪法 P1 第二批接入熵可视化层:
6+
* ChaosMist(遗忘=雾,mist 信号驱动)与 OrderRipples(复习=波纹,
7+
* 世界事件总线驱动)。
68
*/
79
import { Bloom, DepthOfField, Vignette } from '@react-three/postprocessing';
810
import { SafeEffectComposer } from '../core/SafeEffectComposer';
911
import { useEffectiveTier } from '@/lib/performance/usePerformanceMode';
12+
import { ChaosMist } from '../objects/ChaosMist';
13+
import { OrderRipples } from '../objects/OrderRipples';
1014

1115
export function DeepSeaWorld() {
1216
// 有效 tier(自动 tier 受用户性能模式上限约束)
@@ -22,6 +26,10 @@ export function DeepSeaWorld() {
2226
<meshBasicMaterial color="#0A1628" side={2} />
2327
</mesh>
2428

29+
{/* 熵可视化层(宪法 P1):混沌雾=遗忘,秩序波纹=复习 */}
30+
<ChaosMist />
31+
<OrderRipples />
32+
2533
{/* 后处理:低档全关;中档关景深(DepthOfField 是最重的后处理 pass);澎湃档全开。
2634
条件置于 composer 层级(group 接受 false),避免 EffectComposer 子元素严格类型报错 */}
2735
{tier === 'low' ? null : tier === 'high' ? (

0 commit comments

Comments
 (0)