Skip to content

Commit 77cfaf3

Browse files
committed
feat(world): 潮汐节律与沉积地层(宪法P1第四批·叙事层C/D深海先行)
- computeCurrentStreakFromCorals:从珊瑚种植日期派生连击(与DashboardPage口径一致),useWorldSignals接线warmth真值,新增3单测(共10) - TideBreath(叙事层C):番茄钟运行=涨潮——荧光点光增亮脉动+潮线升起,停止=退潮回落;熵的呼吸,reduced-motion跳过脉动 - StrataField(叙事层D):累计专注=海床沉积地层(珊瑚记录数→层数,上限8层),最新沉积带辉光,白化不减层(可逆原则),静态网格零帧耗 - DeepSeaWorld挂载叙事层C/D
1 parent 3165d00 commit 77cfaf3

6 files changed

Lines changed: 187 additions & 5 deletions

File tree

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,19 @@
33
* World signals subscription hook
44
*
55
* @ai-context: 订阅珊瑚生态/深海发现/留存设置 store,实时派生世界信号集,
6-
* 供 3D 场景层(SpatialNav 实体辉光、后续混沌雾/萤火/洋流)消费。
7-
* streak 尚未接入 store(StreakBubble 走 props 传递),潮汐批次接线时补真实值。
6+
* 供 3D 场景层(SpatialNav 实体辉光、混沌雾/潮汐/沉积层)消费。
7+
* streak 从珊瑚种植日期派生(与 DashboardPage 口径一致,无额外存储)。
88
*
99
* @ai-context: Subscribes to retention stores and derives live world signals
10-
* for the 3D scene layer. Streak wiring lands with the tide batch.
10+
* for the 3D scene layer. Streak is derived from coral plant dates.
1111
*/
1212
import { useMemo } from 'react';
1313
import { useShallow } from 'zustand/react/shallow';
1414
import { useEcosystemStore } from '../store/useEcosystemStore';
1515
import { useDiscoveryStore } from '../store/useDiscoveryStore';
1616
import { useRetentionSettings } from '../store/useRetentionSettings';
1717
import {
18+
computeCurrentStreakFromCorals,
1819
deriveWorldSignals,
1920
vitalityToGlowScale,
2021
type WorldSignals,
@@ -35,8 +36,7 @@ export function useWorldSignals(): WorldSignalsWithGlow {
3536
corals,
3637
totalDepth,
3738
discoveriesCount,
38-
// TODO(潮汐批次): streak store 接线后替换为真实连击值
39-
currentStreak: 0,
39+
currentStreak: computeCurrentStreakFromCorals(corals),
4040
enabled,
4141
});
4242
return { ...signals, glowScale: vitalityToGlowScale(signals.vitality) };

‎client/src/features/retention/lib/worldState.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { describe, it, expect } from 'vitest';
99
import {
1010
deriveWorldSignals,
1111
vitalityToGlowScale,
12+
computeCurrentStreakFromCorals,
1213
WORLD_ANCHORS,
1314
} from './worldState';
1415
import type { CoralRecord } from '../types';
@@ -24,6 +25,13 @@ function makeCoral(health: CoralRecord['health'], id = Math.random().toString(36
2425
};
2526
}
2627

28+
/** 指定日期偏移的珊瑚(daysAgo=0 为今天) / Coral planted daysAgo days ago */
29+
function coralOnDay(daysAgo: number): CoralRecord {
30+
const d = new Date();
31+
d.setDate(d.getDate() - daysAgo);
32+
return { ...makeCoral('healthy'), plantedAt: d };
33+
}
34+
2735
const base = {
2836
corals: [] as CoralRecord[],
2937
totalDepth: 0,
@@ -124,3 +132,25 @@ describe('vitalityToGlowScale', () => {
124132
expect(vitalityToGlowScale(9)).toBeCloseTo(1.15);
125133
});
126134
});
135+
136+
describe('computeCurrentStreakFromCorals', () => {
137+
it('should return 0 for empty corals', () => {
138+
expect(computeCurrentStreakFromCorals([])).toBe(0);
139+
});
140+
141+
it('should count consecutive days and dedupe same-day plants', () => {
142+
// Arrange:今天 2 次种植 + 前 2 天连续
143+
const corals = [coralOnDay(0), coralOnDay(0), coralOnDay(1), coralOnDay(2)];
144+
145+
// Act & Assert
146+
expect(computeCurrentStreakFromCorals(corals)).toBe(3);
147+
});
148+
149+
it('should stop at the first gap (与 DashboardPage 口径一致)', () => {
150+
// Arrange:今天、昨天,第三天断裂
151+
const corals = [coralOnDay(0), coralOnDay(1), coralOnDay(3)];
152+
153+
// Act & Assert
154+
expect(computeCurrentStreakFromCorals(corals)).toBe(2);
155+
});
156+
});

‎client/src/features/retention/lib/worldState.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,29 @@ export interface WorldSignals {
5959

6060
const clamp01 = (v: number): number => Math.min(1, Math.max(0, v));
6161

62+
/**
63+
* 从珊瑚种植日期派生当前连击天数 / Derive current streak from coral plant dates
64+
*
65+
* @ai-context 与 DashboardPage 的 StreakState 构建口径一致(复用珊瑚数据、
66+
* 无额外存储):按日期去重倒序,从最近日期向前逐日检查,遇间隔即止。
67+
* 洋流休息日机制由 streakEngine 在展示层处理,此处只算原始连击。
68+
*/
69+
export function computeCurrentStreakFromCorals(corals: CoralRecord[]): number {
70+
if (corals.length === 0) return 0;
71+
const uniqueDays = [...new Set(
72+
corals.map((c) => new Date(c.plantedAt).toISOString().split('T')[0]),
73+
)].sort().reverse();
74+
75+
let streak = 1;
76+
for (let i = 1; i < uniqueDays.length; i++) {
77+
const prev = new Date(uniqueDays[i - 1]).getTime();
78+
const curr = new Date(uniqueDays[i]).getTime();
79+
if ((prev - curr) / 86_400_000 === 1) streak++;
80+
else break;
81+
}
82+
return streak;
83+
}
84+
6285
/**
6386
* 派生世界信号集 / Derive the world signal set
6487
*
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* StrataField — 沉积地层(宪法第十条 · 叙事层 D:学习史即岩芯)
3+
* StrataField — sediment strata (constitution §10 · layer D)
4+
*
5+
* @ai-context: 累计专注的地质层呈现:每次深潜种下的珊瑚即一程沉积,
6+
* 海床上逐层堆叠(最多 8 层可视),最新的沉积层带微弱辉光——
7+
* "你的学习史是一根岩芯"。白化不减少地层(可逆原则:暂停生长而非消亡)。
8+
* 静态网格、无逐帧动画,性能开销近零。
9+
*
10+
* @ai-context: Stacked sediment bands on the seabed derived from coral count;
11+
* latest stratum glows softly. Static meshes, near-zero frame cost.
12+
*/
13+
import { useMemo } from 'react';
14+
import * as THREE from 'three';
15+
import { useEcosystemStore } from '@/features/retention/store/useEcosystemStore';
16+
import { useWorldSignalsSelect } from '@/features/retention/hooks/useWorldSignals';
17+
18+
/** 可视地层上限(性能预算 + 构图克制) / Visible stratum cap */
19+
const MAX_STRATA = 8;
20+
/** 海床基准高度 / Seabed baseline */
21+
const BASE_Y = -3.9;
22+
/** 单层厚度 / Stratum thickness */
23+
const BAND_HEIGHT = 0.22;
24+
25+
export function StrataField() {
26+
const corals = useEcosystemStore((s) => s.corals);
27+
const { depthNorm } = useWorldSignalsSelect((s) => ({ depthNorm: s.depthNorm }));
28+
29+
// 地层数=沉积次数(珊瑚记录数),整体厚度随累计深度微缩放
30+
const bands = Math.min(corals.length, MAX_STRATA);
31+
const scaleY = useMemo(() => 0.7 + depthNorm * 0.5, [depthNorm]);
32+
33+
// 冷启动:未点亮的混沌世界没有沉积(宪法第七条)
34+
if (bands === 0) return null;
35+
36+
return (
37+
<group position={[0, 0, -2]} scale={[1, scaleY, 1]}>
38+
{Array.from({ length: bands }, (_, i) => {
39+
const isLatest = i === bands - 1;
40+
// 越深越古老:宽度递减、色调趋暗;最新层最亮
41+
const width = 7.5 - i * 0.55;
42+
const brightness = 0.16 + (i / Math.max(1, bands - 1)) * 0.3;
43+
return (
44+
<mesh key={i} position={[0, BASE_Y + i * BAND_HEIGHT, 0]}>
45+
<boxGeometry args={[width, BAND_HEIGHT * 0.92, 3.2]} />
46+
<meshStandardMaterial
47+
color={new THREE.Color(0.05 + brightness * 0.2, 0.09 + brightness * 0.3, 0.2 + brightness * 0.5)}
48+
emissive={isLatest ? '#22D3EE' : '#12233F'}
49+
emissiveIntensity={isLatest ? 0.28 : 0.06}
50+
transparent
51+
opacity={0.85}
52+
roughness={0.8}
53+
/>
54+
</mesh>
55+
);
56+
})}
57+
</group>
58+
);
59+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* TideBreath — 潮汐节律(宪法第十条 · 叙事层 C:熵的呼吸)
3+
* TideBreath — tidal rhythm (constitution §10 · layer C)
4+
*
5+
* @ai-context: 实时层的投递机制:番茄钟运行=涨潮(世界收拢变深——
6+
* 荧光点光增亮脉动 + 底部潮线升起),停止/休息=退潮(光照回落、潮线沉降)。
7+
* 呼吸节律与 pomodoro isRunning 联动,无新增数据依赖。
8+
* reduced-motion:跳过脉动,只做强度缓动。
9+
*
10+
* @ai-context: Focus-running = flood tide (light swells, tide line rises);
11+
* idle = ebb. Pulse skipped under prefers-reduced-motion.
12+
*/
13+
import { useMemo, useRef } from 'react';
14+
import * as THREE from 'three';
15+
import { useFrame } from '@react-three/fiber';
16+
import { usePomodoroStore } from '@/features/pomodoro/store/usePomodoroStore';
17+
18+
/** 涨潮/退潮光照强度目标 / Flood & ebb light intensity targets */
19+
const FLOOD_INTENSITY = 0.85;
20+
const EBB_INTENSITY = 0.5;
21+
22+
export function TideBreath() {
23+
const isRunning = usePomodoroStore((s) => s.isRunning);
24+
const lightRef = useRef<THREE.PointLight>(null);
25+
const tideRef = useRef<THREE.Mesh>(null);
26+
const elapsed = useRef(0);
27+
const reduced = useMemo(
28+
() => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches,
29+
[],
30+
);
31+
32+
useFrame((_, delta) => {
33+
const safeDelta = Math.min(delta, 0.1);
34+
elapsed.current += safeDelta;
35+
36+
// 涨潮脉动:呼吸式的强度起伏(reduced-motion 关闭)
37+
const pulse = isRunning && !reduced ? Math.sin(elapsed.current * 0.9) * 0.12 : 0;
38+
const target = (isRunning ? FLOOD_INTENSITY : EBB_INTENSITY) + pulse;
39+
if (lightRef.current) {
40+
lightRef.current.intensity = THREE.MathUtils.lerp(
41+
lightRef.current.intensity, target, safeDelta * 2,
42+
);
43+
}
44+
45+
// 潮线:涨潮升起且波动更明显,退潮沉降趋平
46+
if (tideRef.current) {
47+
const mat = tideRef.current.material as THREE.MeshBasicMaterial;
48+
mat.opacity = THREE.MathUtils.lerp(mat.opacity, isRunning ? 0.16 : 0.05, safeDelta * 2);
49+
const swell = isRunning && !reduced ? Math.sin(elapsed.current * 0.5) * 0.12 : 0;
50+
tideRef.current.position.y = -3.5 + (isRunning ? 0.25 : 0) + swell;
51+
}
52+
});
53+
54+
return (
55+
<group>
56+
{/* 潮汐光:与场景主光源(#00BFFF)同族但偏赛博青,涨潮时世界"变深变亮" */}
57+
<pointLight ref={lightRef} position={[0, 4, 2]} intensity={EBB_INTENSITY} color="#22D3EE" distance={40} />
58+
{/* 潮线:海床上的扁平光环,涨落可见 */}
59+
<mesh ref={tideRef} rotation={[-Math.PI / 2, 0, 0]} position={[0, -3.5, 0]}>
60+
<ringGeometry args={[3.4, 9, 48]} />
61+
<meshBasicMaterial color="#22D3EE" transparent opacity={0.05} side={THREE.DoubleSide} depthWrite={false} />
62+
</mesh>
63+
</group>
64+
);
65+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { SafeEffectComposer } from '../core/SafeEffectComposer';
1111
import { useEffectiveTier } from '@/lib/performance/usePerformanceMode';
1212
import { ChaosMist } from '../objects/ChaosMist';
1313
import { OrderRipples } from '../objects/OrderRipples';
14+
import { TideBreath } from '../objects/TideBreath';
15+
import { StrataField } from '../objects/StrataField';
1416

1517
export function DeepSeaWorld() {
1618
// 有效 tier(自动 tier 受用户性能模式上限约束)
@@ -29,6 +31,9 @@ export function DeepSeaWorld() {
2931
{/* 熵可视化层(宪法 P1):混沌雾=遗忘,秩序波纹=复习 */}
3032
<ChaosMist />
3133
<OrderRipples />
34+
{/* 叙事层叠加(宪法第十条):潮汐=熵的呼吸,地层=累计专注的岩芯 */}
35+
<TideBreath />
36+
<StrataField />
3237

3338
{/* 后处理:低档全关;中档关景深(DepthOfField 是最重的后处理 pass);澎湃档全开。
3439
条件置于 composer 层级(group 接受 false),避免 EffectComposer 子元素严格类型报错 */}

0 commit comments

Comments
 (0)