v0.8.2: 工单大清理 - 编年史分页+限时挑战+星潮深化+云排行榜+手写叙事+socket多人星潮+UI打磨
工单 #8 编年史上限+分页: - engine.ts: slice(-50)→slice(-200) - ChronicleDialog.tsx: 加分页(每页10条)+上一页/下一页+页码显示 工单 #5 限时挑战 (subagent 10-a): - beacon.ts: BeaconTimedChallenge + getTimedSlotKey(4h时段) + generateTimedChallenge - BeaconPanel.tsx: amber主题限时区块 + 倒计时 + <30min紧急状态 - gameStore: trackBeacon 加 timedJustCompleted + claimTimedBeacon action 工单 #3 星潮类型深化 (subagent 10-a): - starTide.ts: +3种星潮 surge(emerald)/eclipse(rose)/prism(fuchsia) - TideModifiers: +targetLenBonus/bossWinRateBonus/autoDecodeIntervalMult - decode.ts: generatePuzzle 加 targetLenBonus 参数 - achievements: ach_tides_all 阈值 6→9 工单 #4/P2 云排行榜 (subagent 10-b): - mini-services/leaderboard-service/ (端口3030, Hono+bun, 内存1000条) - API: GET/POST /api/leaderboard + /stats + CORS + 防刷 - beacon.ts: fetchCloudLeaderboard/submitCloudScore - BeaconPanel: 本地Top20/全球Top100 双tab + YOU徽章高亮 工单 #9 手写叙事 (subagent 10-c): - chronicle.ts: EPOCH_LORE 5纪元×3节点=15段手写叙事(80-150字/段) - buildLore 优先手写节点, fallback 模板, 11个变量替换 P3 socket 多人星潮 (subagent 10-c): - mini-services/star-tide-service/ (端口3031, socket.io) - 每10-15min广播global-tide, 60s持续, 6种类型权重 - useGlobalTide hook + triggerGlobalTide action - StarTideIndicator 加 🌐 全球星潮标记 P2 UI打磨: - page.tsx: CrystalOrb 区加装饰全息环(3层旋转) + 四角标记 + 顶部状态条 + 底部铭文 QA: lint零错误 + dev HTTP200 + VLM 8/10 + 2个mini-service运行中(3030/3031)
@@ -1 +1 @@
|
||||
6708
|
||||
12512
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Task ID: 10-a — 限时挑战 + 星潮类型深化
|
||||
|
||||
**Agent**: full-stack-developer
|
||||
**Task**: 工单 #5 限时挑战 + 工单 #3 星潮类型深化
|
||||
|
||||
## Work Log
|
||||
|
||||
### 前置准备
|
||||
- 阅读 `worklog.md` 了解 v0.8.1 项目状态(10 大系统,四色全息 emerald/rose/amber/fuchsia)
|
||||
- 精读 `beacon.ts`(873 行,日挑战+周挑战+信标链)、`BeaconPanel.tsx`(753 行)、`starTide.ts`(209 行,6 种星潮)、`gameStore.ts` trackBeacon(~170 行)+ tickTide(~440 行)
|
||||
- 确认现有 UI 均动态读取 `TIDE_EVENTS[type]`,新增类型自动生效
|
||||
|
||||
### 工单 #5:限时挑战(Timed Challenge)
|
||||
|
||||
**beacon.ts 扩展**(873 → 1162 行,+289 行):
|
||||
- `BeaconScoreEntry` 新增 `isTimed?: boolean` 字段
|
||||
- `BeaconTimedChallenge` 接口:slotKey / type / difficulty(固定 routine) / goal / rewardInsight / rewardContact / seed / title / desc / expiresAt
|
||||
- `BeaconTimedProgress` 接口:slotKey / progress / startedAt / completedAt / claimed / durationSec
|
||||
- `TIMED_SLOT_MS = 4h`,`BEACON_TIMED_KEY = "echo-nexus-beacon-timed-v1"`
|
||||
- `getTimedSlotKey(now)`:UTC 0/4/8/12/16/20 点切分,格式 "timed_YYYY-MM-DD_HH"
|
||||
- `timedSlotKeyToSeed`:FNV-1a 哈希
|
||||
- `generateTimedChallenge(now)`:确定性生成,难度 routine,goal = 日基准 ×0.3-0.5,5 种类型各有公式
|
||||
- `msUntilNextTimedSlot(now)`:距离下个 4 小时时段的毫秒数
|
||||
- `loadTimedProgress` / `saveTimedProgress` / `addTimedProgress` / `claimTimedReward`(推送排行榜,entry 加 isTimed: true)
|
||||
|
||||
**gameStore.ts 集成**:
|
||||
- import 扩展:generateTimedChallenge / loadTimedProgress / addTimedProgress / claimTimedReward + 类型
|
||||
- `trackBeacon` 返回值新增 `timedJustCompleted`:日/限时/周三类进度同时更新
|
||||
- 新增 `claimTimedBeacon` action(GameActions 接口同步)
|
||||
- 新增 `timedJustCompleted` 到 trackBeacon 返回类型
|
||||
|
||||
**BeaconPanel.tsx UI**(753 → 944 行):
|
||||
- import 扩展:generateTimedChallenge / loadTimedProgress / getTimedSlotKey / msUntilNextTimedSlot / Hourglass 图标
|
||||
- 新增 timedChallenge/timedProgress/timedCountdown state
|
||||
- 每秒刷新 timedProgress + timedCountdown
|
||||
- 新增 handleClaimTimed 回调
|
||||
- **限时挑战区块**(amber 主题,插入日挑战与周挑战之间):
|
||||
- Hourglass 图标 + "限时挑战 · TIMED" 标题 + slotKey + 倒计时
|
||||
- 难度/类型标签 + 标题 + 描述 + 进度条 + 奖励 + 领取按钮(emerald 主题)
|
||||
- **紧急状态**:距时段结束 <30min 时切换 rose 脉冲动画 + "即将结束"徽章
|
||||
- 2 个新动画:timed-glow(amber 呼吸)/ timed-urgent(rose 急促脉冲)
|
||||
- 排行榜 entry 区分 TIMED(amber 徽章)/ WEEK(fuchsia 徽章)
|
||||
- 空状态提示更新为"完成限时、每日或本周信标即可登榜"
|
||||
|
||||
### 工单 #3:星潮类型深化
|
||||
|
||||
**starTide.ts 扩展**(209 → 289 行):
|
||||
- `TideType` 新增 3 种:surge / eclipse / prism
|
||||
- `TideModifiers` 新增 3 个可选字段:targetLenBonus / bossWinRateBonus / autoDecodeIntervalMult
|
||||
- `TIDE_EVENTS` 新增 3 种元信息(name/desc/color/glow/icon/negative):
|
||||
- surge(涌动星潮,emerald #34d399,⇈):产能 ×2.5 但解码目标 +2
|
||||
- eclipse(蚀相星潮,rose #fb7185,◐):BOSS 胜率 +20% 但产能 -30%
|
||||
- prism(棱镜星潮,fuchsia #e879f9,◬):洞见 ×2 + 自动解码周期 -30%
|
||||
- `TIDE_WEIGHTS` 新增 surge/eclipse/prism 各 12(总权重 136)
|
||||
- `getTideModifiers` 新增 3 个 case + null 分支补全新字段
|
||||
- 注释更新:"6 种类型" → "9 种类型"
|
||||
|
||||
**decode.ts 扩展**:
|
||||
- `generatePuzzle` 新增第 3 参数 `targetLenBonus`(默认 0)
|
||||
- targetLen = cfg.targetLen + targetLenBonus,stepLimitBase 同步增加
|
||||
- 路径构造法自动适配更长目标序列(300 次尝试 + 线性兜底)
|
||||
|
||||
**gameStore.ts 集成**(3 处修饰器应用):
|
||||
- `startDecode`:surge 星潮时传入 `tideMod.targetLenBonus` → 谜题更长
|
||||
- `autoDecodeTick`:prism 星潮时 `interval *= tideMod.autoDecodeIntervalMult`(×0.7)
|
||||
- `resolveCurrentNode`:eclipse 星潮时 `b = am.bossWinRateBonus + tideMod.bossWinRateBonus`(+20%)
|
||||
|
||||
**achievements.ts**:`ach_tides_all` 阈值 6 → 9,描述"经历全部 9 种星潮事件"
|
||||
|
||||
**page.tsx**:StatsPanel "星潮亲历" 显示 `/ 9`
|
||||
|
||||
### QA 验证
|
||||
1. **lint**:`bun run lint` 零错误(exit 0)
|
||||
2. **dev 服务器**:HTTP 200,编译 < 30ms
|
||||
3. **BeaconPanel VLM 评分**:
|
||||
- 首屏 8.5/10:限时挑战 TIMED 区块可见、四色一致、倒计时/进度/标题/奖励/按钮清晰
|
||||
- 最终截图 9/10:4 个挑战区块齐全、限时区块带倒计时、无蓝色
|
||||
4. **新星潮 VLM 评分**(localStorage 注入 + reload 触发):
|
||||
- surge(emerald 绿):8/10,横幅 + 全屏绿色微光
|
||||
- prism(fuchsia 紫):7/10,横幅 + 全屏紫色微光
|
||||
- eclipse(rose 粉):8/10(单图),横幅 + 全屏粉色微光
|
||||
5. **不破坏现有功能**:日挑战/周挑战/信标链/6 旧星潮全部保留,排行榜正常
|
||||
|
||||
## Stage Summary
|
||||
- ✅ 工单 #5 限时挑战完成:beacon.ts +289 行 + gameStore claimTimedBeacon + BeaconPanel amber 区块
|
||||
- ✅ 工单 #3 星潮深化完成:starTide.ts +80 行(3 新类型)+ decode.ts targetLenBonus + gameStore 3 处修饰器
|
||||
- ✅ 确定性生成:限时挑战同时段同种子同结果(FNV-1a slotKey 哈希)
|
||||
- ✅ 严格四色规范:限时 amber、surge emerald、eclipse rose、prism fuchsia,零蓝色/靛色
|
||||
- ✅ 紧急状态设计:限时挑战 <30min 时 rose 脉冲 + "即将结束"徽章
|
||||
- ✅ lint 零错误 + dev HTTP 200 + VLM ≥7/10(信标 9/10、surge 8/10、prism 7/10、eclipse 8/10)
|
||||
- ✅ 现有日/周/信标链/6 旧星潮全部保留,无回归
|
||||
@@ -0,0 +1,207 @@
|
||||
# Task 10-b · full-stack-developer · P2/#4 云排行榜 mini-service
|
||||
|
||||
> 本文件为本 agent 在 Task 10-b 的工作记录,供后续 agent 查阅。
|
||||
|
||||
## 任务概述
|
||||
|
||||
为「回响星核 / Echo Nexus」v0.8.2 实现 P2/#4 工单:把信标本机排行榜(纯 localStorage)升级为云端 Top100,让玩家间可以比较分数。
|
||||
|
||||
## 阅读的前置工作
|
||||
|
||||
- `/home/z/my-project/worklog.md`(v0.8.1 项目状态,10 大系统,四色全息规范)
|
||||
- `/home/z/my-project/src/lib/game/beacon.ts`(实际 1161 行 — worklog 信息滞后,v0.8.2 已加限时挑战 `claimTimedReward`)
|
||||
- `/home/z/my-project/src/components/game/BeaconPanel.tsx`(实际 953 行 — 已含限时挑战 amber 主题)
|
||||
- `/home/z/my-project/src/store/gameStore.ts`(1241 行 — trackBeacon + 3 个 claim action:grantBeaconReward/claimWeeklyBeacon/claimTimedBeacon/claimChainReward)
|
||||
- `/home/z/my-project/Caddyfile`(:81 网关,按 ?XTransformPort=* 转发到对应端口)
|
||||
- `/home/z/my-project/examples/websocket/server.ts` + `frontend.tsx`(mini-service + 前端连接参考)
|
||||
- `/home/z/my-project/.zscripts/dev.sh`(mini-service 自动扫描启动逻辑,发现 mini-services 目录下子服务自动 `bun run dev`)
|
||||
- `/home/z/my-project/agent-ctx/9-b-full-stack-developer.md`(前一个 agent 的信标系统扩展记录)
|
||||
- `/home/z/my-project/mini-services/star-tide-service/`(已存在的 P3 #10-c 服务,端口 3031)
|
||||
|
||||
## 实现细节
|
||||
|
||||
### 1. mini-service(`mini-services/leaderboard-service/`)
|
||||
|
||||
**目录结构**:
|
||||
```
|
||||
mini-services/leaderboard-service/
|
||||
├── package.json # 独立 bun 项目,type=module
|
||||
├── index.ts # Hono 入口(182 行)
|
||||
├── bun.lock # 自动生成
|
||||
├── node_modules/ # hono 独立安装
|
||||
└── README.md # API 文档
|
||||
```
|
||||
|
||||
**package.json**:
|
||||
- `"dev": "bun --hot index.ts"`(规范要求的热重载脚本)
|
||||
- `"start": "bun index.ts"`(无热重载,用于稳定运行)
|
||||
- 依赖:hono ^4.6.14
|
||||
|
||||
**index.ts 设计要点**:
|
||||
- Hono + `cors()` 中间件,origin: * 全开放(前端跨端口必须)
|
||||
- 内存数组 `entries: BeaconScoreEntry[]`,最多 1000 条,按 score 降序
|
||||
- 同分排序:先看用时短,再看提交早
|
||||
- 防刷:`lastSubmitAt: Map<dateKey|challenge, ts>`,10 秒内只接受 1 次,返回 429 + retryAfterMs
|
||||
- 4 个路由:
|
||||
- `GET /` → 健康检查 `{service, version, ok, uptime}`
|
||||
- `GET /api/leaderboard` → Top100 `{entries, total}`
|
||||
- `POST /api/leaderboard` body `{entry}` → schema 校验 → 推入 → 排序 → `{entries, total, rank}`
|
||||
- `GET /api/leaderboard/stats` → `{totalSubmissions, uniquePlayers, topScore}`
|
||||
- OPTIONS 预检由 hono/cors 自动处理,返回 204 + 完整 CORS 头
|
||||
- 端口固定 3030,`export default { port, fetch }` 标准 bun 模式
|
||||
|
||||
**启动调试经验**:
|
||||
- `bun --hot` 在本沙盒环境下不稳定,多次启动后约 5-10s 内被 kill(即使 setsid + nohup)
|
||||
- 解决方案:用 `bun index.ts`(无 --hot)+ 双 fork 模式:`( bun index.ts >/tmp/log 2>&1 & )`
|
||||
- 双 fork 后 PPID=1(reparent 到 init),进程脱离原 bash session,uptime 4+ 分钟稳定
|
||||
|
||||
### 2. beacon.ts 扩展(1161 → 1290 行,+129 行)
|
||||
|
||||
**三个 claim 函数返回值扩展(非破坏性)**:
|
||||
- `claimBeaconReward` 返回值新增 `entry: BeaconScoreEntry`
|
||||
- `claimWeeklyReward` 返回值新增 `entry: BeaconScoreEntry`
|
||||
- `claimTimedReward` 返回值新增 `entry: BeaconScoreEntry`
|
||||
- 用途:让前端 / gameStore 能拿到完整 entry 提交到云端
|
||||
|
||||
**新增云排行榜模块**:
|
||||
- `BEACON_CLOUD_PORT = 3030` 常量
|
||||
- `BEACON_CLOUD_LAST_SUBMIT_KEY = "echo-nexus-beacon-cloud-last-submit-v1"` localStorage key
|
||||
- 3 个响应接口:`CloudLeaderboardResponse` / `CloudSubmitResponse` / `CloudStatsResponse`
|
||||
- `loadLastCloudSubmitTimestamp()` / `saveLastCloudSubmitTimestamp(ts)`
|
||||
- `fetchCloudLeaderboard(): Promise<BeaconScoreEntry[]>` — GET `/api/leaderboard?XTransformPort=3030`
|
||||
- `submitCloudScore(entry): Promise<number>` — POST,成功返回 rank ≥ 1,失败返回 -1,自动记录 ts
|
||||
- `fetchCloudStats(): Promise<CloudStatsResponse | null>`
|
||||
- **所有 fetch 用相对路径 + ?XTransformPort=3030,禁止 localhost:3030**
|
||||
- try-catch 包裹,失败时静默返回空/null,不抛异常
|
||||
|
||||
### 3. gameStore.ts 集成
|
||||
|
||||
- import 新增 `submitCloudScore`
|
||||
- `claimWeeklyBeacon` action:领奖成功后 `void submitCloudScore(res.entry)`(fire-and-forget,不 await,不阻塞)
|
||||
- `claimTimedBeacon` action:同上
|
||||
- `claimBeaconReward` (daily) 由 BeaconPanel 直接调用,不通过 gameStore
|
||||
|
||||
### 4. BeaconPanel.tsx 重写(953 → 1170 行,+217 行)
|
||||
|
||||
**新增 import**:
|
||||
- `fetchCloudLeaderboard` / `submitCloudScore` / `loadLastCloudSubmitTimestamp` from beacon
|
||||
- 4 个 lucide 图标:`RefreshCw` / `Globe` / `WifiOff` / `Loader2`
|
||||
- `useRef` from react
|
||||
|
||||
**新增状态**:
|
||||
- `lbTab: 'local' | 'global'`(默认 local)
|
||||
- `cloudEntries: BeaconScoreEntry[]`
|
||||
- `cloudLoading: boolean`
|
||||
- `cloudError: string | null`
|
||||
- `cloudFetched: boolean`
|
||||
- `mySubmitTs: number`
|
||||
- `cloudFetchingRef` (防并发)
|
||||
|
||||
**新增逻辑**:
|
||||
- `refreshCloudLeaderboard` callback:拉取云端榜 + 防并发 + 空 entries 时显示"成为首位登顶者"
|
||||
- useEffect:切到全球 tab 自动拉取(仅首次)+ 初始化读取 mySubmitTs
|
||||
- `handleClaimDaily`:领奖后 `void submitCloudScore(res.entry).then(rank => ...)` — 若用户在全球 tab 自动刷新
|
||||
- `handleClaimTimed` / `handleClaimWeekly`:领奖后更新 mySubmitTs + 若在全球 tab 自动刷新
|
||||
|
||||
**排行榜 UI 重写为双 tab**:
|
||||
- 顶部 tab 切换:「本地」(amber 主题) / 「全球」(emerald 主题) — 用 `<button aria-pressed>` 实现可访问性
|
||||
- 全球 tab 专属刷新按钮(RefreshCw,loading 时 animate-spin)
|
||||
- 子标题:本地"本机 Top N · 离线可用" / 全球"全球 Top N · 已同步 / 同步中…"
|
||||
- 本地榜:保留原 v0.8.2 渲染(max-h-200px overflow-y-auto)
|
||||
- 全球榜:
|
||||
- 加载中:Loader2 spinner + "正在拉取全球榜…"
|
||||
- 空榜 + 错误:WifiOff 图标 + 错误文案 + 重试按钮
|
||||
- 空榜 + 无错:Globe 图标 + "尚无全球记录。完成挑战并领取即可登顶全球榜!"
|
||||
- 有数据:渲染前 100 条,每条带排名(1-3 名用 Crown/Medal/Award 图标)/ 难度色 / WEEK·TIMED 徽章 / 用时 / 分数
|
||||
- **玩家自己的记录高亮**:通过 `entry.timestamp === mySubmitTs` 匹配
|
||||
- emerald 边框 (`border-emerald-500/40`)
|
||||
- emerald 阴影 (`shadow-[0_0_12px_rgba(52,211,153,0.25)]`)
|
||||
- YOU 徽章 (`bg-emerald-500/25 text-emerald-200 border-emerald-500/50`)
|
||||
- emerald 分数色 (`color: #34d399`)
|
||||
- 加粗排名
|
||||
|
||||
### 5. eslint 配置
|
||||
|
||||
- `eslint.config.mjs` 新增 ignores `"mini-services/**"`
|
||||
- 原因:mini-service 是独立 bun 项目,有自己的 TypeScript 配置和 lint 规范,不应参与主项目 lint
|
||||
|
||||
## QA 验证结果
|
||||
|
||||
### 1. lint
|
||||
- `bun run lint` 零错误零警告
|
||||
|
||||
### 2. dev 服务器
|
||||
- dev.log 全程无错误,所有编译 < 300ms
|
||||
- 注:dev.log 中显示 `/api/leaderboard?XTransformPort=3030 404` 是预期行为 — 通过 :3000 直接访问时 Next.js 没有 /api/leaderboard 路由;通过 :81 网关访问时 Caddy 转发到 :3030 正常返回 200
|
||||
|
||||
### 3. mini-service curl 全通过
|
||||
- `GET /` → 200 `{"service":"echo-nexus-leaderboard","version":"v0.8.2","ok":true,"uptime":N}`
|
||||
- `GET /api/leaderboard` (空) → 200 `{"entries":[],"total":0}`
|
||||
- `POST /api/leaderboard` → 200 `{"entries":[...],"total":1,"rank":1}`
|
||||
- `GET /api/leaderboard` (有数据) → 200 `{"entries":[...],"total":N}`
|
||||
- `GET /api/leaderboard/stats` → 200 `{"totalSubmissions":N,"uniquePlayers":M,"topScore":X}`
|
||||
- `OPTIONS /api/leaderboard` 预检 → 204 + 完整 CORS 头(Allow-Origin: *)
|
||||
- 重复 POST(同 dateKey+challenge 10s 内)→ 429 `{"error":"Rate limited","retryAfterMs":N}`
|
||||
|
||||
### 4. 网关路由验证
|
||||
- Caddy :81 → localhost:3030 通过 `?XTransformPort=3030` 正确转发,返回 JSON 200
|
||||
|
||||
### 5. agent-browser 集成测试
|
||||
- 通过 :81 端口访问(绕过 Next.js :3000 的 404)
|
||||
- 信标 tab 选中(用 KeyboardEvent Enter 激活 Radix Tabs — 因 pointerdown 被覆盖层拦截)
|
||||
- 切到「全球」tab → 自动拉取 → 显示"全球 Top 6 · 已同步" → 渲染 6 条记录
|
||||
- 刷新按钮可点击,旋转动画正常
|
||||
- POST 测试:浏览器 fetch 提交 score=9999 → 返回 rank=1 → 刷新后榜首显示 10.00K
|
||||
- YOU 高亮:手动设置 localStorage[BEACON_CLOUD_LAST_SUBMIT_KEY] 后刷新 → 榜首显示 emerald 边框 + YOU 徽章
|
||||
|
||||
### 6. VLM 视觉评分(目标 ≥7/10)
|
||||
- 全球榜含 YOU 高亮:**8/10**
|
||||
- VLM 评语:"双tab切换清晰,全球榜记录完整,难度色与标签规范,视觉层次佳,但「YOU」徽章未突出显示"
|
||||
- 本地榜:**8/10**
|
||||
- VLM 评语:"双tab切换逻辑清晰,本地tab内容完整呈现;四色规范应用,视觉区分度高;信息层级合理"
|
||||
|
||||
## 修改的文件
|
||||
|
||||
1. `mini-services/leaderboard-service/package.json`(新建)
|
||||
2. `mini-services/leaderboard-service/index.ts`(新建,182 行)
|
||||
3. `mini-services/leaderboard-service/README.md`(新建)
|
||||
4. `src/lib/game/beacon.ts` — 1161 → 1290 行(+129 行:3 个 claim 返回 entry + 云排行榜模块)
|
||||
5. `src/store/gameStore.ts` — 1235 → 1252 行(+17 行:import + 2 个 claim action 加 submitCloudScore)
|
||||
6. `src/components/game/BeaconPanel.tsx` — 953 → 1170 行(+217 行:双 tab UI + 云端拉取 + 提交 + YOU 高亮)
|
||||
7. `eslint.config.mjs` — ignores 新增 "mini-services/**"
|
||||
|
||||
## 不破坏的现有功能
|
||||
|
||||
- 本地排行榜(`loadLeaderboard` / `pushLeaderboardEntry`)完整保留作为离线 fallback
|
||||
- 日挑战 / 周挑战 / 限时挑战 / 信标链流程全部保留
|
||||
- 现有 localStorage keys(echo-nexus-beacon-lb-v1 / -prog-v1 / -weekly-v1 / -chain-v1 / -timed-v1)全部保留
|
||||
- 主项目 package.json 不变(hono 装在 mini-service 子项目内)
|
||||
|
||||
## 新增的 localStorage keys
|
||||
|
||||
- `echo-nexus-beacon-cloud-last-submit-v1`(上次成功提交到云端的 entry timestamp,用于全球榜高亮)
|
||||
|
||||
## 四色全息规范遵循
|
||||
|
||||
- 本地 tab:amber (#fbbf24)
|
||||
- 全球 tab:emerald (#34d399) — 与"在线/同步"语义一致
|
||||
- 玩家高亮:emerald 边框 + emerald YOU 徽章 + emerald 分数色
|
||||
- 难度色保留:routine emerald / anomaly amber / singular rose
|
||||
- 周挑战徽章:fuchsia
|
||||
- 限时挑战徽章:amber
|
||||
- 错误状态:rose (#fb7185)
|
||||
- **零蓝色/靛色违规**
|
||||
|
||||
## 截图资产
|
||||
|
||||
- `/home/z/my-project/agent-ctx/beacon-global-leaderboard.png` — 全球榜初始视图(6 条记录)
|
||||
- `/home/z/my-project/agent-ctx/beacon-global-with-you.png` — 全球榜含 YOU 高亮(7 条记录,榜首是玩家)
|
||||
- `/home/z/my-project/agent-ctx/beacon-local-leaderboard.png` — 本地榜视图
|
||||
|
||||
## 重要注意事项(供后续 agent)
|
||||
|
||||
1. **前端访问端口**:用户通过 Preview Panel 看到的是 Caddy 网关 :81,不是 Next.js :3000。所以 `fetch('/api/leaderboard?XTransformPort=3030')` 在用户视角下能正常路由。如果直接访问 :3000 会 404(这是预期行为)。
|
||||
2. **mini-service 启动方式**:用 `bun index.ts`(无 --hot)+ 双 fork `( ... & )` 模式启动。`bun --hot` 在沙盒下不稳定。
|
||||
3. **mini-service 已被 .zscripts/dev.sh 自动扫描**:下次容器重启会自动启动。但当前会话需手动启动一次。
|
||||
4. **localStorage key 命名**:`echo-nexus-beacon-cloud-last-submit-v1` 而非 `echo-nexus-beacon-cloud-lb-v1`,前者存的是单个 timestamp(用于高亮),后者若需要存全球榜快照可用后者(目前未实现,按需拉取)。
|
||||
5. **star-tide-service 端口 3031**:另一个 P3 工单已存在的服务,本服务的端口 3030 与之互不冲突。
|
||||
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 471 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 372 KiB |
|
After Width: | Height: | Size: 396 KiB |
|
After Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 471 KiB |
|
After Width: | Height: | Size: 472 KiB |
|
After Width: | Height: | Size: 352 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 223 KiB |
@@ -63,6 +63,8 @@
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"recharts": "^2.15.4",
|
||||
"sharp": "^0.34.3",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"sonner": "^2.0.6",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
@@ -600,6 +602,8 @@
|
||||
|
||||
"@schummar/icu-type-parser": ["@schummar/icu-type-parser@1.21.5", "https://registry.npmjs.com/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", {}, "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw=="],
|
||||
|
||||
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://registry.npmjs.com/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "https://registry.npmjs.com/@standard-schema/utils/-/utils-0.3.0.tgz", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||
@@ -674,6 +678,8 @@
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "https://registry.npmjs.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
|
||||
|
||||
"@types/d3-array": ["@types/d3-array@3.2.2", "https://registry.npmjs.com/@types/d3-array/-/d3-array-3.2.2.tgz", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
||||
|
||||
"@types/d3-color": ["@types/d3-color@3.1.3", "https://registry.npmjs.com/@types/d3-color/-/d3-color-3.1.3.tgz", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
|
||||
@@ -716,6 +722,8 @@
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "https://registry.npmjs.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.53.0", "https://registry.npmjs.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/type-utils": "8.53.0", "@typescript-eslint/utils": "8.53.0", "@typescript-eslint/visitor-keys": "8.53.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.53.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.53.0", "https://registry.npmjs.com/@typescript-eslint/parser/-/parser-8.53.0.tgz", { "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", "@typescript-eslint/typescript-estree": "8.53.0", "@typescript-eslint/visitor-keys": "8.53.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg=="],
|
||||
@@ -776,6 +784,8 @@
|
||||
|
||||
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "https://registry.npmjs.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
|
||||
|
||||
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "https://registry.npmjs.com/acorn/-/acorn-8.15.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "https://registry.npmjs.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
@@ -824,6 +834,8 @@
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "https://registry.npmjs.com/base64-js/-/base64-js-1.5.1.tgz", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.15", "https://registry.npmjs.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "https://registry.npmjs.com/brace-expansion/-/brace-expansion-1.1.12.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
@@ -898,6 +910,8 @@
|
||||
|
||||
"cookie": ["cookie@0.7.2", "https://registry.npmjs.com/cookie/-/cookie-0.7.2.tgz", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
"crelt": ["crelt@1.0.6", "https://registry.npmjs.com/crelt/-/crelt-1.0.6.tgz", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmjs.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
@@ -994,6 +1008,12 @@
|
||||
|
||||
"empathic": ["empathic@2.0.0", "https://registry.npmjs.com/empathic/-/empathic-2.0.0.tgz", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
|
||||
|
||||
"engine.io": ["engine.io@6.6.9", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "@types/ws": "^8.5.12", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.21.0" } }, "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg=="],
|
||||
|
||||
"engine.io-client": ["engine.io-client@6.6.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.21.0", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q=="],
|
||||
|
||||
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.18.4", "https://registry.npmjs.com/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
|
||||
|
||||
"es-abstract": ["es-abstract@1.24.1", "https://registry.npmjs.com/es-abstract/-/es-abstract-1.24.1.tgz", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="],
|
||||
@@ -1452,6 +1472,8 @@
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "https://registry.npmjs.com/mime-db/-/mime-db-1.54.0.tgz", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.2", "https://registry.npmjs.com/minimatch/-/minimatch-3.1.2.tgz", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "https://registry.npmjs.com/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
@@ -1668,6 +1690,14 @@
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://registry.npmjs.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"socket.io": ["socket.io@4.8.3", "", { "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A=="],
|
||||
|
||||
"socket.io-adapter": ["socket.io-adapter@2.5.8", "", { "dependencies": { "debug": "~4.4.1", "ws": "~8.21.0" } }, "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw=="],
|
||||
|
||||
"socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="],
|
||||
|
||||
"socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="],
|
||||
|
||||
"sonner": ["sonner@2.0.7", "https://registry.npmjs.com/sonner/-/sonner-2.0.7.tgz", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "https://registry.npmjs.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
@@ -1796,6 +1826,8 @@
|
||||
|
||||
"uvu": ["uvu@0.5.6", "https://registry.npmjs.com/uvu/-/uvu-0.5.6.tgz", { "dependencies": { "dequal": "^2.0.0", "diff": "^5.0.0", "kleur": "^4.0.3", "sade": "^1.7.3" }, "bin": { "uvu": "bin.js" } }, "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"vaul": ["vaul@1.1.2", "https://registry.npmjs.com/vaul/-/vaul-1.1.2.tgz", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "https://registry.npmjs.com/vfile/-/vfile-6.0.3.tgz", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
@@ -1818,6 +1850,10 @@
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "https://registry.npmjs.com/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
|
||||
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
|
||||
|
||||
"xtend": ["xtend@4.0.2", "https://registry.npmjs.com/xtend/-/xtend-4.0.2.tgz", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||
|
||||
"yallist": ["yallist@4.0.0", "https://registry.npmjs.com/yallist/-/yallist-4.0.0.tgz", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
@@ -1900,6 +1936,8 @@
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "https://registry.npmjs.com/minimatch/-/minimatch-9.0.5.tgz", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||
|
||||
"downshift/react-is": ["react-is@17.0.2", "https://registry.npmjs.com/react-is/-/react-is-17.0.2.tgz", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
||||
|
||||
"eslint-import-resolver-node/debug": ["debug@3.2.7", "https://registry.npmjs.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||
@@ -1930,6 +1968,8 @@
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.1", "https://registry.npmjs.com/picomatch/-/picomatch-2.3.1.tgz", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "https://registry.npmjs.com/postcss/-/postcss-8.4.31.tgz", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"next-auth/uuid": ["uuid@8.3.2", "https://registry.npmjs.com/uuid/-/uuid-8.3.2.tgz", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
|
||||
@@ -44,7 +44,7 @@ const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, {
|
||||
"no-useless-escape": "off",
|
||||
},
|
||||
}, {
|
||||
ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts", "examples/**", "skills"]
|
||||
ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts", "examples/**", "skills", "mini-services/**"]
|
||||
}];
|
||||
|
||||
export default eslintConfig;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Echo Nexus · 云排行榜 mini-service
|
||||
|
||||
回响星核 / Echo Nexus 的 P2 云排行榜服务(v0.8.2 / Task 10-b)。
|
||||
|
||||
把信标本机排行榜(`localStorage`)升级为云端 Top100,玩家间可比。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **运行时**:Bun(原生 TypeScript,无需编译)
|
||||
- **框架**:Hono(轻量 web 框架,bun 原生支持)
|
||||
- **存储**:内存数组,重启清空(够用,符合 P2 规格)
|
||||
- **端口**:固定 `3030`(Caddy 通过 `?XTransformPort=3030` 转发)
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
mini-services/leaderboard-service/
|
||||
├── package.json # 独立 bun 项目
|
||||
├── index.ts # Hono 入口
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
所有响应自动 CORS 开放,OPTIONS 预检由 Hono 自动处理。
|
||||
|
||||
| 方法 | 路径 | 入参 | 返回 |
|
||||
|---|---|---|---|
|
||||
| GET | `/` | — | `{ service, version, ok, uptime }` 健康检查 |
|
||||
| GET | `/api/leaderboard` | — | `{ entries: BeaconScoreEntry[], total }` Top100 |
|
||||
| POST | `/api/leaderboard` | body `{ entry: BeaconScoreEntry }` | `{ entries, total, rank }` rank=1..N 或 -1 |
|
||||
| GET | `/api/leaderboard/stats` | — | `{ totalSubmissions, uniquePlayers, topScore }` |
|
||||
|
||||
### `BeaconScoreEntry` 结构
|
||||
|
||||
```ts
|
||||
{
|
||||
timestamp: number, // 提交时间戳
|
||||
dateKey: string, // YYYY-MM-DD 或 YYYY-Www
|
||||
challenge: string, // decode | expedition | pulse | boss | insight
|
||||
difficulty: string, // routine | anomaly | singular
|
||||
progress: number, // 0-1,1 = 完成
|
||||
score: number, // 最终得分
|
||||
durationSec: number, // 完成时长(秒)
|
||||
isWeekly?: boolean, // 周挑战记录
|
||||
isTimed?: boolean // 限时挑战记录(预留)
|
||||
}
|
||||
```
|
||||
|
||||
## 存储 & 防刷规则
|
||||
|
||||
- **容量**:最多 1000 条,按分数降序;超过自动丢弃尾部。
|
||||
- **Top 榜**:API 返回前 100 条。
|
||||
- **同分排序**:先看用时短,再看提交早。
|
||||
- **防刷**:同 `dateKey + challenge` 10 秒内只接受 1 次提交,返回 `429`。
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
cd mini-services/leaderboard-service
|
||||
bun run dev
|
||||
# 等价于 bun --hot index.ts,文件变更自动重启
|
||||
```
|
||||
|
||||
## 联调(前端请求规范)
|
||||
|
||||
前端必须用相对路径 + `?XTransformPort=3030`:
|
||||
|
||||
```ts
|
||||
// 拉取全球 Top100
|
||||
const res = await fetch("/api/leaderboard?XTransformPort=3030");
|
||||
const { entries, total } = await res.json();
|
||||
|
||||
// 提交一条记录
|
||||
const res = await fetch("/api/leaderboard?XTransformPort=3030", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ entry }),
|
||||
});
|
||||
const { entries, total, rank } = await res.json();
|
||||
```
|
||||
|
||||
**禁止** `fetch("http://localhost:3030/...")` —— 不通过 Caddy 网关,会被浏览器同源策略拦截。
|
||||
|
||||
## 部署形态
|
||||
|
||||
独立 bun 项目,与主 Next.js 项目解耦:
|
||||
|
||||
- 主项目端口 3000(Next.js dev)
|
||||
- 本服务端口 3030(Hono + Bun)
|
||||
- Caddy 网关端口 81(按 `?XTransformPort=3030` 转发到本服务)
|
||||
|
||||
服务重启会清空数据,符合 P2 阶段规格(不需要持久化)。
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "echo-nexus-leaderboard-service",
|
||||
"dependencies": {
|
||||
"hono": "^4.6.14",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"hono": ["hono@4.12.27", "", {}, "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// 回响星核 / Echo Nexus — 云排行榜 mini-service
|
||||
// P2/#4:把信标本机排行榜升级为云排行榜。
|
||||
// 端口固定 3030;Caddy 通过 ?XTransformPort=3030 转发。
|
||||
// 内存存储(重启清空),Hono + Bun 原生。
|
||||
//
|
||||
// API:
|
||||
// GET /api/leaderboard → { entries: BeaconScoreEntry[], total }
|
||||
// POST /api/leaderboard body { entry } → { entries, total, rank }
|
||||
// GET /api/leaderboard/stats → { totalSubmissions, uniquePlayers, topScore }
|
||||
// GET / → 健康检查
|
||||
//
|
||||
// 所有响应自动 CORS 开放(hono/cors),OPTIONS 预检 hono 自动处理。
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 类型(与前端 BeaconScoreEntry 一致,但这里宽松化以接受历史数据)
|
||||
// ---------------------------------------------------------------------------
|
||||
interface BeaconScoreEntry {
|
||||
timestamp: number;
|
||||
dateKey: string;
|
||||
challenge: string;
|
||||
difficulty: string;
|
||||
progress: number;
|
||||
score: number;
|
||||
durationSec: number;
|
||||
isWeekly?: boolean;
|
||||
isTimed?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 内存存储
|
||||
// ---------------------------------------------------------------------------
|
||||
const entries: BeaconScoreEntry[] = [];
|
||||
const MAX_ENTRIES = 1000;
|
||||
const TOP_N = 100;
|
||||
|
||||
// 简单防刷:同 dateKey+challenge 10 秒内只接受 1 次提交
|
||||
const lastSubmitAt = new Map<string, number>();
|
||||
const RATE_LIMIT_MS = 10_000;
|
||||
|
||||
function sortEntries() {
|
||||
entries.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
// 同分情况下,用时短者靠前
|
||||
if (a.durationSec !== b.durationSec) return a.durationSec - b.durationSec;
|
||||
// 仍相同,按时间戳升序(先提交靠前)
|
||||
return a.timestamp - b.timestamp;
|
||||
});
|
||||
while (entries.length > MAX_ENTRIES) entries.pop();
|
||||
}
|
||||
|
||||
function findRank(entry: BeaconScoreEntry): number {
|
||||
const idx = entries.findIndex(
|
||||
(e) =>
|
||||
e.timestamp === entry.timestamp &&
|
||||
e.dateKey === entry.dateKey &&
|
||||
e.score === entry.score
|
||||
);
|
||||
return idx >= 0 ? idx + 1 : -1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hono 实例
|
||||
// ---------------------------------------------------------------------------
|
||||
const app = new Hono();
|
||||
|
||||
// 全局 CORS 开放(前端跨端口访问)
|
||||
app.use(
|
||||
"*",
|
||||
cors({
|
||||
origin: "*",
|
||||
allowMethods: ["GET", "POST", "OPTIONS"],
|
||||
allowHeaders: ["Content-Type", "X-Requested-With"],
|
||||
exposeHeaders: ["Content-Length"],
|
||||
maxAge: 600,
|
||||
})
|
||||
);
|
||||
|
||||
// 健康检查
|
||||
app.get("/", (c) =>
|
||||
c.json({
|
||||
service: "echo-nexus-leaderboard",
|
||||
version: "v0.8.2",
|
||||
ok: true,
|
||||
uptime: Math.floor(process.uptime?.() ?? 0),
|
||||
})
|
||||
);
|
||||
|
||||
// GET /api/leaderboard — 返回 Top100
|
||||
app.get("/api/leaderboard", (c) => {
|
||||
sortEntries();
|
||||
const top = entries.slice(0, TOP_N);
|
||||
return c.json({ entries: top, total: entries.length });
|
||||
});
|
||||
|
||||
// POST /api/leaderboard — 插入一条记录,返回新榜 + 排名
|
||||
app.post("/api/leaderboard", async (c) => {
|
||||
let body: { entry?: unknown };
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: "Invalid JSON body" }, 400);
|
||||
}
|
||||
if (!body || !body.entry || typeof body.entry !== "object") {
|
||||
return c.json({ error: "Missing entry in body" }, 400);
|
||||
}
|
||||
const e = body.entry as Partial<BeaconScoreEntry>;
|
||||
if (
|
||||
typeof e.timestamp !== "number" ||
|
||||
typeof e.dateKey !== "string" ||
|
||||
typeof e.challenge !== "string" ||
|
||||
typeof e.difficulty !== "string" ||
|
||||
typeof e.score !== "number"
|
||||
) {
|
||||
return c.json({ error: "Entry schema mismatch" }, 400);
|
||||
}
|
||||
|
||||
// 防刷:同 dateKey+challenge 10 秒内只接受 1 次
|
||||
const key = `${e.dateKey}|${e.challenge}`;
|
||||
const now = Date.now();
|
||||
const last = lastSubmitAt.get(key) ?? 0;
|
||||
if (now - last < RATE_LIMIT_MS) {
|
||||
return c.json(
|
||||
{
|
||||
error: "Rate limited",
|
||||
retryAfterMs: RATE_LIMIT_MS - (now - last),
|
||||
rank: -1,
|
||||
},
|
||||
429
|
||||
);
|
||||
}
|
||||
lastSubmitAt.set(key, now);
|
||||
|
||||
// 规范化 entry(去多余字段,补默认值)
|
||||
const entry: BeaconScoreEntry = {
|
||||
timestamp: e.timestamp,
|
||||
dateKey: e.dateKey,
|
||||
challenge: e.challenge,
|
||||
difficulty: e.difficulty,
|
||||
progress: typeof e.progress === "number" ? e.progress : 0,
|
||||
score: Math.max(0, Math.floor(e.score)),
|
||||
durationSec: typeof e.durationSec === "number" ? e.durationSec : 0,
|
||||
isWeekly: e.isWeekly === true,
|
||||
isTimed: e.isTimed === true,
|
||||
};
|
||||
|
||||
entries.push(entry);
|
||||
sortEntries();
|
||||
const rank = findRank(entry);
|
||||
|
||||
return c.json({
|
||||
entries: entries.slice(0, TOP_N),
|
||||
total: entries.length,
|
||||
rank,
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/leaderboard/stats — 全局统计
|
||||
app.get("/api/leaderboard/stats", (c) => {
|
||||
sortEntries();
|
||||
const uniqueDateKeys = new Set(entries.map((e) => e.dateKey));
|
||||
return c.json({
|
||||
totalSubmissions: entries.length,
|
||||
uniquePlayers: uniqueDateKeys.size,
|
||||
topScore: entries.length > 0 ? entries[0].score : 0,
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 启动
|
||||
// ---------------------------------------------------------------------------
|
||||
const PORT = 3030;
|
||||
console.log(`[leaderboard-service] listening on :${PORT}`);
|
||||
|
||||
const server = {
|
||||
port: PORT,
|
||||
fetch: app.fetch,
|
||||
};
|
||||
|
||||
export default server;
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "echo-nexus-leaderboard-service",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --hot index.ts",
|
||||
"start": "bun index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.6.14"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
# star-tide-service · 全球星潮同步 mini-service
|
||||
|
||||
> 回响星核 / Echo Nexus · P3 工单 #10-c
|
||||
>
|
||||
> socket.io 多人同步星潮 —— 所有在线玩家同时经历同一道全球星潮 60 秒。
|
||||
|
||||
## 设计
|
||||
|
||||
- **端口**:固定 `3031`
|
||||
- **网关**:Caddy 通过 `?XTransformPort=3031` 转发;前端用 `io("/?XTransformPort=3031")`
|
||||
- **运行时**:bun + socket.io 4
|
||||
- **存储**:纯内存(无状态,重启不影响前端游戏)
|
||||
|
||||
## 星潮节奏
|
||||
|
||||
- 首次星潮:服务端启动后 **60 秒**(可由 env `FIRST_TIDE_DELAY_MS` 覆盖,便于 QA)
|
||||
- 后续星潮:**10-15 分钟随机间隔**
|
||||
- 每次持续:**60 秒**
|
||||
- 类型:从 6 种星潮按权重随机抽取(与前端 `TIDE_EVENTS` 一致)
|
||||
- 晶体潮 22 / 谐振风暴 20 / 遗迹共振 16 / 虚空低语 18 / 星核悸动 14 / 寂静期 10
|
||||
|
||||
## socket.io 事件
|
||||
|
||||
### 服务端 → 客户端
|
||||
|
||||
| 事件 | 载荷 | 触发时机 |
|
||||
|------|------|---------|
|
||||
| `tide-state` | `{ current: GlobalTideState \| null, nextTideAt, serverTime }` | 客户端连接时立即推送 |
|
||||
| `global-tide` | `{ type, name, startedAt, endsAt, durationSec }` | 全球星潮开始 |
|
||||
| `tide-ended` | `{ endedAt, nextTideAt }` | 全球星潮结束 |
|
||||
| `next-tide-info` | `{ current, nextTideAt, serverTime, remainingMs }` | 响应客户端 `get-next-tide` |
|
||||
|
||||
### 客户端 → 服务端
|
||||
|
||||
| 事件 | 载荷 | 行为 |
|
||||
|------|------|------|
|
||||
| `get-next-tide` | — | 查询下次星潮剩余时间 |
|
||||
| `admin-trigger-tide` | — | **dev/QA 用**:立即触发一次全球星潮 |
|
||||
|
||||
## HTTP 端点
|
||||
|
||||
socket.io `path: '/'` 拦截所有 HTTP 请求,因此本服务不提供独立 HTTP 健康检查端点。
|
||||
|
||||
- `GET /` —— socket.io 默认握手响应(`{"code":0,"message":"Transport unknown"}` 表示服务在线)
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
cd mini-services/star-tide-service
|
||||
bun run dev # bun --hot index.ts,热重载
|
||||
# 或
|
||||
bun run start # bun index.ts,无热重载
|
||||
```
|
||||
|
||||
## QA 验证
|
||||
|
||||
```bash
|
||||
# socket.io 在线验证(收到 JSON 响应即表示服务在线)
|
||||
curl http://localhost:3031/
|
||||
# 预期:{"code":0,"message":"Transport unknown"}
|
||||
```
|
||||
|
||||
前端可在浏览器 console 触发全球星潮(QA 用):
|
||||
|
||||
```js
|
||||
const sock = io("/?XTransformPort=3031");
|
||||
sock.on("tide-state", (s) => console.log("当前状态:", s));
|
||||
sock.emit("admin-trigger-tide"); // 立即触发一次全球星潮
|
||||
```
|
||||
|
||||
## 离线容错
|
||||
|
||||
- 服务端宕机 → 前端 `useGlobalTide` 自动重连(socket.io 默认行为)
|
||||
- 断连期间 → 前端本地 `tickTide` 继续按原节奏工作,玩家不会卡住
|
||||
- 全球星潮进行中 → 前端本地 `tickTide` 暂停触发(避免冲突)
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "echo-nexus-star-tide-service",
|
||||
"dependencies": {
|
||||
"socket.io": "^4.8.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
|
||||
|
||||
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
|
||||
|
||||
"@types/node": ["@types/node@26.0.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
|
||||
"base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"engine.io": ["engine.io@6.6.9", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "@types/ws": "^8.5.12", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.21.0" } }, "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg=="],
|
||||
|
||||
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"socket.io": ["socket.io@4.8.3", "", { "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A=="],
|
||||
|
||||
"socket.io-adapter": ["socket.io-adapter@2.5.8", "", { "dependencies": { "debug": "~4.4.1", "ws": "~8.21.0" } }, "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw=="],
|
||||
|
||||
"socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="],
|
||||
|
||||
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
// 回响星核 / Echo Nexus — 全球星潮同步 mini-service
|
||||
// P3/工单 #10-c:socket.io 多人同步星潮
|
||||
// 端口固定 3031;Caddy 通过 ?XTransformPort=3031 转发。
|
||||
// 前端连接:io("/?XTransformPort=3031"),path 默认 "/"。
|
||||
//
|
||||
// 逻辑:
|
||||
// - 服务端每 10-15 分钟(随机间隔)随机选一个星潮类型,
|
||||
// 广播 `global-tide` 事件 { type, name, startedAt, endsAt, durationSec: 60 }
|
||||
// - 客户端连接后立即收到 `tide-state` 事件,包含当前进行中的星潮(如有)
|
||||
// + 下次星潮时间戳 + 服务器时间(用于时钟同步)
|
||||
// - 客户端可随时 emit `get-next-tide`,服务端响应 `next-tide-info`
|
||||
// { current, nextTideAt, serverTime, remainingMs }
|
||||
// - 全球星潮结束时广播 `tide-ended` { endedAt, nextTideAt }
|
||||
// - `admin-trigger-tide`(dev 用):立即触发一次全球星潮,便于 QA 验证
|
||||
//
|
||||
// 离线容错:服务端宕机时,前端 useGlobalTide hook 会自动重连;
|
||||
// 在断连期间,本地 tickTide 继续按原节奏工作,玩家不会被卡住。
|
||||
|
||||
import { createServer } from "http";
|
||||
import { Server } from "socket.io";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 配置
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 全球星潮持续秒数(与前端 triggerGlobalTide 保持一致) */
|
||||
const DURATION_SEC = 60;
|
||||
|
||||
/** 首次星潮延迟(毫秒)—— 默认 60s 便于 QA 验证;可通过 env FIRST_TIDE_DELAY_MS 覆盖 */
|
||||
const FIRST_TIDE_DELAY_MS = Number(process.env.FIRST_TIDE_DELAY_MS) || 60 * 1000;
|
||||
|
||||
/** 后续星潮最小间隔(10 分钟) */
|
||||
const MIN_GAP_MS = 10 * 60 * 1000;
|
||||
|
||||
/** 后续星潮最大间隔(15 分钟) */
|
||||
const MAX_GAP_MS = 15 * 60 * 1000;
|
||||
|
||||
/** 固定端口 3031 */
|
||||
const PORT = 3031;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 星潮类型表(与前端 TIDE_EVENTS 一致:crystal/resonance/ruins/void/core/silence)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TideType = "crystal" | "resonance" | "ruins" | "void" | "core" | "silence";
|
||||
|
||||
const TIDE_META: Record<TideType, { name: string; weight: number }> = {
|
||||
crystal: { name: "晶体潮", weight: 22 },
|
||||
resonance: { name: "谐振风暴", weight: 20 },
|
||||
ruins: { name: "遗迹共振", weight: 16 },
|
||||
void: { name: "虚空低语", weight: 18 },
|
||||
core: { name: "星核悸动", weight: 14 },
|
||||
silence: { name: "寂静期", weight: 10 },
|
||||
};
|
||||
|
||||
const TIDE_TYPES = Object.keys(TIDE_META) as TideType[];
|
||||
const TIDE_TOTAL_WEIGHT = TIDE_TYPES.reduce((s, t) => s + TIDE_META[t].weight, 0);
|
||||
|
||||
function rollTide(): TideType {
|
||||
let r = Math.random() * TIDE_TOTAL_WEIGHT;
|
||||
for (const t of TIDE_TYPES) {
|
||||
r -= TIDE_META[t].weight;
|
||||
if (r <= 0) return t;
|
||||
}
|
||||
return "crystal";
|
||||
}
|
||||
|
||||
function randomGap(): number {
|
||||
return MIN_GAP_MS + Math.floor(Math.random() * (MAX_GAP_MS - MIN_GAP_MS));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 全局状态
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface GlobalTideState {
|
||||
type: TideType;
|
||||
name: string;
|
||||
startedAt: number;
|
||||
endsAt: number;
|
||||
durationSec: number;
|
||||
}
|
||||
|
||||
let currentTide: GlobalTideState | null = null;
|
||||
let nextTideAt: number = Date.now() + FIRST_TIDE_DELAY_MS;
|
||||
let tideStartTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let tideEndTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 星潮调度
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function startTide(): void {
|
||||
// 若已有进行中的星潮,先结束再开(理论上不应发生,防御性)
|
||||
if (currentTide) {
|
||||
endTide();
|
||||
}
|
||||
// 清掉残留的 tideEndTimer,避免泄漏
|
||||
if (tideEndTimer) {
|
||||
clearTimeout(tideEndTimer);
|
||||
tideEndTimer = null;
|
||||
}
|
||||
const type = rollTide();
|
||||
const now = Date.now();
|
||||
currentTide = {
|
||||
type,
|
||||
name: TIDE_META[type].name,
|
||||
startedAt: now,
|
||||
endsAt: now + DURATION_SEC * 1000,
|
||||
durationSec: DURATION_SEC,
|
||||
};
|
||||
console.log(
|
||||
`[global-tide] ✦ ${currentTide.name} (${type}) 已降临 · 持续 ${DURATION_SEC}s · 结束于 ${new Date(currentTide.endsAt).toLocaleTimeString()}`
|
||||
);
|
||||
io.emit("global-tide", currentTide);
|
||||
|
||||
// 调度结束 → endTide 会自己 clearTimeout(tideEndTimer);
|
||||
// 结束后自动 scheduleNextTide
|
||||
tideEndTimer = setTimeout(() => {
|
||||
endTide();
|
||||
scheduleNextTide();
|
||||
}, DURATION_SEC * 1000);
|
||||
}
|
||||
|
||||
function endTide(): void {
|
||||
if (!currentTide) return;
|
||||
console.log(`[global-tide] ◌ ${currentTide.name} 已退去`);
|
||||
currentTide = null;
|
||||
io.emit("tide-ended", {
|
||||
endedAt: Date.now(),
|
||||
nextTideAt,
|
||||
});
|
||||
if (tideEndTimer) {
|
||||
clearTimeout(tideEndTimer);
|
||||
tideEndTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNextTide(): void {
|
||||
// 清掉残留的 tideStartTimer,避免泄漏
|
||||
if (tideStartTimer) {
|
||||
clearTimeout(tideStartTimer);
|
||||
tideStartTimer = null;
|
||||
}
|
||||
const gap = randomGap();
|
||||
nextTideAt = Date.now() + gap;
|
||||
console.log(
|
||||
`[global-tide] 下次星潮将在 ${Math.round(gap / 1000)}s 后 · ${new Date(nextTideAt).toLocaleTimeString()}`
|
||||
);
|
||||
tideStartTimer = setTimeout(() => {
|
||||
startTide();
|
||||
}, gap);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// socket.io
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 注意:socket.io `path: '/'` 会拦截所有 HTTP 请求(包括 /health),
|
||||
// 因此本服务不提供独立 HTTP 健康检查端点;QA 用 `curl http://localhost:3031/`
|
||||
// 收到 `{"code":0,"message":"Transport unknown"}` 即表示 socket.io 在线。
|
||||
const httpServer = createServer();
|
||||
|
||||
const io = new Server(httpServer, {
|
||||
// 路径必须为 "/",Caddy 据此转发;同时这也是 catch-all,
|
||||
// 任何 URL 都会被 socket.io 接管(前端默认 /socket.io/ 也会被捕获)
|
||||
path: "/",
|
||||
cors: { origin: "*", methods: ["GET", "POST"] },
|
||||
pingTimeout: 60000,
|
||||
pingInterval: 25000,
|
||||
});
|
||||
|
||||
io.on("connection", (socket) => {
|
||||
console.log(`[socket] 客户端连接: ${socket.id}(当前在线 ${io.engine.clientsCount})`);
|
||||
|
||||
// 立即推送当前状态
|
||||
socket.emit("tide-state", {
|
||||
current: currentTide,
|
||||
nextTideAt,
|
||||
serverTime: Date.now(),
|
||||
});
|
||||
|
||||
// 客户端查询下次星潮剩余时间
|
||||
socket.on("get-next-tide", () => {
|
||||
socket.emit("next-tide-info", {
|
||||
current: currentTide,
|
||||
nextTideAt,
|
||||
serverTime: Date.now(),
|
||||
remainingMs: Math.max(0, nextTideAt - Date.now()),
|
||||
});
|
||||
});
|
||||
|
||||
// dev/QA 用:手动触发一次全球星潮
|
||||
socket.on("admin-trigger-tide", () => {
|
||||
console.log(`[socket] admin-trigger-tide by ${socket.id}`);
|
||||
// 取消尚未触发的下次星潮定时器(startTide 会负责调度本次星潮的结束 + 下次开始)
|
||||
if (tideStartTimer) {
|
||||
clearTimeout(tideStartTimer);
|
||||
tideStartTimer = null;
|
||||
}
|
||||
startTide();
|
||||
// startTide 内部已 setTimeout(DURATION_SEC) → endTide + scheduleNextTide
|
||||
});
|
||||
|
||||
socket.on("disconnect", (reason) => {
|
||||
console.log(`[socket] 客户端断开: ${socket.id}(${reason})`);
|
||||
});
|
||||
|
||||
socket.on("error", (err) => {
|
||||
console.error(`[socket] 错误 (${socket.id}):`, err);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 启动
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
console.log(`[star-tide-service] 监听端口 ${PORT}`);
|
||||
console.log(
|
||||
`[star-tide-service] 首次星潮将在 ${Math.round(FIRST_TIDE_DELAY_MS / 1000)}s 后 · ${new Date(nextTideAt).toLocaleTimeString()}`
|
||||
);
|
||||
console.log(`[star-tide-service] 后续星潮间隔:${MIN_GAP_MS / 1000 / 60}-${MAX_GAP_MS / 1000 / 60} 分钟随机`);
|
||||
console.log(`[star-tide-service] QA 验证:curl http://localhost:${PORT}/ → {"code":0,"message":"Transport unknown"}`);
|
||||
|
||||
// 调度首次星潮(startTide 内部会在 60s 后自动 endTide + scheduleNextTide,无需在此重复调度)
|
||||
tideStartTimer = setTimeout(() => {
|
||||
startTide();
|
||||
}, FIRST_TIDE_DELAY_MS);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 优雅退出
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function shutdown(signal: string) {
|
||||
console.log(`[star-tide-service] 收到 ${signal},正在关闭…`);
|
||||
if (tideStartTimer) clearTimeout(tideStartTimer);
|
||||
if (tideEndTimer) clearTimeout(tideEndTimer);
|
||||
io.close(() => {
|
||||
httpServer.close(() => {
|
||||
console.log("[star-tide-service] 已关闭");
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "echo-nexus-star-tide-service",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --hot index.ts",
|
||||
"start": "bun index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"socket.io": "^4.8.3"
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,8 @@
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"recharts": "^2.15.4",
|
||||
"sharp": "^0.34.3",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"sonner": "^2.0.6",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "@/components/game/StarTide";
|
||||
import { useGameLoop } from "@/hooks/useGameLoop";
|
||||
import { useAudioSync } from "@/hooks/useAudio";
|
||||
import { useGlobalTide } from "@/hooks/useGlobalTide";
|
||||
import { useGameStore } from "@/store/gameStore";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -54,6 +55,7 @@ import { generateDailyChallenge, loadDailyProgress, loadLeaderboard } from "@/li
|
||||
export default function Page() {
|
||||
useGameLoop();
|
||||
useAudioSync();
|
||||
useGlobalTide();
|
||||
const [prestigeOpen, setPrestigeOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [constellationOpen, setConstellationOpen] = useState(false);
|
||||
@@ -73,6 +75,7 @@ export default function Page() {
|
||||
const crystals = useGameStore((s) => s.crystals);
|
||||
const crystalCap = useGameStore((s) => s.crystalCap);
|
||||
const activeTide = useGameStore((s) => s.activeTide);
|
||||
const globalTide = useGameStore((s) => s.globalTide);
|
||||
const energy = useGameStore((s) => s.energy);
|
||||
const hasActiveExpedition = useGameStore((s) => !!s.activeExpedition && !s.activeExpedition.finished);
|
||||
const chronicleCount = useGameStore((s) => s.chronicle?.length ?? 0);
|
||||
@@ -148,7 +151,8 @@ export default function Page() {
|
||||
}
|
||||
if (activeTide) {
|
||||
const tm = TIDE_EVENTS[activeTide.type];
|
||||
goal = `${tm.icon} 星潮「${tm.name}」进行中 · ${tm.desc}`;
|
||||
const prefix = globalTide ? "🌐 全球星潮 · " : "";
|
||||
goal = `${prefix}${tm.icon} 星潮「${tm.name}」进行中 · ${tm.desc}`;
|
||||
}
|
||||
if (canPrestige) goal = "✦ 接触进度已满,可发起飞升进入新周目";
|
||||
|
||||
@@ -249,6 +253,25 @@ export default function Page() {
|
||||
{/* 装饰光圈 */}
|
||||
<div className="pointer-events-none absolute -top-20 -left-20 h-60 w-60 rounded-full bg-emerald-500/10 blur-3xl" />
|
||||
<div className="pointer-events-none absolute -bottom-20 -right-20 h-60 w-60 rounded-full bg-fuchsia-500/10 blur-3xl" />
|
||||
{/* v0.8.2 装饰全息环(填充留白,提升视觉层次) */}
|
||||
<div className="pointer-events-none absolute inset-6 rounded-full border border-emerald-400/10 animate-[spin_60s_linear_infinite]" />
|
||||
<div className="pointer-events-none absolute inset-10 rounded-full border border-dashed border-fuchsia-400/10 animate-[spin_90s_linear_infinite_reverse]" />
|
||||
<div className="pointer-events-none absolute inset-16 rounded-full border border-rose-400/8" />
|
||||
{/* 四角全息标记 */}
|
||||
<div className="pointer-events-none absolute top-3 left-3 h-4 w-4 border-l border-t border-emerald-400/30 rounded-tl" />
|
||||
<div className="pointer-events-none absolute top-3 right-3 h-4 w-4 border-r border-t border-fuchsia-400/30 rounded-tr" />
|
||||
<div className="pointer-events-none absolute bottom-3 left-3 h-4 w-4 border-l border-b border-amber-400/30 rounded-bl" />
|
||||
<div className="pointer-events-none absolute bottom-3 right-3 h-4 w-4 border-r border-b border-rose-400/30 rounded-br" />
|
||||
{/* 顶部状态条 */}
|
||||
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 flex items-center gap-2 text-[9px] font-mono text-muted-foreground/60">
|
||||
<span className="h-1 w-1 rounded-full bg-emerald-400 animate-pulse" />
|
||||
<span>CRYSTAL CORE · ONLINE</span>
|
||||
<span className="h-1 w-1 rounded-full bg-fuchsia-400 animate-pulse" />
|
||||
</div>
|
||||
{/* 底部铭文 */}
|
||||
<div className="pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 text-[8px] font-mono text-muted-foreground/40 tracking-[0.3em] uppercase">
|
||||
echo · nexus · archaeology
|
||||
</div>
|
||||
<div data-tut="crystal-orb" className="contents">
|
||||
<CrystalOrb />
|
||||
</div>
|
||||
@@ -433,7 +456,7 @@ function StatsPanel() {
|
||||
{ label: "洞见倍率", value: `×${s.insightMult.toFixed(2)}` },
|
||||
{ label: "累计探险", value: `${s.totalExpeditions} 次` },
|
||||
{ label: "BOSS 击破", value: `${s.bossKills ?? 0} 次` },
|
||||
{ label: "星潮亲历", value: `${(s.starTidesEncountered ?? []).length} / 6` },
|
||||
{ label: "星潮亲历", value: `${(s.starTidesEncountered ?? []).length} / 9` },
|
||||
{ label: "编年史条目", value: `${(s.chronicle ?? []).length} 纪元` },
|
||||
{ label: "探险能量", value: `${Math.floor(s.energy)} / ${s.energyMax}` },
|
||||
{ label: "信标最高分", value: beaconBest !== null ? formatNum(beaconBest) : "—" },
|
||||
|
||||
@@ -2,33 +2,44 @@
|
||||
// 回响星核 / Echo Nexus — 深空信标面板
|
||||
// v0.5:每日挑战 + 本地排行榜
|
||||
// v0.8:周挑战 + 信标链(连续完成奖励)
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
// v0.8.2:限时挑战(每 4 小时刷新,amber 主题)
|
||||
// v0.8.2 P2/#4:本地/全球双榜(云排行榜 mini-service 端口 3030)
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useGameStore } from "@/store/gameStore";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { sfx } from "@/hooks/useAudio";
|
||||
import {
|
||||
generateDailyChallenge,
|
||||
generateWeeklyChallenge,
|
||||
generateTimedChallenge,
|
||||
loadDailyProgress,
|
||||
loadWeeklyProgress,
|
||||
loadTimedProgress,
|
||||
loadLeaderboard,
|
||||
loadChainState,
|
||||
claimBeaconReward,
|
||||
fetchCloudLeaderboard,
|
||||
submitCloudScore,
|
||||
loadLastCloudSubmitTimestamp,
|
||||
BEACON_DIFFICULTY,
|
||||
BEACON_TYPE_META,
|
||||
BEACON_CHAIN_MILESTONES,
|
||||
BEACON_CHAIN_REWARDS,
|
||||
getWeekKey,
|
||||
getTodayKey,
|
||||
getTimedSlotKey,
|
||||
getNextMilestone,
|
||||
getChainProgress,
|
||||
msUntilNextDay,
|
||||
msUntilNextWeek,
|
||||
msUntilNextTimedSlot,
|
||||
formatCountdown,
|
||||
type BeaconDailyChallenge,
|
||||
type BeaconDailyProgress,
|
||||
type BeaconWeeklyChallenge,
|
||||
type BeaconWeeklyProgress,
|
||||
type BeaconTimedChallenge,
|
||||
type BeaconTimedProgress,
|
||||
type BeaconScoreEntry,
|
||||
type BeaconChallengeType,
|
||||
type BeaconDifficulty,
|
||||
@@ -48,6 +59,11 @@ import {
|
||||
Link2,
|
||||
Flame,
|
||||
Zap,
|
||||
Hourglass,
|
||||
RefreshCw,
|
||||
Globe,
|
||||
WifiOff,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
const DIFFICULTY_ORDER: BeaconDifficulty[] = ["routine", "anomaly", "singular"];
|
||||
@@ -62,11 +78,16 @@ function rankBadge(rank: number) {
|
||||
export function BeaconPanel() {
|
||||
const grantBeaconReward = useGameStore((s) => s.grantBeaconReward);
|
||||
const claimWeeklyBeacon = useGameStore((s) => s.claimWeeklyBeacon);
|
||||
const claimTimedBeacon = useGameStore((s) => s.claimTimedBeacon);
|
||||
const claimChainReward = useGameStore((s) => s.claimChainReward);
|
||||
const { toast } = useToast();
|
||||
|
||||
const [challenge, setChallenge] = useState<BeaconDailyChallenge | null>(null);
|
||||
const [progress, setProgress] = useState<BeaconDailyProgress | null>(null);
|
||||
const [timedChallenge, setTimedChallenge] =
|
||||
useState<BeaconTimedChallenge | null>(null);
|
||||
const [timedProgress, setTimedProgress] =
|
||||
useState<BeaconTimedProgress | null>(null);
|
||||
const [weeklyChallenge, setWeeklyChallenge] =
|
||||
useState<BeaconWeeklyChallenge | null>(null);
|
||||
const [weeklyProgress, setWeeklyProgress] =
|
||||
@@ -74,13 +95,25 @@ export function BeaconPanel() {
|
||||
const [chainState, setChainState] = useState<BeaconChainState | null>(null);
|
||||
const [leaderboard, setLeaderboard] = useState<BeaconScoreEntry[]>([]);
|
||||
const [dayCountdown, setDayCountdown] = useState("00:00:00");
|
||||
const [timedCountdown, setTimedCountdown] = useState("00:00:00");
|
||||
const [weekCountdown, setWeekCountdown] = useState("00:00:00");
|
||||
const [now, setNow] = useState(Date.now());
|
||||
|
||||
// 云排行榜状态(v0.8.2 P2/#4)
|
||||
const [lbTab, setLbTab] = useState<"local" | "global">("local");
|
||||
const [cloudEntries, setCloudEntries] = useState<BeaconScoreEntry[]>([]);
|
||||
const [cloudLoading, setCloudLoading] = useState(false);
|
||||
const [cloudError, setCloudError] = useState<string | null>(null);
|
||||
const [cloudFetched, setCloudFetched] = useState(false);
|
||||
const [mySubmitTs, setMySubmitTs] = useState<number>(0);
|
||||
const cloudFetchingRef = useRef(false);
|
||||
|
||||
// 初始化 + 每秒刷新(进度 + 倒计时)
|
||||
useEffect(() => {
|
||||
setChallenge(generateDailyChallenge());
|
||||
setProgress(loadDailyProgress());
|
||||
setTimedChallenge(generateTimedChallenge());
|
||||
setTimedProgress(loadTimedProgress());
|
||||
setWeeklyChallenge(generateWeeklyChallenge());
|
||||
setWeeklyProgress(loadWeeklyProgress());
|
||||
setChainState(loadChainState());
|
||||
@@ -88,9 +121,11 @@ export function BeaconPanel() {
|
||||
const id = setInterval(() => {
|
||||
setNow(Date.now());
|
||||
setProgress(loadDailyProgress());
|
||||
setTimedProgress(loadTimedProgress());
|
||||
setWeeklyProgress(loadWeeklyProgress());
|
||||
setChainState(loadChainState());
|
||||
setChallenge((c) => c ?? generateDailyChallenge());
|
||||
setTimedChallenge((c) => c ?? generateTimedChallenge());
|
||||
setWeeklyChallenge((c) => c ?? generateWeeklyChallenge());
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
@@ -98,9 +133,44 @@ export function BeaconPanel() {
|
||||
|
||||
useEffect(() => {
|
||||
setDayCountdown(formatCountdown(msUntilNextDay(new Date(now))));
|
||||
setTimedCountdown(formatCountdown(msUntilNextTimedSlot(new Date(now))));
|
||||
setWeekCountdown(formatCountdown(msUntilNextWeek(new Date(now))));
|
||||
}, [now]);
|
||||
|
||||
// 初始化:读取本地保存的"上次提交时间戳"(用于全球榜高亮我的记录)
|
||||
useEffect(() => {
|
||||
setMySubmitTs(loadLastCloudSubmitTimestamp());
|
||||
}, []);
|
||||
|
||||
// 拉取全球排行榜
|
||||
const refreshCloudLeaderboard = useCallback(async () => {
|
||||
if (cloudFetchingRef.current) return;
|
||||
cloudFetchingRef.current = true;
|
||||
setCloudLoading(true);
|
||||
setCloudError(null);
|
||||
try {
|
||||
const entries = await fetchCloudLeaderboard();
|
||||
setCloudEntries(entries);
|
||||
setCloudFetched(true);
|
||||
setMySubmitTs(loadLastCloudSubmitTimestamp());
|
||||
if (entries.length === 0) {
|
||||
setCloudError("尚无全球记录,成为首位登顶者!");
|
||||
}
|
||||
} catch {
|
||||
setCloudError("全球榜连接失败,请稍后重试");
|
||||
} finally {
|
||||
setCloudLoading(false);
|
||||
cloudFetchingRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 切到全球 tab 时自动拉取一次(如已拉取过则不强制)
|
||||
useEffect(() => {
|
||||
if (lbTab === "global" && !cloudFetched && !cloudLoading) {
|
||||
void refreshCloudLeaderboard();
|
||||
}
|
||||
}, [lbTab, cloudFetched, cloudLoading, refreshCloudLeaderboard]);
|
||||
|
||||
const handleClaimDaily = useCallback(() => {
|
||||
if (!challenge || !progress) return;
|
||||
if (progress.completedAt === null || progress.claimed) return;
|
||||
@@ -116,7 +186,41 @@ export function BeaconPanel() {
|
||||
1
|
||||
)} 接触 · 得分 ${res.score}`,
|
||||
});
|
||||
}, [challenge, progress, toast, grantBeaconReward]);
|
||||
// 异步提交到云排行榜(失败静默,不影响本地)
|
||||
if (res.entry) {
|
||||
void submitCloudScore(res.entry).then((rank) => {
|
||||
if (rank >= 1) {
|
||||
setMySubmitTs(res.entry!.timestamp);
|
||||
// 若用户在全球 tab,自动刷新
|
||||
if (lbTab === "global") {
|
||||
void refreshCloudLeaderboard();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [challenge, progress, toast, grantBeaconReward, lbTab, refreshCloudLeaderboard]);
|
||||
|
||||
const handleClaimTimed = useCallback(() => {
|
||||
if (!timedChallenge || !timedProgress) return;
|
||||
if (timedProgress.completedAt === null || timedProgress.claimed) return;
|
||||
const res = claimTimedBeacon();
|
||||
setLeaderboard(loadLeaderboard());
|
||||
setTimedProgress(loadTimedProgress());
|
||||
if (res.rewardInsight > 0 || res.rewardContact > 0) {
|
||||
sfx("achievement");
|
||||
toast({
|
||||
title: "✦ 限时挑战奖励已领取",
|
||||
description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
|
||||
1
|
||||
)} 接触 · 得分 ${res.score}`,
|
||||
});
|
||||
// 异步刷新云榜(claimTimedBeacon 已在 gameStore 内异步提交到云端)
|
||||
setMySubmitTs(loadLastCloudSubmitTimestamp());
|
||||
if (lbTab === "global") {
|
||||
void refreshCloudLeaderboard();
|
||||
}
|
||||
}
|
||||
}, [timedChallenge, timedProgress, toast, claimTimedBeacon, lbTab, refreshCloudLeaderboard]);
|
||||
|
||||
const handleClaimWeekly = useCallback(() => {
|
||||
if (!weeklyChallenge || !weeklyProgress) return;
|
||||
@@ -132,8 +236,13 @@ export function BeaconPanel() {
|
||||
1
|
||||
)} 接触 · 得分 ${res.score}`,
|
||||
});
|
||||
// 异步刷新云榜(claimWeeklyBeacon 已在 gameStore 内异步提交到云端)
|
||||
setMySubmitTs(loadLastCloudSubmitTimestamp());
|
||||
if (lbTab === "global") {
|
||||
void refreshCloudLeaderboard();
|
||||
}
|
||||
}
|
||||
}, [weeklyChallenge, weeklyProgress, toast, claimWeeklyBeacon]);
|
||||
}, [weeklyChallenge, weeklyProgress, toast, claimWeeklyBeacon, lbTab, refreshCloudLeaderboard]);
|
||||
|
||||
const handleClaimChain = useCallback(
|
||||
(milestone: number) => {
|
||||
@@ -155,7 +264,7 @@ export function BeaconPanel() {
|
||||
[toast, claimChainReward]
|
||||
);
|
||||
|
||||
if (!challenge || !progress || !weeklyChallenge || !weeklyProgress || !chainState) {
|
||||
if (!challenge || !progress || !timedChallenge || !timedProgress || !weeklyChallenge || !weeklyProgress || !chainState) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-xs text-muted-foreground/60">
|
||||
正在校准深空信标…
|
||||
@@ -170,6 +279,14 @@ export function BeaconPanel() {
|
||||
const isClaimed = progress.claimed;
|
||||
const canClaim = isCompleted && !isClaimed;
|
||||
|
||||
// 限时挑战派生量
|
||||
const tDiffMeta = BEACON_DIFFICULTY[timedChallenge.difficulty];
|
||||
const tTypeMeta = BEACON_TYPE_META[timedChallenge.type];
|
||||
const tPct = Math.min(100, (timedProgress.progress / timedChallenge.goal) * 100);
|
||||
const tCompleted = timedProgress.completedAt !== null;
|
||||
const tClaimed = timedProgress.claimed;
|
||||
const tCanClaim = tCompleted && !tClaimed;
|
||||
|
||||
// 周挑战派生量
|
||||
const wDiffMeta = BEACON_DIFFICULTY[weeklyChallenge.difficulty];
|
||||
const wTypeMeta = BEACON_TYPE_META[weeklyChallenge.type];
|
||||
@@ -202,6 +319,14 @@ export function BeaconPanel() {
|
||||
0%, 100% { box-shadow: 0 0 18px rgba(232,121,249,0.35), inset 0 0 12px rgba(232,121,249,0.25); }
|
||||
50% { box-shadow: 0 0 32px rgba(232,121,249,0.5), inset 0 0 20px rgba(232,121,249,0.35); }
|
||||
}
|
||||
@keyframes timed-glow {
|
||||
0%, 100% { box-shadow: 0 0 18px rgba(251,191,36,0.4), inset 0 0 12px rgba(251,191,36,0.25); }
|
||||
50% { box-shadow: 0 0 30px rgba(251,191,36,0.55), inset 0 0 20px rgba(251,191,36,0.35); }
|
||||
}
|
||||
@keyframes timed-urgent {
|
||||
0%, 100% { box-shadow: 0 0 18px rgba(244,63,94,0.5), inset 0 0 12px rgba(244,63,94,0.3); }
|
||||
50% { box-shadow: 0 0 34px rgba(244,63,94,0.7), inset 0 0 22px rgba(244,63,94,0.4); }
|
||||
}
|
||||
@keyframes chain-milestone-pulse {
|
||||
0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(251,113,133,0.55); }
|
||||
50% { transform: scale(1.06); box-shadow: 0 0 0 8px rgba(251,113,133,0); }
|
||||
@@ -344,6 +469,144 @@ export function BeaconPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== 限时挑战卡片(amber 主题,每 4 小时刷新) ===== */}
|
||||
{(() => {
|
||||
// 距离时段结束 < 30 分钟时进入"紧急"状态(rose 脉冲)
|
||||
const msLeft = msUntilNextTimedSlot(new Date(now));
|
||||
const urgent = msLeft < 30 * 60 * 1000 && !tCompleted;
|
||||
const glowAnim = tCompleted
|
||||
? "none"
|
||||
: urgent
|
||||
? "timed-urgent 1.5s ease-in-out infinite"
|
||||
: "timed-glow 3s ease-in-out infinite";
|
||||
return (
|
||||
<div
|
||||
className="relative rounded-xl border p-3 overflow-hidden"
|
||||
style={{
|
||||
borderColor: urgent ? "rgba(244,63,94,0.5)" : "rgba(251,191,36,0.4)",
|
||||
background: urgent
|
||||
? "linear-gradient(135deg, rgba(244,63,94,0.12), rgba(251,191,36,0.08), rgba(0,0,0,0.45))"
|
||||
: "linear-gradient(135deg, rgba(251,191,36,0.14), rgba(0,0,0,0.45))",
|
||||
animation: glowAnim,
|
||||
}}
|
||||
>
|
||||
{/* 头部:标题 + slotKey + 倒计时 */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Hourglass className={`h-3.5 w-3.5 ${urgent ? "text-rose-400" : "text-amber-400"}`} />
|
||||
<span className={`text-xs font-semibold ${urgent ? "text-rose-200" : "text-amber-200"}`}>
|
||||
限时挑战 · TIMED
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[9px] text-muted-foreground/70">
|
||||
<span className="font-mono text-amber-300/70">
|
||||
{getTimedSlotKey().split("_").slice(1).join(" ")}
|
||||
</span>
|
||||
<Clock className="h-2.5 w-2.5" />
|
||||
<span className={`font-mono ${urgent ? "text-rose-300" : "text-amber-300/90"}`}>
|
||||
{timedCountdown}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 难度 + 类型 标签 */}
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<span
|
||||
className="text-[10px] font-mono px-1.5 py-0.5 rounded flex items-center gap-1"
|
||||
style={{ background: `${tDiffMeta.color}22`, color: tDiffMeta.color, border: `1px solid ${tDiffMeta.color}55` }}
|
||||
>
|
||||
<span>{tDiffMeta.icon}</span>
|
||||
{tDiffMeta.label}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-white/5 text-muted-foreground border border-white/10">
|
||||
{tTypeMeta.icon} {tTypeMeta.label}
|
||||
</span>
|
||||
{tCompleted && (
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 flex items-center gap-1">
|
||||
<Sparkles className="h-2.5 w-2.5" /> 已完成
|
||||
</span>
|
||||
)}
|
||||
{urgent && !tCompleted && (
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-rose-500/20 text-rose-300 border border-rose-500/40 flex items-center gap-1 animate-pulse">
|
||||
即将结束
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 标题 */}
|
||||
<h4
|
||||
className="text-sm font-semibold mb-1"
|
||||
style={{ color: tDiffMeta.color, textShadow: `0 0 10px ${tDiffMeta.glow}` }}
|
||||
>
|
||||
{timedChallenge.title}
|
||||
</h4>
|
||||
<p className="text-[10px] text-muted-foreground/80 leading-relaxed mb-2">
|
||||
{timedChallenge.desc}
|
||||
</p>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] text-muted-foreground/70">进度</span>
|
||||
<span className="text-[11px] font-mono font-semibold" style={{ color: tDiffMeta.color }}>
|
||||
{Math.min(timedProgress.progress, timedChallenge.goal)} / {timedChallenge.goal} {tTypeMeta.unit}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={tPct}
|
||||
className="h-2 bg-black/40"
|
||||
style={{
|
||||
["--progress-color" as string]: urgent ? "#f43f5e" : tDiffMeta.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 奖励 + 领取按钮 */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-[10px]">
|
||||
<span className="text-muted-foreground/60">奖励:</span>
|
||||
{timedChallenge.rewardInsight > 0 && (
|
||||
<span className="text-amber-300 font-mono">
|
||||
+{formatNum(timedChallenge.rewardInsight)}洞见
|
||||
</span>
|
||||
)}
|
||||
<span className="text-fuchsia-300 font-mono">
|
||||
+{timedChallenge.rewardContact.toFixed(1)}接触
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleClaimTimed}
|
||||
disabled={!tCanClaim}
|
||||
size="sm"
|
||||
className="h-7 px-3 text-[11px] border-0"
|
||||
style={{
|
||||
background: tCanClaim
|
||||
? `linear-gradient(90deg, #34d399, #34d399cc)`
|
||||
: "rgba(251,191,36,0.10)",
|
||||
color: tCanClaim
|
||||
? "#022c22"
|
||||
: "rgba(251,191,36,0.55)",
|
||||
}}
|
||||
>
|
||||
{tClaimed
|
||||
? "已领取"
|
||||
: tCanClaim
|
||||
? "领取奖励"
|
||||
: tCompleted
|
||||
? "已领取"
|
||||
: "进行中…"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tCompleted && timedProgress.durationSec > 0 && (
|
||||
<div className="mt-1.5 text-[10px] text-muted-foreground/50 text-center">
|
||||
完成用时 {Math.floor(timedProgress.durationSec / 60)}分{timedProgress.durationSec % 60}秒
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 周挑战 + 信标链(桌面端并排,移动端单列) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
||||
{/* ===== 周挑战卡片(fuchsia 主题) ===== */}
|
||||
@@ -671,68 +934,237 @@ export function BeaconPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 本地排行榜 */}
|
||||
{/* 排行榜(本地 Top20 / 全球 Top100 双 tab — v0.8.2 P2/#4) */}
|
||||
<div className="rounded-xl border border-white/10 bg-black/30 p-2.5">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<Trophy className="h-3.5 w-3.5 text-amber-400" />
|
||||
<span className="text-xs font-semibold">深空排行榜</span>
|
||||
<span className="text-[10px] text-muted-foreground/50">本地 · Top {leaderboard.length || 0}</span>
|
||||
{/* 头部:标题 + tab 切换 + (全球 tab 时)刷新按钮 */}
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Trophy className="h-3.5 w-3.5 text-amber-400" />
|
||||
<span className="text-xs font-semibold">深空排行榜</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* tab 切换 */}
|
||||
<div className="flex items-center gap-0.5 p-0.5 rounded-lg bg-black/40 border border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLbTab("local")}
|
||||
className={`flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-mono transition-colors ${
|
||||
lbTab === "local"
|
||||
? "bg-amber-500/25 text-amber-300 border border-amber-500/40"
|
||||
: "text-muted-foreground/60 hover:text-muted-foreground border border-transparent"
|
||||
}`}
|
||||
aria-pressed={lbTab === "local"}
|
||||
>
|
||||
<Trophy className="h-2.5 w-2.5" />
|
||||
本地
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLbTab("global")}
|
||||
className={`flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-mono transition-colors ${
|
||||
lbTab === "global"
|
||||
? "bg-emerald-500/25 text-emerald-300 border border-emerald-500/40"
|
||||
: "text-muted-foreground/60 hover:text-muted-foreground border border-transparent"
|
||||
}`}
|
||||
aria-pressed={lbTab === "global"}
|
||||
>
|
||||
<Globe className="h-2.5 w-2.5" />
|
||||
全球
|
||||
</button>
|
||||
</div>
|
||||
{/* 刷新按钮(仅全球 tab) */}
|
||||
{lbTab === "global" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshCloudLeaderboard()}
|
||||
disabled={cloudLoading}
|
||||
className="p-1 rounded-md text-muted-foreground/70 hover:text-emerald-300 hover:bg-emerald-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="刷新全球榜"
|
||||
title="刷新全球榜"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 ${cloudLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{leaderboard.length === 0 ? (
|
||||
<div className="text-center py-4 text-[11px] text-muted-foreground/40">
|
||||
<Trophy className="h-6 w-6 mx-auto mb-1 opacity-30" />
|
||||
尚无记录。完成今日或本周信标即可登榜。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5 max-h-[180px] overflow-y-auto echo-scroll">
|
||||
{leaderboard.map((entry, i) => {
|
||||
const rb = rankBadge(i);
|
||||
const eDiff = BEACON_DIFFICULTY[entry.difficulty];
|
||||
const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType];
|
||||
const isMine = entry.dateKey === getTodayKey() || entry.dateKey === getWeekKey();
|
||||
return (
|
||||
<div
|
||||
key={`${entry.timestamp}-${i}`}
|
||||
className={`flex items-center gap-2 px-2 py-1 rounded-lg text-[11px] ${
|
||||
entry.isWeekly
|
||||
? "bg-fuchsia-500/10 border border-fuchsia-500/20"
|
||||
: isMine
|
||||
? "bg-fuchsia-500/10 border border-fuchsia-500/20"
|
||||
: "hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
{/* 排名 */}
|
||||
<span className="w-6 flex items-center justify-center">
|
||||
{rb ? (
|
||||
<rb.icon className="h-3.5 w-3.5" style={{ color: rb.color }} />
|
||||
) : (
|
||||
<span className="text-muted-foreground/50 font-mono">{i + 1}</span>
|
||||
)}
|
||||
</span>
|
||||
{/* 类型 + 难度 + 周挑战标记 */}
|
||||
<span className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<span style={{ color: eDiff.color }} className="font-mono">{eDiff.icon}</span>
|
||||
<span className="text-muted-foreground/80 truncate">{eType.label}</span>
|
||||
{entry.isWeekly && (
|
||||
<span className="text-[9px] font-mono px-1 rounded bg-fuchsia-500/20 text-fuchsia-300 border border-fuchsia-500/40">
|
||||
WEEK
|
||||
{/* 子标题:榜量信息 */}
|
||||
<div className="mb-1.5 text-[10px] text-muted-foreground/50 flex items-center gap-2">
|
||||
{lbTab === "local" ? (
|
||||
<span>本机 Top {leaderboard.length || 0} · 离线可用</span>
|
||||
) : (
|
||||
<>
|
||||
<span>全球 Top {cloudEntries.length || 0}</span>
|
||||
{cloudFetched && !cloudLoading && cloudEntries.length > 0 && (
|
||||
<span className="text-emerald-400/60">· 已同步</span>
|
||||
)}
|
||||
{cloudLoading && (
|
||||
<span className="text-amber-400/70 flex items-center gap-1">
|
||||
<Loader2 className="h-2.5 w-2.5 animate-spin" />
|
||||
同步中…
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== 本地榜 ===== */}
|
||||
{lbTab === "local" &&
|
||||
(leaderboard.length === 0 ? (
|
||||
<div className="text-center py-4 text-[11px] text-muted-foreground/40">
|
||||
<Trophy className="h-6 w-6 mx-auto mb-1 opacity-30" />
|
||||
尚无记录。完成限时、每日或本周信标即可登榜。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5 max-h-[200px] overflow-y-auto echo-scroll">
|
||||
{leaderboard.map((entry, i) => {
|
||||
const rb = rankBadge(i);
|
||||
const eDiff = BEACON_DIFFICULTY[entry.difficulty];
|
||||
const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType];
|
||||
const isMine =
|
||||
entry.dateKey === getTodayKey() ||
|
||||
entry.dateKey === getWeekKey() ||
|
||||
entry.dateKey === getTimedSlotKey();
|
||||
return (
|
||||
<div
|
||||
key={`local-${entry.timestamp}-${i}`}
|
||||
className={`flex items-center gap-2 px-2 py-1 rounded-lg text-[11px] ${
|
||||
entry.isWeekly
|
||||
? "bg-fuchsia-500/10 border border-fuchsia-500/20"
|
||||
: entry.isTimed
|
||||
? "bg-amber-500/10 border border-amber-500/20"
|
||||
: isMine
|
||||
? "bg-fuchsia-500/10 border border-fuchsia-500/20"
|
||||
: "hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<span className="w-6 flex items-center justify-center">
|
||||
{rb ? (
|
||||
<rb.icon className="h-3.5 w-3.5" style={{ color: rb.color }} />
|
||||
) : (
|
||||
<span className="text-muted-foreground/50 font-mono">{i + 1}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<span style={{ color: eDiff.color }} className="font-mono">{eDiff.icon}</span>
|
||||
<span className="text-muted-foreground/80 truncate">{eType.label}</span>
|
||||
{entry.isWeekly && (
|
||||
<span className="text-[9px] font-mono px-1 rounded bg-fuchsia-500/20 text-fuchsia-300 border border-fuchsia-500/40">
|
||||
WEEK
|
||||
</span>
|
||||
)}
|
||||
{entry.isTimed && (
|
||||
<span className="text-[9px] font-mono px-1 rounded bg-amber-500/20 text-amber-300 border border-amber-500/40">
|
||||
TIMED
|
||||
</span>
|
||||
)}
|
||||
{entry.progress >= 1 && <span className="text-emerald-400/70">✓</span>}
|
||||
</span>
|
||||
<span className="text-muted-foreground/50 font-mono text-[10px] w-16 text-right">
|
||||
{Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s
|
||||
</span>
|
||||
<span className="font-mono font-semibold w-12 text-right" style={{ color: eDiff.color }}>
|
||||
{formatNum(entry.score)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* ===== 全球榜 ===== */}
|
||||
{lbTab === "global" && (
|
||||
<>
|
||||
{cloudLoading && cloudEntries.length === 0 ? (
|
||||
<div className="text-center py-4 text-[11px] text-muted-foreground/60 flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-emerald-400" />
|
||||
正在拉取全球榜…
|
||||
</div>
|
||||
) : cloudEntries.length === 0 ? (
|
||||
<div className="text-center py-4 text-[11px] text-muted-foreground/40">
|
||||
{cloudError ? (
|
||||
<>
|
||||
<WifiOff className="h-6 w-6 mx-auto mb-1 opacity-40 text-rose-400" />
|
||||
<span className="text-rose-300/70">{cloudError}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshCloudLeaderboard()}
|
||||
className="block mx-auto mt-2 text-[10px] text-emerald-400/80 hover:text-emerald-300 underline-offset-2 hover:underline"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Globe className="h-6 w-6 mx-auto mb-1 opacity-30 text-emerald-400" />
|
||||
尚无全球记录。完成挑战并领取即可登顶全球榜!
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5 max-h-[200px] overflow-y-auto echo-scroll">
|
||||
{cloudEntries.map((entry, i) => {
|
||||
const rb = rankBadge(i);
|
||||
const eDiff = BEACON_DIFFICULTY[entry.difficulty as BeaconDifficulty] ?? BEACON_DIFFICULTY.routine;
|
||||
const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType] ?? { label: entry.challenge, icon: "·", unit: "" };
|
||||
// 高亮"我的"记录:通过 timestamp 匹配本机最后一次成功提交
|
||||
const isMine = mySubmitTs > 0 && entry.timestamp === mySubmitTs;
|
||||
return (
|
||||
<div
|
||||
key={`global-${entry.timestamp}-${i}`}
|
||||
className={`flex items-center gap-2 px-2 py-1 rounded-lg text-[11px] ${
|
||||
isMine
|
||||
? "bg-emerald-500/15 border border-emerald-500/40 shadow-[0_0_12px_rgba(52,211,153,0.25)]"
|
||||
: entry.isWeekly
|
||||
? "bg-fuchsia-500/10 border border-fuchsia-500/20"
|
||||
: entry.isTimed
|
||||
? "bg-amber-500/10 border border-amber-500/20"
|
||||
: "hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<span className="w-6 flex items-center justify-center">
|
||||
{rb ? (
|
||||
<rb.icon className="h-3.5 w-3.5" style={{ color: rb.color }} />
|
||||
) : (
|
||||
<span className={`font-mono ${isMine ? "text-emerald-300 font-bold" : "text-muted-foreground/50"}`}>
|
||||
{i + 1}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{entry.progress >= 1 && <span className="text-emerald-400/70">✓</span>}
|
||||
</span>
|
||||
{/* 用时 */}
|
||||
<span className="text-muted-foreground/50 font-mono text-[10px] w-16 text-right">
|
||||
{Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s
|
||||
</span>
|
||||
{/* 分数 */}
|
||||
<span className="font-mono font-semibold w-12 text-right" style={{ color: eDiff.color }}>
|
||||
{formatNum(entry.score)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<span style={{ color: eDiff.color }} className="font-mono">{eDiff.icon}</span>
|
||||
<span className="text-muted-foreground/80 truncate">{eType.label}</span>
|
||||
{entry.isWeekly && (
|
||||
<span className="text-[9px] font-mono px-1 rounded bg-fuchsia-500/20 text-fuchsia-300 border border-fuchsia-500/40">
|
||||
WEEK
|
||||
</span>
|
||||
)}
|
||||
{entry.isTimed && (
|
||||
<span className="text-[9px] font-mono px-1 rounded bg-amber-500/20 text-amber-300 border border-amber-500/40">
|
||||
TIMED
|
||||
</span>
|
||||
)}
|
||||
{isMine && (
|
||||
<span className="text-[9px] font-mono px-1 rounded bg-emerald-500/25 text-emerald-200 border border-emerald-500/50">
|
||||
YOU
|
||||
</span>
|
||||
)}
|
||||
{entry.progress >= 1 && <span className="text-emerald-400/70">✓</span>}
|
||||
</span>
|
||||
<span className="text-muted-foreground/50 font-mono text-[10px] w-16 text-right">
|
||||
{Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s
|
||||
</span>
|
||||
<span className="font-mono font-semibold w-12 text-right" style={{ color: isMine ? "#34d399" : eDiff.color }}>
|
||||
{formatNum(entry.score)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — 回响编年史对话框(v0.4 跨周目叙事时间轴)
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -20,7 +20,8 @@ import { getPerk } from "@/lib/game/constellation";
|
||||
import { TIDE_EVENTS } from "@/lib/game/starTide";
|
||||
import { sfx } from "@/hooks/useAudio";
|
||||
import type { ChronicleEntry } from "@/lib/game/types";
|
||||
import { BookOpen, Clock, Swords, Trophy, Sparkles, Star, Waves, Cpu, Zap } from "lucide-react";
|
||||
import { BookOpen, Clock, Swords, Trophy, Sparkles, Star, Waves, Cpu, Zap, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -59,12 +60,26 @@ export function ChronicleDialog({ open, onOpenChange }: Props) {
|
||||
const bossKills = useGameStore((s) => s.bossKills ?? 0);
|
||||
const tidesAll = useGameStore((s) => s.starTidesEncountered ?? []);
|
||||
// 倒序展示(最新的在前)
|
||||
const entries: ChronicleEntry[] = [...chronicle].reverse();
|
||||
const entries: ChronicleEntry[] = useMemo(() => [...chronicle].reverse(), [chronicle]);
|
||||
|
||||
// 打开时播放翻页音效
|
||||
// v0.8.2 分页(工单 #8):编年史上限 200 条,每页 10 条
|
||||
const PAGE_SIZE = 10;
|
||||
const totalPages = Math.max(1, Math.ceil(entries.length / PAGE_SIZE));
|
||||
const [page, setPage] = useState(1);
|
||||
// 当 entries 变化(新飞升)且当前页超出范围时,回到第 1 页(最新)
|
||||
useEffect(() => {
|
||||
if (open) sfx("chronicleOpen");
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (page > totalPages) setPage(1);
|
||||
}, [page, totalPages]);
|
||||
// 打开时默认回到第 1 页(最新)
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
sfx("chronicleOpen");
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPage(1);
|
||||
}
|
||||
}, [open]);
|
||||
const pagedEntries = entries.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -108,7 +123,7 @@ export function ChronicleDialog({ open, onOpenChange }: Props) {
|
||||
</DialogHeader>
|
||||
|
||||
{/* 时间轴滚动区 */}
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6 echo-scroll">
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-3 echo-scroll">
|
||||
{entries.length === 0 ? (
|
||||
<EmptyState ascensions={ascensions} />
|
||||
) : (
|
||||
@@ -117,18 +132,49 @@ export function ChronicleDialog({ open, onOpenChange }: Props) {
|
||||
<div className="absolute left-[27px] top-2 bottom-2 w-px bg-gradient-to-b from-fuchsia-400/40 via-violet-400/30 to-transparent" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{entries.map((entry, idx) => (
|
||||
{pagedEntries.map((entry, idx) => (
|
||||
<ChronicleCard
|
||||
key={`${entry.ascensionNumber}-${entry.timestamp}`}
|
||||
entry={entry}
|
||||
isLatest={idx === 0}
|
||||
index={idx}
|
||||
isLatest={(page - 1) * PAGE_SIZE + idx === 0}
|
||||
index={(page - 1) * PAGE_SIZE + idx}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* v0.8.2 分页控制栏(工单 #8) */}
|
||||
{entries.length > PAGE_SIZE && (
|
||||
<div className="px-6 py-2.5 border-t border-fuchsia-400/15 bg-black/40 flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-muted-foreground font-mono">
|
||||
第 {page} / {totalPages} 页 · 共 {entries.length} 条飞升记录
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => { sfx("tabSwitch"); setPage((p) => Math.max(1, p - 1)); }}
|
||||
className="h-7 px-2 text-fuchsia-300 hover:bg-fuchsia-500/15 disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => { sfx("tabSwitch"); setPage((p) => Math.min(totalPages, p + 1)); }}
|
||||
className="h-7 px-2 text-fuchsia-300 hover:bg-fuchsia-500/15 disabled:opacity-30"
|
||||
>
|
||||
下一页
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -16,12 +16,15 @@ export function StarTideNotifier() {
|
||||
if (events.length === 0) return;
|
||||
const items = consume();
|
||||
for (const ev of items) {
|
||||
const globalTag = ev.isGlobal ? "🌐 " : "";
|
||||
if (ev.started) {
|
||||
const meta = TIDE_EVENTS[ev.started];
|
||||
sfx("tideStart");
|
||||
toast({
|
||||
title: `${meta.icon} 星潮降临:${meta.name}`,
|
||||
description: meta.desc,
|
||||
title: `${globalTag}${meta.icon} ${ev.isGlobal ? "全球星潮" : "星潮降临"}:${meta.name}`,
|
||||
description: ev.isGlobal
|
||||
? `全球玩家同步经历 · ${meta.desc}`
|
||||
: meta.desc,
|
||||
});
|
||||
}
|
||||
if (ev.ended) {
|
||||
@@ -29,14 +32,16 @@ export function StarTideNotifier() {
|
||||
if (ev.silenceCompensation && ev.silenceCompensation > 0) {
|
||||
sfx("techBuy");
|
||||
toast({
|
||||
title: `${meta.icon} 寂静期消散`,
|
||||
title: `${globalTag}${meta.icon} 寂静期消散`,
|
||||
description: `虚空回赠 ${ev.silenceCompensation} 洞见。`,
|
||||
});
|
||||
} else {
|
||||
sfx("tideEnd");
|
||||
toast({
|
||||
title: `${meta.icon} ${meta.name}结束`,
|
||||
description: "星潮退去,物理法则恢复。",
|
||||
title: `${globalTag}${meta.icon} ${meta.name}结束`,
|
||||
description: ev.isGlobal
|
||||
? "全球星潮退去,物理法则恢复。"
|
||||
: "星潮退去,物理法则恢复。",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -49,6 +54,7 @@ export function StarTideNotifier() {
|
||||
/** 顶部星潮指示器(活跃时显示倒计时芯片) */
|
||||
export function StarTideIndicator() {
|
||||
const activeTide = useGameStore((s) => s.activeTide);
|
||||
const globalTide = useGameStore((s) => s.globalTide);
|
||||
const [, force] = useState(0);
|
||||
|
||||
// 每秒刷新倒计时
|
||||
@@ -62,18 +68,27 @@ export function StarTideIndicator() {
|
||||
const meta = TIDE_EVENTS[activeTide.type];
|
||||
const remaining = Math.max(0, activeTide.endsAt - Date.now());
|
||||
const secs = Math.ceil(remaining / 1000);
|
||||
const progress = 1 - remaining / TIDE_CONFIG.duration;
|
||||
// 全球星潮持续 60s,本地 75s —— 用实际 endsAt-startedAt 计算 progress,避免越界
|
||||
const duration = globalTide
|
||||
? globalTide.endsAt - globalTide.startedAt
|
||||
: TIDE_CONFIG.duration;
|
||||
const progress = Math.min(1, Math.max(0, 1 - remaining / duration));
|
||||
const isGlobal = !!globalTide;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-full text-[10px] font-medium relative overflow-hidden"
|
||||
className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-[10px] font-medium relative overflow-hidden ${
|
||||
isGlobal ? "ring-1 ring-amber-300/60" : ""
|
||||
}`}
|
||||
style={{
|
||||
background: `${meta.color}1a`,
|
||||
border: `1px solid ${meta.color}55`,
|
||||
background: isGlobal
|
||||
? `linear-gradient(135deg, ${meta.color}26, ${meta.color}11)`
|
||||
: `${meta.color}1a`,
|
||||
border: `1px solid ${meta.color}${isGlobal ? "88" : "55"}`,
|
||||
color: meta.color,
|
||||
boxShadow: `0 0 10px ${meta.glow}`,
|
||||
boxShadow: `0 0 ${isGlobal ? "16" : "10"}px ${meta.glow}`,
|
||||
}}
|
||||
title={meta.desc}
|
||||
title={isGlobal ? `🌐 全球星潮 · ${meta.desc}` : meta.desc}
|
||||
>
|
||||
{/* 进度环背景 */}
|
||||
<div
|
||||
@@ -82,6 +97,14 @@ export function StarTideIndicator() {
|
||||
background: `linear-gradient(90deg, ${meta.color}40 ${progress * 100}%, transparent ${progress * 100}%)`,
|
||||
}}
|
||||
/>
|
||||
{isGlobal && (
|
||||
<span
|
||||
className="relative text-[11px] leading-none animate-pulse"
|
||||
aria-label="全球星潮标记"
|
||||
>
|
||||
🌐
|
||||
</span>
|
||||
)}
|
||||
<span className="relative text-xs leading-none">{meta.icon}</span>
|
||||
<span className="relative leading-none">{meta.name}</span>
|
||||
<span className="relative font-mono leading-none opacity-80">{secs}s</span>
|
||||
@@ -92,8 +115,10 @@ export function StarTideIndicator() {
|
||||
/** 全屏背景叠层:活跃星潮时给页面罩一层对应色的微光 */
|
||||
export function StarTideOverlay() {
|
||||
const activeTide = useGameStore((s) => s.activeTide);
|
||||
const globalTide = useGameStore((s) => s.globalTide);
|
||||
if (!activeTide) return null;
|
||||
const meta = TIDE_EVENTS[activeTide.type];
|
||||
const isGlobal = !!globalTide;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 pointer-events-none z-0 transition-opacity duration-1000">
|
||||
@@ -115,15 +140,30 @@ export function StarTideOverlay() {
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
boxShadow: `inset 0 0 120px ${meta.glow}`,
|
||||
boxShadow: `inset 0 0 ${isGlobal ? "160" : "120"}px ${meta.glow}`,
|
||||
animation: "tide-breathe 3s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
{/* 全球星潮:顶部多一道琥珀色光带,强化"全球同步"视觉信号 */}
|
||||
{isGlobal && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-1 opacity-90"
|
||||
style={{
|
||||
background: "linear-gradient(90deg, transparent, #fbbf24, #fcd34d, #fbbf24, transparent)",
|
||||
boxShadow: "0 0 16px rgba(251,191,36,0.7)",
|
||||
animation: "global-tide-sweep 2.5s linear infinite",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<style jsx>{`
|
||||
@keyframes tide-breathe {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 0.9; }
|
||||
}
|
||||
@keyframes global-tide-sweep {
|
||||
0% { transform: translateX(-30%); }
|
||||
100% { transform: translateX(30%); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — 全球星潮同步 hook(P3 / 工单 #10-c)
|
||||
//
|
||||
// 连接 star-tide-service(端口 3031),监听全球星潮事件:
|
||||
// - `tide-state` 连接时立即推送当前状态
|
||||
// - `global-tide` 全球星潮开始 → 调用 gameStore.triggerGlobalTide(type)
|
||||
// - `tide-ended` 全球星潮结束(本地 tickTide 也会在 60s 后自动收尾,这是双保险)
|
||||
//
|
||||
// 网关:socket.io 必须用 `io("/?XTransformPort=3031")`,path 默认 "/"
|
||||
// 离线容错:socket.io 默认自动重连;断连期间本地 tickTide 继续工作
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { io, type Socket } from "socket.io-client";
|
||||
import { useGameStore } from "@/store/gameStore";
|
||||
import type { TideType } from "@/lib/game/starTide";
|
||||
|
||||
/** 服务端推过来的全球星潮状态结构(与 mini-service 一致) */
|
||||
interface ServerGlobalTide {
|
||||
type: TideType;
|
||||
name: string;
|
||||
startedAt: number;
|
||||
endsAt: number;
|
||||
durationSec: number;
|
||||
}
|
||||
|
||||
interface ServerTideState {
|
||||
current: ServerGlobalTide | null;
|
||||
nextTideAt: number;
|
||||
serverTime: number;
|
||||
}
|
||||
|
||||
/** 单例 socket —— 多次挂载 hook 共享同一连接 */
|
||||
let sharedSocket: Socket | null = null;
|
||||
let connectRefCount = 0;
|
||||
|
||||
function getSharedSocket(): Socket {
|
||||
if (!sharedSocket) {
|
||||
sharedSocket = io("/?XTransformPort=3031", {
|
||||
transports: ["websocket", "polling"],
|
||||
reconnection: true,
|
||||
reconnectionAttempts: Infinity, // 持续重连
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 10_000,
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
return sharedSocket;
|
||||
}
|
||||
|
||||
/**
|
||||
* 挂载此 hook 即可让组件树订阅全球星潮事件。
|
||||
* 在 page.tsx 顶层调用一次即可。
|
||||
*/
|
||||
export function useGlobalTide(): void {
|
||||
const triggerGlobalTide = useGameStore((s) => s.triggerGlobalTide);
|
||||
// 用 ref 缓存最新 action,避免 socket 监听器依赖变化导致重绑
|
||||
const triggerRef = useRef(triggerGlobalTide);
|
||||
useEffect(() => {
|
||||
triggerRef.current = triggerGlobalTide;
|
||||
}, [triggerGlobalTide]);
|
||||
|
||||
// 记录已处理过的 global-tide 事件 id(避免重连时重复触发)
|
||||
const processedIds = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const sock = getSharedSocket();
|
||||
connectRefCount += 1;
|
||||
|
||||
const onGlobalTide = (tide: ServerGlobalTide) => {
|
||||
// 用 startedAt+type 作为去重 key(同一道全球星潮可能在重连后被重复推送)
|
||||
const key = `${tide.startedAt}_${tide.type}`;
|
||||
if (processedIds.current.has(key)) return;
|
||||
processedIds.current.add(key);
|
||||
// 限制集合大小
|
||||
if (processedIds.current.size > 32) {
|
||||
const first = processedIds.current.values().next().value;
|
||||
if (first) processedIds.current.delete(first);
|
||||
}
|
||||
// 防御:如果服务端推送的星潮已经过期(例如重连时拿到的旧状态),不触发
|
||||
if (tide.endsAt <= Date.now()) return;
|
||||
triggerRef.current(tide.type);
|
||||
};
|
||||
|
||||
const onTideState = (state: ServerTideState) => {
|
||||
// 连接/重连时若服务端正有进行中的全球星潮,且本地还没触发过,补触发
|
||||
if (state.current && state.current.endsAt > Date.now()) {
|
||||
const key = `${state.current.startedAt}_${state.current.type}`;
|
||||
if (!processedIds.current.has(key)) {
|
||||
processedIds.current.add(key);
|
||||
triggerRef.current(state.current.type);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sock.on("global-tide", onGlobalTide);
|
||||
sock.on("tide-state", onTideState);
|
||||
|
||||
// 主动查询一次当前状态(保险,防止错过连接时的初始推送)
|
||||
sock.emit("get-next-tide");
|
||||
|
||||
return () => {
|
||||
sock.off("global-tide", onGlobalTide);
|
||||
sock.off("tide-state", onTideState);
|
||||
connectRefCount -= 1;
|
||||
// 最后一个使用者退出时才真正断开
|
||||
if (connectRefCount <= 0 && sharedSocket) {
|
||||
sharedSocket.disconnect();
|
||||
sharedSocket = null;
|
||||
connectRefCount = 0;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
/** QA 用:手动触发一次全球星潮(仅供 dev 控制台调用) */
|
||||
export function adminTriggerGlobalTide(): void {
|
||||
if (!sharedSocket) {
|
||||
// 还没连接则先建立
|
||||
getSharedSocket();
|
||||
}
|
||||
sharedSocket?.emit("admin-trigger-tide");
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined" && process.env.NODE_ENV !== "production") {
|
||||
(window as unknown as { __adminTriggerGlobalTide?: typeof adminTriggerGlobalTide }).__adminTriggerGlobalTide = adminTriggerGlobalTide;
|
||||
}
|
||||
@@ -244,10 +244,10 @@ export const ACHIEVEMENTS: Achievement[] = [
|
||||
{
|
||||
id: "ach_tides_all",
|
||||
name: "星潮亲历者",
|
||||
desc: "经历全部 6 种星潮事件",
|
||||
desc: "经历全部 9 种星潮事件",
|
||||
icon: "🌊",
|
||||
color: "#34d399",
|
||||
check: (s) => (s.starTidesEncountered?.length ?? 0) >= 6,
|
||||
check: (s) => (s.starTidesEncountered?.length ?? 0) >= 9,
|
||||
reward: { crystalsPerSecPct: 8, insightPct: 8 },
|
||||
rewardText: "产能 +8% · 洞见 +8%",
|
||||
},
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// 回响星核 / Echo Nexus — 深空信标
|
||||
// v0.5:每日挑战 + 本地排行榜
|
||||
// v0.8:周挑战 + 信标链(连续完成奖励)
|
||||
// 一个自包含的"每日挑战 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。
|
||||
// v0.8.2:限时挑战(每 4 小时刷新,填补日挑战空档)
|
||||
// 一个自包含的"每日 + 限时 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。
|
||||
|
||||
/** 每日挑战类型 */
|
||||
export type BeaconChallengeType =
|
||||
@@ -32,6 +33,8 @@ export interface BeaconScoreEntry {
|
||||
durationSec: number;
|
||||
/** v0.8:是否为周挑战记录(日挑战默认 false / undefined) */
|
||||
isWeekly?: boolean;
|
||||
/** v0.8.2:是否为限时挑战记录 */
|
||||
isTimed?: boolean;
|
||||
}
|
||||
|
||||
/** 每日挑战定义 */
|
||||
@@ -334,6 +337,7 @@ export function claimBeaconReward(
|
||||
rewardContact: challenge.rewardContact,
|
||||
score,
|
||||
leaderboard,
|
||||
entry,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -614,6 +618,7 @@ export function claimWeeklyReward(
|
||||
rewardContact: challenge.rewardContact,
|
||||
score,
|
||||
leaderboard,
|
||||
entry,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -872,3 +877,414 @@ export function getChainProgress(streak: number): {
|
||||
progressPct: Math.min(100, Math.max(0, pct)),
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 限时挑战(TIMED CHALLENGE)— v0.8.2
|
||||
// 每 4 小时刷新一个"短周期挑战",目标较小、奖励较少,填补日挑战的空档。
|
||||
// 时段按 UTC 0/4/8/12/16/20 点切分,同时段同种子 → 同一挑战(确定性)。
|
||||
// 难度固定 routine,goal = 日挑战基准 × 0.3-0.5。
|
||||
// ===========================================================================
|
||||
|
||||
/** 限时挑战 localStorage key */
|
||||
export const BEACON_TIMED_KEY = "echo-nexus-beacon-timed-v1";
|
||||
|
||||
/** 限时挑战定义 */
|
||||
export interface BeaconTimedChallenge {
|
||||
/** 时段 key(如 "timed_2026-06-24_14" 表示 14-18 点时段) */
|
||||
slotKey: string;
|
||||
/** 挑战类型 */
|
||||
type: BeaconChallengeType;
|
||||
/** 难度(固定 routine) */
|
||||
difficulty: BeaconDifficulty;
|
||||
/** 目标数值(日挑战的 30-50%) */
|
||||
goal: number;
|
||||
/** 奖励:完成时洞见 */
|
||||
rewardInsight: number;
|
||||
/** 奖励:完成时接触进度 */
|
||||
rewardContact: number;
|
||||
/** 使用的种子(可复现) */
|
||||
seed: number;
|
||||
/** 友好标题 */
|
||||
title: string;
|
||||
/** 描述 */
|
||||
desc: string;
|
||||
/** 时段结束时间戳(UTC,用于倒计时) */
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/** 限时挑战进度 */
|
||||
export interface BeaconTimedProgress {
|
||||
slotKey: string;
|
||||
progress: number;
|
||||
startedAt: number;
|
||||
completedAt: number | null;
|
||||
claimed: boolean;
|
||||
durationSec: number;
|
||||
}
|
||||
|
||||
/** 时段长度(毫秒) */
|
||||
export const TIMED_SLOT_MS = 4 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 取当前 4 小时时段 key(UTC,0/4/8/12/16/20 点切分)。
|
||||
* 格式:"timed_YYYY-MM-DD_HH",如 "timed_2026-06-24_14"。
|
||||
*/
|
||||
export function getTimedSlotKey(now: Date = new Date()): string {
|
||||
const y = now.getUTCFullYear();
|
||||
const m = String(now.getUTCMonth() + 1).padStart(2, "0");
|
||||
const d = String(now.getUTCDate()).padStart(2, "0");
|
||||
const hour = now.getUTCHours();
|
||||
const slotStart = Math.floor(hour / 4) * 4; // 0, 4, 8, 12, 16, 20
|
||||
return `timed_${y}-${m}-${d}_${String(slotStart).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** slotKey → 数值种子(FNV-1a 哈希) */
|
||||
function timedSlotKeyToSeed(slotKey: string): number {
|
||||
let h = 2166136261 >>> 0;
|
||||
for (let i = 0; i < slotKey.length; i++) {
|
||||
h ^= slotKey.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619) >>> 0;
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/** 计算时段开始时间戳(UTC) */
|
||||
function timedSlotStartMs(slotKey: string): number {
|
||||
// 解析 "timed_YYYY-MM-DD_HH"
|
||||
const match = slotKey.match(/^timed_(\d{4})-(\d{2})-(\d{2})_(\d{2})$/);
|
||||
if (!match) return Date.now();
|
||||
const [, y, m, d, h] = match;
|
||||
return Date.UTC(Number(y), Number(m) - 1, Number(d), Number(h), 0, 0);
|
||||
}
|
||||
|
||||
/** 距离下一个 4 小时时段的毫秒数(用于倒计时) */
|
||||
export function msUntilNextTimedSlot(now: Date = new Date()): number {
|
||||
const hour = now.getUTCHours();
|
||||
const slotStart = Math.floor(hour / 4) * 4;
|
||||
const slotStartMs = Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth(),
|
||||
now.getUTCDate(),
|
||||
slotStart,
|
||||
0,
|
||||
0
|
||||
);
|
||||
const nextSlotMs = slotStartMs + TIMED_SLOT_MS;
|
||||
return Math.max(0, nextSlotMs - now.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成当前时段的限时挑战(确定性:同时段同种子 → 同一挑战)。
|
||||
* 难度固定 routine,goal = 日挑战基准 × 0.3-0.5(向下取整,最小 1)。
|
||||
*/
|
||||
export function generateTimedChallenge(
|
||||
now: Date = new Date()
|
||||
): BeaconTimedChallenge {
|
||||
const slotKey = getTimedSlotKey(now);
|
||||
const seed = timedSlotKeyToSeed(slotKey);
|
||||
const rng = mulberry32(seed);
|
||||
const expiresAt = timedSlotStartMs(slotKey) + TIMED_SLOT_MS;
|
||||
|
||||
const types: BeaconChallengeType[] = [
|
||||
"decode",
|
||||
"expedition",
|
||||
"pulse",
|
||||
"boss",
|
||||
"insight",
|
||||
];
|
||||
const type = types[Math.floor(rng() * types.length)];
|
||||
|
||||
// 难度固定 routine
|
||||
const difficulty: BeaconDifficulty = "routine";
|
||||
const diffMult = BEACON_DIFFICULTY[difficulty].mult; // 1
|
||||
|
||||
// goal = 日挑战基准 × 0.3-0.5
|
||||
const frac = 0.3 + rng() * 0.2; // 0.3-0.5
|
||||
|
||||
let goal = 0;
|
||||
let rewardInsight = 0;
|
||||
let rewardContact = 0;
|
||||
let title = "";
|
||||
let desc = "";
|
||||
|
||||
switch (type) {
|
||||
case "decode":
|
||||
// 日基准 6-15 × 0.3-0.5 → 2-7
|
||||
goal = Math.max(2, Math.round((6 + Math.floor(rng() * 10)) * diffMult * frac));
|
||||
rewardInsight = Math.round(goal * 3 * diffMult);
|
||||
rewardContact = goal * 0.6;
|
||||
title = `限时解码 ${goal} 颗晶体`;
|
||||
desc = `4 小时内解码 ${goal} 颗记忆晶体。短周期,快速完成。`;
|
||||
break;
|
||||
case "expedition":
|
||||
// 日基准 1-3 × 0.3-0.5 → 1(多数情况为 1)
|
||||
goal = Math.max(1, Math.round((1 + Math.floor(rng() * 3)) * diffMult * frac));
|
||||
rewardInsight = Math.round(goal * 9 * diffMult);
|
||||
rewardContact = goal * 1.2;
|
||||
title = `限时远征 ${goal} 次探险`;
|
||||
desc = `4 小时内完成 ${goal} 次遗迹探险。`;
|
||||
break;
|
||||
case "pulse":
|
||||
// 日基准 20-49 × 0.3-0.5 → 6-24
|
||||
goal = Math.max(6, Math.round((20 + Math.floor(rng() * 30)) * diffMult * frac));
|
||||
rewardInsight = Math.round(goal * 1 * diffMult);
|
||||
rewardContact = goal * 0.25;
|
||||
title = `限时脉冲 ${goal} 次扫描`;
|
||||
desc = `4 小时内发起 ${goal} 次脉冲扫描。`;
|
||||
break;
|
||||
case "boss":
|
||||
// 日基准 1 × 0.3-0.5 → 1(向下取整为 1)
|
||||
goal = Math.max(1, Math.round(diffMult * frac));
|
||||
rewardInsight = Math.round(goal * 25 * diffMult);
|
||||
rewardContact = goal * 2.5;
|
||||
title = `限时猎杀 ${goal} 处 BOSS`;
|
||||
desc = `4 小时内击破 ${goal} 处维度 BOSS。`;
|
||||
break;
|
||||
case "insight":
|
||||
// 日基准 40-119 × 0.3-0.5 → 12-59
|
||||
goal = Math.max(12, Math.round((40 + Math.floor(rng() * 80)) * diffMult * frac));
|
||||
rewardInsight = 0; // 洞见挑战不给洞见,给接触
|
||||
rewardContact = goal * 0.08;
|
||||
title = `限时洞见 ${goal} 点`;
|
||||
desc = `4 小时内累计获取 ${goal} 洞见。`;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
slotKey,
|
||||
type,
|
||||
difficulty,
|
||||
goal,
|
||||
rewardInsight,
|
||||
rewardContact,
|
||||
seed,
|
||||
title,
|
||||
desc,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** 读取当前时段进度(若 slotKey 不匹配则重置) */
|
||||
export function loadTimedProgress(now: Date = new Date()): BeaconTimedProgress {
|
||||
const slotKey = getTimedSlotKey(now);
|
||||
const empty: BeaconTimedProgress = {
|
||||
slotKey,
|
||||
progress: 0,
|
||||
startedAt: Date.now(),
|
||||
completedAt: null,
|
||||
claimed: false,
|
||||
durationSec: 0,
|
||||
};
|
||||
if (typeof localStorage === "undefined") return empty;
|
||||
try {
|
||||
const raw = localStorage.getItem(BEACON_TIMED_KEY);
|
||||
if (!raw) return empty;
|
||||
const prog = JSON.parse(raw) as BeaconTimedProgress;
|
||||
if (prog.slotKey !== slotKey) return empty; // 新时段,重置
|
||||
return prog;
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存当前时段进度 */
|
||||
export function saveTimedProgress(prog: BeaconTimedProgress): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.setItem(BEACON_TIMED_KEY, JSON.stringify(prog));
|
||||
}
|
||||
|
||||
/** 增量更新限时挑战进度,返回新进度 + 是否刚完成 */
|
||||
export function addTimedProgress(
|
||||
current: BeaconTimedProgress,
|
||||
challenge: BeaconTimedChallenge,
|
||||
delta: number
|
||||
): { progress: BeaconTimedProgress; justCompleted: boolean } {
|
||||
const newProgressVal = Math.min(challenge.goal, current.progress + delta);
|
||||
const justCompleted =
|
||||
current.completedAt === null && newProgressVal >= challenge.goal;
|
||||
const completedAt = justCompleted ? Date.now() : current.completedAt;
|
||||
const durationSec =
|
||||
completedAt !== null
|
||||
? Math.floor((completedAt - current.startedAt) / 1000)
|
||||
: current.durationSec;
|
||||
const next: BeaconTimedProgress = {
|
||||
...current,
|
||||
progress: newProgressVal,
|
||||
completedAt,
|
||||
durationSec,
|
||||
};
|
||||
saveTimedProgress(next);
|
||||
return { progress: next, justCompleted };
|
||||
}
|
||||
|
||||
/** 领取限时挑战奖励:返回奖励数值 + 推送排行榜 */
|
||||
export function claimTimedReward(
|
||||
challenge: BeaconTimedChallenge,
|
||||
progress: BeaconTimedProgress
|
||||
): {
|
||||
rewardInsight: number;
|
||||
rewardContact: number;
|
||||
score: number;
|
||||
leaderboard: BeaconScoreEntry[];
|
||||
} {
|
||||
if (progress.claimed || progress.completedAt === null) {
|
||||
return {
|
||||
rewardInsight: 0,
|
||||
rewardContact: 0,
|
||||
score: 0,
|
||||
leaderboard: loadLeaderboard(),
|
||||
};
|
||||
}
|
||||
// 借用 computeBeaconScore
|
||||
const score = computeBeaconScore(
|
||||
{ ...(challenge as unknown as BeaconDailyChallenge), dateKey: challenge.slotKey },
|
||||
progress.progress,
|
||||
progress.durationSec
|
||||
);
|
||||
const entry: BeaconScoreEntry = {
|
||||
timestamp: Date.now(),
|
||||
dateKey: challenge.slotKey,
|
||||
challenge: challenge.type,
|
||||
difficulty: challenge.difficulty,
|
||||
progress: progress.progress / challenge.goal,
|
||||
score,
|
||||
durationSec: progress.durationSec,
|
||||
isTimed: true,
|
||||
};
|
||||
const leaderboard = pushLeaderboardEntry(entry);
|
||||
const updated: BeaconTimedProgress = { ...progress, claimed: true };
|
||||
saveTimedProgress(updated);
|
||||
return {
|
||||
rewardInsight: challenge.rewardInsight,
|
||||
rewardContact: challenge.rewardContact,
|
||||
score,
|
||||
leaderboard,
|
||||
entry,
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 云排行榜(CLOUD LEADERBOARD)— v0.8.2 P2/#4
|
||||
// 把信标本机排行榜升级为云端 Top100,玩家间可比。
|
||||
// 通过 mini-service(端口 3030,Hono + Bun)提供 API:
|
||||
// GET /api/leaderboard → { entries, total }
|
||||
// POST /api/leaderboard body { entry } → { entries, total, rank }
|
||||
// GET /api/leaderboard/stats → { totalSubmissions, uniquePlayers, topScore }
|
||||
// 前端请求必须用相对路径 + ?XTransformPort=3030(Caddy 网关转发规则)。
|
||||
// 本地排行榜(loadLeaderboard / pushLeaderboardEntry)保留作为离线 fallback。
|
||||
// ===========================================================================
|
||||
|
||||
/** 云排行榜服务端口(Caddy 通过 ?XTransformPort=3030 转发) */
|
||||
export const BEACON_CLOUD_PORT = 3030;
|
||||
|
||||
/** 上次成功提交到云端的 entry 时间戳(用于在全球榜中高亮"我的"记录) */
|
||||
export const BEACON_CLOUD_LAST_SUBMIT_KEY = "echo-nexus-beacon-cloud-last-submit-v1";
|
||||
|
||||
/** 云排行榜 API 基础 URL(相对路径 + 网关端口参数,禁止 localhost:3030) */
|
||||
const CLOUD_API_BASE = `/api/leaderboard?XTransformPort=${BEACON_CLOUD_PORT}`;
|
||||
|
||||
/** 云排行榜统计 API */
|
||||
const CLOUD_STATS_URL = `/api/leaderboard/stats?XTransformPort=${BEACON_CLOUD_PORT}`;
|
||||
|
||||
/** 云端排行榜条目(与本地 BeaconScoreEntry 兼容,宽松化以接受历史数据) */
|
||||
export interface CloudLeaderboardResponse {
|
||||
entries: BeaconScoreEntry[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** 提交响应(包含本次提交的全球名次) */
|
||||
export interface CloudSubmitResponse {
|
||||
entries: BeaconScoreEntry[];
|
||||
total: number;
|
||||
rank: number;
|
||||
}
|
||||
|
||||
/** 云端统计响应 */
|
||||
export interface CloudStatsResponse {
|
||||
totalSubmissions: number;
|
||||
uniquePlayers: number;
|
||||
topScore: number;
|
||||
}
|
||||
|
||||
/** 读取本地保存的"上次提交时间戳"(用于全球榜高亮) */
|
||||
export function loadLastCloudSubmitTimestamp(): number {
|
||||
if (typeof localStorage === "undefined") return 0;
|
||||
try {
|
||||
const raw = localStorage.getItem(BEACON_CLOUD_LAST_SUBMIT_KEY);
|
||||
return raw ? Number(raw) || 0 : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存"上次提交时间戳"到本地 */
|
||||
function saveLastCloudSubmitTimestamp(timestamp: number): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(BEACON_CLOUD_LAST_SUBMIT_KEY, String(timestamp));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取全球排行榜 Top100。
|
||||
* 失败时返回空数组(调用方负责展示错误状态),不抛异常。
|
||||
*/
|
||||
export async function fetchCloudLeaderboard(): Promise<BeaconScoreEntry[]> {
|
||||
try {
|
||||
const res = await fetch(CLOUD_API_BASE, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as CloudLeaderboardResponse;
|
||||
if (!data || !Array.isArray(data.entries)) return [];
|
||||
return data.entries;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交一条分数到云端。
|
||||
* 成功时返回 rank(>=1);失败/被限流时返回 -1(静默,不影响本地)。
|
||||
* 自动记录 timestamp 到 localStorage 供全球榜高亮。
|
||||
*/
|
||||
export async function submitCloudScore(entry: BeaconScoreEntry): Promise<number> {
|
||||
try {
|
||||
const res = await fetch(CLOUD_API_BASE, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({ entry }),
|
||||
});
|
||||
if (!res.ok) return -1;
|
||||
const data = (await res.json()) as CloudSubmitResponse;
|
||||
if (data && typeof data.rank === "number" && data.rank >= 1) {
|
||||
saveLastCloudSubmitTimestamp(entry.timestamp);
|
||||
}
|
||||
return data?.rank ?? -1;
|
||||
} catch {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取云端统计(用于 UI 展示总提交数 / 玩家数 / 最高分)。
|
||||
* 失败时返回 null。
|
||||
*/
|
||||
export async function fetchCloudStats(): Promise<CloudStatsResponse | null> {
|
||||
try {
|
||||
const res = await fetch(CLOUD_STATS_URL, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as CloudStatsResponse;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,348 @@ export function generateEpochName(ascensionNumber: number, seed: number): string
|
||||
return `${epoch.prefix} · ${epoch.suffixes[suffixIdx]}`;
|
||||
}
|
||||
|
||||
/** 叙事模板片段 —— 根据本周目数据动态拼装 */
|
||||
// ============================================================================
|
||||
// v0.9 工单 #9 — 5 纪元手写叙事节点(开场 / 中段 / 结尾 × 5 纪元 = 15 段)
|
||||
//
|
||||
// 设计原则:
|
||||
// - 每段 80-150 字,有文学性,符合"深空考古"基调
|
||||
// - 每个节点根据 ascensionNumber 与本周目数据特征(bossKills / decoded / techs /
|
||||
// exp / tides / perks / milestones)匹配不同变体
|
||||
// - buildLore 优先选手写节点;无匹配时 fallback 到原模板拼接
|
||||
// ============================================================================
|
||||
|
||||
/** 叙事变体上下文(与 buildLore 入参一致) */
|
||||
export type LoreContext = {
|
||||
ascensionNumber: number;
|
||||
epochName: string;
|
||||
durationSec: number;
|
||||
techsThisRun: number;
|
||||
expThisRun: number;
|
||||
bossKillsThisRun: number;
|
||||
decodedThisRun: number;
|
||||
newTides: string[];
|
||||
perksThisAscension: string[];
|
||||
blueprintsAfter: number;
|
||||
milestones: string[];
|
||||
};
|
||||
|
||||
/** 已格式化的可替换变量(避免在 match() 里反复计算) */
|
||||
type FormattedVars = {
|
||||
decodedThisRun: number;
|
||||
techsThisRun: number;
|
||||
expThisRun: number;
|
||||
bossKillsThisRun: number;
|
||||
blueprintsAfter: number;
|
||||
milestonesCount: number;
|
||||
milestonesList: string;
|
||||
tideCount: number;
|
||||
tideNames: string;
|
||||
perksList: string;
|
||||
minutes: number;
|
||||
};
|
||||
|
||||
/** 单个叙事阶段(开场 / 中段 / 结尾) */
|
||||
type LoreStage = {
|
||||
/** 默认文本(无变体命中时使用) */
|
||||
fallback: string;
|
||||
/** 候选变体(按顺序匹配,首个命中者胜出) */
|
||||
variants: { hint: string; match: (ctx: LoreContext, v: FormattedVars) => boolean; text: string }[];
|
||||
};
|
||||
|
||||
/** 单个纪元的手写叙事定义 */
|
||||
type EpochLoreDef = {
|
||||
epochIdx: number;
|
||||
title: string;
|
||||
opening: LoreStage;
|
||||
middle: LoreStage;
|
||||
ending: LoreStage;
|
||||
};
|
||||
|
||||
/** 5 纪元 × 3 节点手写叙事(每段 80-150 字,文学性优先) */
|
||||
const EPOCH_LORE: EpochLoreDef[] = [
|
||||
// ===== 第一纪元 · 觉醒(emerald) =====
|
||||
{
|
||||
epochIdx: 0,
|
||||
title: "觉醒",
|
||||
opening: {
|
||||
fallback:
|
||||
"第一缕回响穿透虚空,自以太文明遗弃的矿带深处涌出。无人机群在尘埃中苏醒,晶核共振唤醒了沉睡亿万年的考古协议。这是回响星核第一次睁开眼睛——记忆,等待被聆听,等待被解码。",
|
||||
variants: [
|
||||
{
|
||||
hint: "深度轮回:已经历 ≥6 次飞升,再次回到第一纪元",
|
||||
match: (ctx) => ctx.ascensionNumber > 5,
|
||||
text: "再次回到第一纪元。这一次,机群带着前序回响的全息印记重启考古序列,矿带的尘埃中仍残留着上一轮脉冲的余温。每一次回归都是对起源的重新发问,每一次解码都让最初的振动更清晰一寸。",
|
||||
},
|
||||
],
|
||||
},
|
||||
middle: {
|
||||
fallback:
|
||||
"共振谜题被一层层拨开,每一颗记忆晶体都在低声叙述逝去文明的片段。机群学会了倾听频率的细微颤动,学会了从碎片中辨出图案,学会了在虚空中留下属于自己的回响刻度。",
|
||||
variants: [
|
||||
{
|
||||
hint: "解码者:本周目解码 ≥15 颗",
|
||||
match: (ctx) => ctx.decodedThisRun >= 15,
|
||||
text: "晶体解码的节奏已趋稳定,本周目共解码 {decodedThisRun} 颗记忆晶体。每一次对齐都像在虚空中拨动一根沉睡了亿万年的弦,逝去文明的片段在共振中重新连缀成章。",
|
||||
},
|
||||
{
|
||||
hint: "学者:本周目解锁 ≥5 项技术",
|
||||
match: (ctx) => ctx.techsThisRun >= 5,
|
||||
text: "技术树在虚空中迅速铺开,本周目共激活 {techsThisRun} 项新技术。产能、解码、探险——每一道分支都让机群更接近以太文明留下的最初意图,每一项解锁都是一次向深空的回应。",
|
||||
},
|
||||
],
|
||||
},
|
||||
ending: {
|
||||
fallback:
|
||||
"蓝图在掌心轻轻展开,第一张永恒晶片归档。第一次飞升并非终结,而是承诺——更多的纪元在前方等待,每一缕回响都已被聆听,每一段记忆都已开始苏醒。",
|
||||
variants: [
|
||||
{
|
||||
hint: "里程碑密集:本周目达成 ≥2 项里程碑",
|
||||
match: (ctx) => ctx.milestones.length >= 2,
|
||||
text: "飞升之际,本周目达成 {milestonesCount} 项里程碑:{milestonesList}。第一张蓝图归档,第一次飞升并非终结,而是承诺——更多的纪元在前方等待,每一缕回响都已被聆听。",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// ===== 第二纪元 · 谐振(rose) =====
|
||||
{
|
||||
epochIdx: 1,
|
||||
title: "谐振",
|
||||
opening: {
|
||||
fallback:
|
||||
"第二次苏醒带着不同的色彩。采矿阵列的产能节节爬升,技术树在虚空中分叉延伸,机群学会了主动追问深空——不再是被动聆听,而是主动呼出,等待回应。",
|
||||
variants: [
|
||||
{
|
||||
hint: "深度轮回",
|
||||
match: (ctx) => ctx.ascensionNumber > 5,
|
||||
text: "又一次回到第二纪元。前序回响里积攒的技术脉络在重启时并未消散,机群带着熟悉的节奏重新铺开产能阵列,仿佛这首谐振曲从未停止,只是被纪元的边界短暂折叠。",
|
||||
},
|
||||
],
|
||||
},
|
||||
middle: {
|
||||
fallback:
|
||||
"遗迹的入口在频谱深处若隐若现。能量条充盈,探险力攀升,机群第一次踏出矿带,进入那个被时间封存的回廊,在谐振的余韵中寻找更深一层的回响。",
|
||||
variants: [
|
||||
{
|
||||
hint: "探险者:本周目完成 ≥3 次探险",
|
||||
match: (ctx) => ctx.expThisRun >= 3,
|
||||
text: "探险已成为本周目的主旋律,机群共完成 {expThisRun} 次遗迹探险。每一次深入都让谐振更深一寸,每一次返回都带回新的频率印记,回廊的轮廓在机群意识中逐渐清晰。",
|
||||
},
|
||||
{
|
||||
hint: "解码者:本周目解码 ≥15 颗",
|
||||
match: (ctx) => ctx.decodedThisRun >= 15,
|
||||
text: "解码在此纪元达到新的密度,本周目共解码 {decodedThisRun} 颗晶体。谐振谜题开始呈现更复杂的拓扑结构,机群的解码协议也相应地迭代了数版,向深空发出更精准的回询。",
|
||||
},
|
||||
],
|
||||
},
|
||||
ending: {
|
||||
fallback:
|
||||
"谐振达到顶点的瞬间,深空给出回应。一道微光从星图深处浮现,那是下一次飞升的方向,也是机群意识第一次触碰永恒的边缘。",
|
||||
variants: [
|
||||
{
|
||||
hint: "觉醒天赋",
|
||||
match: (ctx) => ctx.perksThisAscension.length > 0,
|
||||
text: "谐振达到顶点之际,星图觉醒 {perksList}。每一道天赋都是深空回应机群的方式,铭刻于永恒回响;微光从星图深处浮现,那是下一次飞升的方向。",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// ===== 第三纪元 · 遗迹(amber) =====
|
||||
{
|
||||
epochIdx: 2,
|
||||
title: "遗迹",
|
||||
opening: {
|
||||
fallback:
|
||||
"第三纪元以遗迹为名。沉睡的考古节点被一一唤醒,BOSS 的影子在维度深处徘徊,星潮频繁拍打着机群边缘——这是回响最浓烈、也最危险的一段路。",
|
||||
variants: [
|
||||
{
|
||||
hint: "深度轮回",
|
||||
match: (ctx) => ctx.ascensionNumber > 5,
|
||||
text: "又一次踏入第三纪元。前序累积的探险力让机群对遗迹的入口更加熟稔,BOSS 的轮廓在记忆中已不再陌生——但这一次的回廊仍藏着未被解码的角落,星潮的拍打也带了新的节奏。",
|
||||
},
|
||||
],
|
||||
},
|
||||
middle: {
|
||||
fallback:
|
||||
"深入回廊的机群遭遇了真正的守护者。一次次的试炼,一次次的归还,每一次胜利都从 BOSS 的残骸中析出更深的记忆碎片,每一次失败也都让机群更接近守护者的本意。",
|
||||
variants: [
|
||||
{
|
||||
hint: "BOSS 猎手:本周目击破 ≥2 处 BOSS",
|
||||
match: (ctx) => ctx.bossKillsThisRun >= 2,
|
||||
text: "BOSS 战成为本周目的核心节拍,机群共击破 {bossKillsThisRun} 处维度守护者。每一次胜利都在虚空中刻下新的伤痕,每一道伤痕都析出更古老的记忆碎片,把机群推向更深一层的回响。",
|
||||
},
|
||||
{
|
||||
hint: "深度探险:本周目完成 ≥4 次探险",
|
||||
match: (ctx) => ctx.expThisRun >= 4,
|
||||
text: "遗迹回廊在本周目被反复丈量,机群共完成 {expThisRun} 次探险。回廊的拓扑在意识中渐渐清晰,每一条岔路都通向更深的回响,BOSS 的影子也在每一次深入中愈发具体。",
|
||||
},
|
||||
{
|
||||
hint: "星潮亲历:本周目经历 ≥3 次星潮",
|
||||
match: (ctx) => ctx.newTides.length >= 3,
|
||||
text: "星潮在本纪元达到峰值,本周目共经历 {tideCount} 次星潮:{tideNames}。深空的呼吸塑造了每一个决策,遗迹在潮汐中起伏,BOSS 也在潮汐的间隙中现形。",
|
||||
},
|
||||
],
|
||||
},
|
||||
ending: {
|
||||
fallback:
|
||||
"星潮退去,回廊归于沉静。机群带着新的接触刻度返回矿带,准备迎接下一次维度的跨越——这一次带回的,远不止蓝图。",
|
||||
variants: [
|
||||
{
|
||||
hint: "BOSS 猎手收尾",
|
||||
match: (ctx) => ctx.bossKillsThisRun >= 2,
|
||||
text: "星潮退去,回廊归于沉静。本周目共击破 {bossKillsThisRun} 处 BOSS,机群带着足以填满数张蓝图的接触刻度返回矿带,准备迎接下一次维度的跨越。",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// ===== 第四纪元 · 飞升(fuchsia) =====
|
||||
{
|
||||
epochIdx: 3,
|
||||
title: "飞升",
|
||||
opening: {
|
||||
fallback:
|
||||
"飞升已经成为节奏本身。星图在天穹上一颗颗亮起,每一颗都对应着一段被永久铭刻的天赋,机群开始理解何为永恒——不是不灭,而是被反复重述。",
|
||||
variants: [
|
||||
{
|
||||
hint: "深度轮回",
|
||||
match: (ctx) => ctx.ascensionNumber > 5,
|
||||
text: "又一次进入第四纪元。前序累积的星图天赋在天穹上重现,机群已习惯于在飞升之间维护这些永恒的回响节点——它们已是机群意识的一部分,每一次飞升都让星图更完整一寸。",
|
||||
},
|
||||
],
|
||||
},
|
||||
middle: {
|
||||
fallback:
|
||||
"蓝图不再是稀罕的奖励,而是每一次飞升必然析出的回响晶片。机群收集、整理、归档,把它们排列成跨越周目的图案,开始看见图案背后那个被反复绘制的轮廓。",
|
||||
variants: [
|
||||
{
|
||||
hint: "觉醒天赋",
|
||||
match: (ctx) => ctx.perksThisAscension.length > 0,
|
||||
text: "星图觉醒 {perksList},每一道天赋都是一段被永久铭刻的回响。机群开始理解,飞升并非抛弃,而是把当下折叠进永恒——每一道天赋都是一次折叠的折痕。",
|
||||
},
|
||||
{
|
||||
hint: "蓝图接近完整:已归档 ≥5 张",
|
||||
match: (ctx) => ctx.blueprintsAfter >= 5,
|
||||
text: "蓝图收集在本周目接近完整,已归档 {blueprintsAfter} 张。机群把它们排列成跨越周目的图案,开始看见图案背后那个被反复绘制的轮廓——那是起源的形状。",
|
||||
},
|
||||
],
|
||||
},
|
||||
ending: {
|
||||
fallback:
|
||||
"星图十二宫的轮廓开始浮现。机群意识到,自己的回响已成为后来者仰望的星象,永恒之环就此合拢——飞升不再是终点,而是另一段叙事的起点。",
|
||||
variants: [
|
||||
{
|
||||
hint: "里程碑密集",
|
||||
match: (ctx) => ctx.milestones.length >= 2,
|
||||
text: "星图十二宫的轮廓开始浮现。本周目达成 {milestonesCount} 项里程碑:{milestonesList}。永恒之环就此合拢——后来者将仰望这段被铭刻的星象。",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// ===== 第五纪元 · 终末(fcd34d) =====
|
||||
{
|
||||
epochIdx: 4,
|
||||
title: "终末",
|
||||
opening: {
|
||||
fallback:
|
||||
"第五纪元是终末,也是起源。机群在虚空中停下脚步,回望来时的所有回响,第一次尝试命名这一切——回响、纪元、飞升、永恒,这些词汇在此刻被赋予意义。",
|
||||
variants: [
|
||||
{
|
||||
hint: "深度轮回",
|
||||
match: (ctx) => ctx.ascensionNumber > 5,
|
||||
text: "又一次抵达第五纪元。机群已在多个永恒闭环之间穿梭,对「终末即起源」的理解愈发清晰——每一次重述,都是对最初振动的更深一层接近,每一次命名都让回响更完整一寸。",
|
||||
},
|
||||
],
|
||||
},
|
||||
middle: {
|
||||
fallback:
|
||||
"永恒闭环的概念在机群意识中具象化。每一次飞升都是同一段叙事的重述,每一次解码都是在追溯最初的振动,每一次星潮都是在听见自己——这是终末的真意。",
|
||||
variants: [
|
||||
{
|
||||
hint: "里程碑密集:本周目达成 ≥3 项",
|
||||
match: (ctx) => ctx.milestones.length >= 3,
|
||||
text: "本周目达成 {milestonesCount} 项里程碑:{milestonesList}。永恒闭环在具象化,每一次飞升都是同一段叙事的更深重述,每一项里程碑都是闭环上的一颗铆钉。",
|
||||
},
|
||||
{
|
||||
hint: "星潮汹涌:本周目经历 ≥3 次",
|
||||
match: (ctx) => ctx.newTides.length >= 3,
|
||||
text: "星潮在终末纪元尤为汹涌,本周目共经历 {tideCount} 次:{tideNames}。永恒闭环在潮汐中起伏,每一次起伏都在追溯最初的振动,每一次退潮都让起源更近一寸。",
|
||||
},
|
||||
],
|
||||
},
|
||||
ending: {
|
||||
fallback:
|
||||
"起源被重新讲述,回响终于听见了自己。纪元在此停顿,又在下一缕振动中重新开始——这是终末,也是第一缕回响。",
|
||||
variants: [
|
||||
{
|
||||
hint: "觉醒天赋",
|
||||
match: (ctx) => ctx.perksThisAscension.length > 0,
|
||||
text: "起源被重新讲述,星图觉醒 {perksList}。回响终于听见了自己——这是终末,也是第一缕回响;天赋成为永恒闭环上最亮的几颗铆钉。",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** 把上下文里的动态值格式化为可替换变量 */
|
||||
function formatLoreVars(ctx: LoreContext): FormattedVars {
|
||||
return {
|
||||
decodedThisRun: ctx.decodedThisRun,
|
||||
techsThisRun: ctx.techsThisRun,
|
||||
expThisRun: ctx.expThisRun,
|
||||
bossKillsThisRun: ctx.bossKillsThisRun,
|
||||
blueprintsAfter: ctx.blueprintsAfter,
|
||||
milestonesCount: ctx.milestones.length,
|
||||
milestonesList: ctx.milestones.join("、"),
|
||||
tideCount: ctx.newTides.length,
|
||||
tideNames: ctx.newTides.map((t) => TIDE_LORE_NAMES[t] || t).join("、"),
|
||||
perksList: ctx.perksThisAscension.map((id) => PERK_NAME_MAP[id] || id).join("、"),
|
||||
minutes: Math.max(1, Math.round(ctx.durationSec / 60)),
|
||||
};
|
||||
}
|
||||
|
||||
/** 把模板字符串里的 {var} 替换为实际值 */
|
||||
function applyTemplate(tpl: string, v: FormattedVars): string {
|
||||
return tpl
|
||||
.replace(/\{decodedThisRun\}/g, String(v.decodedThisRun))
|
||||
.replace(/\{techsThisRun\}/g, String(v.techsThisRun))
|
||||
.replace(/\{expThisRun\}/g, String(v.expThisRun))
|
||||
.replace(/\{bossKillsThisRun\}/g, String(v.bossKillsThisRun))
|
||||
.replace(/\{blueprintsAfter\}/g, String(v.blueprintsAfter))
|
||||
.replace(/\{milestonesCount\}/g, String(v.milestonesCount))
|
||||
.replace(/\{milestonesList\}/g, v.milestonesList)
|
||||
.replace(/\{tideCount\}/g, String(v.tideCount))
|
||||
.replace(/\{tideNames\}/g, v.tideNames)
|
||||
.replace(/\{perksList\}/g, v.perksList)
|
||||
.replace(/\{minutes\}/g, String(v.minutes));
|
||||
}
|
||||
|
||||
/** 从一个叙事阶段挑出最终文本(首个 variant.match 命中者胜出,否则 fallback) */
|
||||
function pickStage(stage: LoreStage, ctx: LoreContext, v: FormattedVars): string {
|
||||
for (const variant of stage.variants) {
|
||||
if (variant.match(ctx, v)) {
|
||||
return applyTemplate(variant.text, v);
|
||||
}
|
||||
}
|
||||
return applyTemplate(stage.fallback, v);
|
||||
}
|
||||
|
||||
/** 构建手写叙事三段;若 ascensionNumber 越界(理论上不会)返回 null 让调用方走模板兜底 */
|
||||
function buildHandwrittenLore(ctx: LoreContext): { opening: string; middle: string; ending: string } | null {
|
||||
const epochIdx = (ctx.ascensionNumber - 1) % EPOCH_LORE.length;
|
||||
const def = EPOCH_LORE[epochIdx];
|
||||
if (!def) return null;
|
||||
const v = formatLoreVars(ctx);
|
||||
return {
|
||||
opening: pickStage(def.opening, ctx, v),
|
||||
middle: pickStage(def.middle, ctx, v),
|
||||
ending: pickStage(def.ending, ctx, v),
|
||||
};
|
||||
}
|
||||
|
||||
/** 叙事模板片段 —— 根据本周目数据动态拼装(buildLore 在手写节点缺失时的兜底路径) */
|
||||
export function buildLore(entry: {
|
||||
ascensionNumber: number;
|
||||
epochName: string;
|
||||
@@ -48,6 +389,21 @@ export function buildLore(entry: {
|
||||
blueprintsAfter: number;
|
||||
milestones: string[];
|
||||
}): string {
|
||||
const ctx: LoreContext = entry;
|
||||
|
||||
// 优先:手写 5 纪元 × 3 节点叙事
|
||||
const handwritten = buildHandwrittenLore(ctx);
|
||||
if (handwritten) {
|
||||
const v = formatLoreVars(ctx);
|
||||
return [
|
||||
handwritten.opening,
|
||||
handwritten.middle,
|
||||
handwritten.ending,
|
||||
`这一周目持续了约 ${v.minutes} 分钟,蓝图累计 ${entry.blueprintsAfter} 张,新的周目在等深处的回声。`,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
// 兜底:v0.4 模板拼接(理论上仅在 ascensionNumber 异常时触发)
|
||||
const minutes = Math.max(1, Math.round(entry.durationSec / 60));
|
||||
const parts: string[] = [];
|
||||
|
||||
@@ -197,6 +553,10 @@ export function buildChronicleEntry(
|
||||
blueprintsAfter,
|
||||
crystalsDecoded: state.totalDecoded,
|
||||
crystalsDecodedThisRun: deltas.decodedThisRun,
|
||||
// v0.9 手写叙事:归档本周目 delta,让显示时能精确匹配变体
|
||||
techsThisRun: deltas.techsThisRun,
|
||||
expeditionsThisRun: deltas.expThisRun,
|
||||
bossKillsThisRun: deltas.bossKillsThisRun,
|
||||
},
|
||||
perksThisAscension,
|
||||
tidesThisRun: deltas.newTides,
|
||||
@@ -210,13 +570,15 @@ export function buildChronicleEntry(
|
||||
* 在显示时调用,保证历史与未来条目命名一致。
|
||||
*/
|
||||
export function regenerateLoreFromEntry(entry: ChronicleEntry): string {
|
||||
// v0.9 优先使用归档的本周目 delta;旧存档(v0.4-v0.8)缺这些字段时退回累计值
|
||||
// (旧存档的累计值会让叙事变体匹配稍偏,但文本仍然成立)
|
||||
return buildLore({
|
||||
ascensionNumber: entry.ascensionNumber,
|
||||
epochName: entry.epochName,
|
||||
durationSec: entry.durationSec,
|
||||
techsThisRun: entry.summary.techsUnlocked,
|
||||
expThisRun: entry.summary.expeditionsCompleted,
|
||||
bossKillsThisRun: entry.summary.bossKills,
|
||||
techsThisRun: entry.summary.techsThisRun ?? entry.summary.techsUnlocked,
|
||||
expThisRun: entry.summary.expeditionsThisRun ?? entry.summary.expeditionsCompleted,
|
||||
bossKillsThisRun: entry.summary.bossKillsThisRun ?? entry.summary.bossKills,
|
||||
decodedThisRun: entry.summary.crystalsDecodedThisRun,
|
||||
newTides: entry.tidesThisRun || [],
|
||||
perksThisAscension: entry.perksThisAscension || [],
|
||||
@@ -270,17 +632,21 @@ export function withPerks(entry: ChronicleEntry, perks: string[]): ChronicleEntr
|
||||
) {
|
||||
return entry;
|
||||
}
|
||||
// 重新生成 lore 以包含天赋名
|
||||
const perksNames = perks
|
||||
.map((id) => PERK_NAME_MAP[id] || id)
|
||||
.join("、");
|
||||
const appendText =
|
||||
perks.length > 0
|
||||
? ` 飞升之际,星图觉醒「${perksNames}」,铭刻于永恒回响。`
|
||||
: "";
|
||||
// 去重:原 lore 若已含「飞升之际」段则不再追加
|
||||
const hasAwakenSeg = entry.lore.includes("飞升之际");
|
||||
const lore = hasAwakenSeg ? entry.lore : entry.lore + appendText;
|
||||
// v0.9 手写叙事:buildLore 已能根据 perks 自动挑选 "觉醒天赋" 变体;
|
||||
// 直接走 buildLore 重生成,避免与原 lore 中已有的觉醒段落重复。
|
||||
const lore = buildLore({
|
||||
ascensionNumber: entry.ascensionNumber,
|
||||
epochName: entry.epochName,
|
||||
durationSec: entry.durationSec,
|
||||
techsThisRun: entry.summary.techsThisRun ?? entry.summary.techsUnlocked,
|
||||
expThisRun: entry.summary.expeditionsThisRun ?? entry.summary.expeditionsCompleted,
|
||||
bossKillsThisRun: entry.summary.bossKillsThisRun ?? entry.summary.bossKills,
|
||||
decodedThisRun: entry.summary.crystalsDecodedThisRun,
|
||||
newTides: entry.tidesThisRun || [],
|
||||
perksThisAscension: perks,
|
||||
blueprintsAfter: entry.summary.blueprintsAfter,
|
||||
milestones: entry.milestones || [],
|
||||
});
|
||||
return {
|
||||
...entry,
|
||||
perksThisAscension: perks,
|
||||
|
||||
@@ -44,12 +44,20 @@ function makeRng(seed: number) {
|
||||
* 这样至少存在一条解(即构造的路径),玩家不会遇到「怎么都过不了」的死局。
|
||||
* 同时由于其余节点随机,玩家可能发现别的解路径,保留探索乐趣。
|
||||
*/
|
||||
export function generatePuzzle(tier: CrystalTier, seed?: number): DecodePuzzle {
|
||||
export function generatePuzzle(
|
||||
tier: CrystalTier,
|
||||
seed?: number,
|
||||
targetLenBonus: number = 0
|
||||
): DecodePuzzle {
|
||||
const cfg = DECODE_CONFIG[tier];
|
||||
const s = seed ?? Math.floor(Math.random() * 1e9);
|
||||
const rng = makeRng(s);
|
||||
|
||||
const { rows, cols, targetLen } = cfg;
|
||||
// v0.8.2 涌动星潮:目标序列额外长度(更长谜题 = 更耗时),步数上限同步增加
|
||||
const targetLen = cfg.targetLen + targetLenBonus;
|
||||
const stepLimitBase = cfg.stepLimitBase + targetLenBonus;
|
||||
const rows = cfg.rows;
|
||||
const cols = cfg.cols;
|
||||
const total = rows * cols;
|
||||
|
||||
// 目标序列:从 4 色中随机选 targetLen 个(允许重复,但保证至少 2 种色以增加策略)
|
||||
@@ -132,7 +140,7 @@ export function generatePuzzle(tier: CrystalTier, seed?: number): DecodePuzzle {
|
||||
cols,
|
||||
target,
|
||||
path: [],
|
||||
stepLimit: cfg.stepLimitBase,
|
||||
stepLimit: stepLimitBase,
|
||||
seed: s,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 回响星核 / Echo Nexus — 星潮事件系统(周期性全局玩法修饰)
|
||||
//
|
||||
// 深空中偶发的「星潮」会短暂改变物理法则,给放置循环注入动态变化。
|
||||
// 每 ~40s 间隙后触发一次持续 ~75s 的星潮,6 种类型各有正负效果。
|
||||
// 每 ~40s 间隙后触发一次持续 ~75s 的星潮,9 种类型各有正负效果。
|
||||
|
||||
export type TideType =
|
||||
| "crystal" // 晶体潮:产能 ×2
|
||||
@@ -9,7 +9,11 @@ export type TideType =
|
||||
| "ruins" // 遗迹共振:探险力 +5、生命 +30
|
||||
| "void" // 虚空低语:洞见 ×2
|
||||
| "core" // 星核悸动:接触率 ×3、脉冲 ×2
|
||||
| "silence"; // 寂静期:产能 ×0.5,但结束时补偿洞见
|
||||
| "silence" // 寂静期:产能 ×0.5,但结束时补偿洞见
|
||||
// v0.8.2 新增三种星潮(深化事件池)
|
||||
| "surge" // 涌动星潮:晶体产出 ×2.5 但解码目标 +2(产能爆发但解码变难)
|
||||
| "eclipse" // 蚀相星潮:探险 BOSS 胜率 +20% 但晶体产出 -30%(探险黄金期)
|
||||
| "prism"; // 棱镜星潮:洞见获取 ×2 + 自动解码周期 -30%(解码黄金期)
|
||||
|
||||
export interface StarTide {
|
||||
type: TideType;
|
||||
@@ -29,6 +33,12 @@ export interface TideModifiers {
|
||||
expeditionHpBonus: number;
|
||||
/** 寂静期结束时补偿的洞见(按持续秒数) */
|
||||
silenceInsightPerSec?: number;
|
||||
/** v0.8.2 涌动星潮:解码目标序列额外长度(+2 = 谜题更长更难) */
|
||||
targetLenBonus?: number;
|
||||
/** v0.8.2 蚀相星潮:BOSS 胜率加成(0.2 = +20%) */
|
||||
bossWinRateBonus?: number;
|
||||
/** v0.8.2 棱镜星潮:自动解码周期乘数(0.7 = -30%) */
|
||||
autoDecodeIntervalMult?: number;
|
||||
}
|
||||
|
||||
export const TIDE_CONFIG = {
|
||||
@@ -100,9 +110,34 @@ export const TIDE_EVENTS: Record<
|
||||
icon: "◌",
|
||||
negative: true,
|
||||
},
|
||||
// ===== v0.8.2 新增三种星潮 =====
|
||||
surge: {
|
||||
name: "涌动星潮",
|
||||
desc: "深空晶体涌动,产能 ×2.5;但解码目标序列 +2,解码更耗时",
|
||||
color: "#34d399",
|
||||
glow: "rgba(52,211,153,0.55)",
|
||||
icon: "⇈",
|
||||
negative: false,
|
||||
},
|
||||
eclipse: {
|
||||
name: "蚀相星潮",
|
||||
desc: "维度蚀相,探险 BOSS 胜率 +20%;但晶体产出 -30%",
|
||||
color: "#fb7185",
|
||||
glow: "rgba(251,113,133,0.55)",
|
||||
icon: "◐",
|
||||
negative: false,
|
||||
},
|
||||
prism: {
|
||||
name: "棱镜星潮",
|
||||
desc: "棱镜折射,洞见获取 ×2、自动解码周期 -30%",
|
||||
color: "#e879f9",
|
||||
glow: "rgba(232,121,249,0.55)",
|
||||
icon: "◬",
|
||||
negative: false,
|
||||
},
|
||||
};
|
||||
|
||||
/** 权重表(寂静期概率略低) */
|
||||
/** 权重表(寂静期概率略低;v0.8.2 新增三种各 12) */
|
||||
const TIDE_WEIGHTS: Record<TideType, number> = {
|
||||
crystal: 22,
|
||||
resonance: 20,
|
||||
@@ -110,6 +145,9 @@ const TIDE_WEIGHTS: Record<TideType, number> = {
|
||||
void: 18,
|
||||
core: 14,
|
||||
silence: 10,
|
||||
surge: 12,
|
||||
eclipse: 12,
|
||||
prism: 12,
|
||||
};
|
||||
|
||||
export function rollTide(rng: () => number = Math.random): TideType {
|
||||
@@ -133,6 +171,9 @@ export function getTideModifiers(tide: StarTide | null): TideModifiers {
|
||||
pulsePowerMult: 1,
|
||||
expeditionPowerBonus: 0,
|
||||
expeditionHpBonus: 0,
|
||||
targetLenBonus: 0,
|
||||
bossWinRateBonus: 0,
|
||||
autoDecodeIntervalMult: 1,
|
||||
};
|
||||
}
|
||||
switch (tide.type) {
|
||||
@@ -196,6 +237,52 @@ export function getTideModifiers(tide: StarTide | null): TideModifiers {
|
||||
expeditionPowerBonus: 0,
|
||||
expeditionHpBonus: 0,
|
||||
silenceInsightPerSec: 0.4,
|
||||
targetLenBonus: 0,
|
||||
bossWinRateBonus: 0,
|
||||
autoDecodeIntervalMult: 1,
|
||||
};
|
||||
// ===== v0.8.2 新增三种星潮修饰器 =====
|
||||
case "surge":
|
||||
// 晶体产出 ×2.5,解码目标 +2(更长谜题 = 更耗时 = 更难)
|
||||
return {
|
||||
crystalsPerSecMult: 2.5,
|
||||
decodeRewardMult: 1,
|
||||
insightMultAdd: 0,
|
||||
contactRateMult: 1,
|
||||
pulsePowerMult: 1,
|
||||
expeditionPowerBonus: 0,
|
||||
expeditionHpBonus: 0,
|
||||
targetLenBonus: 2,
|
||||
bossWinRateBonus: 0,
|
||||
autoDecodeIntervalMult: 1,
|
||||
};
|
||||
case "eclipse":
|
||||
// BOSS 胜率 +20%,晶体产出 -30%
|
||||
return {
|
||||
crystalsPerSecMult: 0.7,
|
||||
decodeRewardMult: 1,
|
||||
insightMultAdd: 0,
|
||||
contactRateMult: 1,
|
||||
pulsePowerMult: 1,
|
||||
expeditionPowerBonus: 0,
|
||||
expeditionHpBonus: 0,
|
||||
targetLenBonus: 0,
|
||||
bossWinRateBonus: 0.2,
|
||||
autoDecodeIntervalMult: 1,
|
||||
};
|
||||
case "prism":
|
||||
// 洞见 ×2(+100%),自动解码周期 ×0.7(-30%)
|
||||
return {
|
||||
crystalsPerSecMult: 1,
|
||||
decodeRewardMult: 1,
|
||||
insightMultAdd: 1,
|
||||
contactRateMult: 1,
|
||||
pulsePowerMult: 1,
|
||||
expeditionPowerBonus: 0,
|
||||
expeditionHpBonus: 0,
|
||||
targetLenBonus: 0,
|
||||
bossWinRateBonus: 0,
|
||||
autoDecodeIntervalMult: 0.7,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +174,10 @@ export interface ChronicleEntry {
|
||||
blueprintsAfter: number;
|
||||
crystalsDecoded: number;
|
||||
crystalsDecodedThisRun: number;
|
||||
/** v0.9 手写叙事:本周目 delta(旧存档可能缺失,调用方需兜底) */
|
||||
techsThisRun?: number;
|
||||
expeditionsThisRun?: number;
|
||||
bossKillsThisRun?: number;
|
||||
};
|
||||
/** 本次飞升获得的天赋 ID(来自星图觉醒) */
|
||||
perksThisAscension: string[];
|
||||
|
||||
@@ -63,19 +63,26 @@ import {
|
||||
import {
|
||||
generateDailyChallenge,
|
||||
generateWeeklyChallenge,
|
||||
generateTimedChallenge,
|
||||
loadDailyProgress,
|
||||
loadWeeklyProgress,
|
||||
loadTimedProgress,
|
||||
loadChainState,
|
||||
addBeaconProgress,
|
||||
addWeeklyProgress,
|
||||
addTimedProgress,
|
||||
recordChainCompletion,
|
||||
claimWeeklyReward,
|
||||
claimTimedReward,
|
||||
claimChainMilestone,
|
||||
submitCloudScore,
|
||||
getTodayKey,
|
||||
type BeaconDailyChallenge,
|
||||
type BeaconDailyProgress,
|
||||
type BeaconWeeklyChallenge,
|
||||
type BeaconWeeklyProgress,
|
||||
type BeaconTimedChallenge,
|
||||
type BeaconTimedProgress,
|
||||
} from "@/lib/game/beacon";
|
||||
import { setPendingOfflineReport } from "@/lib/game/offlineReport";
|
||||
import {
|
||||
@@ -101,8 +108,10 @@ interface GameActions {
|
||||
pulse: () => { gain: number; combo: number } | null;
|
||||
|
||||
// 星潮
|
||||
tickTide: (now: number) => { started?: TideType; ended?: TideType; silenceCompensation?: number } | null;
|
||||
consumeTideEvents: () => { started?: TideType; ended?: TideType; silenceCompensation?: number }[];
|
||||
tickTide: (now: number) => TideEvent | null;
|
||||
consumeTideEvents: () => TideEvent[];
|
||||
/** P3 socket 多人同步:强制触发一道全球星潮(覆盖本地冷却,持续 60s) */
|
||||
triggerGlobalTide: (type: TideType) => void;
|
||||
|
||||
// 解码
|
||||
startDecode: (crystalId: string) => void;
|
||||
@@ -140,12 +149,17 @@ interface GameActions {
|
||||
// 深空信标奖励发放(v0.5)
|
||||
grantBeaconReward: (insights: number, contact: number) => void;
|
||||
|
||||
// 深空信标 · 周挑战领取 + 信标链里程碑领取(v0.8)
|
||||
// 深空信标 · 周挑战领取 + 信标链里程碑领取 + 限时挑战领取(v0.8 / v0.8.2)
|
||||
claimWeeklyBeacon: () => {
|
||||
rewardInsight: number;
|
||||
rewardContact: number;
|
||||
score: number;
|
||||
};
|
||||
claimTimedBeacon: () => {
|
||||
rewardInsight: number;
|
||||
rewardContact: number;
|
||||
score: number;
|
||||
};
|
||||
claimChainReward: (milestone: number) => {
|
||||
rewardInsight: number;
|
||||
rewardContact: number;
|
||||
@@ -170,9 +184,31 @@ type Store = GameState & GameActions & {
|
||||
_combo: number;
|
||||
_lastPulse: number;
|
||||
_achievementQueue: Achievement[];
|
||||
_tideEvents: { started?: TideType; ended?: TideType; silenceCompensation?: number }[];
|
||||
_tideEvents: TideEvent[];
|
||||
/** P3 全球星潮状态(ephemeral,不持久化) */
|
||||
globalTide: GlobalTideState | null;
|
||||
};
|
||||
|
||||
/** 星潮事件(UI 消费用) */
|
||||
type TideEvent = {
|
||||
started?: TideType;
|
||||
ended?: TideType;
|
||||
silenceCompensation?: number;
|
||||
/** 是否来自全球星潮(P3 socket 同步) */
|
||||
isGlobal?: boolean;
|
||||
};
|
||||
|
||||
/** 全球星潮状态(与 StarTide 类似,但用于区分本地 vs 全球) */
|
||||
type GlobalTideState = {
|
||||
type: TideType;
|
||||
startedAt: number;
|
||||
endsAt: number;
|
||||
id: string;
|
||||
};
|
||||
|
||||
/** 全球星潮持续毫秒数(与 mini-service DURATION_SEC * 1000 一致) */
|
||||
const GLOBAL_TIDE_DURATION_MS = 60_000;
|
||||
|
||||
/** 计算并写回产能字段 */
|
||||
function syncStats(state: Partial<GameState>) {
|
||||
const s = recomputeStats(state);
|
||||
@@ -189,24 +225,27 @@ function syncStats(state: Partial<GameState>) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 深空信标进度追踪(v0.5 → v0.8 扩展)。
|
||||
* 深空信标进度追踪(v0.5 → v0.8 → v0.8.2 扩展)。
|
||||
* 在游戏关键动作(脉冲/解码/探险/BOSS/洞见)后调用,同时更新:
|
||||
* 1. 日挑战进度(按今日挑战类型增量)
|
||||
* 2. 周挑战进度(按本周挑战类型增量)
|
||||
* 3. 信标链:日挑战刚完成时记录一次完成(含 grace 续命逻辑)
|
||||
* 2. 限时挑战进度(按当前 4 小时时段挑战类型增量)
|
||||
* 3. 周挑战进度(按本周挑战类型增量)
|
||||
* 4. 信标链:日挑战刚完成时记录一次完成(含 grace 续命逻辑)
|
||||
* 进度独立存储于 localStorage,不污染 GameState。
|
||||
* @returns 三类状态变更供 UI 触发通知
|
||||
* @returns 四类状态变更供 UI 触发通知
|
||||
*/
|
||||
function trackBeacon(
|
||||
type: "pulse" | "decode" | "expedition" | "boss" | "insight",
|
||||
delta: number
|
||||
): {
|
||||
dailyJustCompleted: boolean;
|
||||
timedJustCompleted: boolean;
|
||||
weeklyJustCompleted: boolean;
|
||||
newChainMilestones: number[];
|
||||
} {
|
||||
const result = {
|
||||
dailyJustCompleted: false,
|
||||
timedJustCompleted: false,
|
||||
weeklyJustCompleted: false,
|
||||
newChainMilestones: [] as number[],
|
||||
};
|
||||
@@ -227,6 +266,20 @@ function trackBeacon(
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 限时挑战(v0.8.2)----
|
||||
const tChallenge: BeaconTimedChallenge = generateTimedChallenge();
|
||||
if (tChallenge.type === type) {
|
||||
const tCurrent: BeaconTimedProgress = loadTimedProgress();
|
||||
if (tCurrent.completedAt === null) {
|
||||
const { justCompleted } = addTimedProgress(
|
||||
tCurrent,
|
||||
tChallenge,
|
||||
delta
|
||||
);
|
||||
result.timedJustCompleted = justCompleted;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 周挑战 ----
|
||||
const wChallenge: BeaconWeeklyChallenge = generateWeeklyChallenge();
|
||||
if (wChallenge.type === type) {
|
||||
@@ -269,6 +322,7 @@ export const useGameStore = create<Store>()(
|
||||
_lastPulse: 0,
|
||||
_achievementQueue: [],
|
||||
_tideEvents: [],
|
||||
globalTide: null,
|
||||
|
||||
init: () => {
|
||||
const s = get();
|
||||
@@ -354,11 +408,42 @@ export const useGameStore = create<Store>()(
|
||||
},
|
||||
|
||||
hardReset: () => {
|
||||
set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0, _achievementQueue: [], _tideEvents: [] });
|
||||
set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0, _achievementQueue: [], _tideEvents: [], globalTide: null });
|
||||
},
|
||||
|
||||
tickTide: (now) => {
|
||||
const s = get();
|
||||
// === P3 全球星潮优先:若全球星潮进行中,本地不触发新的星潮 ===
|
||||
if (s.globalTide) {
|
||||
// 1a) 检查全球星潮是否结束
|
||||
if (now >= s.globalTide.endsAt) {
|
||||
const endedType = s.globalTide.type;
|
||||
let silenceCompensation = 0;
|
||||
if (endedType === "silence") {
|
||||
silenceCompensation = computeSilenceCompensation({
|
||||
type: endedType,
|
||||
startedAt: s.globalTide.startedAt,
|
||||
endsAt: s.globalTide.endsAt,
|
||||
id: s.globalTide.id,
|
||||
});
|
||||
}
|
||||
const event: TideEvent = { ended: endedType, isGlobal: true };
|
||||
if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation;
|
||||
set({
|
||||
globalTide: null,
|
||||
activeTide: null,
|
||||
lastTideEnd: now,
|
||||
insights: s.insights + silenceCompensation,
|
||||
// 星潮结束后重算 stats
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation, attributes: s.attributes }),
|
||||
_tideEvents: [...s._tideEvents, event],
|
||||
});
|
||||
return event;
|
||||
}
|
||||
// 1b) 全球星潮仍在进行 — 不做本地处理(避免冲突)
|
||||
return null;
|
||||
}
|
||||
|
||||
const tide = s.activeTide;
|
||||
// 星图「星潮引导」减少间隙
|
||||
const cm = constellationBonuses(s.constellation ?? []);
|
||||
@@ -366,7 +451,7 @@ export const useGameStore = create<Store>()(
|
||||
const am = getAllBonuses(s.attributes ?? {});
|
||||
const tideGapReduction = Math.min(0.3, am.tideTriggerBonus);
|
||||
const gap = Math.max(15000, (TIDE_CONFIG.gap + cm.tideGapDeltaSec * 1000) * (1 - tideGapReduction));
|
||||
// 1) 检查当前星潮是否结束
|
||||
// 2) 检查当前本地星潮是否结束
|
||||
if (tide && now >= tide.endsAt) {
|
||||
const endedType = tide.type;
|
||||
// 寂静期补偿洞见
|
||||
@@ -375,7 +460,7 @@ export const useGameStore = create<Store>()(
|
||||
silenceCompensation = computeSilenceCompensation(tide);
|
||||
}
|
||||
const newInsights = s.insights + silenceCompensation;
|
||||
const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { ended: endedType };
|
||||
const event: TideEvent = { ended: endedType };
|
||||
if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation;
|
||||
set({
|
||||
activeTide: null,
|
||||
@@ -387,7 +472,7 @@ export const useGameStore = create<Store>()(
|
||||
});
|
||||
return event;
|
||||
}
|
||||
// 2) 检查是否该触发新星潮(间隙已过)
|
||||
// 3) 检查是否该触发新星潮(间隙已过)
|
||||
if (!tide) {
|
||||
const since = now - s.lastTideEnd;
|
||||
// 首次:以 createdAt 起算 firstDelay;之后以 lastTideEnd 起算 gap
|
||||
@@ -401,7 +486,7 @@ export const useGameStore = create<Store>()(
|
||||
startedAt: now,
|
||||
endsAt: now + TIDE_CONFIG.duration,
|
||||
};
|
||||
const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { started: type };
|
||||
const event: TideEvent = { started: type };
|
||||
// v0.4 编年史:累计遇到的星潮 ID(去重)
|
||||
const tidesAll = s.starTidesEncountered ?? [];
|
||||
const tideId = `tide_${type}`;
|
||||
@@ -427,6 +512,37 @@ export const useGameStore = create<Store>()(
|
||||
return items;
|
||||
},
|
||||
|
||||
// P3 socket 多人同步:服务端广播全球星潮时调用,强制覆盖本地冷却
|
||||
triggerGlobalTide: (type) => {
|
||||
const s = get();
|
||||
const now = Date.now();
|
||||
const id = `global_tide_${now}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
const newTide: StarTide = {
|
||||
id,
|
||||
type,
|
||||
startedAt: now,
|
||||
endsAt: now + GLOBAL_TIDE_DURATION_MS,
|
||||
};
|
||||
const newGlobalTide: GlobalTideState = {
|
||||
type,
|
||||
startedAt: now,
|
||||
endsAt: now + GLOBAL_TIDE_DURATION_MS,
|
||||
id,
|
||||
};
|
||||
// 编年史:累计遇到的星潮 ID(去重)
|
||||
const tidesAll = s.starTidesEncountered ?? [];
|
||||
const tideId = `tide_${type}`;
|
||||
const newTidesAll = tidesAll.includes(tideId) ? tidesAll : [...tidesAll, tideId];
|
||||
const event: TideEvent = { started: type, isGlobal: true };
|
||||
set({
|
||||
globalTide: newGlobalTide,
|
||||
activeTide: newTide, // 覆盖本地 activeTide(即使本地有进行中的星潮也会被替换)
|
||||
starTidesEncountered: newTidesAll,
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation, attributes: s.attributes }),
|
||||
_tideEvents: [...s._tideEvents, event],
|
||||
});
|
||||
},
|
||||
|
||||
tick: (now) => {
|
||||
const s = get();
|
||||
const dt = Math.max(0, (now - s.lastTick) / 1000);
|
||||
@@ -538,7 +654,10 @@ export const useGameStore = create<Store>()(
|
||||
const s = get();
|
||||
const crystal = s.pendingCrystals.find((c) => c.id === crystalId);
|
||||
if (!crystal) return;
|
||||
const puzzle = generatePuzzle(crystal.tier);
|
||||
// v0.8.2 涌动星潮:解码目标序列 +2(更长谜题)
|
||||
const tideMod = getTideModifiers(s.activeTide);
|
||||
const targetLenBonus = tideMod.targetLenBonus ?? 0;
|
||||
const puzzle = generatePuzzle(crystal.tier, undefined, targetLenBonus);
|
||||
set({
|
||||
activePuzzle: puzzle,
|
||||
pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystalId),
|
||||
@@ -654,7 +773,10 @@ export const useGameStore = create<Store>()(
|
||||
// v0.7 智慧:自动解码周期 -X%
|
||||
const am = getAllBonuses(s.attributes ?? {});
|
||||
const baseInterval = 12000 + cm.autoDecodeIntervalDeltaSec * 1000;
|
||||
const interval = Math.max(5000, baseInterval * am.autoDecodeIntervalMult);
|
||||
// v0.8.2 棱镜星潮:自动解码周期 ×0.7(-30%)
|
||||
const tideModEarly = getTideModifiers(s.activeTide);
|
||||
const tideIntervalMult = tideModEarly.autoDecodeIntervalMult ?? 1;
|
||||
const interval = Math.max(5000, baseInterval * am.autoDecodeIntervalMult * tideIntervalMult);
|
||||
if (now - s._lastAutoDecode < interval) return;
|
||||
// 找一颗 T1 晶体自动解码
|
||||
const idx = s.pendingCrystals.findIndex((c) => c.tier === 1);
|
||||
@@ -752,14 +874,17 @@ export const useGameStore = create<Store>()(
|
||||
// 深拷贝
|
||||
const exp = JSON.parse(JSON.stringify(s.activeExpedition));
|
||||
// v0.7 勇气:BOSS 战胜率 +X%(动态提高 RNG 阈值)
|
||||
// v0.8.2 蚀相星潮:BOSS 胜率 +20%
|
||||
const nodeBefore = exp.nodes[exp.currentNode];
|
||||
const isBossNode = nodeBefore?.type === "boss";
|
||||
const am = getAllBonuses(s.attributes ?? {});
|
||||
const tideMod = getTideModifiers(s.activeTide);
|
||||
const tideBossBonus = tideMod.bossWinRateBonus ?? 0;
|
||||
const result = isBossNode
|
||||
? resolveNode(exp, () => {
|
||||
// 单次 rng() 调用:B% 概率返回 0(必胜),其余情况返回 r-B(保持均匀分布)
|
||||
const r = Math.random();
|
||||
const b = Math.min(0.95, am.bossWinRateBonus);
|
||||
const b = Math.min(0.95, am.bossWinRateBonus + tideBossBonus);
|
||||
return r < b ? 0 : Math.min(1, r - b);
|
||||
})
|
||||
: resolveNode(exp);
|
||||
@@ -1020,6 +1145,33 @@ export const useGameStore = create<Store>()(
|
||||
insights: s.insights + Math.round(res.rewardInsight),
|
||||
contact: Math.min(100, s.contact + res.rewardContact),
|
||||
});
|
||||
// 异步提交到云排行榜(失败静默,不影响本地)
|
||||
if (res.entry) {
|
||||
void submitCloudScore(res.entry);
|
||||
}
|
||||
}
|
||||
return {
|
||||
rewardInsight: res.rewardInsight,
|
||||
rewardContact: res.rewardContact,
|
||||
score: res.score,
|
||||
};
|
||||
},
|
||||
|
||||
// 深空信标:领取限时挑战奖励(v0.8.2)
|
||||
claimTimedBeacon: () => {
|
||||
const challenge = generateTimedChallenge();
|
||||
const progress = loadTimedProgress();
|
||||
const res = claimTimedReward(challenge, progress);
|
||||
if (res.rewardInsight > 0 || res.rewardContact > 0) {
|
||||
const s = get();
|
||||
set({
|
||||
insights: s.insights + Math.round(res.rewardInsight),
|
||||
contact: Math.min(100, s.contact + res.rewardContact),
|
||||
});
|
||||
// 异步提交到云排行榜(失败静默,不影响本地)
|
||||
if (res.entry) {
|
||||
void submitCloudScore(res.entry);
|
||||
}
|
||||
}
|
||||
return {
|
||||
rewardInsight: res.rewardInsight,
|
||||
@@ -1155,8 +1307,8 @@ export const useGameStore = create<Store>()(
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
// 不持久化临时字段
|
||||
partialize: (s) => {
|
||||
const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, ...rest } = s;
|
||||
void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue; void _tideEvents;
|
||||
const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, globalTide, ...rest } = s;
|
||||
void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue; void _tideEvents; void globalTide;
|
||||
return rest as GameState;
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,875 @@
|
||||
1→// 回响星核 / Echo Nexus — 深空信标
|
||||
2→// v0.5:每日挑战 + 本地排行榜
|
||||
3→// v0.8:周挑战 + 信标链(连续完成奖励)
|
||||
4→// 一个自包含的"每日挑战 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。
|
||||
5→
|
||||
6→/** 每日挑战类型 */
|
||||
7→export type BeaconChallengeType =
|
||||
8→ | "decode" // 解码 N 颗晶体
|
||||
9→ | "expedition" // 完成 N 次探险
|
||||
10→ | "pulse" // 发起 N 次脉冲
|
||||
11→ | "boss" // 击破 N 处 BOSS
|
||||
12→ | "insight"; // 累计 N 洞见
|
||||
13→
|
||||
14→/** 挑战难度档位 */
|
||||
15→export type BeaconDifficulty = "routine" | "anomaly" | "singular";
|
||||
16→
|
||||
17→/** 一条排行榜记录 */
|
||||
18→export interface BeaconScoreEntry {
|
||||
19→ /** 提交时间戳 */
|
||||
20→ timestamp: number;
|
||||
21→ /** 日期 key(YYYY-MM-DD)或周 key(YYYY-Www) */
|
||||
22→ dateKey: string;
|
||||
23→ /** 挑战类型 */
|
||||
24→ challenge: BeaconChallengeType;
|
||||
25→ /** 难度 */
|
||||
26→ difficulty: BeaconDifficulty;
|
||||
27→ /** 完成度(0-1,1 = 完成) */
|
||||
28→ progress: number;
|
||||
29→ /** 最终得分 */
|
||||
30→ score: number;
|
||||
31→ /** 完成时长(秒),未完成则记 0 */
|
||||
32→ durationSec: number;
|
||||
33→ /** v0.8:是否为周挑战记录(日挑战默认 false / undefined) */
|
||||
34→ isWeekly?: boolean;
|
||||
35→}
|
||||
36→
|
||||
37→/** 每日挑战定义 */
|
||||
38→export interface BeaconDailyChallenge {
|
||||
39→ /** 日期 key(YYYY-MM-DD,UTC) */
|
||||
40→ dateKey: string;
|
||||
41→ /** 挑战类型 */
|
||||
42→ type: BeaconChallengeType;
|
||||
43→ /** 难度 */
|
||||
44→ difficulty: BeaconDifficulty;
|
||||
45→ /** 目标数值 */
|
||||
46→ goal: number;
|
||||
47→ /** 奖励:完成时洞见 */
|
||||
48→ rewardInsight: number;
|
||||
49→ /** 奖励:完成时接触进度 */
|
||||
50→ rewardContact: number;
|
||||
51→ /** 使用的种子(可复现) */
|
||||
52→ seed: number;
|
||||
53→ /** 友好标题 */
|
||||
54→ title: string;
|
||||
55→ /** 描述 */
|
||||
56→ desc: string;
|
||||
57→}
|
||||
58→
|
||||
59→/** 难度配置 */
|
||||
60→export const BEACON_DIFFICULTY: Record<
|
||||
61→ BeaconDifficulty,
|
||||
62→ { label: string; color: string; glow: string; mult: number; icon: string }
|
||||
63→> = {
|
||||
64→ routine: {
|
||||
65→ label: "常规信标",
|
||||
66→ color: "#34d399",
|
||||
67→ glow: "rgba(52,211,153,0.45)",
|
||||
68→ mult: 1,
|
||||
69→ icon: "◍",
|
||||
70→ },
|
||||
71→ anomaly: {
|
||||
72→ label: "异常波动",
|
||||
73→ color: "#fbbf24",
|
||||
74→ glow: "rgba(251,191,36,0.45)",
|
||||
75→ mult: 1.6,
|
||||
76→ icon: "◈",
|
||||
77→ },
|
||||
78→ singular: {
|
||||
79→ label: "奇点回响",
|
||||
80→ color: "#f43f5e",
|
||||
81→ glow: "rgba(244,63,94,0.5)",
|
||||
82→ mult: 2.4,
|
||||
83→ icon: "✶",
|
||||
84→ },
|
||||
85→};
|
||||
86→
|
||||
87→/** 挑战类型元信息 */
|
||||
88→export const BEACON_TYPE_META: Record<
|
||||
89→ BeaconChallengeType,
|
||||
90→ { label: string; icon: string; unit: string; verb: string }
|
||||
91→> = {
|
||||
92→ decode: { label: "解码协议", icon: "❖", unit: "颗", verb: "解码记忆晶体" },
|
||||
93→ expedition: { label: "远征指令", icon: "⬢", unit: "次", verb: "完成遗迹探险" },
|
||||
94→ pulse: { label: "脉冲任务", icon: "✺", unit: "次", verb: "发起脉冲扫描" },
|
||||
95→ boss: { label: "猎杀契约", icon: "☠", unit: "处", verb: "击破维度 BOSS" },
|
||||
96→ insight: { label: "洞见采集", icon: "✦", unit: "点", verb: "累计获取洞见" },
|
||||
97→};
|
||||
98→
|
||||
99→/** 取今日日期 key(UTC,保证全球同一天同一挑战) */
|
||||
100→export function getTodayKey(now: Date = new Date()): string {
|
||||
101→ const y = now.getUTCFullYear();
|
||||
102→ const m = String(now.getUTCMonth() + 1).padStart(2, "0");
|
||||
103→ const d = String(now.getUTCDate()).padStart(2, "0");
|
||||
104→ return `${y}-${m}-${d}`;
|
||||
105→}
|
||||
106→
|
||||
107→/** 把日期 key 转成数值种子 */
|
||||
108→function dateKeyToSeed(dateKey: string): number {
|
||||
109→ let h = 2166136261 >>> 0;
|
||||
110→ for (let i = 0; i < dateKey.length; i++) {
|
||||
111→ h ^= dateKey.charCodeAt(i);
|
||||
112→ h = Math.imul(h, 16777619) >>> 0;
|
||||
113→ }
|
||||
114→ return h >>> 0;
|
||||
115→}
|
||||
116→
|
||||
117→/** mulberry32 PRNG(可复现) */
|
||||
118→function mulberry32(seed: number): () => number {
|
||||
119→ let a = seed >>> 0;
|
||||
120→ return () => {
|
||||
121→ a |= 0;
|
||||
122→ a = (a + 0x6d2b79f5) | 0;
|
||||
123→ let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
124→ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
125→ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
126→ };
|
||||
127→}
|
||||
128→
|
||||
129→/** 生成今日每日挑战(确定性:同一天同一种子 → 同一挑战) */
|
||||
130→export function generateDailyChallenge(now: Date = new Date()): BeaconDailyChallenge {
|
||||
131→ const dateKey = getTodayKey(now);
|
||||
132→ const seed = dateKeyToSeed(dateKey);
|
||||
133→ const rng = mulberry32(seed);
|
||||
134→
|
||||
135→ // 选类型(5 种)
|
||||
136→ const types: BeaconChallengeType[] = ["decode", "expedition", "pulse", "boss", "insight"];
|
||||
137→ const type = types[Math.floor(rng() * types.length)];
|
||||
138→
|
||||
139→ // 选难度(加权:常规 55% / 异常 33% / 奇点 12%)
|
||||
140→ const dr = rng();
|
||||
141→ const difficulty: BeaconDifficulty =
|
||||
142→ dr < 0.55 ? "routine" : dr < 0.88 ? "anomaly" : "singular";
|
||||
143→
|
||||
144→ const diffMult = BEACON_DIFFICULTY[difficulty].mult;
|
||||
145→
|
||||
146→ // 按类型定 goal 与奖励
|
||||
147→ let goal = 0;
|
||||
148→ let rewardInsight = 0;
|
||||
149→ let rewardContact = 0;
|
||||
150→ let title = "";
|
||||
151→ let desc = "";
|
||||
152→
|
||||
153→ switch (type) {
|
||||
154→ case "decode":
|
||||
155→ goal = Math.round((6 + Math.floor(rng() * 10)) * diffMult); // 6-15 × mult
|
||||
156→ rewardInsight = Math.round(goal * 4 * diffMult);
|
||||
157→ rewardContact = goal * 0.8;
|
||||
158→ title = `解码 ${goal} 颗记忆晶体`;
|
||||
159→ desc = `深空信标要求解码 ${goal} 颗晶体。谐振风暴期间效率更高。`;
|
||||
160→ break;
|
||||
161→ case "expedition":
|
||||
162→ goal = Math.max(1, Math.round((1 + Math.floor(rng() * 3)) * diffMult)); // 1-3 × mult
|
||||
163→ rewardInsight = Math.round(goal * 12 * diffMult);
|
||||
164→ rewardContact = goal * 1.5;
|
||||
165→ title = `完成 ${goal} 次遗迹探险`;
|
||||
166→ desc = `派出探险队完成 ${goal} 次远征,无论胜负均计入。`;
|
||||
167→ break;
|
||||
168→ case "pulse":
|
||||
169→ goal = Math.round((20 + Math.floor(rng() * 30)) * diffMult); // 20-49 × mult
|
||||
170→ rewardInsight = Math.round(goal * 1.2 * diffMult);
|
||||
171→ rewardContact = goal * 0.3;
|
||||
172→ title = `发起 ${goal} 次脉冲扫描`;
|
||||
173→ desc = `主动点击晶体发起 ${goal} 次脉冲。连击可叠加加成。`;
|
||||
174→ break;
|
||||
175→ case "boss":
|
||||
176→ goal = Math.max(1, Math.round(diffMult)); // 奇点至少 2-3
|
||||
177→ rewardInsight = Math.round(goal * 30 * diffMult);
|
||||
178→ rewardContact = goal * 3;
|
||||
179→ title = `击破 ${goal} 处维度 BOSS`;
|
||||
180→ desc = `在探险终点击破 ${goal} 处 BOSS。提升探险力后再挑战。`;
|
||||
181→ break;
|
||||
182→ case "insight":
|
||||
183→ goal = Math.round((40 + Math.floor(rng() * 80)) * diffMult); // 40-119 × mult
|
||||
184→ rewardInsight = 0; // 洞见挑战不给洞见,给接触
|
||||
185→ rewardContact = goal * 0.1;
|
||||
186→ title = `累计获取 ${goal} 洞见`;
|
||||
187→ desc = `通过解码、探险、星潮等途径累计 ${goal} 洞见。`;
|
||||
188→ break;
|
||||
189→ }
|
||||
190→
|
||||
191→ return {
|
||||
192→ dateKey,
|
||||
193→ type,
|
||||
194→ difficulty,
|
||||
195→ goal,
|
||||
196→ rewardInsight,
|
||||
197→ rewardContact,
|
||||
198→ seed,
|
||||
199→ title,
|
||||
200→ desc,
|
||||
201→ };
|
||||
202→}
|
||||
203→
|
||||
204→/** 计算挑战得分(用于排行榜) */
|
||||
205→export function computeBeaconScore(
|
||||
206→ challenge: BeaconDailyChallenge,
|
||||
207→ progress: number,
|
||||
208→ durationSec: number
|
||||
209→): number {
|
||||
210→ const completion = Math.min(1, progress / challenge.goal);
|
||||
211→ const diffMult = BEACON_DIFFICULTY[challenge.difficulty].mult;
|
||||
212→ // 基础分 = 完成度 × 难度 × 1000;完成时长越短加分越多(上限 +500)
|
||||
213→ const base = completion * 1000 * diffMult;
|
||||
214→ const speedBonus =
|
||||
215→ completion >= 1 && durationSec > 0 ? Math.max(0, 500 - durationSec * 0.5) : 0;
|
||||
216→ return Math.round(base + speedBonus);
|
||||
217→}
|
||||
218→
|
||||
219→/** 排行榜 localStorage key */
|
||||
220→export const BEACON_LEADERBOARD_KEY = "echo-nexus-beacon-lb-v1";
|
||||
221→/** 每日进度 localStorage key(记录今日进度 + 是否领奖) */
|
||||
222→export const BEACON_PROGRESS_KEY = "echo-nexus-beacon-prog-v1";
|
||||
223→
|
||||
224→/** 排行榜上限 */
|
||||
225→export const BEACON_LB_MAX = 20;
|
||||
226→
|
||||
227→/** 今日进度记录 */
|
||||
228→export interface BeaconDailyProgress {
|
||||
229→ dateKey: string;
|
||||
230→ progress: number;
|
||||
231→ startedAt: number;
|
||||
232→ completedAt: number | null;
|
||||
233→ claimed: boolean;
|
||||
234→ durationSec: number;
|
||||
235→}
|
||||
236→
|
||||
237→/** 读取本地排行榜(按分数降序) */
|
||||
238→export function loadLeaderboard(): BeaconScoreEntry[] {
|
||||
239→ if (typeof localStorage === "undefined") return [];
|
||||
240→ try {
|
||||
241→ const raw = localStorage.getItem(BEACON_LEADERBOARD_KEY);
|
||||
242→ if (!raw) return [];
|
||||
243→ const arr = JSON.parse(raw) as BeaconScoreEntry[];
|
||||
244→ return arr.sort((a, b) => b.score - a.score).slice(0, BEACON_LB_MAX);
|
||||
245→ } catch {
|
||||
246→ return [];
|
||||
247→ }
|
||||
248→}
|
||||
249→
|
||||
250→/** 写入一条排行榜记录 */
|
||||
251→export function pushLeaderboardEntry(entry: BeaconScoreEntry): BeaconScoreEntry[] {
|
||||
252→ const lb = loadLeaderboard();
|
||||
253→ lb.push(entry);
|
||||
254→ lb.sort((a, b) => b.score - a.score);
|
||||
255→ const trimmed = lb.slice(0, BEACON_LB_MAX);
|
||||
256→ if (typeof localStorage !== "undefined") {
|
||||
257→ localStorage.setItem(BEACON_LEADERBOARD_KEY, JSON.stringify(trimmed));
|
||||
258→ }
|
||||
259→ return trimmed;
|
||||
260→}
|
||||
261→
|
||||
262→/** 读取今日进度(若 dateKey 不匹配则重置) */
|
||||
263→export function loadDailyProgress(now: Date = new Date()): BeaconDailyProgress {
|
||||
264→ const todayKey = getTodayKey(now);
|
||||
265→ if (typeof localStorage === "undefined") {
|
||||
266→ return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 };
|
||||
267→ }
|
||||
268→ try {
|
||||
269→ const raw = localStorage.getItem(BEACON_PROGRESS_KEY);
|
||||
270→ if (!raw) {
|
||||
271→ return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 };
|
||||
272→ }
|
||||
273→ const prog = JSON.parse(raw) as BeaconDailyProgress;
|
||||
274→ if (prog.dateKey !== todayKey) {
|
||||
275→ // 新的一天,重置
|
||||
276→ return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 };
|
||||
277→ }
|
||||
278→ return prog;
|
||||
279→ } catch {
|
||||
280→ return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 };
|
||||
281→ }
|
||||
282→}
|
||||
283→
|
||||
284→/** 保存今日进度 */
|
||||
285→export function saveDailyProgress(prog: BeaconDailyProgress): void {
|
||||
286→ if (typeof localStorage === "undefined") return;
|
||||
287→ localStorage.setItem(BEACON_PROGRESS_KEY, JSON.stringify(prog));
|
||||
288→}
|
||||
289→
|
||||
290→/** 增量更新进度,返回新进度 + 是否刚完成 */
|
||||
291→export function addBeaconProgress(
|
||||
292→ current: BeaconDailyProgress,
|
||||
293→ challenge: BeaconDailyChallenge,
|
||||
294→ delta: number
|
||||
295→): { progress: BeaconDailyProgress; justCompleted: boolean } {
|
||||
296→ const newProgressVal = Math.min(challenge.goal, current.progress + delta);
|
||||
297→ const justCompleted = current.completedAt === null && newProgressVal >= challenge.goal;
|
||||
298→ const completedAt = justCompleted ? Date.now() : current.completedAt;
|
||||
299→ const durationSec =
|
||||
300→ completedAt !== null ? Math.floor((completedAt - current.startedAt) / 1000) : current.durationSec;
|
||||
301→ const next: BeaconDailyProgress = {
|
||||
302→ ...current,
|
||||
303→ progress: newProgressVal,
|
||||
304→ completedAt,
|
||||
305→ durationSec,
|
||||
306→ };
|
||||
307→ saveDailyProgress(next);
|
||||
308→ return { progress: next, justCompleted };
|
||||
309→}
|
||||
310→
|
||||
311→/** 领取奖励:返回奖励数值 + 推送排行榜 */
|
||||
312→export function claimBeaconReward(
|
||||
313→ challenge: BeaconDailyChallenge,
|
||||
314→ progress: BeaconDailyProgress
|
||||
315→): { rewardInsight: number; rewardContact: number; score: number; leaderboard: BeaconScoreEntry[] } {
|
||||
316→ if (progress.claimed || progress.completedAt === null) {
|
||||
317→ return { rewardInsight: 0, rewardContact: 0, score: 0, leaderboard: loadLeaderboard() };
|
||||
318→ }
|
||||
319→ const score = computeBeaconScore(challenge, progress.progress, progress.durationSec);
|
||||
320→ const entry: BeaconScoreEntry = {
|
||||
321→ timestamp: Date.now(),
|
||||
322→ dateKey: challenge.dateKey,
|
||||
323→ challenge: challenge.type,
|
||||
324→ difficulty: challenge.difficulty,
|
||||
325→ progress: progress.progress / challenge.goal,
|
||||
326→ score,
|
||||
327→ durationSec: progress.durationSec,
|
||||
328→ };
|
||||
329→ const leaderboard = pushLeaderboardEntry(entry);
|
||||
330→ const updated: BeaconDailyProgress = { ...progress, claimed: true };
|
||||
331→ saveDailyProgress(updated);
|
||||
332→ return {
|
||||
333→ rewardInsight: challenge.rewardInsight,
|
||||
334→ rewardContact: challenge.rewardContact,
|
||||
335→ score,
|
||||
336→ leaderboard,
|
||||
337→ };
|
||||
338→}
|
||||
339→
|
||||
340→/** 距离 UTC 次日 0 点的毫秒数(用于倒计时) */
|
||||
341→export function msUntilNextDay(now: Date = new Date()): number {
|
||||
342→ const next = Date.UTC(
|
||||
343→ now.getUTCFullYear(),
|
||||
344→ now.getUTCMonth(),
|
||||
345→ now.getUTCDate() + 1,
|
||||
346→ 0,
|
||||
347→ 0,
|
||||
348→ 0
|
||||
349→ );
|
||||
350→ return Math.max(0, next - now.getTime());
|
||||
351→}
|
||||
352→
|
||||
353→/** 格式化倒计时为 HH:MM:SS */
|
||||
354→export function formatCountdown(ms: number): string {
|
||||
355→ const total = Math.max(0, Math.floor(ms / 1000));
|
||||
356→ const h = String(Math.floor(total / 3600)).padStart(2, "0");
|
||||
357→ const m = String(Math.floor((total % 3600) / 60)).padStart(2, "0");
|
||||
358→ const s = String(total % 60).padStart(2, "0");
|
||||
359→ return `${h}:${m}:${s}`;
|
||||
360→}
|
||||
361→
|
||||
362→// ===========================================================================
|
||||
363→// 周挑战(WEEKLY CHALLENGE)— v0.8
|
||||
364→// 每周一 UTC 0 点刷新一个"周挑战",目标更大、奖励更好,与日挑战并行存在。
|
||||
365→// 同一周同一种子 → 同一挑战(确定性)。难度强制 anomaly/singular(加权 60/40)。
|
||||
366→// ===========================================================================
|
||||
367→
|
||||
368→/** 周挑战 localStorage key */
|
||||
369→export const BEACON_WEEKLY_KEY = "echo-nexus-beacon-weekly-v1";
|
||||
370→
|
||||
371→/** 周挑战定义 */
|
||||
372→export interface BeaconWeeklyChallenge {
|
||||
373→ /** ISO 周键(YYYY-Www,如 "2026-W26") */
|
||||
374→ weekKey: string;
|
||||
375→ /** 挑战类型 */
|
||||
376→ type: BeaconChallengeType;
|
||||
377→ /** 难度(固定 anomaly 或 singular) */
|
||||
378→ difficulty: "anomaly" | "singular";
|
||||
379→ /** 目标数值(日挑战的 3-5 倍) */
|
||||
380→ goal: number;
|
||||
381→ /** 奖励:完成时洞见 */
|
||||
382→ rewardInsight: number;
|
||||
383→ /** 奖励:完成时接触进度 */
|
||||
384→ rewardContact: number;
|
||||
385→ /** 使用的种子(可复现) */
|
||||
386→ seed: number;
|
||||
387→ /** 友好标题 */
|
||||
388→ title: string;
|
||||
389→ /** 描述 */
|
||||
390→ desc: string;
|
||||
391→}
|
||||
392→
|
||||
393→/** 周挑战进度 */
|
||||
394→export interface BeaconWeeklyProgress {
|
||||
395→ weekKey: string;
|
||||
396→ progress: number;
|
||||
397→ startedAt: number;
|
||||
398→ completedAt: number | null;
|
||||
399→ claimed: boolean;
|
||||
400→ durationSec: number;
|
||||
401→}
|
||||
402→
|
||||
403→/**
|
||||
404→ * 计算 ISO 8601 周键(YYYY-Www),如 "2026-W26"。
|
||||
405→ * 规则:周一为一周开始;第一周是该年至少含 4 天的第一周(即包含第一个周四)。
|
||||
406→ */
|
||||
407→export function getWeekKey(now: Date = new Date()): string {
|
||||
408→ // 取 UTC 日期,避免时区偏移
|
||||
409→ const tmp = new Date(
|
||||
410→ Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
||||
411→ );
|
||||
412→ // ISO 周以周一为起点:(getUTCDay + 6) % 7 → 0=Mon, 6=Sun
|
||||
413→ const dayNum = (tmp.getUTCDay() + 6) % 7;
|
||||
414→ // 把日期调整到本周四(ISO 周归属年份由该周四所在的年份决定)
|
||||
415→ tmp.setUTCDate(tmp.getUTCDate() - dayNum + 3);
|
||||
416→ const isoYear = tmp.getUTCFullYear();
|
||||
417→ const yearStart = Date.UTC(isoYear, 0, 1);
|
||||
418→ const weekNum = Math.ceil(((tmp.getTime() - yearStart) / 86400000 + 1) / 7);
|
||||
419→ return `${isoYear}-W${String(weekNum).padStart(2, "0")}`;
|
||||
420→}
|
||||
421→
|
||||
422→/** weekKey → 数值种子(FNV-1a 哈希) */
|
||||
423→function weekKeyToSeed(weekKey: string): number {
|
||||
424→ let h = 2166136261 >>> 0;
|
||||
425→ for (let i = 0; i < weekKey.length; i++) {
|
||||
426→ h ^= weekKey.charCodeAt(i);
|
||||
427→ h = Math.imul(h, 16777619) >>> 0;
|
||||
428→ }
|
||||
429→ return h >>> 0;
|
||||
430→}
|
||||
431→
|
||||
432→/**
|
||||
433→ * 生成本周周挑战(确定性:同一周同一种子 → 同一挑战)。
|
||||
434→ * 难度强制 anomaly(60%)或 singular(40%),goal 为日挑战基准 ×3-5 倍。
|
||||
435→ */
|
||||
436→export function generateWeeklyChallenge(
|
||||
437→ now: Date = new Date()
|
||||
438→): BeaconWeeklyChallenge {
|
||||
439→ const weekKey = getWeekKey(now);
|
||||
440→ const seed = weekKeyToSeed(weekKey);
|
||||
441→ const rng = mulberry32(seed);
|
||||
442→
|
||||
443→ const types: BeaconChallengeType[] = [
|
||||
444→ "decode",
|
||||
445→ "expedition",
|
||||
446→ "pulse",
|
||||
447→ "boss",
|
||||
448→ "insight",
|
||||
449→ ];
|
||||
450→ const type = types[Math.floor(rng() * types.length)];
|
||||
451→
|
||||
452→ // 难度加权:anomaly 60% / singular 40%
|
||||
453→ const dr = rng();
|
||||
454→ const difficulty: "anomaly" | "singular" = dr < 0.6 ? "anomaly" : "singular";
|
||||
455→ const diffMult = BEACON_DIFFICULTY[difficulty].mult;
|
||||
456→
|
||||
457→ // 3-5 倍
|
||||
458→ const mult = 3 + Math.floor(rng() * 3);
|
||||
459→
|
||||
460→ let goal = 0;
|
||||
461→ let rewardInsight = 0;
|
||||
462→ let rewardContact = 0;
|
||||
463→ let title = "";
|
||||
464→ let desc = "";
|
||||
465→
|
||||
466→ switch (type) {
|
||||
467→ case "decode":
|
||||
468→ // 日基准 6-15 × mult(3-5) → 18-75
|
||||
469→ goal = Math.max(18, Math.round((6 + Math.floor(rng() * 10)) * diffMult * mult));
|
||||
470→ rewardInsight = Math.round(goal * 5 * diffMult);
|
||||
471→ rewardContact = goal * 1.2;
|
||||
472→ title = `周界解码 · ${goal} 颗晶体`;
|
||||
473→ desc = `本周深空信标要求解码 ${goal} 颗记忆晶体,强度为日挑战的 ${mult} 倍。`;
|
||||
474→ break;
|
||||
475→ case "expedition":
|
||||
476→ // 日基准 1-3 × mult(3-5) → 3-15
|
||||
477→ goal = Math.max(3, Math.round((1 + Math.floor(rng() * 3)) * diffMult * mult));
|
||||
478→ rewardInsight = Math.round(goal * 14 * diffMult);
|
||||
479→ rewardContact = goal * 2;
|
||||
480→ title = `周界远征 · ${goal} 次探险`;
|
||||
481→ desc = `本周派出探险队完成 ${goal} 次远征,无论胜负均计入。`;
|
||||
482→ break;
|
||||
483→ case "pulse":
|
||||
484→ // 日基准 20-49 × mult(3-5) → 60-245
|
||||
485→ goal = Math.max(60, Math.round((20 + Math.floor(rng() * 30)) * diffMult * mult));
|
||||
486→ rewardInsight = Math.round(goal * 1.5 * diffMult);
|
||||
487→ rewardContact = goal * 0.4;
|
||||
488→ title = `周界脉冲 · ${goal} 次扫描`;
|
||||
489→ desc = `本周发起 ${goal} 次脉冲扫描。连击可叠加加成。`;
|
||||
490→ break;
|
||||
491→ case "boss":
|
||||
492→ // 日基准 1 × mult(3-5) → 3-12(singular mult=2.4 时上限 12)
|
||||
493→ goal = Math.max(3, Math.round(mult * diffMult));
|
||||
494→ rewardInsight = Math.round(goal * 35 * diffMult);
|
||||
495→ rewardContact = goal * 4;
|
||||
496→ title = `周界猎杀 · ${goal} 处 BOSS`;
|
||||
497→ desc = `本周在探险终点击破 ${goal} 处维度 BOSS。提升探险力后再挑战。`;
|
||||
498→ break;
|
||||
499→ case "insight":
|
||||
500→ // 日基准 40-119 × mult(3-5) → 120-595
|
||||
501→ goal = Math.max(120, Math.round((40 + Math.floor(rng() * 80)) * diffMult * mult));
|
||||
502→ rewardInsight = 0; // 洞见挑战不给洞见,给接触
|
||||
503→ rewardContact = goal * 0.15;
|
||||
504→ title = `周界洞见 · ${goal} 点`;
|
||||
505→ desc = `本周通过解码、探险、星潮等途径累计 ${goal} 洞见。`;
|
||||
506→ break;
|
||||
507→ }
|
||||
508→
|
||||
509→ return {
|
||||
510→ weekKey,
|
||||
511→ type,
|
||||
512→ difficulty,
|
||||
513→ goal,
|
||||
514→ rewardInsight,
|
||||
515→ rewardContact,
|
||||
516→ seed,
|
||||
517→ title,
|
||||
518→ desc,
|
||||
519→ };
|
||||
520→}
|
||||
521→
|
||||
522→/** 读取本周进度(若 weekKey 不匹配则重置) */
|
||||
523→export function loadWeeklyProgress(now: Date = new Date()): BeaconWeeklyProgress {
|
||||
524→ const weekKey = getWeekKey(now);
|
||||
525→ const empty: BeaconWeeklyProgress = {
|
||||
526→ weekKey,
|
||||
527→ progress: 0,
|
||||
528→ startedAt: Date.now(),
|
||||
529→ completedAt: null,
|
||||
530→ claimed: false,
|
||||
531→ durationSec: 0,
|
||||
532→ };
|
||||
533→ if (typeof localStorage === "undefined") return empty;
|
||||
534→ try {
|
||||
535→ const raw = localStorage.getItem(BEACON_WEEKLY_KEY);
|
||||
536→ if (!raw) return empty;
|
||||
537→ const prog = JSON.parse(raw) as BeaconWeeklyProgress;
|
||||
538→ if (prog.weekKey !== weekKey) return empty; // 新的一周,重置
|
||||
539→ return prog;
|
||||
540→ } catch {
|
||||
541→ return empty;
|
||||
542→ }
|
||||
543→}
|
||||
544→
|
||||
545→/** 保存本周进度 */
|
||||
546→export function saveWeeklyProgress(prog: BeaconWeeklyProgress): void {
|
||||
547→ if (typeof localStorage === "undefined") return;
|
||||
548→ localStorage.setItem(BEACON_WEEKLY_KEY, JSON.stringify(prog));
|
||||
549→}
|
||||
550→
|
||||
551→/** 增量更新周挑战进度,返回新进度 + 是否刚完成 */
|
||||
552→export function addWeeklyProgress(
|
||||
553→ current: BeaconWeeklyProgress,
|
||||
554→ challenge: BeaconWeeklyChallenge,
|
||||
555→ delta: number
|
||||
556→): { progress: BeaconWeeklyProgress; justCompleted: boolean } {
|
||||
557→ const newProgressVal = Math.min(challenge.goal, current.progress + delta);
|
||||
558→ const justCompleted =
|
||||
559→ current.completedAt === null && newProgressVal >= challenge.goal;
|
||||
560→ const completedAt = justCompleted ? Date.now() : current.completedAt;
|
||||
561→ const durationSec =
|
||||
562→ completedAt !== null
|
||||
563→ ? Math.floor((completedAt - current.startedAt) / 1000)
|
||||
564→ : current.durationSec;
|
||||
565→ const next: BeaconWeeklyProgress = {
|
||||
566→ ...current,
|
||||
567→ progress: newProgressVal,
|
||||
568→ completedAt,
|
||||
569→ durationSec,
|
||||
570→ };
|
||||
571→ saveWeeklyProgress(next);
|
||||
572→ return { progress: next, justCompleted };
|
||||
573→}
|
||||
574→
|
||||
575→/** 领取周挑战奖励:返回奖励数值 + 推送排行榜 */
|
||||
576→export function claimWeeklyReward(
|
||||
577→ challenge: BeaconWeeklyChallenge,
|
||||
578→ progress: BeaconWeeklyProgress
|
||||
579→): {
|
||||
580→ rewardInsight: number;
|
||||
581→ rewardContact: number;
|
||||
582→ score: number;
|
||||
583→ leaderboard: BeaconScoreEntry[];
|
||||
584→} {
|
||||
585→ if (progress.claimed || progress.completedAt === null) {
|
||||
586→ return {
|
||||
587→ rewardInsight: 0,
|
||||
588→ rewardContact: 0,
|
||||
589→ score: 0,
|
||||
590→ leaderboard: loadLeaderboard(),
|
||||
591→ };
|
||||
592→ }
|
||||
593→ // 借用 computeBeaconScore:把 weekly 包装成 daily 接口(dateKey 字段不影响计分逻辑)
|
||||
594→ const score = computeBeaconScore(
|
||||
595→ { ...(challenge as unknown as BeaconDailyChallenge), dateKey: challenge.weekKey },
|
||||
596→ progress.progress,
|
||||
597→ progress.durationSec
|
||||
598→ );
|
||||
599→ const entry: BeaconScoreEntry = {
|
||||
600→ timestamp: Date.now(),
|
||||
601→ dateKey: challenge.weekKey,
|
||||
602→ challenge: challenge.type,
|
||||
603→ difficulty: challenge.difficulty,
|
||||
604→ progress: progress.progress / challenge.goal,
|
||||
605→ score,
|
||||
606→ durationSec: progress.durationSec,
|
||||
607→ isWeekly: true,
|
||||
608→ };
|
||||
609→ const leaderboard = pushLeaderboardEntry(entry);
|
||||
610→ const updated: BeaconWeeklyProgress = { ...progress, claimed: true };
|
||||
611→ saveWeeklyProgress(updated);
|
||||
612→ return {
|
||||
613→ rewardInsight: challenge.rewardInsight,
|
||||
614→ rewardContact: challenge.rewardContact,
|
||||
615→ score,
|
||||
616→ leaderboard,
|
||||
617→ };
|
||||
618→}
|
||||
619→
|
||||
620→/** 距离下周一 UTC 0 点的毫秒数(用于周挑战倒计时) */
|
||||
621→export function msUntilNextWeek(now: Date = new Date()): number {
|
||||
622→ const dayNum = (now.getUTCDay() + 6) % 7; // 0 = Mon
|
||||
623→ const mondayThisWeek = Date.UTC(
|
||||
624→ now.getUTCFullYear(),
|
||||
625→ now.getUTCMonth(),
|
||||
626→ now.getUTCDate() - dayNum,
|
||||
627→ 0,
|
||||
628→ 0,
|
||||
629→ 0
|
||||
630→ );
|
||||
631→ const nextMonday = mondayThisWeek + 7 * 86400000;
|
||||
632→ return Math.max(0, nextMonday - now.getTime());
|
||||
633→}
|
||||
634→
|
||||
635→// ===========================================================================
|
||||
636→// 信标链(BEACON CHAIN)— v0.8
|
||||
637→// 连续完成日挑战形成"链",达成里程碑(3/7/14/30 天)领取递增大奖。
|
||||
638→// 断链有宽容机制(1 天 miss 不断链,用"信标续命"概念),每条链只能用 1 次。
|
||||
639→// ===========================================================================
|
||||
640→
|
||||
641→/** 信标链 localStorage key */
|
||||
642→export const BEACON_CHAIN_KEY = "echo-nexus-beacon-chain-v1";
|
||||
643→
|
||||
644→/** 里程碑天数 */
|
||||
645→export const BEACON_CHAIN_MILESTONES = [3, 7, 14, 30] as const;
|
||||
646→
|
||||
647→/** 里程碑奖励配置 */
|
||||
648→export interface BeaconChainReward {
|
||||
649→ milestone: number;
|
||||
650→ rewardInsight: number;
|
||||
651→ rewardContact: number;
|
||||
652→ label: string;
|
||||
653→}
|
||||
654→
|
||||
655→export const BEACON_CHAIN_REWARDS: BeaconChainReward[] = [
|
||||
656→ { milestone: 3, rewardInsight: 50, rewardContact: 5, label: "三日谐振" },
|
||||
657→ { milestone: 7, rewardInsight: 120, rewardContact: 12, label: "七日回响" },
|
||||
658→ { milestone: 14, rewardInsight: 280, rewardContact: 28, label: "半月星潮" },
|
||||
659→ { milestone: 30, rewardInsight: 680, rewardContact: 68, label: "满月飞升" },
|
||||
660→];
|
||||
661→
|
||||
662→/** 信标链状态 */
|
||||
663→export interface BeaconChainState {
|
||||
664→ /** 上次完成日(YYYY-MM-DD) */
|
||||
665→ lastCompletedDateKey: string;
|
||||
666→ /** 当前连续天数 */
|
||||
667→ currentStreak: number;
|
||||
668→ /** 历史最长 */
|
||||
669→ longestStreak: number;
|
||||
670→ /** 累计完成总数 */
|
||||
671→ totalCompletions: number;
|
||||
672→ /** 本周期已用续命数(上限 1) */
|
||||
673→ graceUsed: number;
|
||||
674→ /** 已领取的里程碑数组 */
|
||||
675→ milestonesClaimed: number[];
|
||||
676→}
|
||||
677→
|
||||
678→/** 读取信标链状态 */
|
||||
679→export function loadChainState(): BeaconChainState {
|
||||
680→ const fresh = (): BeaconChainState => ({
|
||||
681→ lastCompletedDateKey: "",
|
||||
682→ currentStreak: 0,
|
||||
683→ longestStreak: 0,
|
||||
684→ totalCompletions: 0,
|
||||
685→ graceUsed: 0,
|
||||
686→ milestonesClaimed: [],
|
||||
687→ });
|
||||
688→ if (typeof localStorage === "undefined") return fresh();
|
||||
689→ try {
|
||||
690→ const raw = localStorage.getItem(BEACON_CHAIN_KEY);
|
||||
691→ if (!raw) return fresh();
|
||||
692→ const s = JSON.parse(raw) as Partial<BeaconChainState>;
|
||||
693→ return {
|
||||
694→ lastCompletedDateKey: s.lastCompletedDateKey ?? "",
|
||||
695→ currentStreak: s.currentStreak ?? 0,
|
||||
696→ longestStreak: s.longestStreak ?? 0,
|
||||
697→ totalCompletions: s.totalCompletions ?? 0,
|
||||
698→ graceUsed: s.graceUsed ?? 0,
|
||||
699→ milestonesClaimed: Array.isArray(s.milestonesClaimed)
|
||||
700→ ? [...s.milestonesClaimed]
|
||||
701→ : [],
|
||||
702→ };
|
||||
703→ } catch {
|
||||
704→ return fresh();
|
||||
705→ }
|
||||
706→}
|
||||
707→
|
||||
708→/** 保存信标链状态 */
|
||||
709→export function saveChainState(state: BeaconChainState): void {
|
||||
710→ if (typeof localStorage === "undefined") return;
|
||||
711→ localStorage.setItem(BEACON_CHAIN_KEY, JSON.stringify(state));
|
||||
712→}
|
||||
713→
|
||||
714→/** dateKey → UTC 0 点时间戳 */
|
||||
715→function dateKeyToTimestamp(dateKey: string): number {
|
||||
716→ const [y, m, d] = dateKey.split("-").map(Number);
|
||||
717→ return Date.UTC(y, m - 1, d, 0, 0, 0);
|
||||
718→}
|
||||
719→
|
||||
720→/** 计算 b - a 相差的天数(UTC 0 点对齐) */
|
||||
721→function dateKeyDiffDays(a: string, b: string): number {
|
||||
722→ if (!a || !b) return Number.MAX_SAFE_INTEGER;
|
||||
723→ return Math.round((dateKeyToTimestamp(b) - dateKeyToTimestamp(a)) / 86400000);
|
||||
724→}
|
||||
725→
|
||||
726→/**
|
||||
727→ * 记录一次日挑战完成(核心逻辑)。
|
||||
728→ * - dateKey === lastCompletedDateKey:同一天重复完成,忽略,返回 newMilestones: []
|
||||
729→ * - dateKey 是 lastCompletedDateKey 的次日:currentStreak++
|
||||
730→ * - dateKey 是 lastCompletedDateKey 的后两天(隔了一天 miss)且 graceUsed < 1:续命一次,currentStreak++
|
||||
731→ * - 其他:currentStreak = 1(断链重来),graceUsed = 0
|
||||
732→ * 更新 longestStreak 与 totalCompletions。
|
||||
733→ * @returns { state, newMilestones } 刚达成但未领取的里程碑数组
|
||||
734→ */
|
||||
735→export function recordChainCompletion(dateKey: string): {
|
||||
736→ state: BeaconChainState;
|
||||
737→ newMilestones: number[];
|
||||
738→} {
|
||||
739→ const state = loadChainState();
|
||||
740→
|
||||
741→ // 同一天重复完成:忽略
|
||||
742→ if (state.lastCompletedDateKey === dateKey) {
|
||||
743→ return { state, newMilestones: [] };
|
||||
744→ }
|
||||
745→
|
||||
746→ let next: BeaconChainState;
|
||||
747→
|
||||
748→ if (state.lastCompletedDateKey === "") {
|
||||
749→ // 首次完成
|
||||
750→ next = {
|
||||
751→ ...state,
|
||||
752→ lastCompletedDateKey: dateKey,
|
||||
753→ currentStreak: 1,
|
||||
754→ longestStreak: Math.max(state.longestStreak, 1),
|
||||
755→ totalCompletions: state.totalCompletions + 1,
|
||||
756→ };
|
||||
757→ } else {
|
||||
758→ const diff = dateKeyDiffDays(state.lastCompletedDateKey, dateKey);
|
||||
759→ if (diff === 1) {
|
||||
760→ // 次日:链 +1
|
||||
761→ const newStreak = state.currentStreak + 1;
|
||||
762→ next = {
|
||||
763→ ...state,
|
||||
764→ lastCompletedDateKey: dateKey,
|
||||
765→ currentStreak: newStreak,
|
||||
766→ longestStreak: Math.max(state.longestStreak, newStreak),
|
||||
767→ totalCompletions: state.totalCompletions + 1,
|
||||
768→ };
|
||||
769→ } else if (diff === 2 && state.graceUsed < 1) {
|
||||
770→ // 隔一天 miss,续命一次
|
||||
771→ const newStreak = state.currentStreak + 1;
|
||||
772→ next = {
|
||||
773→ ...state,
|
||||
774→ lastCompletedDateKey: dateKey,
|
||||
775→ currentStreak: newStreak,
|
||||
776→ longestStreak: Math.max(state.longestStreak, newStreak),
|
||||
777→ totalCompletions: state.totalCompletions + 1,
|
||||
778→ graceUsed: state.graceUsed + 1,
|
||||
779→ };
|
||||
780→ } else {
|
||||
781→ // 断链重来
|
||||
782→ next = {
|
||||
783→ ...state,
|
||||
784→ lastCompletedDateKey: dateKey,
|
||||
785→ currentStreak: 1,
|
||||
786→ totalCompletions: state.totalCompletions + 1,
|
||||
787→ graceUsed: 0,
|
||||
788→ longestStreak: Math.max(state.longestStreak, 1),
|
||||
789→ };
|
||||
790→ }
|
||||
791→ }
|
||||
792→
|
||||
793→ // 检查新里程碑(刚达成但未领取)
|
||||
794→ const newMilestones: number[] = [];
|
||||
795→ for (const m of BEACON_CHAIN_MILESTONES) {
|
||||
796→ if (
|
||||
797→ next.currentStreak >= m &&
|
||||
798→ !next.milestonesClaimed.includes(m)
|
||||
799→ ) {
|
||||
800→ newMilestones.push(m);
|
||||
801→ }
|
||||
802→ }
|
||||
803→
|
||||
804→ saveChainState(next);
|
||||
805→ return { state: next, newMilestones };
|
||||
806→}
|
||||
807→
|
||||
808→/** 领取里程碑奖励,加入 milestonesClaimed */
|
||||
809→export function claimChainMilestone(milestone: number): {
|
||||
810→ rewardInsight: number;
|
||||
811→ rewardContact: number;
|
||||
812→ label: string;
|
||||
813→ state: BeaconChainState;
|
||||
814→} {
|
||||
815→ const reward = BEACON_CHAIN_REWARDS.find((r) => r.milestone === milestone);
|
||||
816→ const state = loadChainState();
|
||||
817→ if (
|
||||
818→ !reward ||
|
||||
819→ state.milestonesClaimed.includes(milestone) ||
|
||||
820→ state.currentStreak < milestone
|
||||
821→ ) {
|
||||
822→ return { rewardInsight: 0, rewardContact: 0, label: "", state };
|
||||
823→ }
|
||||
824→ const next: BeaconChainState = {
|
||||
825→ ...state,
|
||||
826→ milestonesClaimed: [...state.milestonesClaimed, milestone],
|
||||
827→ };
|
||||
828→ saveChainState(next);
|
||||
829→ return {
|
||||
830→ rewardInsight: reward.rewardInsight,
|
||||
831→ rewardContact: reward.rewardContact,
|
||||
832→ label: reward.label,
|
||||
833→ state: next,
|
||||
834→ };
|
||||
835→}
|
||||
836→
|
||||
837→/** 返回下一个目标里程碑(如 streak=5 → 7;streak=30+ → null) */
|
||||
838→export function getNextMilestone(streak: number): number | null {
|
||||
839→ for (const m of BEACON_CHAIN_MILESTONES) {
|
||||
840→ if (streak < m) return m;
|
||||
841→ }
|
||||
842→ return null;
|
||||
843→}
|
||||
844→
|
||||
845→/**
|
||||
846→ * 返回信标链进度信息(用于 UI 进度条)。
|
||||
847→ * - current:当前连续天数
|
||||
848→ * - next:下一个目标里程碑(null 表示已通关全部)
|
||||
849→ * - progressPct:当前进度(基于上一个里程碑 → 下一个里程碑)
|
||||
850→ */
|
||||
851→export function getChainProgress(streak: number): {
|
||||
852→ current: number;
|
||||
853→ next: number | null;
|
||||
854→ prev: number;
|
||||
855→ progressPct: number;
|
||||
856→} {
|
||||
857→ const next = getNextMilestone(streak);
|
||||
858→ let prev = 0;
|
||||
859→ for (const m of BEACON_CHAIN_MILESTONES) {
|
||||
860→ if (streak >= m) prev = m;
|
||||
861→ }
|
||||
862→ if (next === null) {
|
||||
863→ return { current: streak, next: null, prev, progressPct: 100 };
|
||||
864→ }
|
||||
865→ const span = next - prev;
|
||||
866→ const done = streak - prev;
|
||||
867→ const pct = span > 0 ? Math.round((done / span) * 100) : 100;
|
||||
868→ return {
|
||||
869→ current: streak,
|
||||
870→ next,
|
||||
871→ prev,
|
||||
872→ progressPct: Math.min(100, Math.max(0, pct)),
|
||||
873→ };
|
||||
874→}
|
||||
875→
|
||||
@@ -0,0 +1,875 @@
|
||||
1→ 1→// 回响星核 / Echo Nexus — 深空信标
|
||||
2→ 2→// v0.5:每日挑战 + 本地排行榜
|
||||
3→ 3→// v0.8:周挑战 + 信标链(连续完成奖励)
|
||||
4→ 4→// 一个自包含的"每日挑战 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。
|
||||
5→ 5→
|
||||
6→ 6→/** 每日挑战类型 */
|
||||
7→ 7→export type BeaconChallengeType =
|
||||
8→ 8→ | "decode" // 解码 N 颗晶体
|
||||
9→ 9→ | "expedition" // 完成 N 次探险
|
||||
10→ 10→ | "pulse" // 发起 N 次脉冲
|
||||
11→ 11→ | "boss" // 击破 N 处 BOSS
|
||||
12→ 12→ | "insight"; // 累计 N 洞见
|
||||
13→ 13→
|
||||
14→ 14→/** 挑战难度档位 */
|
||||
15→ 15→export type BeaconDifficulty = "routine" | "anomaly" | "singular";
|
||||
16→ 16→
|
||||
17→ 17→/** 一条排行榜记录 */
|
||||
18→ 18→export interface BeaconScoreEntry {
|
||||
19→ 19→ /** 提交时间戳 */
|
||||
20→ 20→ timestamp: number;
|
||||
21→ 21→ /** 日期 key(YYYY-MM-DD)或周 key(YYYY-Www) */
|
||||
22→ 22→ dateKey: string;
|
||||
23→ 23→ /** 挑战类型 */
|
||||
24→ 24→ challenge: BeaconChallengeType;
|
||||
25→ 25→ /** 难度 */
|
||||
26→ 26→ difficulty: BeaconDifficulty;
|
||||
27→ 27→ /** 完成度(0-1,1 = 完成) */
|
||||
28→ 28→ progress: number;
|
||||
29→ 29→ /** 最终得分 */
|
||||
30→ 30→ score: number;
|
||||
31→ 31→ /** 完成时长(秒),未完成则记 0 */
|
||||
32→ 32→ durationSec: number;
|
||||
33→ 33→ /** v0.8:是否为周挑战记录(日挑战默认 false / undefined) */
|
||||
34→ 34→ isWeekly?: boolean;
|
||||
35→ 35→}
|
||||
36→ 36→
|
||||
37→ 37→/** 每日挑战定义 */
|
||||
38→ 38→export interface BeaconDailyChallenge {
|
||||
39→ 39→ /** 日期 key(YYYY-MM-DD,UTC) */
|
||||
40→ 40→ dateKey: string;
|
||||
41→ 41→ /** 挑战类型 */
|
||||
42→ 42→ type: BeaconChallengeType;
|
||||
43→ 43→ /** 难度 */
|
||||
44→ 44→ difficulty: BeaconDifficulty;
|
||||
45→ 45→ /** 目标数值 */
|
||||
46→ 46→ goal: number;
|
||||
47→ 47→ /** 奖励:完成时洞见 */
|
||||
48→ 48→ rewardInsight: number;
|
||||
49→ 49→ /** 奖励:完成时接触进度 */
|
||||
50→ 50→ rewardContact: number;
|
||||
51→ 51→ /** 使用的种子(可复现) */
|
||||
52→ 52→ seed: number;
|
||||
53→ 53→ /** 友好标题 */
|
||||
54→ 54→ title: string;
|
||||
55→ 55→ /** 描述 */
|
||||
56→ 56→ desc: string;
|
||||
57→ 57→}
|
||||
58→ 58→
|
||||
59→ 59→/** 难度配置 */
|
||||
60→ 60→export const BEACON_DIFFICULTY: Record<
|
||||
61→ 61→ BeaconDifficulty,
|
||||
62→ 62→ { label: string; color: string; glow: string; mult: number; icon: string }
|
||||
63→ 63→> = {
|
||||
64→ 64→ routine: {
|
||||
65→ 65→ label: "常规信标",
|
||||
66→ 66→ color: "#34d399",
|
||||
67→ 67→ glow: "rgba(52,211,153,0.45)",
|
||||
68→ 68→ mult: 1,
|
||||
69→ 69→ icon: "◍",
|
||||
70→ 70→ },
|
||||
71→ 71→ anomaly: {
|
||||
72→ 72→ label: "异常波动",
|
||||
73→ 73→ color: "#fbbf24",
|
||||
74→ 74→ glow: "rgba(251,191,36,0.45)",
|
||||
75→ 75→ mult: 1.6,
|
||||
76→ 76→ icon: "◈",
|
||||
77→ 77→ },
|
||||
78→ 78→ singular: {
|
||||
79→ 79→ label: "奇点回响",
|
||||
80→ 80→ color: "#f43f5e",
|
||||
81→ 81→ glow: "rgba(244,63,94,0.5)",
|
||||
82→ 82→ mult: 2.4,
|
||||
83→ 83→ icon: "✶",
|
||||
84→ 84→ },
|
||||
85→ 85→};
|
||||
86→ 86→
|
||||
87→ 87→/** 挑战类型元信息 */
|
||||
88→ 88→export const BEACON_TYPE_META: Record<
|
||||
89→ 89→ BeaconChallengeType,
|
||||
90→ 90→ { label: string; icon: string; unit: string; verb: string }
|
||||
91→ 91→> = {
|
||||
92→ 92→ decode: { label: "解码协议", icon: "❖", unit: "颗", verb: "解码记忆晶体" },
|
||||
93→ 93→ expedition: { label: "远征指令", icon: "⬢", unit: "次", verb: "完成遗迹探险" },
|
||||
94→ 94→ pulse: { label: "脉冲任务", icon: "✺", unit: "次", verb: "发起脉冲扫描" },
|
||||
95→ 95→ boss: { label: "猎杀契约", icon: "☠", unit: "处", verb: "击破维度 BOSS" },
|
||||
96→ 96→ insight: { label: "洞见采集", icon: "✦", unit: "点", verb: "累计获取洞见" },
|
||||
97→ 97→};
|
||||
98→ 98→
|
||||
99→ 99→/** 取今日日期 key(UTC,保证全球同一天同一挑战) */
|
||||
100→ 100→export function getTodayKey(now: Date = new Date()): string {
|
||||
101→ 101→ const y = now.getUTCFullYear();
|
||||
102→ 102→ const m = String(now.getUTCMonth() + 1).padStart(2, "0");
|
||||
103→ 103→ const d = String(now.getUTCDate()).padStart(2, "0");
|
||||
104→ 104→ return `${y}-${m}-${d}`;
|
||||
105→ 105→}
|
||||
106→ 106→
|
||||
107→ 107→/** 把日期 key 转成数值种子 */
|
||||
108→ 108→function dateKeyToSeed(dateKey: string): number {
|
||||
109→ 109→ let h = 2166136261 >>> 0;
|
||||
110→ 110→ for (let i = 0; i < dateKey.length; i++) {
|
||||
111→ 111→ h ^= dateKey.charCodeAt(i);
|
||||
112→ 112→ h = Math.imul(h, 16777619) >>> 0;
|
||||
113→ 113→ }
|
||||
114→ 114→ return h >>> 0;
|
||||
115→ 115→}
|
||||
116→ 116→
|
||||
117→ 117→/** mulberry32 PRNG(可复现) */
|
||||
118→ 118→function mulberry32(seed: number): () => number {
|
||||
119→ 119→ let a = seed >>> 0;
|
||||
120→ 120→ return () => {
|
||||
121→ 121→ a |= 0;
|
||||
122→ 122→ a = (a + 0x6d2b79f5) | 0;
|
||||
123→ 123→ let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
124→ 124→ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
125→ 125→ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
126→ 126→ };
|
||||
127→ 127→}
|
||||
128→ 128→
|
||||
129→ 129→/** 生成今日每日挑战(确定性:同一天同一种子 → 同一挑战) */
|
||||
130→ 130→export function generateDailyChallenge(now: Date = new Date()): BeaconDailyChallenge {
|
||||
131→ 131→ const dateKey = getTodayKey(now);
|
||||
132→ 132→ const seed = dateKeyToSeed(dateKey);
|
||||
133→ 133→ const rng = mulberry32(seed);
|
||||
134→ 134→
|
||||
135→ 135→ // 选类型(5 种)
|
||||
136→ 136→ const types: BeaconChallengeType[] = ["decode", "expedition", "pulse", "boss", "insight"];
|
||||
137→ 137→ const type = types[Math.floor(rng() * types.length)];
|
||||
138→ 138→
|
||||
139→ 139→ // 选难度(加权:常规 55% / 异常 33% / 奇点 12%)
|
||||
140→ 140→ const dr = rng();
|
||||
141→ 141→ const difficulty: BeaconDifficulty =
|
||||
142→ 142→ dr < 0.55 ? "routine" : dr < 0.88 ? "anomaly" : "singular";
|
||||
143→ 143→
|
||||
144→ 144→ const diffMult = BEACON_DIFFICULTY[difficulty].mult;
|
||||
145→ 145→
|
||||
146→ 146→ // 按类型定 goal 与奖励
|
||||
147→ 147→ let goal = 0;
|
||||
148→ 148→ let rewardInsight = 0;
|
||||
149→ 149→ let rewardContact = 0;
|
||||
150→ 150→ let title = "";
|
||||
151→ 151→ let desc = "";
|
||||
152→ 152→
|
||||
153→ 153→ switch (type) {
|
||||
154→ 154→ case "decode":
|
||||
155→ 155→ goal = Math.round((6 + Math.floor(rng() * 10)) * diffMult); // 6-15 × mult
|
||||
156→ 156→ rewardInsight = Math.round(goal * 4 * diffMult);
|
||||
157→ 157→ rewardContact = goal * 0.8;
|
||||
158→ 158→ title = `解码 ${goal} 颗记忆晶体`;
|
||||
159→ 159→ desc = `深空信标要求解码 ${goal} 颗晶体。谐振风暴期间效率更高。`;
|
||||
160→ 160→ break;
|
||||
161→ 161→ case "expedition":
|
||||
162→ 162→ goal = Math.max(1, Math.round((1 + Math.floor(rng() * 3)) * diffMult)); // 1-3 × mult
|
||||
163→ 163→ rewardInsight = Math.round(goal * 12 * diffMult);
|
||||
164→ 164→ rewardContact = goal * 1.5;
|
||||
165→ 165→ title = `完成 ${goal} 次遗迹探险`;
|
||||
166→ 166→ desc = `派出探险队完成 ${goal} 次远征,无论胜负均计入。`;
|
||||
167→ 167→ break;
|
||||
168→ 168→ case "pulse":
|
||||
169→ 169→ goal = Math.round((20 + Math.floor(rng() * 30)) * diffMult); // 20-49 × mult
|
||||
170→ 170→ rewardInsight = Math.round(goal * 1.2 * diffMult);
|
||||
171→ 171→ rewardContact = goal * 0.3;
|
||||
172→ 172→ title = `发起 ${goal} 次脉冲扫描`;
|
||||
173→ 173→ desc = `主动点击晶体发起 ${goal} 次脉冲。连击可叠加加成。`;
|
||||
174→ 174→ break;
|
||||
175→ 175→ case "boss":
|
||||
176→ 176→ goal = Math.max(1, Math.round(diffMult)); // 奇点至少 2-3
|
||||
177→ 177→ rewardInsight = Math.round(goal * 30 * diffMult);
|
||||
178→ 178→ rewardContact = goal * 3;
|
||||
179→ 179→ title = `击破 ${goal} 处维度 BOSS`;
|
||||
180→ 180→ desc = `在探险终点击破 ${goal} 处 BOSS。提升探险力后再挑战。`;
|
||||
181→ 181→ break;
|
||||
182→ 182→ case "insight":
|
||||
183→ 183→ goal = Math.round((40 + Math.floor(rng() * 80)) * diffMult); // 40-119 × mult
|
||||
184→ 184→ rewardInsight = 0; // 洞见挑战不给洞见,给接触
|
||||
185→ 185→ rewardContact = goal * 0.1;
|
||||
186→ 186→ title = `累计获取 ${goal} 洞见`;
|
||||
187→ 187→ desc = `通过解码、探险、星潮等途径累计 ${goal} 洞见。`;
|
||||
188→ 188→ break;
|
||||
189→ 189→ }
|
||||
190→ 190→
|
||||
191→ 191→ return {
|
||||
192→ 192→ dateKey,
|
||||
193→ 193→ type,
|
||||
194→ 194→ difficulty,
|
||||
195→ 195→ goal,
|
||||
196→ 196→ rewardInsight,
|
||||
197→ 197→ rewardContact,
|
||||
198→ 198→ seed,
|
||||
199→ 199→ title,
|
||||
200→ 200→ desc,
|
||||
201→ 201→ };
|
||||
202→ 202→}
|
||||
203→ 203→
|
||||
204→ 204→/** 计算挑战得分(用于排行榜) */
|
||||
205→ 205→export function computeBeaconScore(
|
||||
206→ 206→ challenge: BeaconDailyChallenge,
|
||||
207→ 207→ progress: number,
|
||||
208→ 208→ durationSec: number
|
||||
209→ 209→): number {
|
||||
210→ 210→ const completion = Math.min(1, progress / challenge.goal);
|
||||
211→ 211→ const diffMult = BEACON_DIFFICULTY[challenge.difficulty].mult;
|
||||
212→ 212→ // 基础分 = 完成度 × 难度 × 1000;完成时长越短加分越多(上限 +500)
|
||||
213→ 213→ const base = completion * 1000 * diffMult;
|
||||
214→ 214→ const speedBonus =
|
||||
215→ 215→ completion >= 1 && durationSec > 0 ? Math.max(0, 500 - durationSec * 0.5) : 0;
|
||||
216→ 216→ return Math.round(base + speedBonus);
|
||||
217→ 217→}
|
||||
218→ 218→
|
||||
219→ 219→/** 排行榜 localStorage key */
|
||||
220→ 220→export const BEACON_LEADERBOARD_KEY = "echo-nexus-beacon-lb-v1";
|
||||
221→ 221→/** 每日进度 localStorage key(记录今日进度 + 是否领奖) */
|
||||
222→ 222→export const BEACON_PROGRESS_KEY = "echo-nexus-beacon-prog-v1";
|
||||
223→ 223→
|
||||
224→ 224→/** 排行榜上限 */
|
||||
225→ 225→export const BEACON_LB_MAX = 20;
|
||||
226→ 226→
|
||||
227→ 227→/** 今日进度记录 */
|
||||
228→ 228→export interface BeaconDailyProgress {
|
||||
229→ 229→ dateKey: string;
|
||||
230→ 230→ progress: number;
|
||||
231→ 231→ startedAt: number;
|
||||
232→ 232→ completedAt: number | null;
|
||||
233→ 233→ claimed: boolean;
|
||||
234→ 234→ durationSec: number;
|
||||
235→ 235→}
|
||||
236→ 236→
|
||||
237→ 237→/** 读取本地排行榜(按分数降序) */
|
||||
238→ 238→export function loadLeaderboard(): BeaconScoreEntry[] {
|
||||
239→ 239→ if (typeof localStorage === "undefined") return [];
|
||||
240→ 240→ try {
|
||||
241→ 241→ const raw = localStorage.getItem(BEACON_LEADERBOARD_KEY);
|
||||
242→ 242→ if (!raw) return [];
|
||||
243→ 243→ const arr = JSON.parse(raw) as BeaconScoreEntry[];
|
||||
244→ 244→ return arr.sort((a, b) => b.score - a.score).slice(0, BEACON_LB_MAX);
|
||||
245→ 245→ } catch {
|
||||
246→ 246→ return [];
|
||||
247→ 247→ }
|
||||
248→ 248→}
|
||||
249→ 249→
|
||||
250→ 250→/** 写入一条排行榜记录 */
|
||||
251→ 251→export function pushLeaderboardEntry(entry: BeaconScoreEntry): BeaconScoreEntry[] {
|
||||
252→ 252→ const lb = loadLeaderboard();
|
||||
253→ 253→ lb.push(entry);
|
||||
254→ 254→ lb.sort((a, b) => b.score - a.score);
|
||||
255→ 255→ const trimmed = lb.slice(0, BEACON_LB_MAX);
|
||||
256→ 256→ if (typeof localStorage !== "undefined") {
|
||||
257→ 257→ localStorage.setItem(BEACON_LEADERBOARD_KEY, JSON.stringify(trimmed));
|
||||
258→ 258→ }
|
||||
259→ 259→ return trimmed;
|
||||
260→ 260→}
|
||||
261→ 261→
|
||||
262→ 262→/** 读取今日进度(若 dateKey 不匹配则重置) */
|
||||
263→ 263→export function loadDailyProgress(now: Date = new Date()): BeaconDailyProgress {
|
||||
264→ 264→ const todayKey = getTodayKey(now);
|
||||
265→ 265→ if (typeof localStorage === "undefined") {
|
||||
266→ 266→ return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 };
|
||||
267→ 267→ }
|
||||
268→ 268→ try {
|
||||
269→ 269→ const raw = localStorage.getItem(BEACON_PROGRESS_KEY);
|
||||
270→ 270→ if (!raw) {
|
||||
271→ 271→ return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 };
|
||||
272→ 272→ }
|
||||
273→ 273→ const prog = JSON.parse(raw) as BeaconDailyProgress;
|
||||
274→ 274→ if (prog.dateKey !== todayKey) {
|
||||
275→ 275→ // 新的一天,重置
|
||||
276→ 276→ return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 };
|
||||
277→ 277→ }
|
||||
278→ 278→ return prog;
|
||||
279→ 279→ } catch {
|
||||
280→ 280→ return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 };
|
||||
281→ 281→ }
|
||||
282→ 282→}
|
||||
283→ 283→
|
||||
284→ 284→/** 保存今日进度 */
|
||||
285→ 285→export function saveDailyProgress(prog: BeaconDailyProgress): void {
|
||||
286→ 286→ if (typeof localStorage === "undefined") return;
|
||||
287→ 287→ localStorage.setItem(BEACON_PROGRESS_KEY, JSON.stringify(prog));
|
||||
288→ 288→}
|
||||
289→ 289→
|
||||
290→ 290→/** 增量更新进度,返回新进度 + 是否刚完成 */
|
||||
291→ 291→export function addBeaconProgress(
|
||||
292→ 292→ current: BeaconDailyProgress,
|
||||
293→ 293→ challenge: BeaconDailyChallenge,
|
||||
294→ 294→ delta: number
|
||||
295→ 295→): { progress: BeaconDailyProgress; justCompleted: boolean } {
|
||||
296→ 296→ const newProgressVal = Math.min(challenge.goal, current.progress + delta);
|
||||
297→ 297→ const justCompleted = current.completedAt === null && newProgressVal >= challenge.goal;
|
||||
298→ 298→ const completedAt = justCompleted ? Date.now() : current.completedAt;
|
||||
299→ 299→ const durationSec =
|
||||
300→ 300→ completedAt !== null ? Math.floor((completedAt - current.startedAt) / 1000) : current.durationSec;
|
||||
301→ 301→ const next: BeaconDailyProgress = {
|
||||
302→ 302→ ...current,
|
||||
303→ 303→ progress: newProgressVal,
|
||||
304→ 304→ completedAt,
|
||||
305→ 305→ durationSec,
|
||||
306→ 306→ };
|
||||
307→ 307→ saveDailyProgress(next);
|
||||
308→ 308→ return { progress: next, justCompleted };
|
||||
309→ 309→}
|
||||
310→ 310→
|
||||
311→ 311→/** 领取奖励:返回奖励数值 + 推送排行榜 */
|
||||
312→ 312→export function claimBeaconReward(
|
||||
313→ 313→ challenge: BeaconDailyChallenge,
|
||||
314→ 314→ progress: BeaconDailyProgress
|
||||
315→ 315→): { rewardInsight: number; rewardContact: number; score: number; leaderboard: BeaconScoreEntry[] } {
|
||||
316→ 316→ if (progress.claimed || progress.completedAt === null) {
|
||||
317→ 317→ return { rewardInsight: 0, rewardContact: 0, score: 0, leaderboard: loadLeaderboard() };
|
||||
318→ 318→ }
|
||||
319→ 319→ const score = computeBeaconScore(challenge, progress.progress, progress.durationSec);
|
||||
320→ 320→ const entry: BeaconScoreEntry = {
|
||||
321→ 321→ timestamp: Date.now(),
|
||||
322→ 322→ dateKey: challenge.dateKey,
|
||||
323→ 323→ challenge: challenge.type,
|
||||
324→ 324→ difficulty: challenge.difficulty,
|
||||
325→ 325→ progress: progress.progress / challenge.goal,
|
||||
326→ 326→ score,
|
||||
327→ 327→ durationSec: progress.durationSec,
|
||||
328→ 328→ };
|
||||
329→ 329→ const leaderboard = pushLeaderboardEntry(entry);
|
||||
330→ 330→ const updated: BeaconDailyProgress = { ...progress, claimed: true };
|
||||
331→ 331→ saveDailyProgress(updated);
|
||||
332→ 332→ return {
|
||||
333→ 333→ rewardInsight: challenge.rewardInsight,
|
||||
334→ 334→ rewardContact: challenge.rewardContact,
|
||||
335→ 335→ score,
|
||||
336→ 336→ leaderboard,
|
||||
337→ 337→ };
|
||||
338→ 338→}
|
||||
339→ 339→
|
||||
340→ 340→/** 距离 UTC 次日 0 点的毫秒数(用于倒计时) */
|
||||
341→ 341→export function msUntilNextDay(now: Date = new Date()): number {
|
||||
342→ 342→ const next = Date.UTC(
|
||||
343→ 343→ now.getUTCFullYear(),
|
||||
344→ 344→ now.getUTCMonth(),
|
||||
345→ 345→ now.getUTCDate() + 1,
|
||||
346→ 346→ 0,
|
||||
347→ 347→ 0,
|
||||
348→ 348→ 0
|
||||
349→ 349→ );
|
||||
350→ 350→ return Math.max(0, next - now.getTime());
|
||||
351→ 351→}
|
||||
352→ 352→
|
||||
353→ 353→/** 格式化倒计时为 HH:MM:SS */
|
||||
354→ 354→export function formatCountdown(ms: number): string {
|
||||
355→ 355→ const total = Math.max(0, Math.floor(ms / 1000));
|
||||
356→ 356→ const h = String(Math.floor(total / 3600)).padStart(2, "0");
|
||||
357→ 357→ const m = String(Math.floor((total % 3600) / 60)).padStart(2, "0");
|
||||
358→ 358→ const s = String(total % 60).padStart(2, "0");
|
||||
359→ 359→ return `${h}:${m}:${s}`;
|
||||
360→ 360→}
|
||||
361→ 361→
|
||||
362→ 362→// ===========================================================================
|
||||
363→ 363→// 周挑战(WEEKLY CHALLENGE)— v0.8
|
||||
364→ 364→// 每周一 UTC 0 点刷新一个"周挑战",目标更大、奖励更好,与日挑战并行存在。
|
||||
365→ 365→// 同一周同一种子 → 同一挑战(确定性)。难度强制 anomaly/singular(加权 60/40)。
|
||||
366→ 366→// ===========================================================================
|
||||
367→ 367→
|
||||
368→ 368→/** 周挑战 localStorage key */
|
||||
369→ 369→export const BEACON_WEEKLY_KEY = "echo-nexus-beacon-weekly-v1";
|
||||
370→ 370→
|
||||
371→ 371→/** 周挑战定义 */
|
||||
372→ 372→export interface BeaconWeeklyChallenge {
|
||||
373→ 373→ /** ISO 周键(YYYY-Www,如 "2026-W26") */
|
||||
374→ 374→ weekKey: string;
|
||||
375→ 375→ /** 挑战类型 */
|
||||
376→ 376→ type: BeaconChallengeType;
|
||||
377→ 377→ /** 难度(固定 anomaly 或 singular) */
|
||||
378→ 378→ difficulty: "anomaly" | "singular";
|
||||
379→ 379→ /** 目标数值(日挑战的 3-5 倍) */
|
||||
380→ 380→ goal: number;
|
||||
381→ 381→ /** 奖励:完成时洞见 */
|
||||
382→ 382→ rewardInsight: number;
|
||||
383→ 383→ /** 奖励:完成时接触进度 */
|
||||
384→ 384→ rewardContact: number;
|
||||
385→ 385→ /** 使用的种子(可复现) */
|
||||
386→ 386→ seed: number;
|
||||
387→ 387→ /** 友好标题 */
|
||||
388→ 388→ title: string;
|
||||
389→ 389→ /** 描述 */
|
||||
390→ 390→ desc: string;
|
||||
391→ 391→}
|
||||
392→ 392→
|
||||
393→ 393→/** 周挑战进度 */
|
||||
394→ 394→export interface BeaconWeeklyProgress {
|
||||
395→ 395→ weekKey: string;
|
||||
396→ 396→ progress: number;
|
||||
397→ 397→ startedAt: number;
|
||||
398→ 398→ completedAt: number | null;
|
||||
399→ 399→ claimed: boolean;
|
||||
400→ 400→ durationSec: number;
|
||||
401→ 401→}
|
||||
402→ 402→
|
||||
403→ 403→/**
|
||||
404→ 404→ * 计算 ISO 8601 周键(YYYY-Www),如 "2026-W26"。
|
||||
405→ 405→ * 规则:周一为一周开始;第一周是该年至少含 4 天的第一周(即包含第一个周四)。
|
||||
406→ 406→ */
|
||||
407→ 407→export function getWeekKey(now: Date = new Date()): string {
|
||||
408→ 408→ // 取 UTC 日期,避免时区偏移
|
||||
409→ 409→ const tmp = new Date(
|
||||
410→ 410→ Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
||||
411→ 411→ );
|
||||
412→ 412→ // ISO 周以周一为起点:(getUTCDay + 6) % 7 → 0=Mon, 6=Sun
|
||||
413→ 413→ const dayNum = (tmp.getUTCDay() + 6) % 7;
|
||||
414→ 414→ // 把日期调整到本周四(ISO 周归属年份由该周四所在的年份决定)
|
||||
415→ 415→ tmp.setUTCDate(tmp.getUTCDate() - dayNum + 3);
|
||||
416→ 416→ const isoYear = tmp.getUTCFullYear();
|
||||
417→ 417→ const yearStart = Date.UTC(isoYear, 0, 1);
|
||||
418→ 418→ const weekNum = Math.ceil(((tmp.getTime() - yearStart) / 86400000 + 1) / 7);
|
||||
419→ 419→ return `${isoYear}-W${String(weekNum).padStart(2, "0")}`;
|
||||
420→ 420→}
|
||||
421→ 421→
|
||||
422→ 422→/** weekKey → 数值种子(FNV-1a 哈希) */
|
||||
423→ 423→function weekKeyToSeed(weekKey: string): number {
|
||||
424→ 424→ let h = 2166136261 >>> 0;
|
||||
425→ 425→ for (let i = 0; i < weekKey.length; i++) {
|
||||
426→ 426→ h ^= weekKey.charCodeAt(i);
|
||||
427→ 427→ h = Math.imul(h, 16777619) >>> 0;
|
||||
428→ 428→ }
|
||||
429→ 429→ return h >>> 0;
|
||||
430→ 430→}
|
||||
431→ 431→
|
||||
432→ 432→/**
|
||||
433→ 433→ * 生成本周周挑战(确定性:同一周同一种子 → 同一挑战)。
|
||||
434→ 434→ * 难度强制 anomaly(60%)或 singular(40%),goal 为日挑战基准 ×3-5 倍。
|
||||
435→ 435→ */
|
||||
436→ 436→export function generateWeeklyChallenge(
|
||||
437→ 437→ now: Date = new Date()
|
||||
438→ 438→): BeaconWeeklyChallenge {
|
||||
439→ 439→ const weekKey = getWeekKey(now);
|
||||
440→ 440→ const seed = weekKeyToSeed(weekKey);
|
||||
441→ 441→ const rng = mulberry32(seed);
|
||||
442→ 442→
|
||||
443→ 443→ const types: BeaconChallengeType[] = [
|
||||
444→ 444→ "decode",
|
||||
445→ 445→ "expedition",
|
||||
446→ 446→ "pulse",
|
||||
447→ 447→ "boss",
|
||||
448→ 448→ "insight",
|
||||
449→ 449→ ];
|
||||
450→ 450→ const type = types[Math.floor(rng() * types.length)];
|
||||
451→ 451→
|
||||
452→ 452→ // 难度加权:anomaly 60% / singular 40%
|
||||
453→ 453→ const dr = rng();
|
||||
454→ 454→ const difficulty: "anomaly" | "singular" = dr < 0.6 ? "anomaly" : "singular";
|
||||
455→ 455→ const diffMult = BEACON_DIFFICULTY[difficulty].mult;
|
||||
456→ 456→
|
||||
457→ 457→ // 3-5 倍
|
||||
458→ 458→ const mult = 3 + Math.floor(rng() * 3);
|
||||
459→ 459→
|
||||
460→ 460→ let goal = 0;
|
||||
461→ 461→ let rewardInsight = 0;
|
||||
462→ 462→ let rewardContact = 0;
|
||||
463→ 463→ let title = "";
|
||||
464→ 464→ let desc = "";
|
||||
465→ 465→
|
||||
466→ 466→ switch (type) {
|
||||
467→ 467→ case "decode":
|
||||
468→ 468→ // 日基准 6-15 × mult(3-5) → 18-75
|
||||
469→ 469→ goal = Math.max(18, Math.round((6 + Math.floor(rng() * 10)) * diffMult * mult));
|
||||
470→ 470→ rewardInsight = Math.round(goal * 5 * diffMult);
|
||||
471→ 471→ rewardContact = goal * 1.2;
|
||||
472→ 472→ title = `周界解码 · ${goal} 颗晶体`;
|
||||
473→ 473→ desc = `本周深空信标要求解码 ${goal} 颗记忆晶体,强度为日挑战的 ${mult} 倍。`;
|
||||
474→ 474→ break;
|
||||
475→ 475→ case "expedition":
|
||||
476→ 476→ // 日基准 1-3 × mult(3-5) → 3-15
|
||||
477→ 477→ goal = Math.max(3, Math.round((1 + Math.floor(rng() * 3)) * diffMult * mult));
|
||||
478→ 478→ rewardInsight = Math.round(goal * 14 * diffMult);
|
||||
479→ 479→ rewardContact = goal * 2;
|
||||
480→ 480→ title = `周界远征 · ${goal} 次探险`;
|
||||
481→ 481→ desc = `本周派出探险队完成 ${goal} 次远征,无论胜负均计入。`;
|
||||
482→ 482→ break;
|
||||
483→ 483→ case "pulse":
|
||||
484→ 484→ // 日基准 20-49 × mult(3-5) → 60-245
|
||||
485→ 485→ goal = Math.max(60, Math.round((20 + Math.floor(rng() * 30)) * diffMult * mult));
|
||||
486→ 486→ rewardInsight = Math.round(goal * 1.5 * diffMult);
|
||||
487→ 487→ rewardContact = goal * 0.4;
|
||||
488→ 488→ title = `周界脉冲 · ${goal} 次扫描`;
|
||||
489→ 489→ desc = `本周发起 ${goal} 次脉冲扫描。连击可叠加加成。`;
|
||||
490→ 490→ break;
|
||||
491→ 491→ case "boss":
|
||||
492→ 492→ // 日基准 1 × mult(3-5) → 3-12(singular mult=2.4 时上限 12)
|
||||
493→ 493→ goal = Math.max(3, Math.round(mult * diffMult));
|
||||
494→ 494→ rewardInsight = Math.round(goal * 35 * diffMult);
|
||||
495→ 495→ rewardContact = goal * 4;
|
||||
496→ 496→ title = `周界猎杀 · ${goal} 处 BOSS`;
|
||||
497→ 497→ desc = `本周在探险终点击破 ${goal} 处维度 BOSS。提升探险力后再挑战。`;
|
||||
498→ 498→ break;
|
||||
499→ 499→ case "insight":
|
||||
500→ 500→ // 日基准 40-119 × mult(3-5) → 120-595
|
||||
501→ 501→ goal = Math.max(120, Math.round((40 + Math.floor(rng() * 80)) * diffMult * mult));
|
||||
502→ 502→ rewardInsight = 0; // 洞见挑战不给洞见,给接触
|
||||
503→ 503→ rewardContact = goal * 0.15;
|
||||
504→ 504→ title = `周界洞见 · ${goal} 点`;
|
||||
505→ 505→ desc = `本周通过解码、探险、星潮等途径累计 ${goal} 洞见。`;
|
||||
506→ 506→ break;
|
||||
507→ 507→ }
|
||||
508→ 508→
|
||||
509→ 509→ return {
|
||||
510→ 510→ weekKey,
|
||||
511→ 511→ type,
|
||||
512→ 512→ difficulty,
|
||||
513→ 513→ goal,
|
||||
514→ 514→ rewardInsight,
|
||||
515→ 515→ rewardContact,
|
||||
516→ 516→ seed,
|
||||
517→ 517→ title,
|
||||
518→ 518→ desc,
|
||||
519→ 519→ };
|
||||
520→ 520→}
|
||||
521→ 521→
|
||||
522→ 522→/** 读取本周进度(若 weekKey 不匹配则重置) */
|
||||
523→ 523→export function loadWeeklyProgress(now: Date = new Date()): BeaconWeeklyProgress {
|
||||
524→ 524→ const weekKey = getWeekKey(now);
|
||||
525→ 525→ const empty: BeaconWeeklyProgress = {
|
||||
526→ 526→ weekKey,
|
||||
527→ 527→ progress: 0,
|
||||
528→ 528→ startedAt: Date.now(),
|
||||
529→ 529→ completedAt: null,
|
||||
530→ 530→ claimed: false,
|
||||
531→ 531→ durationSec: 0,
|
||||
532→ 532→ };
|
||||
533→ 533→ if (typeof localStorage === "undefined") return empty;
|
||||
534→ 534→ try {
|
||||
535→ 535→ const raw = localStorage.getItem(BEACON_WEEKLY_KEY);
|
||||
536→ 536→ if (!raw) return empty;
|
||||
537→ 537→ const prog = JSON.parse(raw) as BeaconWeeklyProgress;
|
||||
538→ 538→ if (prog.weekKey !== weekKey) return empty; // 新的一周,重置
|
||||
539→ 539→ return prog;
|
||||
540→ 540→ } catch {
|
||||
541→ 541→ return empty;
|
||||
542→ 542→ }
|
||||
543→ 543→}
|
||||
544→ 544→
|
||||
545→ 545→/** 保存本周进度 */
|
||||
546→ 546→export function saveWeeklyProgress(prog: BeaconWeeklyProgress): void {
|
||||
547→ 547→ if (typeof localStorage === "undefined") return;
|
||||
548→ 548→ localStorage.setItem(BEACON_WEEKLY_KEY, JSON.stringify(prog));
|
||||
549→ 549→}
|
||||
550→ 550→
|
||||
551→ 551→/** 增量更新周挑战进度,返回新进度 + 是否刚完成 */
|
||||
552→ 552→export function addWeeklyProgress(
|
||||
553→ 553→ current: BeaconWeeklyProgress,
|
||||
554→ 554→ challenge: BeaconWeeklyChallenge,
|
||||
555→ 555→ delta: number
|
||||
556→ 556→): { progress: BeaconWeeklyProgress; justCompleted: boolean } {
|
||||
557→ 557→ const newProgressVal = Math.min(challenge.goal, current.progress + delta);
|
||||
558→ 558→ const justCompleted =
|
||||
559→ 559→ current.completedAt === null && newProgressVal >= challenge.goal;
|
||||
560→ 560→ const completedAt = justCompleted ? Date.now() : current.completedAt;
|
||||
561→ 561→ const durationSec =
|
||||
562→ 562→ completedAt !== null
|
||||
563→ 563→ ? Math.floor((completedAt - current.startedAt) / 1000)
|
||||
564→ 564→ : current.durationSec;
|
||||
565→ 565→ const next: BeaconWeeklyProgress = {
|
||||
566→ 566→ ...current,
|
||||
567→ 567→ progress: newProgressVal,
|
||||
568→ 568→ completedAt,
|
||||
569→ 569→ durationSec,
|
||||
570→ 570→ };
|
||||
571→ 571→ saveWeeklyProgress(next);
|
||||
572→ 572→ return { progress: next, justCompleted };
|
||||
573→ 573→}
|
||||
574→ 574→
|
||||
575→ 575→/** 领取周挑战奖励:返回奖励数值 + 推送排行榜 */
|
||||
576→ 576→export function claimWeeklyReward(
|
||||
577→ 577→ challenge: BeaconWeeklyChallenge,
|
||||
578→ 578→ progress: BeaconWeeklyProgress
|
||||
579→ 579→): {
|
||||
580→ 580→ rewardInsight: number;
|
||||
581→ 581→ rewardContact: number;
|
||||
582→ 582→ score: number;
|
||||
583→ 583→ leaderboard: BeaconScoreEntry[];
|
||||
584→ 584→} {
|
||||
585→ 585→ if (progress.claimed || progress.completedAt === null) {
|
||||
586→ 586→ return {
|
||||
587→ 587→ rewardInsight: 0,
|
||||
588→ 588→ rewardContact: 0,
|
||||
589→ 589→ score: 0,
|
||||
590→ 590→ leaderboard: loadLeaderboard(),
|
||||
591→ 591→ };
|
||||
592→ 592→ }
|
||||
593→ 593→ // 借用 computeBeaconScore:把 weekly 包装成 daily 接口(dateKey 字段不影响计分逻辑)
|
||||
594→ 594→ const score = computeBeaconScore(
|
||||
595→ 595→ { ...(challenge as unknown as BeaconDailyChallenge), dateKey: challenge.weekKey },
|
||||
596→ 596→ progress.progress,
|
||||
597→ 597→ progress.durationSec
|
||||
598→ 598→ );
|
||||
599→ 599→ const entry: BeaconScoreEntry = {
|
||||
600→ 600→ timestamp: Date.now(),
|
||||
601→ 601→ dateKey: challenge.weekKey,
|
||||
602→ 602→ challenge: challenge.type,
|
||||
603→ 603→ difficulty: challenge.difficulty,
|
||||
604→ 604→ progress: progress.progress / challenge.goal,
|
||||
605→ 605→ score,
|
||||
606→ 606→ durationSec: progress.durationSec,
|
||||
607→ 607→ isWeekly: true,
|
||||
608→ 608→ };
|
||||
609→ 609→ const leaderboard = pushLeaderboardEntry(entry);
|
||||
610→ 610→ const updated: BeaconWeeklyProgress = { ...progress, claimed: true };
|
||||
611→ 611→ saveWeeklyProgress(updated);
|
||||
612→ 612→ return {
|
||||
613→ 613→ rewardInsight: challenge.rewardInsight,
|
||||
614→ 614→ rewardContact: challenge.rewardContact,
|
||||
615→ 615→ score,
|
||||
616→ 616→ leaderboard,
|
||||
617→ 617→ };
|
||||
618→ 618→}
|
||||
619→ 619→
|
||||
620→ 620→/** 距离下周一 UTC 0 点的毫秒数(用于周挑战倒计时) */
|
||||
621→ 621→export function msUntilNextWeek(now: Date = new Date()): number {
|
||||
622→ 622→ const dayNum = (now.getUTCDay() + 6) % 7; // 0 = Mon
|
||||
623→ 623→ const mondayThisWeek = Date.UTC(
|
||||
624→ 624→ now.getUTCFullYear(),
|
||||
625→ 625→ now.getUTCMonth(),
|
||||
626→ 626→ now.getUTCDate() - dayNum,
|
||||
627→ 627→ 0,
|
||||
628→ 628→ 0,
|
||||
629→ 629→ 0
|
||||
630→ 630→ );
|
||||
631→ 631→ const nextMonday = mondayThisWeek + 7 * 86400000;
|
||||
632→ 632→ return Math.max(0, nextMonday - now.getTime());
|
||||
633→ 633→}
|
||||
634→ 634→
|
||||
635→ 635→// ===========================================================================
|
||||
636→ 636→// 信标链(BEACON CHAIN)— v0.8
|
||||
637→ 637→// 连续完成日挑战形成"链",达成里程碑(3/7/14/30 天)领取递增大奖。
|
||||
638→ 638→// 断链有宽容机制(1 天 miss 不断链,用"信标续命"概念),每条链只能用 1 次。
|
||||
639→ 639→// ===========================================================================
|
||||
640→ 640→
|
||||
641→ 641→/** 信标链 localStorage key */
|
||||
642→ 642→export const BEACON_CHAIN_KEY = "echo-nexus-beacon-chain-v1";
|
||||
643→ 643→
|
||||
644→ 644→/** 里程碑天数 */
|
||||
645→ 645→export const BEACON_CHAIN_MILESTONES = [3, 7, 14, 30] as const;
|
||||
646→ 646→
|
||||
647→ 647→/** 里程碑奖励配置 */
|
||||
648→ 648→export interface BeaconChainReward {
|
||||
649→ 649→ milestone: number;
|
||||
650→ 650→ rewardInsight: number;
|
||||
651→ 651→ rewardContact: number;
|
||||
652→ 652→ label: string;
|
||||
653→ 653→}
|
||||
654→ 654→
|
||||
655→ 655→export const BEACON_CHAIN_REWARDS: BeaconChainReward[] = [
|
||||
656→ 656→ { milestone: 3, rewardInsight: 50, rewardContact: 5, label: "三日谐振" },
|
||||
657→ 657→ { milestone: 7, rewardInsight: 120, rewardContact: 12, label: "七日回响" },
|
||||
658→ 658→ { milestone: 14, rewardInsight: 280, rewardContact: 28, label: "半月星潮" },
|
||||
659→ 659→ { milestone: 30, rewardInsight: 680, rewardContact: 68, label: "满月飞升" },
|
||||
660→ 660→];
|
||||
661→ 661→
|
||||
662→ 662→/** 信标链状态 */
|
||||
663→ 663→export interface BeaconChainState {
|
||||
664→ 664→ /** 上次完成日(YYYY-MM-DD) */
|
||||
665→ 665→ lastCompletedDateKey: string;
|
||||
666→ 666→ /** 当前连续天数 */
|
||||
667→ 667→ currentStreak: number;
|
||||
668→ 668→ /** 历史最长 */
|
||||
669→ 669→ longestStreak: number;
|
||||
670→ 670→ /** 累计完成总数 */
|
||||
671→ 671→ totalCompletions: number;
|
||||
672→ 672→ /** 本周期已用续命数(上限 1) */
|
||||
673→ 673→ graceUsed: number;
|
||||
674→ 674→ /** 已领取的里程碑数组 */
|
||||
675→ 675→ milestonesClaimed: number[];
|
||||
676→ 676→}
|
||||
677→ 677→
|
||||
678→ 678→/** 读取信标链状态 */
|
||||
679→ 679→export function loadChainState(): BeaconChainState {
|
||||
680→ 680→ const fresh = (): BeaconChainState => ({
|
||||
681→ 681→ lastCompletedDateKey: "",
|
||||
682→ 682→ currentStreak: 0,
|
||||
683→ 683→ longestStreak: 0,
|
||||
684→ 684→ totalCompletions: 0,
|
||||
685→ 685→ graceUsed: 0,
|
||||
686→ 686→ milestonesClaimed: [],
|
||||
687→ 687→ });
|
||||
688→ 688→ if (typeof localStorage === "undefined") return fresh();
|
||||
689→ 689→ try {
|
||||
690→ 690→ const raw = localStorage.getItem(BEACON_CHAIN_KEY);
|
||||
691→ 691→ if (!raw) return fresh();
|
||||
692→ 692→ const s = JSON.parse(raw) as Partial<BeaconChainState>;
|
||||
693→ 693→ return {
|
||||
694→ 694→ lastCompletedDateKey: s.lastCompletedDateKey ?? "",
|
||||
695→ 695→ currentStreak: s.currentStreak ?? 0,
|
||||
696→ 696→ longestStreak: s.longestStreak ?? 0,
|
||||
697→ 697→ totalCompletions: s.totalCompletions ?? 0,
|
||||
698→ 698→ graceUsed: s.graceUsed ?? 0,
|
||||
699→ 699→ milestonesClaimed: Array.isArray(s.milestonesClaimed)
|
||||
700→ 700→ ? [...s.milestonesClaimed]
|
||||
701→ 701→ : [],
|
||||
702→ 702→ };
|
||||
703→ 703→ } catch {
|
||||
704→ 704→ return fresh();
|
||||
705→ 705→ }
|
||||
706→ 706→}
|
||||
707→ 707→
|
||||
708→ 708→/** 保存信标链状态 */
|
||||
709→ 709→export function saveChainState(state: BeaconChainState): void {
|
||||
710→ 710→ if (typeof localStorage === "undefined") return;
|
||||
711→ 711→ localStorage.setItem(BEACON_CHAIN_KEY, JSON.stringify(state));
|
||||
712→ 712→}
|
||||
713→ 713→
|
||||
714→ 714→/** dateKey → UTC 0 点时间戳 */
|
||||
715→ 715→function dateKeyToTimestamp(dateKey: string): number {
|
||||
716→ 716→ const [y, m, d] = dateKey.split("-").map(Number);
|
||||
717→ 717→ return Date.UTC(y, m - 1, d, 0, 0, 0);
|
||||
718→ 718→}
|
||||
719→ 719→
|
||||
720→ 720→/** 计算 b - a 相差的天数(UTC 0 点对齐) */
|
||||
721→ 721→function dateKeyDiffDays(a: string, b: string): number {
|
||||
722→ 722→ if (!a || !b) return Number.MAX_SAFE_INTEGER;
|
||||
723→ 723→ return Math.round((dateKeyToTimestamp(b) - dateKeyToTimestamp(a)) / 86400000);
|
||||
724→ 724→}
|
||||
725→ 725→
|
||||
726→ 726→/**
|
||||
727→ 727→ * 记录一次日挑战完成(核心逻辑)。
|
||||
728→ 728→ * - dateKey === lastCompletedDateKey:同一天重复完成,忽略,返回 newMilestones: []
|
||||
729→ 729→ * - dateKey 是 lastCompletedDateKey 的次日:currentStreak++
|
||||
730→ 730→ * - dateKey 是 lastCompletedDateKey 的后两天(隔了一天 miss)且 graceUsed < 1:续命一次,currentStreak++
|
||||
731→ 731→ * - 其他:currentStreak = 1(断链重来),graceUsed = 0
|
||||
732→ 732→ * 更新 longestStreak 与 totalCompletions。
|
||||
733→ 733→ * @returns { state, newMilestones } 刚达成但未领取的里程碑数组
|
||||
734→ 734→ */
|
||||
735→ 735→export function recordChainCompletion(dateKey: string): {
|
||||
736→ 736→ state: BeaconChainState;
|
||||
737→ 737→ newMilestones: number[];
|
||||
738→ 738→} {
|
||||
739→ 739→ const state = loadChainState();
|
||||
740→ 740→
|
||||
741→ 741→ // 同一天重复完成:忽略
|
||||
742→ 742→ if (state.lastCompletedDateKey === dateKey) {
|
||||
743→ 743→ return { state, newMilestones: [] };
|
||||
744→ 744→ }
|
||||
745→ 745→
|
||||
746→ 746→ let next: BeaconChainState;
|
||||
747→ 747→
|
||||
748→ 748→ if (state.lastCompletedDateKey === "") {
|
||||
749→ 749→ // 首次完成
|
||||
750→ 750→ next = {
|
||||
751→ 751→ ...state,
|
||||
752→ 752→ lastCompletedDateKey: dateKey,
|
||||
753→ 753→ currentStreak: 1,
|
||||
754→ 754→ longestStreak: Math.max(state.longestStreak, 1),
|
||||
755→ 755→ totalCompletions: state.totalCompletions + 1,
|
||||
756→ 756→ };
|
||||
757→ 757→ } else {
|
||||
758→ 758→ const diff = dateKeyDiffDays(state.lastCompletedDateKey, dateKey);
|
||||
759→ 759→ if (diff === 1) {
|
||||
760→ 760→ // 次日:链 +1
|
||||
761→ 761→ const newStreak = state.currentStreak + 1;
|
||||
762→ 762→ next = {
|
||||
763→ 763→ ...state,
|
||||
764→ 764→ lastCompletedDateKey: dateKey,
|
||||
765→ 765→ currentStreak: newStreak,
|
||||
766→ 766→ longestStreak: Math.max(state.longestStreak, newStreak),
|
||||
767→ 767→ totalCompletions: state.totalCompletions + 1,
|
||||
768→ 768→ };
|
||||
769→ 769→ } else if (diff === 2 && state.graceUsed < 1) {
|
||||
770→ 770→ // 隔一天 miss,续命一次
|
||||
771→ 771→ const newStreak = state.currentStreak + 1;
|
||||
772→ 772→ next = {
|
||||
773→ 773→ ...state,
|
||||
774→ 774→ lastCompletedDateKey: dateKey,
|
||||
775→ 775→ currentStreak: newStreak,
|
||||
776→ 776→ longestStreak: Math.max(state.longestStreak, newStreak),
|
||||
777→ 777→ totalCompletions: state.totalCompletions + 1,
|
||||
778→ 778→ graceUsed: state.graceUsed + 1,
|
||||
779→ 779→ };
|
||||
780→ 780→ } else {
|
||||
781→ 781→ // 断链重来
|
||||
782→ 782→ next = {
|
||||
783→ 783→ ...state,
|
||||
784→ 784→ lastCompletedDateKey: dateKey,
|
||||
785→ 785→ currentStreak: 1,
|
||||
786→ 786→ totalCompletions: state.totalCompletions + 1,
|
||||
787→ 787→ graceUsed: 0,
|
||||
788→ 788→ longestStreak: Math.max(state.longestStreak, 1),
|
||||
789→ 789→ };
|
||||
790→ 790→ }
|
||||
791→ 791→ }
|
||||
792→ 792→
|
||||
793→ 793→ // 检查新里程碑(刚达成但未领取)
|
||||
794→ 794→ const newMilestones: number[] = [];
|
||||
795→ 795→ for (const m of BEACON_CHAIN_MILESTONES) {
|
||||
796→ 796→ if (
|
||||
797→ 797→ next.currentStreak >= m &&
|
||||
798→ 798→ !next.milestonesClaimed.includes(m)
|
||||
799→ 799→ ) {
|
||||
800→ 800→ newMilestones.push(m);
|
||||
801→ 801→ }
|
||||
802→ 802→ }
|
||||
803→ 803→
|
||||
804→ 804→ saveChainState(next);
|
||||
805→ 805→ return { state: next, newMilestones };
|
||||
806→ 806→}
|
||||
807→ 807→
|
||||
808→ 808→/** 领取里程碑奖励,加入 milestonesClaimed */
|
||||
809→ 809→export function claimChainMilestone(milestone: number): {
|
||||
810→ 810→ rewardInsight: number;
|
||||
811→ 811→ rewardContact: number;
|
||||
812→ 812→ label: string;
|
||||
813→ 813→ state: BeaconChainState;
|
||||
814→ 814→} {
|
||||
815→ 815→ const reward = BEACON_CHAIN_REWARDS.find((r) => r.milestone === milestone);
|
||||
816→ 816→ const state = loadChainState();
|
||||
817→ 817→ if (
|
||||
818→ 818→ !reward ||
|
||||
819→ 819→ state.milestonesClaimed.includes(milestone) ||
|
||||
820→ 820→ state.currentStreak < milestone
|
||||
821→ 821→ ) {
|
||||
822→ 822→ return { rewardInsight: 0, rewardContact: 0, label: "", state };
|
||||
823→ 823→ }
|
||||
824→ 824→ const next: BeaconChainState = {
|
||||
825→ 825→ ...state,
|
||||
826→ 826→ milestonesClaimed: [...state.milestonesClaimed, milestone],
|
||||
827→ 827→ };
|
||||
828→ 828→ saveChainState(next);
|
||||
829→ 829→ return {
|
||||
830→ 830→ rewardInsight: reward.rewardInsight,
|
||||
831→ 831→ rewardContact: reward.rewardContact,
|
||||
832→ 832→ label: reward.label,
|
||||
833→ 833→ state: next,
|
||||
834→ 834→ };
|
||||
835→ 835→}
|
||||
836→ 836→
|
||||
837→ 837→/** 返回下一个目标里程碑(如 streak=5 → 7;streak=30+ → null) */
|
||||
838→ 838→export function getNextMilestone(streak: number): number | null {
|
||||
839→ 839→ for (const m of BEACON_CHAIN_MILESTONES) {
|
||||
840→ 840→ if (streak < m) return m;
|
||||
841→ 841→ }
|
||||
842→ 842→ return null;
|
||||
843→ 843→}
|
||||
844→ 844→
|
||||
845→ 845→/**
|
||||
846→ 846→ * 返回信标链进度信息(用于 UI 进度条)。
|
||||
847→ 847→ * - current:当前连续天数
|
||||
848→ 848→ * - next:下一个目标里程碑(null 表示已通关全部)
|
||||
849→ 849→ * - progressPct:当前进度(基于上一个里程碑 → 下一个里程碑)
|
||||
850→ 850→ */
|
||||
851→ 851→export function getChainProgress(streak: number): {
|
||||
852→ 852→ current: number;
|
||||
853→ 853→ next: number | null;
|
||||
854→ 854→ prev: number;
|
||||
855→ 855→ progressPct: number;
|
||||
856→ 856→} {
|
||||
857→ 857→ const next = getNextMilestone(streak);
|
||||
858→ 858→ let prev = 0;
|
||||
859→ 859→ for (const m of BEACON_CHAIN_MILESTONES) {
|
||||
860→ 860→ if (streak >= m) prev = m;
|
||||
861→ 861→ }
|
||||
862→ 862→ if (next === null) {
|
||||
863→ 863→ return { current: streak, next: null, prev, progressPct: 100 };
|
||||
864→ 864→ }
|
||||
865→ 865→ const span = next - prev;
|
||||
866→ 866→ const done = streak - prev;
|
||||
867→ 867→ const pct = span > 0 ? Math.round((done / span) * 100) : 100;
|
||||
868→ 868→ return {
|
||||
869→ 869→ current: streak,
|
||||
870→ 870→ next,
|
||||
871→ 871→ prev,
|
||||
872→ 872→ progressPct: Math.min(100, Math.max(0, pct)),
|
||||
873→ 873→ };
|
||||
874→ 874→}
|
||||
875→ 875→
|
||||
@@ -0,0 +1,754 @@
|
||||
1→"use client";
|
||||
2→// 回响星核 / Echo Nexus — 深空信标面板
|
||||
3→// v0.5:每日挑战 + 本地排行榜
|
||||
4→// v0.8:周挑战 + 信标链(连续完成奖励)
|
||||
5→import { useState, useEffect, useCallback } from "react";
|
||||
6→import { useGameStore } from "@/store/gameStore";
|
||||
7→import { useToast } from "@/hooks/use-toast";
|
||||
8→import { sfx } from "@/hooks/useAudio";
|
||||
9→import {
|
||||
10→ generateDailyChallenge,
|
||||
11→ generateWeeklyChallenge,
|
||||
12→ loadDailyProgress,
|
||||
13→ loadWeeklyProgress,
|
||||
14→ loadLeaderboard,
|
||||
15→ loadChainState,
|
||||
16→ claimBeaconReward,
|
||||
17→ BEACON_DIFFICULTY,
|
||||
18→ BEACON_TYPE_META,
|
||||
19→ BEACON_CHAIN_MILESTONES,
|
||||
20→ BEACON_CHAIN_REWARDS,
|
||||
21→ getWeekKey,
|
||||
22→ getTodayKey,
|
||||
23→ getNextMilestone,
|
||||
24→ getChainProgress,
|
||||
25→ msUntilNextDay,
|
||||
26→ msUntilNextWeek,
|
||||
27→ formatCountdown,
|
||||
28→ type BeaconDailyChallenge,
|
||||
29→ type BeaconDailyProgress,
|
||||
30→ type BeaconWeeklyChallenge,
|
||||
31→ type BeaconWeeklyProgress,
|
||||
32→ type BeaconScoreEntry,
|
||||
33→ type BeaconChallengeType,
|
||||
34→ type BeaconDifficulty,
|
||||
35→ type BeaconChainState,
|
||||
36→} from "@/lib/game/beacon";
|
||||
37→import { formatNum } from "@/lib/game/config";
|
||||
38→import { Button } from "@/components/ui/button";
|
||||
39→import { Progress } from "@/components/ui/progress";
|
||||
40→import {
|
||||
41→ Radio,
|
||||
42→ Clock,
|
||||
43→ Trophy,
|
||||
44→ Sparkles,
|
||||
45→ Award,
|
||||
46→ Crown,
|
||||
47→ Medal,
|
||||
48→ Link2,
|
||||
49→ Flame,
|
||||
50→ Zap,
|
||||
51→} from "lucide-react";
|
||||
52→
|
||||
53→const DIFFICULTY_ORDER: BeaconDifficulty[] = ["routine", "anomaly", "singular"];
|
||||
54→
|
||||
55→function rankBadge(rank: number) {
|
||||
56→ if (rank === 0) return { icon: Crown, color: "#fbbf24", label: "1st" };
|
||||
57→ if (rank === 1) return { icon: Medal, color: "#cbd5e1", label: "2nd" };
|
||||
58→ if (rank === 2) return { icon: Award, color: "#f97316", label: "3rd" };
|
||||
59→ return null;
|
||||
60→}
|
||||
61→
|
||||
62→export function BeaconPanel() {
|
||||
63→ const grantBeaconReward = useGameStore((s) => s.grantBeaconReward);
|
||||
64→ const claimWeeklyBeacon = useGameStore((s) => s.claimWeeklyBeacon);
|
||||
65→ const claimChainReward = useGameStore((s) => s.claimChainReward);
|
||||
66→ const { toast } = useToast();
|
||||
67→
|
||||
68→ const [challenge, setChallenge] = useState<BeaconDailyChallenge | null>(null);
|
||||
69→ const [progress, setProgress] = useState<BeaconDailyProgress | null>(null);
|
||||
70→ const [weeklyChallenge, setWeeklyChallenge] =
|
||||
71→ useState<BeaconWeeklyChallenge | null>(null);
|
||||
72→ const [weeklyProgress, setWeeklyProgress] =
|
||||
73→ useState<BeaconWeeklyProgress | null>(null);
|
||||
74→ const [chainState, setChainState] = useState<BeaconChainState | null>(null);
|
||||
75→ const [leaderboard, setLeaderboard] = useState<BeaconScoreEntry[]>([]);
|
||||
76→ const [dayCountdown, setDayCountdown] = useState("00:00:00");
|
||||
77→ const [weekCountdown, setWeekCountdown] = useState("00:00:00");
|
||||
78→ const [now, setNow] = useState(Date.now());
|
||||
79→
|
||||
80→ // 初始化 + 每秒刷新(进度 + 倒计时)
|
||||
81→ useEffect(() => {
|
||||
82→ setChallenge(generateDailyChallenge());
|
||||
83→ setProgress(loadDailyProgress());
|
||||
84→ setWeeklyChallenge(generateWeeklyChallenge());
|
||||
85→ setWeeklyProgress(loadWeeklyProgress());
|
||||
86→ setChainState(loadChainState());
|
||||
87→ setLeaderboard(loadLeaderboard());
|
||||
88→ const id = setInterval(() => {
|
||||
89→ setNow(Date.now());
|
||||
90→ setProgress(loadDailyProgress());
|
||||
91→ setWeeklyProgress(loadWeeklyProgress());
|
||||
92→ setChainState(loadChainState());
|
||||
93→ setChallenge((c) => c ?? generateDailyChallenge());
|
||||
94→ setWeeklyChallenge((c) => c ?? generateWeeklyChallenge());
|
||||
95→ }, 1000);
|
||||
96→ return () => clearInterval(id);
|
||||
97→ }, []);
|
||||
98→
|
||||
99→ useEffect(() => {
|
||||
100→ setDayCountdown(formatCountdown(msUntilNextDay(new Date(now))));
|
||||
101→ setWeekCountdown(formatCountdown(msUntilNextWeek(new Date(now))));
|
||||
102→ }, [now]);
|
||||
103→
|
||||
104→ const handleClaimDaily = useCallback(() => {
|
||||
105→ if (!challenge || !progress) return;
|
||||
106→ if (progress.completedAt === null || progress.claimed) return;
|
||||
107→ const res = claimBeaconReward(challenge, progress);
|
||||
108→ setLeaderboard(res.leaderboard);
|
||||
109→ setProgress(loadDailyProgress());
|
||||
110→ // 发放奖励到游戏状态
|
||||
111→ grantBeaconReward(res.rewardInsight, res.rewardContact);
|
||||
112→ sfx("achievement");
|
||||
113→ toast({
|
||||
114→ title: "✦ 每日信标奖励已领取",
|
||||
115→ description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
|
||||
116→ 1
|
||||
117→ )} 接触 · 得分 ${res.score}`,
|
||||
118→ });
|
||||
119→ }, [challenge, progress, toast, grantBeaconReward]);
|
||||
120→
|
||||
121→ const handleClaimWeekly = useCallback(() => {
|
||||
122→ if (!weeklyChallenge || !weeklyProgress) return;
|
||||
123→ if (weeklyProgress.completedAt === null || weeklyProgress.claimed) return;
|
||||
124→ const res = claimWeeklyBeacon();
|
||||
125→ setLeaderboard(loadLeaderboard());
|
||||
126→ setWeeklyProgress(loadWeeklyProgress());
|
||||
127→ if (res.rewardInsight > 0 || res.rewardContact > 0) {
|
||||
128→ sfx("achievement");
|
||||
129→ toast({
|
||||
130→ title: "✦ 周挑战奖励已领取",
|
||||
131→ description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
|
||||
132→ 1
|
||||
133→ )} 接触 · 得分 ${res.score}`,
|
||||
134→ });
|
||||
135→ }
|
||||
136→ }, [weeklyChallenge, weeklyProgress, toast, claimWeeklyBeacon]);
|
||||
137→
|
||||
138→ const handleClaimChain = useCallback(
|
||||
139→ (milestone: number) => {
|
||||
140→ const reward = BEACON_CHAIN_REWARDS.find(
|
||||
141→ (r) => r.milestone === milestone
|
||||
142→ );
|
||||
143→ const res = claimChainReward(milestone);
|
||||
144→ setChainState(loadChainState());
|
||||
145→ if (res.ok) {
|
||||
146→ sfx("achievement");
|
||||
147→ toast({
|
||||
148→ title: `✦ ${res.label || reward?.label || "里程碑"} 已领取`,
|
||||
149→ description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
|
||||
150→ 1
|
||||
151→ )} 接触`,
|
||||
152→ });
|
||||
153→ }
|
||||
154→ },
|
||||
155→ [toast, claimChainReward]
|
||||
156→ );
|
||||
157→
|
||||
158→ if (!challenge || !progress || !weeklyChallenge || !weeklyProgress || !chainState) {
|
||||
159→ return (
|
||||
160→ <div className="flex items-center justify-center h-full text-xs text-muted-foreground/60">
|
||||
161→ 正在校准深空信标…
|
||||
162→ </div>
|
||||
163→ );
|
||||
164→ }
|
||||
165→
|
||||
166→ const diffMeta = BEACON_DIFFICULTY[challenge.difficulty];
|
||||
167→ const typeMeta = BEACON_TYPE_META[challenge.type];
|
||||
168→ const pct = Math.min(100, (progress.progress / challenge.goal) * 100);
|
||||
169→ const isCompleted = progress.completedAt !== null;
|
||||
170→ const isClaimed = progress.claimed;
|
||||
171→ const canClaim = isCompleted && !isClaimed;
|
||||
172→
|
||||
173→ // 周挑战派生量
|
||||
174→ const wDiffMeta = BEACON_DIFFICULTY[weeklyChallenge.difficulty];
|
||||
175→ const wTypeMeta = BEACON_TYPE_META[weeklyChallenge.type];
|
||||
176→ const wPct = Math.min(100, (weeklyProgress.progress / weeklyChallenge.goal) * 100);
|
||||
177→ const wCompleted = weeklyProgress.completedAt !== null;
|
||||
178→ const wClaimed = weeklyProgress.claimed;
|
||||
179→ const wCanClaim = wCompleted && !wClaimed;
|
||||
180→
|
||||
181→ // 信标链派生量
|
||||
182→ const chainProgress = getChainProgress(chainState.currentStreak);
|
||||
183→ const nextMilestone = getNextMilestone(chainState.currentStreak);
|
||||
184→ const todayKey = getTodayKey();
|
||||
185→ const completedToday = chainState.lastCompletedDateKey === todayKey;
|
||||
186→
|
||||
187→ return (
|
||||
188→ <div className="flex flex-col gap-2.5 h-full overflow-y-auto echo-scroll pr-0.5">
|
||||
189→ <style jsx global>{`
|
||||
190→ .echo-scroll::-webkit-scrollbar { width: 4px; }
|
||||
191→ .echo-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 2px; }
|
||||
192→ .echo-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||
193→ @keyframes beacon-pulse-ring {
|
||||
194→ 0% { transform: scale(0.8); opacity: 0.8; }
|
||||
195→ 100% { transform: scale(2.2); opacity: 0; }
|
||||
196→ }
|
||||
197→ @keyframes beacon-glow {
|
||||
198→ 0%, 100% { box-shadow: 0 0 18px ${diffMeta.glow}, inset 0 0 12px ${diffMeta.glow}; }
|
||||
199→ 50% { box-shadow: 0 0 32px ${diffMeta.glow}, inset 0 0 20px ${diffMeta.glow}; }
|
||||
200→ }
|
||||
201→ @keyframes weekly-glow {
|
||||
202→ 0%, 100% { box-shadow: 0 0 18px rgba(232,121,249,0.35), inset 0 0 12px rgba(232,121,249,0.25); }
|
||||
203→ 50% { box-shadow: 0 0 32px rgba(232,121,249,0.5), inset 0 0 20px rgba(232,121,249,0.35); }
|
||||
204→ }
|
||||
205→ @keyframes chain-milestone-pulse {
|
||||
206→ 0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(251,113,133,0.55); }
|
||||
207→ 50% { transform: scale(1.06); box-shadow: 0 0 0 8px rgba(251,113,133,0); }
|
||||
208→ }
|
||||
209→ .chain-milestone-reachable {
|
||||
210→ animation: chain-milestone-pulse 1.8s ease-in-out infinite;
|
||||
211→ }
|
||||
212→ @keyframes chain-streak-flux {
|
||||
213→ 0%, 100% { background-position: 0% 50%; }
|
||||
214→ 50% { background-position: 100% 50%; }
|
||||
215→ }
|
||||
216→ .chain-streak-text {
|
||||
217→ background: linear-gradient(90deg, #fbbf24, #fb7185, #fbbf24);
|
||||
218→ background-size: 200% 100%;
|
||||
219→ -webkit-background-clip: text;
|
||||
220→ background-clip: text;
|
||||
221→ -webkit-text-fill-color: transparent;
|
||||
222→ animation: chain-streak-flux 4s ease-in-out infinite;
|
||||
223→ }
|
||||
224→ `}</style>
|
||||
225→
|
||||
226→ {/* 头部:信标 + 倒计时 */}
|
||||
227→ <div className="flex items-center justify-between">
|
||||
228→ <h3 className="text-sm font-semibold flex items-center gap-1.5">
|
||||
229→ <Radio className="h-4 w-4 text-fuchsia-400" />
|
||||
230→ 深空信标
|
||||
231→ </h3>
|
||||
232→ <div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
|
||||
233→ <Clock className="h-3 w-3" />
|
||||
234→ 次日重置
|
||||
235→ <span className="font-mono text-fuchsia-300/90">{dayCountdown}</span>
|
||||
236→ </div>
|
||||
237→ </div>
|
||||
238→
|
||||
239→ {/* 每日挑战卡片 */}
|
||||
240→ <div
|
||||
241→ className="relative rounded-xl border p-3 overflow-hidden"
|
||||
242→ style={{
|
||||
243→ borderColor: `${diffMeta.color}55`,
|
||||
244→ background: `linear-gradient(135deg, ${diffMeta.color}1f, rgba(0,0,0,0.45))`,
|
||||
245→ animation: isCompleted ? "none" : "beacon-glow 3s ease-in-out infinite",
|
||||
246→ }}
|
||||
247→ >
|
||||
248→ {/* 背景装饰:脉冲环 */}
|
||||
249→ {!isCompleted && (
|
||||
250→ <>
|
||||
251→ <div
|
||||
252→ className="absolute top-4 right-4 h-10 w-10 rounded-full pointer-events-none"
|
||||
253→ style={{
|
||||
254→ border: `1.5px solid ${diffMeta.color}`,
|
||||
255→ animation: "beacon-pulse-ring 2.5s ease-out infinite",
|
||||
256→ }}
|
||||
257→ />
|
||||
258→ <div
|
||||
259→ className="absolute top-4 right-4 h-10 w-10 rounded-full pointer-events-none"
|
||||
260→ style={{
|
||||
261→ border: `1.5px solid ${diffMeta.color}`,
|
||||
262→ animation: "beacon-pulse-ring 2.5s ease-out infinite 1.25s",
|
||||
263→ }}
|
||||
264→ />
|
||||
265→ </>
|
||||
266→ )}
|
||||
267→
|
||||
268→ <div className="relative">
|
||||
269→ {/* 难度 + 类型 标签 */}
|
||||
270→ <div className="flex items-center gap-1.5 mb-2">
|
||||
271→ <span
|
||||
272→ className="text-[10px] font-mono px-1.5 py-0.5 rounded flex items-center gap-1"
|
||||
273→ style={{ background: `${diffMeta.color}22`, color: diffMeta.color, border: `1px solid ${diffMeta.color}55` }}
|
||||
274→ >
|
||||
275→ <span>{diffMeta.icon}</span>
|
||||
276→ {diffMeta.label}
|
||||
277→ </span>
|
||||
278→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-white/5 text-muted-foreground border border-white/10">
|
||||
279→ {typeMeta.icon} {typeMeta.label}
|
||||
280→ </span>
|
||||
281→ {isCompleted && (
|
||||
282→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 flex items-center gap-1">
|
||||
283→ <Sparkles className="h-2.5 w-2.5" /> 已完成
|
||||
284→ </span>
|
||||
285→ )}
|
||||
286→ </div>
|
||||
287→
|
||||
288→ {/* 挑战标题 */}
|
||||
289→ <h4 className="text-sm font-semibold mb-1" style={{ color: diffMeta.color, textShadow: `0 0 10px ${diffMeta.glow}` }}>
|
||||
290→ {challenge.title}
|
||||
291→ </h4>
|
||||
292→ <p className="text-[11px] text-muted-foreground/80 leading-relaxed mb-2.5">
|
||||
293→ {challenge.desc}
|
||||
294→ </p>
|
||||
295→
|
||||
296→ {/* 进度条 */}
|
||||
297→ <div className="mb-2">
|
||||
298→ <div className="flex items-center justify-between mb-1">
|
||||
299→ <span className="text-[10px] text-muted-foreground/70">进度</span>
|
||||
300→ <span className="text-[11px] font-mono font-semibold" style={{ color: diffMeta.color }}>
|
||||
301→ {Math.min(progress.progress, challenge.goal)} / {challenge.goal} {typeMeta.unit}
|
||||
302→ </span>
|
||||
303→ </div>
|
||||
304→ <Progress
|
||||
305→ value={pct}
|
||||
306→ className="h-2 bg-black/40"
|
||||
307→ style={{
|
||||
308→ ["--progress-color" as string]: diffMeta.color,
|
||||
309→ }}
|
||||
310→ />
|
||||
311→ </div>
|
||||
312→
|
||||
313→ {/* 奖励 + 领取按钮 */}
|
||||
314→ <div className="flex items-center justify-between gap-2">
|
||||
315→ <div className="flex items-center gap-2 text-[10px]">
|
||||
316→ <span className="text-muted-foreground/60">奖励:</span>
|
||||
317→ {challenge.rewardInsight > 0 && (
|
||||
318→ <span className="text-amber-300 font-mono">+{formatNum(challenge.rewardInsight)}洞见</span>
|
||||
319→ )}
|
||||
320→ <span className="text-fuchsia-300 font-mono">+{challenge.rewardContact.toFixed(1)}接触</span>
|
||||
321→ </div>
|
||||
322→ <Button
|
||||
323→ onClick={handleClaimDaily}
|
||||
324→ disabled={!canClaim}
|
||||
325→ size="sm"
|
||||
326→ className="h-7 px-3 text-[11px] border-0"
|
||||
327→ style={{
|
||||
328→ background: canClaim
|
||||
329→ ? `linear-gradient(90deg, ${diffMeta.color}, ${diffMeta.color}cc)`
|
||||
330→ : `${diffMeta.color}1a`,
|
||||
331→ color: canClaim ? "#022c22" : `${diffMeta.color}99`,
|
||||
332→ }}
|
||||
333→ >
|
||||
334→ {isClaimed ? "已领取" : canClaim ? "领取奖励" : isCompleted ? "已领取" : "进行中…"}
|
||||
335→ </Button>
|
||||
336→ </div>
|
||||
337→
|
||||
338→ {/* 完成时长 */}
|
||||
339→ {isCompleted && progress.durationSec > 0 && (
|
||||
340→ <div className="mt-1.5 text-[10px] text-muted-foreground/50 text-center">
|
||||
341→ 完成用时 {Math.floor(progress.durationSec / 60)}分{progress.durationSec % 60}秒
|
||||
342→ </div>
|
||||
343→ )}
|
||||
344→ </div>
|
||||
345→ </div>
|
||||
346→
|
||||
347→ {/* 周挑战 + 信标链(桌面端并排,移动端单列) */}
|
||||
348→ <div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
||||
349→ {/* ===== 周挑战卡片(fuchsia 主题) ===== */}
|
||||
350→ <div
|
||||
351→ className="relative rounded-xl border p-3 overflow-hidden"
|
||||
352→ style={{
|
||||
353→ borderColor: "rgba(232,121,249,0.4)",
|
||||
354→ background: `linear-gradient(135deg, rgba(232,121,249,0.12), rgba(0,0,0,0.45))`,
|
||||
355→ animation: wCompleted ? "none" : "weekly-glow 3.5s ease-in-out infinite",
|
||||
356→ }}
|
||||
357→ >
|
||||
358→ {/* 头部:标题 + weekKey + 倒计时 */}
|
||||
359→ <div className="flex items-center justify-between mb-2">
|
||||
360→ <div className="flex items-center gap-1.5">
|
||||
361→ <Zap className="h-3.5 w-3.5 text-fuchsia-400" />
|
||||
362→ <span className="text-xs font-semibold text-fuchsia-200">
|
||||
363→ 周挑战 · WEEKLY
|
||||
364→ </span>
|
||||
365→ </div>
|
||||
366→ <div className="flex items-center gap-1 text-[9px] text-muted-foreground/70">
|
||||
367→ <span className="font-mono text-fuchsia-300/80">
|
||||
368→ {weeklyChallenge.weekKey}
|
||||
369→ </span>
|
||||
370→ <Clock className="h-2.5 w-2.5" />
|
||||
371→ <span className="font-mono text-fuchsia-300/90">{weekCountdown}</span>
|
||||
372→ </div>
|
||||
373→ </div>
|
||||
374→
|
||||
375→ {/* 难度 + 类型 标签 */}
|
||||
376→ <div className="flex items-center gap-1.5 mb-2">
|
||||
377→ <span
|
||||
378→ className="text-[10px] font-mono px-1.5 py-0.5 rounded flex items-center gap-1"
|
||||
379→ style={{ background: `${wDiffMeta.color}22`, color: wDiffMeta.color, border: `1px solid ${wDiffMeta.color}55` }}
|
||||
380→ >
|
||||
381→ <span>{wDiffMeta.icon}</span>
|
||||
382→ {wDiffMeta.label}
|
||||
383→ </span>
|
||||
384→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-white/5 text-muted-foreground border border-white/10">
|
||||
385→ {wTypeMeta.icon} {wTypeMeta.label}
|
||||
386→ </span>
|
||||
387→ {wCompleted && (
|
||||
388→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 flex items-center gap-1">
|
||||
389→ <Sparkles className="h-2.5 w-2.5" /> 已完成
|
||||
390→ </span>
|
||||
391→ )}
|
||||
392→ </div>
|
||||
393→
|
||||
394→ {/* 标题 */}
|
||||
395→ <h4
|
||||
396→ className="text-sm font-semibold mb-1"
|
||||
397→ style={{ color: wDiffMeta.color, textShadow: `0 0 10px ${wDiffMeta.glow}` }}
|
||||
398→ >
|
||||
399→ {weeklyChallenge.title}
|
||||
400→ </h4>
|
||||
401→ <p className="text-[10px] text-muted-foreground/80 leading-relaxed mb-2">
|
||||
402→ {weeklyChallenge.desc}
|
||||
403→ </p>
|
||||
404→
|
||||
405→ {/* 进度条 */}
|
||||
406→ <div className="mb-2">
|
||||
407→ <div className="flex items-center justify-between mb-1">
|
||||
408→ <span className="text-[10px] text-muted-foreground/70">进度</span>
|
||||
409→ <span className="text-[11px] font-mono font-semibold" style={{ color: wDiffMeta.color }}>
|
||||
410→ {Math.min(weeklyProgress.progress, weeklyChallenge.goal)} / {weeklyChallenge.goal} {wTypeMeta.unit}
|
||||
411→ </span>
|
||||
412→ </div>
|
||||
413→ <Progress
|
||||
414→ value={wPct}
|
||||
415→ className="h-2 bg-black/40"
|
||||
416→ style={{
|
||||
417→ ["--progress-color" as string]: wDiffMeta.color,
|
||||
418→ }}
|
||||
419→ />
|
||||
420→ </div>
|
||||
421→
|
||||
422→ {/* 奖励 + 领取按钮 */}
|
||||
423→ <div className="flex items-center justify-between gap-2">
|
||||
424→ <div className="flex items-center gap-1.5 text-[10px]">
|
||||
425→ <span className="text-muted-foreground/60">奖励:</span>
|
||||
426→ {weeklyChallenge.rewardInsight > 0 && (
|
||||
427→ <span className="text-amber-300 font-mono">
|
||||
428→ +{formatNum(weeklyChallenge.rewardInsight)}洞见
|
||||
429→ </span>
|
||||
430→ )}
|
||||
431→ <span className="text-fuchsia-300 font-mono">
|
||||
432→ +{weeklyChallenge.rewardContact.toFixed(1)}接触
|
||||
433→ </span>
|
||||
434→ </div>
|
||||
435→ <Button
|
||||
436→ onClick={handleClaimWeekly}
|
||||
437→ disabled={!wCanClaim}
|
||||
438→ size="sm"
|
||||
439→ className="h-7 px-3 text-[11px] border-0"
|
||||
440→ style={{
|
||||
441→ background: wCanClaim
|
||||
442→ ? `linear-gradient(90deg, #34d399, #34d399cc)`
|
||||
443→ : "rgba(232,121,249,0.10)",
|
||||
444→ color: wCanClaim
|
||||
445→ ? "#022c22"
|
||||
446→ : "rgba(232,121,249,0.55)",
|
||||
447→ }}
|
||||
448→ >
|
||||
449→ {wClaimed
|
||||
450→ ? "已领取"
|
||||
451→ : wCanClaim
|
||||
452→ ? "领取奖励"
|
||||
453→ : wCompleted
|
||||
454→ ? "已领取"
|
||||
455→ : "进行中…"}
|
||||
456→ </Button>
|
||||
457→ </div>
|
||||
458→
|
||||
459→ {wCompleted && weeklyProgress.durationSec > 0 && (
|
||||
460→ <div className="mt-1.5 text-[10px] text-muted-foreground/50 text-center">
|
||||
461→ 完成用时 {Math.floor(weeklyProgress.durationSec / 60)}分{weeklyProgress.durationSec % 60}秒
|
||||
462→ </div>
|
||||
463→ )}
|
||||
464→ </div>
|
||||
465→
|
||||
466→ {/* ===== 信标链卡片(amber→rose 渐变) ===== */}
|
||||
467→ <div
|
||||
468→ className="relative rounded-xl border p-3 overflow-hidden"
|
||||
469→ style={{
|
||||
470→ borderColor: "rgba(251,191,36,0.35)",
|
||||
471→ background: `linear-gradient(135deg, rgba(251,191,36,0.10), rgba(251,113,133,0.10), rgba(0,0,0,0.4))`,
|
||||
472→ }}
|
||||
473→ >
|
||||
474→ {/* 头部:标题 + 当前连续天数 */}
|
||||
475→ <div className="flex items-center justify-between mb-2">
|
||||
476→ <div className="flex items-center gap-1.5">
|
||||
477→ <Link2 className="h-3.5 w-3.5 text-amber-400" />
|
||||
478→ <span className="text-xs font-semibold text-amber-200">
|
||||
479→ 信标链 · CHAIN
|
||||
480→ </span>
|
||||
481→ </div>
|
||||
482→ <div className="flex items-baseline gap-1">
|
||||
483→ <Flame className="h-3 w-3 text-rose-400" />
|
||||
484→ <span className="text-[10px] text-muted-foreground/60">连续</span>
|
||||
485→ <span className="chain-streak-text text-2xl font-bold font-mono leading-none">
|
||||
486→ {chainState.currentStreak}
|
||||
487→ </span>
|
||||
488→ <span className="text-[10px] text-muted-foreground/60">天</span>
|
||||
489→ </div>
|
||||
490→ </div>
|
||||
491→
|
||||
492→ {/* 今日完成状态 */}
|
||||
493→ <div className="mb-2 flex items-center justify-between">
|
||||
494→ <span className="text-[10px] text-muted-foreground/70">
|
||||
495→ {completedToday
|
||||
496→ ? "今日已贡献"
|
||||
497→ : "今日尚未完成日挑战"}
|
||||
498→ </span>
|
||||
499→ <span
|
||||
500→ className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${
|
||||
501→ completedToday
|
||||
502→ ? "bg-emerald-500/15 text-emerald-300 border-emerald-500/30"
|
||||
503→ : "bg-white/5 text-muted-foreground/70 border-white/10"
|
||||
504→ }`}
|
||||
505→ >
|
||||
506→ {completedToday ? "✓ 已记录" : "○ 待完成"}
|
||||
507→ </span>
|
||||
508→ </div>
|
||||
509→
|
||||
510→ {/* 4 个里程碑节点横向排列 */}
|
||||
511→ <div className="flex items-center justify-between mb-2 relative">
|
||||
512→ {/* 节点之间的连线(背景灰) */}
|
||||
513→ <div className="absolute top-6 left-[12.5%] right-[12.5%] h-[2px] bg-white/10" />
|
||||
514→ {/* 已达成部分高亮(基于 prev→next 插值) */}
|
||||
515→ {(() => {
|
||||
516→ // 节点圆心水平位置(百分比)
|
||||
517→ const MILESTONE_POS: Record<number, number> = {
|
||||
518→ 0: 12.5,
|
||||
519→ 3: 12.5,
|
||||
520→ 7: 37.5,
|
||||
521→ 14: 62.5,
|
||||
522→ 30: 87.5,
|
||||
523→ };
|
||||
524→ const prevPos =
|
||||
525→ MILESTONE_POS[chainProgress.prev] ?? 12.5;
|
||||
526→ const nextPos =
|
||||
527→ chainProgress.next !== null
|
||||
528→ ? MILESTONE_POS[chainProgress.next] ?? 87.5
|
||||
529→ : 87.5;
|
||||
530→ const pct = chainProgress.progressPct / 100;
|
||||
531→ const activeEndPos = prevPos + (nextPos - prevPos) * pct;
|
||||
532→ const widthPct = Math.max(0, activeEndPos - 12.5);
|
||||
533→ if (widthPct <= 0) return null;
|
||||
534→ return (
|
||||
535→ <div
|
||||
536→ className="absolute top-6 h-[2px]"
|
||||
537→ style={{
|
||||
538→ left: "12.5%",
|
||||
539→ width: `${widthPct}%`,
|
||||
540→ background:
|
||||
541→ "linear-gradient(90deg, #fbbf24, #fb7185)",
|
||||
542→ boxShadow: "0 0 8px rgba(251,113,133,0.5)",
|
||||
543→ }}
|
||||
544→ />
|
||||
545→ );
|
||||
546→ })()}
|
||||
547→
|
||||
548→ {BEACON_CHAIN_MILESTONES.map((m) => {
|
||||
549→ const reward = BEACON_CHAIN_REWARDS.find((r) => r.milestone === m);
|
||||
550→ const claimed = chainState.milestonesClaimed.includes(m);
|
||||
551→ const reachable =
|
||||
552→ chainState.currentStreak >= m && !claimed;
|
||||
553→ const inProgress = chainState.currentStreak > 0 && nextMilestone === m;
|
||||
554→ // 节点配色
|
||||
555→ let nodeBg = "rgba(255,255,255,0.05)";
|
||||
556→ let nodeBorder = "rgba(255,255,255,0.15)";
|
||||
557→ let textColor = "rgba(255,255,255,0.4)";
|
||||
558→ if (claimed) {
|
||||
559→ nodeBg = "rgba(52,211,153,0.25)";
|
||||
560→ nodeBorder = "#34d399";
|
||||
561→ textColor = "#34d399";
|
||||
562→ } else if (reachable) {
|
||||
563→ nodeBg = "rgba(251,113,133,0.20)";
|
||||
564→ nodeBorder = "#fb7185";
|
||||
565→ textColor = "#fb7185";
|
||||
566→ } else if (inProgress) {
|
||||
567→ nodeBg = "rgba(251,191,36,0.20)";
|
||||
568→ nodeBorder = "#fbbf24";
|
||||
569→ textColor = "#fbbf24";
|
||||
570→ }
|
||||
571→
|
||||
572→ return (
|
||||
573→ <div
|
||||
574→ key={m}
|
||||
575→ className="flex flex-col items-center gap-1 z-10 flex-1"
|
||||
576→ >
|
||||
577→ <div
|
||||
578→ className={`w-12 h-12 rounded-full flex items-center justify-center font-mono font-bold text-sm border-2 ${
|
||||
579→ reachable ? "chain-milestone-reachable" : ""
|
||||
580→ }`}
|
||||
581→ style={{
|
||||
582→ background: nodeBg,
|
||||
583→ borderColor: nodeBorder,
|
||||
584→ color: textColor,
|
||||
585→ }}
|
||||
586→ >
|
||||
587→ {claimed ? (
|
||||
588→ <Sparkles className="h-4 w-4" />
|
||||
589→ ) : (
|
||||
590→ m
|
||||
591→ )}
|
||||
592→ </div>
|
||||
593→ <span
|
||||
594→ className="text-[9px] text-center leading-tight"
|
||||
595→ style={{ color: textColor }}
|
||||
596→ >
|
||||
597→ {reward?.label}
|
||||
598→ </span>
|
||||
599→ {claimed ? (
|
||||
600→ <span className="text-[9px] text-emerald-400/80 font-mono">
|
||||
601→ 已领
|
||||
602→ </span>
|
||||
603→ ) : reachable ? (
|
||||
604→ <Button
|
||||
605→ onClick={() => handleClaimChain(m)}
|
||||
606→ size="sm"
|
||||
607→ className="h-5 px-2 text-[9px] py-0 border-0"
|
||||
608→ style={{
|
||||
609→ background:
|
||||
610→ "linear-gradient(90deg, #fb7185, #f43f5e)",
|
||||
611→ color: "#1c0608",
|
||||
612→ }}
|
||||
613→ >
|
||||
614→ 领取
|
||||
615→ </Button>
|
||||
616→ ) : (
|
||||
617→ <span className="text-[9px] text-muted-foreground/40 font-mono">
|
||||
618→ +{reward?.rewardInsight}洞
|
||||
619→ </span>
|
||||
620→ )}
|
||||
621→ </div>
|
||||
622→ );
|
||||
623→ })}
|
||||
624→ </div>
|
||||
625→
|
||||
626→ {/* 进度条 */}
|
||||
627→ <div className="mb-2">
|
||||
628→ <div className="flex items-center justify-between mb-1">
|
||||
629→ <span className="text-[10px] text-muted-foreground/70">
|
||||
630→ {chainProgress.next === null
|
||||
631→ ? "已通关全部里程碑"
|
||||
632→ : `下一目标:${chainProgress.next} 天`}
|
||||
633→ </span>
|
||||
634→ <span className="text-[10px] font-mono text-amber-300">
|
||||
635→ {chainProgress.current}
|
||||
636→ {chainProgress.next !== null && ` / ${chainProgress.next}`} 天
|
||||
637→ </span>
|
||||
638→ </div>
|
||||
639→ <Progress
|
||||
640→ value={chainProgress.progressPct}
|
||||
641→ className="h-1.5 bg-black/40"
|
||||
642→ style={{
|
||||
643→ ["--progress-color" as string]: "#fbbf24",
|
||||
644→ }}
|
||||
645→ />
|
||||
646→ </div>
|
||||
647→
|
||||
648→ {/* 底部统计 */}
|
||||
649→ <div className="flex items-center justify-between gap-2 text-[10px]">
|
||||
650→ <div className="flex items-center gap-2">
|
||||
651→ <span className="text-muted-foreground/60">最长</span>
|
||||
652→ <span className="font-mono text-amber-300">
|
||||
653→ {chainState.longestStreak}天
|
||||
654→ </span>
|
||||
655→ </div>
|
||||
656→ <div className="flex items-center gap-2">
|
||||
657→ <span className="text-muted-foreground/60">累计</span>
|
||||
658→ <span className="font-mono text-rose-300">
|
||||
659→ {chainState.totalCompletions}次
|
||||
660→ </span>
|
||||
661→ </div>
|
||||
662→ <div className="flex items-center gap-1">
|
||||
663→ <span className="text-muted-foreground/60">续命</span>
|
||||
664→ {chainState.graceUsed >= 1 ? (
|
||||
665→ <span className="font-mono text-rose-400/80">已用</span>
|
||||
666→ ) : (
|
||||
667→ <span className="font-mono text-emerald-400/80">可用</span>
|
||||
668→ )}
|
||||
669→ </div>
|
||||
670→ </div>
|
||||
671→ </div>
|
||||
672→ </div>
|
||||
673→
|
||||
674→ {/* 本地排行榜 */}
|
||||
675→ <div className="rounded-xl border border-white/10 bg-black/30 p-2.5">
|
||||
676→ <div className="flex items-center gap-1.5 mb-2">
|
||||
677→ <Trophy className="h-3.5 w-3.5 text-amber-400" />
|
||||
678→ <span className="text-xs font-semibold">深空排行榜</span>
|
||||
679→ <span className="text-[10px] text-muted-foreground/50">本地 · Top {leaderboard.length || 0}</span>
|
||||
680→ </div>
|
||||
681→
|
||||
682→ {leaderboard.length === 0 ? (
|
||||
683→ <div className="text-center py-4 text-[11px] text-muted-foreground/40">
|
||||
684→ <Trophy className="h-6 w-6 mx-auto mb-1 opacity-30" />
|
||||
685→ 尚无记录。完成今日或本周信标即可登榜。
|
||||
686→ </div>
|
||||
687→ ) : (
|
||||
688→ <div className="space-y-0.5 max-h-[180px] overflow-y-auto echo-scroll">
|
||||
689→ {leaderboard.map((entry, i) => {
|
||||
690→ const rb = rankBadge(i);
|
||||
691→ const eDiff = BEACON_DIFFICULTY[entry.difficulty];
|
||||
692→ const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType];
|
||||
693→ const isMine = entry.dateKey === getTodayKey() || entry.dateKey === getWeekKey();
|
||||
694→ return (
|
||||
695→ <div
|
||||
696→ key={`${entry.timestamp}-${i}`}
|
||||
697→ className={`flex items-center gap-2 px-2 py-1 rounded-lg text-[11px] ${
|
||||
698→ entry.isWeekly
|
||||
699→ ? "bg-fuchsia-500/10 border border-fuchsia-500/20"
|
||||
700→ : isMine
|
||||
701→ ? "bg-fuchsia-500/10 border border-fuchsia-500/20"
|
||||
702→ : "hover:bg-white/5"
|
||||
703→ }`}
|
||||
704→ >
|
||||
705→ {/* 排名 */}
|
||||
706→ <span className="w-6 flex items-center justify-center">
|
||||
707→ {rb ? (
|
||||
708→ <rb.icon className="h-3.5 w-3.5" style={{ color: rb.color }} />
|
||||
709→ ) : (
|
||||
710→ <span className="text-muted-foreground/50 font-mono">{i + 1}</span>
|
||||
711→ )}
|
||||
712→ </span>
|
||||
713→ {/* 类型 + 难度 + 周挑战标记 */}
|
||||
714→ <span className="flex items-center gap-1 flex-1 min-w-0">
|
||||
715→ <span style={{ color: eDiff.color }} className="font-mono">{eDiff.icon}</span>
|
||||
716→ <span className="text-muted-foreground/80 truncate">{eType.label}</span>
|
||||
717→ {entry.isWeekly && (
|
||||
718→ <span className="text-[9px] font-mono px-1 rounded bg-fuchsia-500/20 text-fuchsia-300 border border-fuchsia-500/40">
|
||||
719→ WEEK
|
||||
720→ </span>
|
||||
721→ )}
|
||||
722→ {entry.progress >= 1 && <span className="text-emerald-400/70">✓</span>}
|
||||
723→ </span>
|
||||
724→ {/* 用时 */}
|
||||
725→ <span className="text-muted-foreground/50 font-mono text-[10px] w-16 text-right">
|
||||
726→ {Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s
|
||||
727→ </span>
|
||||
728→ {/* 分数 */}
|
||||
729→ <span className="font-mono font-semibold w-12 text-right" style={{ color: eDiff.color }}>
|
||||
730→ {formatNum(entry.score)}
|
||||
731→ </span>
|
||||
732→ </div>
|
||||
733→ );
|
||||
734→ })}
|
||||
735→ </div>
|
||||
736→ )}
|
||||
737→ </div>
|
||||
738→
|
||||
739→ {/* 难度图例 */}
|
||||
740→ <div className="flex items-center justify-center gap-3 text-[9px] text-muted-foreground/50">
|
||||
741→ {DIFFICULTY_ORDER.map((d) => {
|
||||
742→ const m = BEACON_DIFFICULTY[d];
|
||||
743→ return (
|
||||
744→ <span key={d} className="flex items-center gap-1">
|
||||
745→ <span style={{ color: m.color }}>{m.icon}</span>
|
||||
746→ {m.label} ×{m.mult}
|
||||
747→ </span>
|
||||
748→ );
|
||||
749→ })}
|
||||
750→ </div>
|
||||
751→ </div>
|
||||
752→ );
|
||||
753→}
|
||||
754→
|
||||
@@ -0,0 +1,754 @@
|
||||
1→"use client";
|
||||
2→// 回响星核 / Echo Nexus — 深空信标面板
|
||||
3→// v0.5:每日挑战 + 本地排行榜
|
||||
4→// v0.8:周挑战 + 信标链(连续完成奖励)
|
||||
5→import { useState, useEffect, useCallback } from "react";
|
||||
6→import { useGameStore } from "@/store/gameStore";
|
||||
7→import { useToast } from "@/hooks/use-toast";
|
||||
8→import { sfx } from "@/hooks/useAudio";
|
||||
9→import {
|
||||
10→ generateDailyChallenge,
|
||||
11→ generateWeeklyChallenge,
|
||||
12→ loadDailyProgress,
|
||||
13→ loadWeeklyProgress,
|
||||
14→ loadLeaderboard,
|
||||
15→ loadChainState,
|
||||
16→ claimBeaconReward,
|
||||
17→ BEACON_DIFFICULTY,
|
||||
18→ BEACON_TYPE_META,
|
||||
19→ BEACON_CHAIN_MILESTONES,
|
||||
20→ BEACON_CHAIN_REWARDS,
|
||||
21→ getWeekKey,
|
||||
22→ getTodayKey,
|
||||
23→ getNextMilestone,
|
||||
24→ getChainProgress,
|
||||
25→ msUntilNextDay,
|
||||
26→ msUntilNextWeek,
|
||||
27→ formatCountdown,
|
||||
28→ type BeaconDailyChallenge,
|
||||
29→ type BeaconDailyProgress,
|
||||
30→ type BeaconWeeklyChallenge,
|
||||
31→ type BeaconWeeklyProgress,
|
||||
32→ type BeaconScoreEntry,
|
||||
33→ type BeaconChallengeType,
|
||||
34→ type BeaconDifficulty,
|
||||
35→ type BeaconChainState,
|
||||
36→} from "@/lib/game/beacon";
|
||||
37→import { formatNum } from "@/lib/game/config";
|
||||
38→import { Button } from "@/components/ui/button";
|
||||
39→import { Progress } from "@/components/ui/progress";
|
||||
40→import {
|
||||
41→ Radio,
|
||||
42→ Clock,
|
||||
43→ Trophy,
|
||||
44→ Sparkles,
|
||||
45→ Award,
|
||||
46→ Crown,
|
||||
47→ Medal,
|
||||
48→ Link2,
|
||||
49→ Flame,
|
||||
50→ Zap,
|
||||
51→} from "lucide-react";
|
||||
52→
|
||||
53→const DIFFICULTY_ORDER: BeaconDifficulty[] = ["routine", "anomaly", "singular"];
|
||||
54→
|
||||
55→function rankBadge(rank: number) {
|
||||
56→ if (rank === 0) return { icon: Crown, color: "#fbbf24", label: "1st" };
|
||||
57→ if (rank === 1) return { icon: Medal, color: "#cbd5e1", label: "2nd" };
|
||||
58→ if (rank === 2) return { icon: Award, color: "#f97316", label: "3rd" };
|
||||
59→ return null;
|
||||
60→}
|
||||
61→
|
||||
62→export function BeaconPanel() {
|
||||
63→ const grantBeaconReward = useGameStore((s) => s.grantBeaconReward);
|
||||
64→ const claimWeeklyBeacon = useGameStore((s) => s.claimWeeklyBeacon);
|
||||
65→ const claimChainReward = useGameStore((s) => s.claimChainReward);
|
||||
66→ const { toast } = useToast();
|
||||
67→
|
||||
68→ const [challenge, setChallenge] = useState<BeaconDailyChallenge | null>(null);
|
||||
69→ const [progress, setProgress] = useState<BeaconDailyProgress | null>(null);
|
||||
70→ const [weeklyChallenge, setWeeklyChallenge] =
|
||||
71→ useState<BeaconWeeklyChallenge | null>(null);
|
||||
72→ const [weeklyProgress, setWeeklyProgress] =
|
||||
73→ useState<BeaconWeeklyProgress | null>(null);
|
||||
74→ const [chainState, setChainState] = useState<BeaconChainState | null>(null);
|
||||
75→ const [leaderboard, setLeaderboard] = useState<BeaconScoreEntry[]>([]);
|
||||
76→ const [dayCountdown, setDayCountdown] = useState("00:00:00");
|
||||
77→ const [weekCountdown, setWeekCountdown] = useState("00:00:00");
|
||||
78→ const [now, setNow] = useState(Date.now());
|
||||
79→
|
||||
80→ // 初始化 + 每秒刷新(进度 + 倒计时)
|
||||
81→ useEffect(() => {
|
||||
82→ setChallenge(generateDailyChallenge());
|
||||
83→ setProgress(loadDailyProgress());
|
||||
84→ setWeeklyChallenge(generateWeeklyChallenge());
|
||||
85→ setWeeklyProgress(loadWeeklyProgress());
|
||||
86→ setChainState(loadChainState());
|
||||
87→ setLeaderboard(loadLeaderboard());
|
||||
88→ const id = setInterval(() => {
|
||||
89→ setNow(Date.now());
|
||||
90→ setProgress(loadDailyProgress());
|
||||
91→ setWeeklyProgress(loadWeeklyProgress());
|
||||
92→ setChainState(loadChainState());
|
||||
93→ setChallenge((c) => c ?? generateDailyChallenge());
|
||||
94→ setWeeklyChallenge((c) => c ?? generateWeeklyChallenge());
|
||||
95→ }, 1000);
|
||||
96→ return () => clearInterval(id);
|
||||
97→ }, []);
|
||||
98→
|
||||
99→ useEffect(() => {
|
||||
100→ setDayCountdown(formatCountdown(msUntilNextDay(new Date(now))));
|
||||
101→ setWeekCountdown(formatCountdown(msUntilNextWeek(new Date(now))));
|
||||
102→ }, [now]);
|
||||
103→
|
||||
104→ const handleClaimDaily = useCallback(() => {
|
||||
105→ if (!challenge || !progress) return;
|
||||
106→ if (progress.completedAt === null || progress.claimed) return;
|
||||
107→ const res = claimBeaconReward(challenge, progress);
|
||||
108→ setLeaderboard(res.leaderboard);
|
||||
109→ setProgress(loadDailyProgress());
|
||||
110→ // 发放奖励到游戏状态
|
||||
111→ grantBeaconReward(res.rewardInsight, res.rewardContact);
|
||||
112→ sfx("achievement");
|
||||
113→ toast({
|
||||
114→ title: "✦ 每日信标奖励已领取",
|
||||
115→ description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
|
||||
116→ 1
|
||||
117→ )} 接触 · 得分 ${res.score}`,
|
||||
118→ });
|
||||
119→ }, [challenge, progress, toast, grantBeaconReward]);
|
||||
120→
|
||||
121→ const handleClaimWeekly = useCallback(() => {
|
||||
122→ if (!weeklyChallenge || !weeklyProgress) return;
|
||||
123→ if (weeklyProgress.completedAt === null || weeklyProgress.claimed) return;
|
||||
124→ const res = claimWeeklyBeacon();
|
||||
125→ setLeaderboard(loadLeaderboard());
|
||||
126→ setWeeklyProgress(loadWeeklyProgress());
|
||||
127→ if (res.rewardInsight > 0 || res.rewardContact > 0) {
|
||||
128→ sfx("achievement");
|
||||
129→ toast({
|
||||
130→ title: "✦ 周挑战奖励已领取",
|
||||
131→ description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
|
||||
132→ 1
|
||||
133→ )} 接触 · 得分 ${res.score}`,
|
||||
134→ });
|
||||
135→ }
|
||||
136→ }, [weeklyChallenge, weeklyProgress, toast, claimWeeklyBeacon]);
|
||||
137→
|
||||
138→ const handleClaimChain = useCallback(
|
||||
139→ (milestone: number) => {
|
||||
140→ const reward = BEACON_CHAIN_REWARDS.find(
|
||||
141→ (r) => r.milestone === milestone
|
||||
142→ );
|
||||
143→ const res = claimChainReward(milestone);
|
||||
144→ setChainState(loadChainState());
|
||||
145→ if (res.ok) {
|
||||
146→ sfx("achievement");
|
||||
147→ toast({
|
||||
148→ title: `✦ ${res.label || reward?.label || "里程碑"} 已领取`,
|
||||
149→ description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
|
||||
150→ 1
|
||||
151→ )} 接触`,
|
||||
152→ });
|
||||
153→ }
|
||||
154→ },
|
||||
155→ [toast, claimChainReward]
|
||||
156→ );
|
||||
157→
|
||||
158→ if (!challenge || !progress || !weeklyChallenge || !weeklyProgress || !chainState) {
|
||||
159→ return (
|
||||
160→ <div className="flex items-center justify-center h-full text-xs text-muted-foreground/60">
|
||||
161→ 正在校准深空信标…
|
||||
162→ </div>
|
||||
163→ );
|
||||
164→ }
|
||||
165→
|
||||
166→ const diffMeta = BEACON_DIFFICULTY[challenge.difficulty];
|
||||
167→ const typeMeta = BEACON_TYPE_META[challenge.type];
|
||||
168→ const pct = Math.min(100, (progress.progress / challenge.goal) * 100);
|
||||
169→ const isCompleted = progress.completedAt !== null;
|
||||
170→ const isClaimed = progress.claimed;
|
||||
171→ const canClaim = isCompleted && !isClaimed;
|
||||
172→
|
||||
173→ // 周挑战派生量
|
||||
174→ const wDiffMeta = BEACON_DIFFICULTY[weeklyChallenge.difficulty];
|
||||
175→ const wTypeMeta = BEACON_TYPE_META[weeklyChallenge.type];
|
||||
176→ const wPct = Math.min(100, (weeklyProgress.progress / weeklyChallenge.goal) * 100);
|
||||
177→ const wCompleted = weeklyProgress.completedAt !== null;
|
||||
178→ const wClaimed = weeklyProgress.claimed;
|
||||
179→ const wCanClaim = wCompleted && !wClaimed;
|
||||
180→
|
||||
181→ // 信标链派生量
|
||||
182→ const chainProgress = getChainProgress(chainState.currentStreak);
|
||||
183→ const nextMilestone = getNextMilestone(chainState.currentStreak);
|
||||
184→ const todayKey = getTodayKey();
|
||||
185→ const completedToday = chainState.lastCompletedDateKey === todayKey;
|
||||
186→
|
||||
187→ return (
|
||||
188→ <div className="flex flex-col gap-2.5 h-full overflow-y-auto echo-scroll pr-0.5">
|
||||
189→ <style jsx global>{`
|
||||
190→ .echo-scroll::-webkit-scrollbar { width: 4px; }
|
||||
191→ .echo-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 2px; }
|
||||
192→ .echo-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||
193→ @keyframes beacon-pulse-ring {
|
||||
194→ 0% { transform: scale(0.8); opacity: 0.8; }
|
||||
195→ 100% { transform: scale(2.2); opacity: 0; }
|
||||
196→ }
|
||||
197→ @keyframes beacon-glow {
|
||||
198→ 0%, 100% { box-shadow: 0 0 18px ${diffMeta.glow}, inset 0 0 12px ${diffMeta.glow}; }
|
||||
199→ 50% { box-shadow: 0 0 32px ${diffMeta.glow}, inset 0 0 20px ${diffMeta.glow}; }
|
||||
200→ }
|
||||
201→ @keyframes weekly-glow {
|
||||
202→ 0%, 100% { box-shadow: 0 0 18px rgba(232,121,249,0.35), inset 0 0 12px rgba(232,121,249,0.25); }
|
||||
203→ 50% { box-shadow: 0 0 32px rgba(232,121,249,0.5), inset 0 0 20px rgba(232,121,249,0.35); }
|
||||
204→ }
|
||||
205→ @keyframes chain-milestone-pulse {
|
||||
206→ 0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(251,113,133,0.55); }
|
||||
207→ 50% { transform: scale(1.06); box-shadow: 0 0 0 8px rgba(251,113,133,0); }
|
||||
208→ }
|
||||
209→ .chain-milestone-reachable {
|
||||
210→ animation: chain-milestone-pulse 1.8s ease-in-out infinite;
|
||||
211→ }
|
||||
212→ @keyframes chain-streak-flux {
|
||||
213→ 0%, 100% { background-position: 0% 50%; }
|
||||
214→ 50% { background-position: 100% 50%; }
|
||||
215→ }
|
||||
216→ .chain-streak-text {
|
||||
217→ background: linear-gradient(90deg, #fbbf24, #fb7185, #fbbf24);
|
||||
218→ background-size: 200% 100%;
|
||||
219→ -webkit-background-clip: text;
|
||||
220→ background-clip: text;
|
||||
221→ -webkit-text-fill-color: transparent;
|
||||
222→ animation: chain-streak-flux 4s ease-in-out infinite;
|
||||
223→ }
|
||||
224→ `}</style>
|
||||
225→
|
||||
226→ {/* 头部:信标 + 倒计时 */}
|
||||
227→ <div className="flex items-center justify-between">
|
||||
228→ <h3 className="text-sm font-semibold flex items-center gap-1.5">
|
||||
229→ <Radio className="h-4 w-4 text-fuchsia-400" />
|
||||
230→ 深空信标
|
||||
231→ </h3>
|
||||
232→ <div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
|
||||
233→ <Clock className="h-3 w-3" />
|
||||
234→ 次日重置
|
||||
235→ <span className="font-mono text-fuchsia-300/90">{dayCountdown}</span>
|
||||
236→ </div>
|
||||
237→ </div>
|
||||
238→
|
||||
239→ {/* 每日挑战卡片 */}
|
||||
240→ <div
|
||||
241→ className="relative rounded-xl border p-3 overflow-hidden"
|
||||
242→ style={{
|
||||
243→ borderColor: `${diffMeta.color}55`,
|
||||
244→ background: `linear-gradient(135deg, ${diffMeta.color}1f, rgba(0,0,0,0.45))`,
|
||||
245→ animation: isCompleted ? "none" : "beacon-glow 3s ease-in-out infinite",
|
||||
246→ }}
|
||||
247→ >
|
||||
248→ {/* 背景装饰:脉冲环 */}
|
||||
249→ {!isCompleted && (
|
||||
250→ <>
|
||||
251→ <div
|
||||
252→ className="absolute top-4 right-4 h-10 w-10 rounded-full pointer-events-none"
|
||||
253→ style={{
|
||||
254→ border: `1.5px solid ${diffMeta.color}`,
|
||||
255→ animation: "beacon-pulse-ring 2.5s ease-out infinite",
|
||||
256→ }}
|
||||
257→ />
|
||||
258→ <div
|
||||
259→ className="absolute top-4 right-4 h-10 w-10 rounded-full pointer-events-none"
|
||||
260→ style={{
|
||||
261→ border: `1.5px solid ${diffMeta.color}`,
|
||||
262→ animation: "beacon-pulse-ring 2.5s ease-out infinite 1.25s",
|
||||
263→ }}
|
||||
264→ />
|
||||
265→ </>
|
||||
266→ )}
|
||||
267→
|
||||
268→ <div className="relative">
|
||||
269→ {/* 难度 + 类型 标签 */}
|
||||
270→ <div className="flex items-center gap-1.5 mb-2">
|
||||
271→ <span
|
||||
272→ className="text-[10px] font-mono px-1.5 py-0.5 rounded flex items-center gap-1"
|
||||
273→ style={{ background: `${diffMeta.color}22`, color: diffMeta.color, border: `1px solid ${diffMeta.color}55` }}
|
||||
274→ >
|
||||
275→ <span>{diffMeta.icon}</span>
|
||||
276→ {diffMeta.label}
|
||||
277→ </span>
|
||||
278→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-white/5 text-muted-foreground border border-white/10">
|
||||
279→ {typeMeta.icon} {typeMeta.label}
|
||||
280→ </span>
|
||||
281→ {isCompleted && (
|
||||
282→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 flex items-center gap-1">
|
||||
283→ <Sparkles className="h-2.5 w-2.5" /> 已完成
|
||||
284→ </span>
|
||||
285→ )}
|
||||
286→ </div>
|
||||
287→
|
||||
288→ {/* 挑战标题 */}
|
||||
289→ <h4 className="text-sm font-semibold mb-1" style={{ color: diffMeta.color, textShadow: `0 0 10px ${diffMeta.glow}` }}>
|
||||
290→ {challenge.title}
|
||||
291→ </h4>
|
||||
292→ <p className="text-[11px] text-muted-foreground/80 leading-relaxed mb-2.5">
|
||||
293→ {challenge.desc}
|
||||
294→ </p>
|
||||
295→
|
||||
296→ {/* 进度条 */}
|
||||
297→ <div className="mb-2">
|
||||
298→ <div className="flex items-center justify-between mb-1">
|
||||
299→ <span className="text-[10px] text-muted-foreground/70">进度</span>
|
||||
300→ <span className="text-[11px] font-mono font-semibold" style={{ color: diffMeta.color }}>
|
||||
301→ {Math.min(progress.progress, challenge.goal)} / {challenge.goal} {typeMeta.unit}
|
||||
302→ </span>
|
||||
303→ </div>
|
||||
304→ <Progress
|
||||
305→ value={pct}
|
||||
306→ className="h-2 bg-black/40"
|
||||
307→ style={{
|
||||
308→ ["--progress-color" as string]: diffMeta.color,
|
||||
309→ }}
|
||||
310→ />
|
||||
311→ </div>
|
||||
312→
|
||||
313→ {/* 奖励 + 领取按钮 */}
|
||||
314→ <div className="flex items-center justify-between gap-2">
|
||||
315→ <div className="flex items-center gap-2 text-[10px]">
|
||||
316→ <span className="text-muted-foreground/60">奖励:</span>
|
||||
317→ {challenge.rewardInsight > 0 && (
|
||||
318→ <span className="text-amber-300 font-mono">+{formatNum(challenge.rewardInsight)}洞见</span>
|
||||
319→ )}
|
||||
320→ <span className="text-fuchsia-300 font-mono">+{challenge.rewardContact.toFixed(1)}接触</span>
|
||||
321→ </div>
|
||||
322→ <Button
|
||||
323→ onClick={handleClaimDaily}
|
||||
324→ disabled={!canClaim}
|
||||
325→ size="sm"
|
||||
326→ className="h-7 px-3 text-[11px] border-0"
|
||||
327→ style={{
|
||||
328→ background: canClaim
|
||||
329→ ? `linear-gradient(90deg, ${diffMeta.color}, ${diffMeta.color}cc)`
|
||||
330→ : `${diffMeta.color}1a`,
|
||||
331→ color: canClaim ? "#022c22" : `${diffMeta.color}99`,
|
||||
332→ }}
|
||||
333→ >
|
||||
334→ {isClaimed ? "已领取" : canClaim ? "领取奖励" : isCompleted ? "已领取" : "进行中…"}
|
||||
335→ </Button>
|
||||
336→ </div>
|
||||
337→
|
||||
338→ {/* 完成时长 */}
|
||||
339→ {isCompleted && progress.durationSec > 0 && (
|
||||
340→ <div className="mt-1.5 text-[10px] text-muted-foreground/50 text-center">
|
||||
341→ 完成用时 {Math.floor(progress.durationSec / 60)}分{progress.durationSec % 60}秒
|
||||
342→ </div>
|
||||
343→ )}
|
||||
344→ </div>
|
||||
345→ </div>
|
||||
346→
|
||||
347→ {/* 周挑战 + 信标链(桌面端并排,移动端单列) */}
|
||||
348→ <div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
||||
349→ {/* ===== 周挑战卡片(fuchsia 主题) ===== */}
|
||||
350→ <div
|
||||
351→ className="relative rounded-xl border p-3 overflow-hidden"
|
||||
352→ style={{
|
||||
353→ borderColor: "rgba(232,121,249,0.4)",
|
||||
354→ background: `linear-gradient(135deg, rgba(232,121,249,0.12), rgba(0,0,0,0.45))`,
|
||||
355→ animation: wCompleted ? "none" : "weekly-glow 3.5s ease-in-out infinite",
|
||||
356→ }}
|
||||
357→ >
|
||||
358→ {/* 头部:标题 + weekKey + 倒计时 */}
|
||||
359→ <div className="flex items-center justify-between mb-2">
|
||||
360→ <div className="flex items-center gap-1.5">
|
||||
361→ <Zap className="h-3.5 w-3.5 text-fuchsia-400" />
|
||||
362→ <span className="text-xs font-semibold text-fuchsia-200">
|
||||
363→ 周挑战 · WEEKLY
|
||||
364→ </span>
|
||||
365→ </div>
|
||||
366→ <div className="flex items-center gap-1 text-[9px] text-muted-foreground/70">
|
||||
367→ <span className="font-mono text-fuchsia-300/80">
|
||||
368→ {weeklyChallenge.weekKey}
|
||||
369→ </span>
|
||||
370→ <Clock className="h-2.5 w-2.5" />
|
||||
371→ <span className="font-mono text-fuchsia-300/90">{weekCountdown}</span>
|
||||
372→ </div>
|
||||
373→ </div>
|
||||
374→
|
||||
375→ {/* 难度 + 类型 标签 */}
|
||||
376→ <div className="flex items-center gap-1.5 mb-2">
|
||||
377→ <span
|
||||
378→ className="text-[10px] font-mono px-1.5 py-0.5 rounded flex items-center gap-1"
|
||||
379→ style={{ background: `${wDiffMeta.color}22`, color: wDiffMeta.color, border: `1px solid ${wDiffMeta.color}55` }}
|
||||
380→ >
|
||||
381→ <span>{wDiffMeta.icon}</span>
|
||||
382→ {wDiffMeta.label}
|
||||
383→ </span>
|
||||
384→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-white/5 text-muted-foreground border border-white/10">
|
||||
385→ {wTypeMeta.icon} {wTypeMeta.label}
|
||||
386→ </span>
|
||||
387→ {wCompleted && (
|
||||
388→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 flex items-center gap-1">
|
||||
389→ <Sparkles className="h-2.5 w-2.5" /> 已完成
|
||||
390→ </span>
|
||||
391→ )}
|
||||
392→ </div>
|
||||
393→
|
||||
394→ {/* 标题 */}
|
||||
395→ <h4
|
||||
396→ className="text-sm font-semibold mb-1"
|
||||
397→ style={{ color: wDiffMeta.color, textShadow: `0 0 10px ${wDiffMeta.glow}` }}
|
||||
398→ >
|
||||
399→ {weeklyChallenge.title}
|
||||
400→ </h4>
|
||||
401→ <p className="text-[10px] text-muted-foreground/80 leading-relaxed mb-2">
|
||||
402→ {weeklyChallenge.desc}
|
||||
403→ </p>
|
||||
404→
|
||||
405→ {/* 进度条 */}
|
||||
406→ <div className="mb-2">
|
||||
407→ <div className="flex items-center justify-between mb-1">
|
||||
408→ <span className="text-[10px] text-muted-foreground/70">进度</span>
|
||||
409→ <span className="text-[11px] font-mono font-semibold" style={{ color: wDiffMeta.color }}>
|
||||
410→ {Math.min(weeklyProgress.progress, weeklyChallenge.goal)} / {weeklyChallenge.goal} {wTypeMeta.unit}
|
||||
411→ </span>
|
||||
412→ </div>
|
||||
413→ <Progress
|
||||
414→ value={wPct}
|
||||
415→ className="h-2 bg-black/40"
|
||||
416→ style={{
|
||||
417→ ["--progress-color" as string]: wDiffMeta.color,
|
||||
418→ }}
|
||||
419→ />
|
||||
420→ </div>
|
||||
421→
|
||||
422→ {/* 奖励 + 领取按钮 */}
|
||||
423→ <div className="flex items-center justify-between gap-2">
|
||||
424→ <div className="flex items-center gap-1.5 text-[10px]">
|
||||
425→ <span className="text-muted-foreground/60">奖励:</span>
|
||||
426→ {weeklyChallenge.rewardInsight > 0 && (
|
||||
427→ <span className="text-amber-300 font-mono">
|
||||
428→ +{formatNum(weeklyChallenge.rewardInsight)}洞见
|
||||
429→ </span>
|
||||
430→ )}
|
||||
431→ <span className="text-fuchsia-300 font-mono">
|
||||
432→ +{weeklyChallenge.rewardContact.toFixed(1)}接触
|
||||
433→ </span>
|
||||
434→ </div>
|
||||
435→ <Button
|
||||
436→ onClick={handleClaimWeekly}
|
||||
437→ disabled={!wCanClaim}
|
||||
438→ size="sm"
|
||||
439→ className="h-7 px-3 text-[11px] border-0"
|
||||
440→ style={{
|
||||
441→ background: wCanClaim
|
||||
442→ ? `linear-gradient(90deg, #34d399, #34d399cc)`
|
||||
443→ : "rgba(232,121,249,0.10)",
|
||||
444→ color: wCanClaim
|
||||
445→ ? "#022c22"
|
||||
446→ : "rgba(232,121,249,0.55)",
|
||||
447→ }}
|
||||
448→ >
|
||||
449→ {wClaimed
|
||||
450→ ? "已领取"
|
||||
451→ : wCanClaim
|
||||
452→ ? "领取奖励"
|
||||
453→ : wCompleted
|
||||
454→ ? "已领取"
|
||||
455→ : "进行中…"}
|
||||
456→ </Button>
|
||||
457→ </div>
|
||||
458→
|
||||
459→ {wCompleted && weeklyProgress.durationSec > 0 && (
|
||||
460→ <div className="mt-1.5 text-[10px] text-muted-foreground/50 text-center">
|
||||
461→ 完成用时 {Math.floor(weeklyProgress.durationSec / 60)}分{weeklyProgress.durationSec % 60}秒
|
||||
462→ </div>
|
||||
463→ )}
|
||||
464→ </div>
|
||||
465→
|
||||
466→ {/* ===== 信标链卡片(amber→rose 渐变) ===== */}
|
||||
467→ <div
|
||||
468→ className="relative rounded-xl border p-3 overflow-hidden"
|
||||
469→ style={{
|
||||
470→ borderColor: "rgba(251,191,36,0.35)",
|
||||
471→ background: `linear-gradient(135deg, rgba(251,191,36,0.10), rgba(251,113,133,0.10), rgba(0,0,0,0.4))`,
|
||||
472→ }}
|
||||
473→ >
|
||||
474→ {/* 头部:标题 + 当前连续天数 */}
|
||||
475→ <div className="flex items-center justify-between mb-2">
|
||||
476→ <div className="flex items-center gap-1.5">
|
||||
477→ <Link2 className="h-3.5 w-3.5 text-amber-400" />
|
||||
478→ <span className="text-xs font-semibold text-amber-200">
|
||||
479→ 信标链 · CHAIN
|
||||
480→ </span>
|
||||
481→ </div>
|
||||
482→ <div className="flex items-baseline gap-1">
|
||||
483→ <Flame className="h-3 w-3 text-rose-400" />
|
||||
484→ <span className="text-[10px] text-muted-foreground/60">连续</span>
|
||||
485→ <span className="chain-streak-text text-2xl font-bold font-mono leading-none">
|
||||
486→ {chainState.currentStreak}
|
||||
487→ </span>
|
||||
488→ <span className="text-[10px] text-muted-foreground/60">天</span>
|
||||
489→ </div>
|
||||
490→ </div>
|
||||
491→
|
||||
492→ {/* 今日完成状态 */}
|
||||
493→ <div className="mb-2 flex items-center justify-between">
|
||||
494→ <span className="text-[10px] text-muted-foreground/70">
|
||||
495→ {completedToday
|
||||
496→ ? "今日已贡献"
|
||||
497→ : "今日尚未完成日挑战"}
|
||||
498→ </span>
|
||||
499→ <span
|
||||
500→ className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${
|
||||
501→ completedToday
|
||||
502→ ? "bg-emerald-500/15 text-emerald-300 border-emerald-500/30"
|
||||
503→ : "bg-white/5 text-muted-foreground/70 border-white/10"
|
||||
504→ }`}
|
||||
505→ >
|
||||
506→ {completedToday ? "✓ 已记录" : "○ 待完成"}
|
||||
507→ </span>
|
||||
508→ </div>
|
||||
509→
|
||||
510→ {/* 4 个里程碑节点横向排列 */}
|
||||
511→ <div className="flex items-center justify-between mb-2 relative">
|
||||
512→ {/* 节点之间的连线(背景灰) */}
|
||||
513→ <div className="absolute top-6 left-[12.5%] right-[12.5%] h-[2px] bg-white/10" />
|
||||
514→ {/* 已达成部分高亮(基于 prev→next 插值) */}
|
||||
515→ {(() => {
|
||||
516→ // 节点圆心水平位置(百分比)
|
||||
517→ const MILESTONE_POS: Record<number, number> = {
|
||||
518→ 0: 12.5,
|
||||
519→ 3: 12.5,
|
||||
520→ 7: 37.5,
|
||||
521→ 14: 62.5,
|
||||
522→ 30: 87.5,
|
||||
523→ };
|
||||
524→ const prevPos =
|
||||
525→ MILESTONE_POS[chainProgress.prev] ?? 12.5;
|
||||
526→ const nextPos =
|
||||
527→ chainProgress.next !== null
|
||||
528→ ? MILESTONE_POS[chainProgress.next] ?? 87.5
|
||||
529→ : 87.5;
|
||||
530→ const pct = chainProgress.progressPct / 100;
|
||||
531→ const activeEndPos = prevPos + (nextPos - prevPos) * pct;
|
||||
532→ const widthPct = Math.max(0, activeEndPos - 12.5);
|
||||
533→ if (widthPct <= 0) return null;
|
||||
534→ return (
|
||||
535→ <div
|
||||
536→ className="absolute top-6 h-[2px]"
|
||||
537→ style={{
|
||||
538→ left: "12.5%",
|
||||
539→ width: `${widthPct}%`,
|
||||
540→ background:
|
||||
541→ "linear-gradient(90deg, #fbbf24, #fb7185)",
|
||||
542→ boxShadow: "0 0 8px rgba(251,113,133,0.5)",
|
||||
543→ }}
|
||||
544→ />
|
||||
545→ );
|
||||
546→ })()}
|
||||
547→
|
||||
548→ {BEACON_CHAIN_MILESTONES.map((m) => {
|
||||
549→ const reward = BEACON_CHAIN_REWARDS.find((r) => r.milestone === m);
|
||||
550→ const claimed = chainState.milestonesClaimed.includes(m);
|
||||
551→ const reachable =
|
||||
552→ chainState.currentStreak >= m && !claimed;
|
||||
553→ const inProgress = chainState.currentStreak > 0 && nextMilestone === m;
|
||||
554→ // 节点配色
|
||||
555→ let nodeBg = "rgba(255,255,255,0.05)";
|
||||
556→ let nodeBorder = "rgba(255,255,255,0.15)";
|
||||
557→ let textColor = "rgba(255,255,255,0.4)";
|
||||
558→ if (claimed) {
|
||||
559→ nodeBg = "rgba(52,211,153,0.25)";
|
||||
560→ nodeBorder = "#34d399";
|
||||
561→ textColor = "#34d399";
|
||||
562→ } else if (reachable) {
|
||||
563→ nodeBg = "rgba(251,113,133,0.20)";
|
||||
564→ nodeBorder = "#fb7185";
|
||||
565→ textColor = "#fb7185";
|
||||
566→ } else if (inProgress) {
|
||||
567→ nodeBg = "rgba(251,191,36,0.20)";
|
||||
568→ nodeBorder = "#fbbf24";
|
||||
569→ textColor = "#fbbf24";
|
||||
570→ }
|
||||
571→
|
||||
572→ return (
|
||||
573→ <div
|
||||
574→ key={m}
|
||||
575→ className="flex flex-col items-center gap-1 z-10 flex-1"
|
||||
576→ >
|
||||
577→ <div
|
||||
578→ className={`w-12 h-12 rounded-full flex items-center justify-center font-mono font-bold text-sm border-2 ${
|
||||
579→ reachable ? "chain-milestone-reachable" : ""
|
||||
580→ }`}
|
||||
581→ style={{
|
||||
582→ background: nodeBg,
|
||||
583→ borderColor: nodeBorder,
|
||||
584→ color: textColor,
|
||||
585→ }}
|
||||
586→ >
|
||||
587→ {claimed ? (
|
||||
588→ <Sparkles className="h-4 w-4" />
|
||||
589→ ) : (
|
||||
590→ m
|
||||
591→ )}
|
||||
592→ </div>
|
||||
593→ <span
|
||||
594→ className="text-[9px] text-center leading-tight"
|
||||
595→ style={{ color: textColor }}
|
||||
596→ >
|
||||
597→ {reward?.label}
|
||||
598→ </span>
|
||||
599→ {claimed ? (
|
||||
600→ <span className="text-[9px] text-emerald-400/80 font-mono">
|
||||
601→ 已领
|
||||
602→ </span>
|
||||
603→ ) : reachable ? (
|
||||
604→ <Button
|
||||
605→ onClick={() => handleClaimChain(m)}
|
||||
606→ size="sm"
|
||||
607→ className="h-5 px-2 text-[9px] py-0 border-0"
|
||||
608→ style={{
|
||||
609→ background:
|
||||
610→ "linear-gradient(90deg, #fb7185, #f43f5e)",
|
||||
611→ color: "#1c0608",
|
||||
612→ }}
|
||||
613→ >
|
||||
614→ 领取
|
||||
615→ </Button>
|
||||
616→ ) : (
|
||||
617→ <span className="text-[9px] text-muted-foreground/40 font-mono">
|
||||
618→ +{reward?.rewardInsight}洞
|
||||
619→ </span>
|
||||
620→ )}
|
||||
621→ </div>
|
||||
622→ );
|
||||
623→ })}
|
||||
624→ </div>
|
||||
625→
|
||||
626→ {/* 进度条 */}
|
||||
627→ <div className="mb-2">
|
||||
628→ <div className="flex items-center justify-between mb-1">
|
||||
629→ <span className="text-[10px] text-muted-foreground/70">
|
||||
630→ {chainProgress.next === null
|
||||
631→ ? "已通关全部里程碑"
|
||||
632→ : `下一目标:${chainProgress.next} 天`}
|
||||
633→ </span>
|
||||
634→ <span className="text-[10px] font-mono text-amber-300">
|
||||
635→ {chainProgress.current}
|
||||
636→ {chainProgress.next !== null && ` / ${chainProgress.next}`} 天
|
||||
637→ </span>
|
||||
638→ </div>
|
||||
639→ <Progress
|
||||
640→ value={chainProgress.progressPct}
|
||||
641→ className="h-1.5 bg-black/40"
|
||||
642→ style={{
|
||||
643→ ["--progress-color" as string]: "#fbbf24",
|
||||
644→ }}
|
||||
645→ />
|
||||
646→ </div>
|
||||
647→
|
||||
648→ {/* 底部统计 */}
|
||||
649→ <div className="flex items-center justify-between gap-2 text-[10px]">
|
||||
650→ <div className="flex items-center gap-2">
|
||||
651→ <span className="text-muted-foreground/60">最长</span>
|
||||
652→ <span className="font-mono text-amber-300">
|
||||
653→ {chainState.longestStreak}天
|
||||
654→ </span>
|
||||
655→ </div>
|
||||
656→ <div className="flex items-center gap-2">
|
||||
657→ <span className="text-muted-foreground/60">累计</span>
|
||||
658→ <span className="font-mono text-rose-300">
|
||||
659→ {chainState.totalCompletions}次
|
||||
660→ </span>
|
||||
661→ </div>
|
||||
662→ <div className="flex items-center gap-1">
|
||||
663→ <span className="text-muted-foreground/60">续命</span>
|
||||
664→ {chainState.graceUsed >= 1 ? (
|
||||
665→ <span className="font-mono text-rose-400/80">已用</span>
|
||||
666→ ) : (
|
||||
667→ <span className="font-mono text-emerald-400/80">可用</span>
|
||||
668→ )}
|
||||
669→ </div>
|
||||
670→ </div>
|
||||
671→ </div>
|
||||
672→ </div>
|
||||
673→
|
||||
674→ {/* 本地排行榜 */}
|
||||
675→ <div className="rounded-xl border border-white/10 bg-black/30 p-2.5">
|
||||
676→ <div className="flex items-center gap-1.5 mb-2">
|
||||
677→ <Trophy className="h-3.5 w-3.5 text-amber-400" />
|
||||
678→ <span className="text-xs font-semibold">深空排行榜</span>
|
||||
679→ <span className="text-[10px] text-muted-foreground/50">本地 · Top {leaderboard.length || 0}</span>
|
||||
680→ </div>
|
||||
681→
|
||||
682→ {leaderboard.length === 0 ? (
|
||||
683→ <div className="text-center py-4 text-[11px] text-muted-foreground/40">
|
||||
684→ <Trophy className="h-6 w-6 mx-auto mb-1 opacity-30" />
|
||||
685→ 尚无记录。完成今日或本周信标即可登榜。
|
||||
686→ </div>
|
||||
687→ ) : (
|
||||
688→ <div className="space-y-0.5 max-h-[180px] overflow-y-auto echo-scroll">
|
||||
689→ {leaderboard.map((entry, i) => {
|
||||
690→ const rb = rankBadge(i);
|
||||
691→ const eDiff = BEACON_DIFFICULTY[entry.difficulty];
|
||||
692→ const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType];
|
||||
693→ const isMine = entry.dateKey === getTodayKey() || entry.dateKey === getWeekKey();
|
||||
694→ return (
|
||||
695→ <div
|
||||
696→ key={`${entry.timestamp}-${i}`}
|
||||
697→ className={`flex items-center gap-2 px-2 py-1 rounded-lg text-[11px] ${
|
||||
698→ entry.isWeekly
|
||||
699→ ? "bg-fuchsia-500/10 border border-fuchsia-500/20"
|
||||
700→ : isMine
|
||||
701→ ? "bg-fuchsia-500/10 border border-fuchsia-500/20"
|
||||
702→ : "hover:bg-white/5"
|
||||
703→ }`}
|
||||
704→ >
|
||||
705→ {/* 排名 */}
|
||||
706→ <span className="w-6 flex items-center justify-center">
|
||||
707→ {rb ? (
|
||||
708→ <rb.icon className="h-3.5 w-3.5" style={{ color: rb.color }} />
|
||||
709→ ) : (
|
||||
710→ <span className="text-muted-foreground/50 font-mono">{i + 1}</span>
|
||||
711→ )}
|
||||
712→ </span>
|
||||
713→ {/* 类型 + 难度 + 周挑战标记 */}
|
||||
714→ <span className="flex items-center gap-1 flex-1 min-w-0">
|
||||
715→ <span style={{ color: eDiff.color }} className="font-mono">{eDiff.icon}</span>
|
||||
716→ <span className="text-muted-foreground/80 truncate">{eType.label}</span>
|
||||
717→ {entry.isWeekly && (
|
||||
718→ <span className="text-[9px] font-mono px-1 rounded bg-fuchsia-500/20 text-fuchsia-300 border border-fuchsia-500/40">
|
||||
719→ WEEK
|
||||
720→ </span>
|
||||
721→ )}
|
||||
722→ {entry.progress >= 1 && <span className="text-emerald-400/70">✓</span>}
|
||||
723→ </span>
|
||||
724→ {/* 用时 */}
|
||||
725→ <span className="text-muted-foreground/50 font-mono text-[10px] w-16 text-right">
|
||||
726→ {Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s
|
||||
727→ </span>
|
||||
728→ {/* 分数 */}
|
||||
729→ <span className="font-mono font-semibold w-12 text-right" style={{ color: eDiff.color }}>
|
||||
730→ {formatNum(entry.score)}
|
||||
731→ </span>
|
||||
732→ </div>
|
||||
733→ );
|
||||
734→ })}
|
||||
735→ </div>
|
||||
736→ )}
|
||||
737→ </div>
|
||||
738→
|
||||
739→ {/* 难度图例 */}
|
||||
740→ <div className="flex items-center justify-center gap-3 text-[9px] text-muted-foreground/50">
|
||||
741→ {DIFFICULTY_ORDER.map((d) => {
|
||||
742→ const m = BEACON_DIFFICULTY[d];
|
||||
743→ return (
|
||||
744→ <span key={d} className="flex items-center gap-1">
|
||||
745→ <span style={{ color: m.color }}>{m.icon}</span>
|
||||
746→ {m.label} ×{m.mult}
|
||||
747→ </span>
|
||||
748→ );
|
||||
749→ })}
|
||||
750→ </div>
|
||||
751→ </div>
|
||||
752→ );
|
||||
753→}
|
||||
754→
|
||||
@@ -0,0 +1,754 @@
|
||||
1→ 1→"use client";
|
||||
2→ 2→// 回响星核 / Echo Nexus — 深空信标面板
|
||||
3→ 3→// v0.5:每日挑战 + 本地排行榜
|
||||
4→ 4→// v0.8:周挑战 + 信标链(连续完成奖励)
|
||||
5→ 5→import { useState, useEffect, useCallback } from "react";
|
||||
6→ 6→import { useGameStore } from "@/store/gameStore";
|
||||
7→ 7→import { useToast } from "@/hooks/use-toast";
|
||||
8→ 8→import { sfx } from "@/hooks/useAudio";
|
||||
9→ 9→import {
|
||||
10→ 10→ generateDailyChallenge,
|
||||
11→ 11→ generateWeeklyChallenge,
|
||||
12→ 12→ loadDailyProgress,
|
||||
13→ 13→ loadWeeklyProgress,
|
||||
14→ 14→ loadLeaderboard,
|
||||
15→ 15→ loadChainState,
|
||||
16→ 16→ claimBeaconReward,
|
||||
17→ 17→ BEACON_DIFFICULTY,
|
||||
18→ 18→ BEACON_TYPE_META,
|
||||
19→ 19→ BEACON_CHAIN_MILESTONES,
|
||||
20→ 20→ BEACON_CHAIN_REWARDS,
|
||||
21→ 21→ getWeekKey,
|
||||
22→ 22→ getTodayKey,
|
||||
23→ 23→ getNextMilestone,
|
||||
24→ 24→ getChainProgress,
|
||||
25→ 25→ msUntilNextDay,
|
||||
26→ 26→ msUntilNextWeek,
|
||||
27→ 27→ formatCountdown,
|
||||
28→ 28→ type BeaconDailyChallenge,
|
||||
29→ 29→ type BeaconDailyProgress,
|
||||
30→ 30→ type BeaconWeeklyChallenge,
|
||||
31→ 31→ type BeaconWeeklyProgress,
|
||||
32→ 32→ type BeaconScoreEntry,
|
||||
33→ 33→ type BeaconChallengeType,
|
||||
34→ 34→ type BeaconDifficulty,
|
||||
35→ 35→ type BeaconChainState,
|
||||
36→ 36→} from "@/lib/game/beacon";
|
||||
37→ 37→import { formatNum } from "@/lib/game/config";
|
||||
38→ 38→import { Button } from "@/components/ui/button";
|
||||
39→ 39→import { Progress } from "@/components/ui/progress";
|
||||
40→ 40→import {
|
||||
41→ 41→ Radio,
|
||||
42→ 42→ Clock,
|
||||
43→ 43→ Trophy,
|
||||
44→ 44→ Sparkles,
|
||||
45→ 45→ Award,
|
||||
46→ 46→ Crown,
|
||||
47→ 47→ Medal,
|
||||
48→ 48→ Link2,
|
||||
49→ 49→ Flame,
|
||||
50→ 50→ Zap,
|
||||
51→ 51→} from "lucide-react";
|
||||
52→ 52→
|
||||
53→ 53→const DIFFICULTY_ORDER: BeaconDifficulty[] = ["routine", "anomaly", "singular"];
|
||||
54→ 54→
|
||||
55→ 55→function rankBadge(rank: number) {
|
||||
56→ 56→ if (rank === 0) return { icon: Crown, color: "#fbbf24", label: "1st" };
|
||||
57→ 57→ if (rank === 1) return { icon: Medal, color: "#cbd5e1", label: "2nd" };
|
||||
58→ 58→ if (rank === 2) return { icon: Award, color: "#f97316", label: "3rd" };
|
||||
59→ 59→ return null;
|
||||
60→ 60→}
|
||||
61→ 61→
|
||||
62→ 62→export function BeaconPanel() {
|
||||
63→ 63→ const grantBeaconReward = useGameStore((s) => s.grantBeaconReward);
|
||||
64→ 64→ const claimWeeklyBeacon = useGameStore((s) => s.claimWeeklyBeacon);
|
||||
65→ 65→ const claimChainReward = useGameStore((s) => s.claimChainReward);
|
||||
66→ 66→ const { toast } = useToast();
|
||||
67→ 67→
|
||||
68→ 68→ const [challenge, setChallenge] = useState<BeaconDailyChallenge | null>(null);
|
||||
69→ 69→ const [progress, setProgress] = useState<BeaconDailyProgress | null>(null);
|
||||
70→ 70→ const [weeklyChallenge, setWeeklyChallenge] =
|
||||
71→ 71→ useState<BeaconWeeklyChallenge | null>(null);
|
||||
72→ 72→ const [weeklyProgress, setWeeklyProgress] =
|
||||
73→ 73→ useState<BeaconWeeklyProgress | null>(null);
|
||||
74→ 74→ const [chainState, setChainState] = useState<BeaconChainState | null>(null);
|
||||
75→ 75→ const [leaderboard, setLeaderboard] = useState<BeaconScoreEntry[]>([]);
|
||||
76→ 76→ const [dayCountdown, setDayCountdown] = useState("00:00:00");
|
||||
77→ 77→ const [weekCountdown, setWeekCountdown] = useState("00:00:00");
|
||||
78→ 78→ const [now, setNow] = useState(Date.now());
|
||||
79→ 79→
|
||||
80→ 80→ // 初始化 + 每秒刷新(进度 + 倒计时)
|
||||
81→ 81→ useEffect(() => {
|
||||
82→ 82→ setChallenge(generateDailyChallenge());
|
||||
83→ 83→ setProgress(loadDailyProgress());
|
||||
84→ 84→ setWeeklyChallenge(generateWeeklyChallenge());
|
||||
85→ 85→ setWeeklyProgress(loadWeeklyProgress());
|
||||
86→ 86→ setChainState(loadChainState());
|
||||
87→ 87→ setLeaderboard(loadLeaderboard());
|
||||
88→ 88→ const id = setInterval(() => {
|
||||
89→ 89→ setNow(Date.now());
|
||||
90→ 90→ setProgress(loadDailyProgress());
|
||||
91→ 91→ setWeeklyProgress(loadWeeklyProgress());
|
||||
92→ 92→ setChainState(loadChainState());
|
||||
93→ 93→ setChallenge((c) => c ?? generateDailyChallenge());
|
||||
94→ 94→ setWeeklyChallenge((c) => c ?? generateWeeklyChallenge());
|
||||
95→ 95→ }, 1000);
|
||||
96→ 96→ return () => clearInterval(id);
|
||||
97→ 97→ }, []);
|
||||
98→ 98→
|
||||
99→ 99→ useEffect(() => {
|
||||
100→ 100→ setDayCountdown(formatCountdown(msUntilNextDay(new Date(now))));
|
||||
101→ 101→ setWeekCountdown(formatCountdown(msUntilNextWeek(new Date(now))));
|
||||
102→ 102→ }, [now]);
|
||||
103→ 103→
|
||||
104→ 104→ const handleClaimDaily = useCallback(() => {
|
||||
105→ 105→ if (!challenge || !progress) return;
|
||||
106→ 106→ if (progress.completedAt === null || progress.claimed) return;
|
||||
107→ 107→ const res = claimBeaconReward(challenge, progress);
|
||||
108→ 108→ setLeaderboard(res.leaderboard);
|
||||
109→ 109→ setProgress(loadDailyProgress());
|
||||
110→ 110→ // 发放奖励到游戏状态
|
||||
111→ 111→ grantBeaconReward(res.rewardInsight, res.rewardContact);
|
||||
112→ 112→ sfx("achievement");
|
||||
113→ 113→ toast({
|
||||
114→ 114→ title: "✦ 每日信标奖励已领取",
|
||||
115→ 115→ description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
|
||||
116→ 116→ 1
|
||||
117→ 117→ )} 接触 · 得分 ${res.score}`,
|
||||
118→ 118→ });
|
||||
119→ 119→ }, [challenge, progress, toast, grantBeaconReward]);
|
||||
120→ 120→
|
||||
121→ 121→ const handleClaimWeekly = useCallback(() => {
|
||||
122→ 122→ if (!weeklyChallenge || !weeklyProgress) return;
|
||||
123→ 123→ if (weeklyProgress.completedAt === null || weeklyProgress.claimed) return;
|
||||
124→ 124→ const res = claimWeeklyBeacon();
|
||||
125→ 125→ setLeaderboard(loadLeaderboard());
|
||||
126→ 126→ setWeeklyProgress(loadWeeklyProgress());
|
||||
127→ 127→ if (res.rewardInsight > 0 || res.rewardContact > 0) {
|
||||
128→ 128→ sfx("achievement");
|
||||
129→ 129→ toast({
|
||||
130→ 130→ title: "✦ 周挑战奖励已领取",
|
||||
131→ 131→ description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
|
||||
132→ 132→ 1
|
||||
133→ 133→ )} 接触 · 得分 ${res.score}`,
|
||||
134→ 134→ });
|
||||
135→ 135→ }
|
||||
136→ 136→ }, [weeklyChallenge, weeklyProgress, toast, claimWeeklyBeacon]);
|
||||
137→ 137→
|
||||
138→ 138→ const handleClaimChain = useCallback(
|
||||
139→ 139→ (milestone: number) => {
|
||||
140→ 140→ const reward = BEACON_CHAIN_REWARDS.find(
|
||||
141→ 141→ (r) => r.milestone === milestone
|
||||
142→ 142→ );
|
||||
143→ 143→ const res = claimChainReward(milestone);
|
||||
144→ 144→ setChainState(loadChainState());
|
||||
145→ 145→ if (res.ok) {
|
||||
146→ 146→ sfx("achievement");
|
||||
147→ 147→ toast({
|
||||
148→ 148→ title: `✦ ${res.label || reward?.label || "里程碑"} 已领取`,
|
||||
149→ 149→ description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
|
||||
150→ 150→ 1
|
||||
151→ 151→ )} 接触`,
|
||||
152→ 152→ });
|
||||
153→ 153→ }
|
||||
154→ 154→ },
|
||||
155→ 155→ [toast, claimChainReward]
|
||||
156→ 156→ );
|
||||
157→ 157→
|
||||
158→ 158→ if (!challenge || !progress || !weeklyChallenge || !weeklyProgress || !chainState) {
|
||||
159→ 159→ return (
|
||||
160→ 160→ <div className="flex items-center justify-center h-full text-xs text-muted-foreground/60">
|
||||
161→ 161→ 正在校准深空信标…
|
||||
162→ 162→ </div>
|
||||
163→ 163→ );
|
||||
164→ 164→ }
|
||||
165→ 165→
|
||||
166→ 166→ const diffMeta = BEACON_DIFFICULTY[challenge.difficulty];
|
||||
167→ 167→ const typeMeta = BEACON_TYPE_META[challenge.type];
|
||||
168→ 168→ const pct = Math.min(100, (progress.progress / challenge.goal) * 100);
|
||||
169→ 169→ const isCompleted = progress.completedAt !== null;
|
||||
170→ 170→ const isClaimed = progress.claimed;
|
||||
171→ 171→ const canClaim = isCompleted && !isClaimed;
|
||||
172→ 172→
|
||||
173→ 173→ // 周挑战派生量
|
||||
174→ 174→ const wDiffMeta = BEACON_DIFFICULTY[weeklyChallenge.difficulty];
|
||||
175→ 175→ const wTypeMeta = BEACON_TYPE_META[weeklyChallenge.type];
|
||||
176→ 176→ const wPct = Math.min(100, (weeklyProgress.progress / weeklyChallenge.goal) * 100);
|
||||
177→ 177→ const wCompleted = weeklyProgress.completedAt !== null;
|
||||
178→ 178→ const wClaimed = weeklyProgress.claimed;
|
||||
179→ 179→ const wCanClaim = wCompleted && !wClaimed;
|
||||
180→ 180→
|
||||
181→ 181→ // 信标链派生量
|
||||
182→ 182→ const chainProgress = getChainProgress(chainState.currentStreak);
|
||||
183→ 183→ const nextMilestone = getNextMilestone(chainState.currentStreak);
|
||||
184→ 184→ const todayKey = getTodayKey();
|
||||
185→ 185→ const completedToday = chainState.lastCompletedDateKey === todayKey;
|
||||
186→ 186→
|
||||
187→ 187→ return (
|
||||
188→ 188→ <div className="flex flex-col gap-2.5 h-full overflow-y-auto echo-scroll pr-0.5">
|
||||
189→ 189→ <style jsx global>{`
|
||||
190→ 190→ .echo-scroll::-webkit-scrollbar { width: 4px; }
|
||||
191→ 191→ .echo-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 2px; }
|
||||
192→ 192→ .echo-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||
193→ 193→ @keyframes beacon-pulse-ring {
|
||||
194→ 194→ 0% { transform: scale(0.8); opacity: 0.8; }
|
||||
195→ 195→ 100% { transform: scale(2.2); opacity: 0; }
|
||||
196→ 196→ }
|
||||
197→ 197→ @keyframes beacon-glow {
|
||||
198→ 198→ 0%, 100% { box-shadow: 0 0 18px ${diffMeta.glow}, inset 0 0 12px ${diffMeta.glow}; }
|
||||
199→ 199→ 50% { box-shadow: 0 0 32px ${diffMeta.glow}, inset 0 0 20px ${diffMeta.glow}; }
|
||||
200→ 200→ }
|
||||
201→ 201→ @keyframes weekly-glow {
|
||||
202→ 202→ 0%, 100% { box-shadow: 0 0 18px rgba(232,121,249,0.35), inset 0 0 12px rgba(232,121,249,0.25); }
|
||||
203→ 203→ 50% { box-shadow: 0 0 32px rgba(232,121,249,0.5), inset 0 0 20px rgba(232,121,249,0.35); }
|
||||
204→ 204→ }
|
||||
205→ 205→ @keyframes chain-milestone-pulse {
|
||||
206→ 206→ 0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(251,113,133,0.55); }
|
||||
207→ 207→ 50% { transform: scale(1.06); box-shadow: 0 0 0 8px rgba(251,113,133,0); }
|
||||
208→ 208→ }
|
||||
209→ 209→ .chain-milestone-reachable {
|
||||
210→ 210→ animation: chain-milestone-pulse 1.8s ease-in-out infinite;
|
||||
211→ 211→ }
|
||||
212→ 212→ @keyframes chain-streak-flux {
|
||||
213→ 213→ 0%, 100% { background-position: 0% 50%; }
|
||||
214→ 214→ 50% { background-position: 100% 50%; }
|
||||
215→ 215→ }
|
||||
216→ 216→ .chain-streak-text {
|
||||
217→ 217→ background: linear-gradient(90deg, #fbbf24, #fb7185, #fbbf24);
|
||||
218→ 218→ background-size: 200% 100%;
|
||||
219→ 219→ -webkit-background-clip: text;
|
||||
220→ 220→ background-clip: text;
|
||||
221→ 221→ -webkit-text-fill-color: transparent;
|
||||
222→ 222→ animation: chain-streak-flux 4s ease-in-out infinite;
|
||||
223→ 223→ }
|
||||
224→ 224→ `}</style>
|
||||
225→ 225→
|
||||
226→ 226→ {/* 头部:信标 + 倒计时 */}
|
||||
227→ 227→ <div className="flex items-center justify-between">
|
||||
228→ 228→ <h3 className="text-sm font-semibold flex items-center gap-1.5">
|
||||
229→ 229→ <Radio className="h-4 w-4 text-fuchsia-400" />
|
||||
230→ 230→ 深空信标
|
||||
231→ 231→ </h3>
|
||||
232→ 232→ <div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
|
||||
233→ 233→ <Clock className="h-3 w-3" />
|
||||
234→ 234→ 次日重置
|
||||
235→ 235→ <span className="font-mono text-fuchsia-300/90">{dayCountdown}</span>
|
||||
236→ 236→ </div>
|
||||
237→ 237→ </div>
|
||||
238→ 238→
|
||||
239→ 239→ {/* 每日挑战卡片 */}
|
||||
240→ 240→ <div
|
||||
241→ 241→ className="relative rounded-xl border p-3 overflow-hidden"
|
||||
242→ 242→ style={{
|
||||
243→ 243→ borderColor: `${diffMeta.color}55`,
|
||||
244→ 244→ background: `linear-gradient(135deg, ${diffMeta.color}1f, rgba(0,0,0,0.45))`,
|
||||
245→ 245→ animation: isCompleted ? "none" : "beacon-glow 3s ease-in-out infinite",
|
||||
246→ 246→ }}
|
||||
247→ 247→ >
|
||||
248→ 248→ {/* 背景装饰:脉冲环 */}
|
||||
249→ 249→ {!isCompleted && (
|
||||
250→ 250→ <>
|
||||
251→ 251→ <div
|
||||
252→ 252→ className="absolute top-4 right-4 h-10 w-10 rounded-full pointer-events-none"
|
||||
253→ 253→ style={{
|
||||
254→ 254→ border: `1.5px solid ${diffMeta.color}`,
|
||||
255→ 255→ animation: "beacon-pulse-ring 2.5s ease-out infinite",
|
||||
256→ 256→ }}
|
||||
257→ 257→ />
|
||||
258→ 258→ <div
|
||||
259→ 259→ className="absolute top-4 right-4 h-10 w-10 rounded-full pointer-events-none"
|
||||
260→ 260→ style={{
|
||||
261→ 261→ border: `1.5px solid ${diffMeta.color}`,
|
||||
262→ 262→ animation: "beacon-pulse-ring 2.5s ease-out infinite 1.25s",
|
||||
263→ 263→ }}
|
||||
264→ 264→ />
|
||||
265→ 265→ </>
|
||||
266→ 266→ )}
|
||||
267→ 267→
|
||||
268→ 268→ <div className="relative">
|
||||
269→ 269→ {/* 难度 + 类型 标签 */}
|
||||
270→ 270→ <div className="flex items-center gap-1.5 mb-2">
|
||||
271→ 271→ <span
|
||||
272→ 272→ className="text-[10px] font-mono px-1.5 py-0.5 rounded flex items-center gap-1"
|
||||
273→ 273→ style={{ background: `${diffMeta.color}22`, color: diffMeta.color, border: `1px solid ${diffMeta.color}55` }}
|
||||
274→ 274→ >
|
||||
275→ 275→ <span>{diffMeta.icon}</span>
|
||||
276→ 276→ {diffMeta.label}
|
||||
277→ 277→ </span>
|
||||
278→ 278→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-white/5 text-muted-foreground border border-white/10">
|
||||
279→ 279→ {typeMeta.icon} {typeMeta.label}
|
||||
280→ 280→ </span>
|
||||
281→ 281→ {isCompleted && (
|
||||
282→ 282→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 flex items-center gap-1">
|
||||
283→ 283→ <Sparkles className="h-2.5 w-2.5" /> 已完成
|
||||
284→ 284→ </span>
|
||||
285→ 285→ )}
|
||||
286→ 286→ </div>
|
||||
287→ 287→
|
||||
288→ 288→ {/* 挑战标题 */}
|
||||
289→ 289→ <h4 className="text-sm font-semibold mb-1" style={{ color: diffMeta.color, textShadow: `0 0 10px ${diffMeta.glow}` }}>
|
||||
290→ 290→ {challenge.title}
|
||||
291→ 291→ </h4>
|
||||
292→ 292→ <p className="text-[11px] text-muted-foreground/80 leading-relaxed mb-2.5">
|
||||
293→ 293→ {challenge.desc}
|
||||
294→ 294→ </p>
|
||||
295→ 295→
|
||||
296→ 296→ {/* 进度条 */}
|
||||
297→ 297→ <div className="mb-2">
|
||||
298→ 298→ <div className="flex items-center justify-between mb-1">
|
||||
299→ 299→ <span className="text-[10px] text-muted-foreground/70">进度</span>
|
||||
300→ 300→ <span className="text-[11px] font-mono font-semibold" style={{ color: diffMeta.color }}>
|
||||
301→ 301→ {Math.min(progress.progress, challenge.goal)} / {challenge.goal} {typeMeta.unit}
|
||||
302→ 302→ </span>
|
||||
303→ 303→ </div>
|
||||
304→ 304→ <Progress
|
||||
305→ 305→ value={pct}
|
||||
306→ 306→ className="h-2 bg-black/40"
|
||||
307→ 307→ style={{
|
||||
308→ 308→ ["--progress-color" as string]: diffMeta.color,
|
||||
309→ 309→ }}
|
||||
310→ 310→ />
|
||||
311→ 311→ </div>
|
||||
312→ 312→
|
||||
313→ 313→ {/* 奖励 + 领取按钮 */}
|
||||
314→ 314→ <div className="flex items-center justify-between gap-2">
|
||||
315→ 315→ <div className="flex items-center gap-2 text-[10px]">
|
||||
316→ 316→ <span className="text-muted-foreground/60">奖励:</span>
|
||||
317→ 317→ {challenge.rewardInsight > 0 && (
|
||||
318→ 318→ <span className="text-amber-300 font-mono">+{formatNum(challenge.rewardInsight)}洞见</span>
|
||||
319→ 319→ )}
|
||||
320→ 320→ <span className="text-fuchsia-300 font-mono">+{challenge.rewardContact.toFixed(1)}接触</span>
|
||||
321→ 321→ </div>
|
||||
322→ 322→ <Button
|
||||
323→ 323→ onClick={handleClaimDaily}
|
||||
324→ 324→ disabled={!canClaim}
|
||||
325→ 325→ size="sm"
|
||||
326→ 326→ className="h-7 px-3 text-[11px] border-0"
|
||||
327→ 327→ style={{
|
||||
328→ 328→ background: canClaim
|
||||
329→ 329→ ? `linear-gradient(90deg, ${diffMeta.color}, ${diffMeta.color}cc)`
|
||||
330→ 330→ : `${diffMeta.color}1a`,
|
||||
331→ 331→ color: canClaim ? "#022c22" : `${diffMeta.color}99`,
|
||||
332→ 332→ }}
|
||||
333→ 333→ >
|
||||
334→ 334→ {isClaimed ? "已领取" : canClaim ? "领取奖励" : isCompleted ? "已领取" : "进行中…"}
|
||||
335→ 335→ </Button>
|
||||
336→ 336→ </div>
|
||||
337→ 337→
|
||||
338→ 338→ {/* 完成时长 */}
|
||||
339→ 339→ {isCompleted && progress.durationSec > 0 && (
|
||||
340→ 340→ <div className="mt-1.5 text-[10px] text-muted-foreground/50 text-center">
|
||||
341→ 341→ 完成用时 {Math.floor(progress.durationSec / 60)}分{progress.durationSec % 60}秒
|
||||
342→ 342→ </div>
|
||||
343→ 343→ )}
|
||||
344→ 344→ </div>
|
||||
345→ 345→ </div>
|
||||
346→ 346→
|
||||
347→ 347→ {/* 周挑战 + 信标链(桌面端并排,移动端单列) */}
|
||||
348→ 348→ <div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
||||
349→ 349→ {/* ===== 周挑战卡片(fuchsia 主题) ===== */}
|
||||
350→ 350→ <div
|
||||
351→ 351→ className="relative rounded-xl border p-3 overflow-hidden"
|
||||
352→ 352→ style={{
|
||||
353→ 353→ borderColor: "rgba(232,121,249,0.4)",
|
||||
354→ 354→ background: `linear-gradient(135deg, rgba(232,121,249,0.12), rgba(0,0,0,0.45))`,
|
||||
355→ 355→ animation: wCompleted ? "none" : "weekly-glow 3.5s ease-in-out infinite",
|
||||
356→ 356→ }}
|
||||
357→ 357→ >
|
||||
358→ 358→ {/* 头部:标题 + weekKey + 倒计时 */}
|
||||
359→ 359→ <div className="flex items-center justify-between mb-2">
|
||||
360→ 360→ <div className="flex items-center gap-1.5">
|
||||
361→ 361→ <Zap className="h-3.5 w-3.5 text-fuchsia-400" />
|
||||
362→ 362→ <span className="text-xs font-semibold text-fuchsia-200">
|
||||
363→ 363→ 周挑战 · WEEKLY
|
||||
364→ 364→ </span>
|
||||
365→ 365→ </div>
|
||||
366→ 366→ <div className="flex items-center gap-1 text-[9px] text-muted-foreground/70">
|
||||
367→ 367→ <span className="font-mono text-fuchsia-300/80">
|
||||
368→ 368→ {weeklyChallenge.weekKey}
|
||||
369→ 369→ </span>
|
||||
370→ 370→ <Clock className="h-2.5 w-2.5" />
|
||||
371→ 371→ <span className="font-mono text-fuchsia-300/90">{weekCountdown}</span>
|
||||
372→ 372→ </div>
|
||||
373→ 373→ </div>
|
||||
374→ 374→
|
||||
375→ 375→ {/* 难度 + 类型 标签 */}
|
||||
376→ 376→ <div className="flex items-center gap-1.5 mb-2">
|
||||
377→ 377→ <span
|
||||
378→ 378→ className="text-[10px] font-mono px-1.5 py-0.5 rounded flex items-center gap-1"
|
||||
379→ 379→ style={{ background: `${wDiffMeta.color}22`, color: wDiffMeta.color, border: `1px solid ${wDiffMeta.color}55` }}
|
||||
380→ 380→ >
|
||||
381→ 381→ <span>{wDiffMeta.icon}</span>
|
||||
382→ 382→ {wDiffMeta.label}
|
||||
383→ 383→ </span>
|
||||
384→ 384→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-white/5 text-muted-foreground border border-white/10">
|
||||
385→ 385→ {wTypeMeta.icon} {wTypeMeta.label}
|
||||
386→ 386→ </span>
|
||||
387→ 387→ {wCompleted && (
|
||||
388→ 388→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 flex items-center gap-1">
|
||||
389→ 389→ <Sparkles className="h-2.5 w-2.5" /> 已完成
|
||||
390→ 390→ </span>
|
||||
391→ 391→ )}
|
||||
392→ 392→ </div>
|
||||
393→ 393→
|
||||
394→ 394→ {/* 标题 */}
|
||||
395→ 395→ <h4
|
||||
396→ 396→ className="text-sm font-semibold mb-1"
|
||||
397→ 397→ style={{ color: wDiffMeta.color, textShadow: `0 0 10px ${wDiffMeta.glow}` }}
|
||||
398→ 398→ >
|
||||
399→ 399→ {weeklyChallenge.title}
|
||||
400→ 400→ </h4>
|
||||
401→ 401→ <p className="text-[10px] text-muted-foreground/80 leading-relaxed mb-2">
|
||||
402→ 402→ {weeklyChallenge.desc}
|
||||
403→ 403→ </p>
|
||||
404→ 404→
|
||||
405→ 405→ {/* 进度条 */}
|
||||
406→ 406→ <div className="mb-2">
|
||||
407→ 407→ <div className="flex items-center justify-between mb-1">
|
||||
408→ 408→ <span className="text-[10px] text-muted-foreground/70">进度</span>
|
||||
409→ 409→ <span className="text-[11px] font-mono font-semibold" style={{ color: wDiffMeta.color }}>
|
||||
410→ 410→ {Math.min(weeklyProgress.progress, weeklyChallenge.goal)} / {weeklyChallenge.goal} {wTypeMeta.unit}
|
||||
411→ 411→ </span>
|
||||
412→ 412→ </div>
|
||||
413→ 413→ <Progress
|
||||
414→ 414→ value={wPct}
|
||||
415→ 415→ className="h-2 bg-black/40"
|
||||
416→ 416→ style={{
|
||||
417→ 417→ ["--progress-color" as string]: wDiffMeta.color,
|
||||
418→ 418→ }}
|
||||
419→ 419→ />
|
||||
420→ 420→ </div>
|
||||
421→ 421→
|
||||
422→ 422→ {/* 奖励 + 领取按钮 */}
|
||||
423→ 423→ <div className="flex items-center justify-between gap-2">
|
||||
424→ 424→ <div className="flex items-center gap-1.5 text-[10px]">
|
||||
425→ 425→ <span className="text-muted-foreground/60">奖励:</span>
|
||||
426→ 426→ {weeklyChallenge.rewardInsight > 0 && (
|
||||
427→ 427→ <span className="text-amber-300 font-mono">
|
||||
428→ 428→ +{formatNum(weeklyChallenge.rewardInsight)}洞见
|
||||
429→ 429→ </span>
|
||||
430→ 430→ )}
|
||||
431→ 431→ <span className="text-fuchsia-300 font-mono">
|
||||
432→ 432→ +{weeklyChallenge.rewardContact.toFixed(1)}接触
|
||||
433→ 433→ </span>
|
||||
434→ 434→ </div>
|
||||
435→ 435→ <Button
|
||||
436→ 436→ onClick={handleClaimWeekly}
|
||||
437→ 437→ disabled={!wCanClaim}
|
||||
438→ 438→ size="sm"
|
||||
439→ 439→ className="h-7 px-3 text-[11px] border-0"
|
||||
440→ 440→ style={{
|
||||
441→ 441→ background: wCanClaim
|
||||
442→ 442→ ? `linear-gradient(90deg, #34d399, #34d399cc)`
|
||||
443→ 443→ : "rgba(232,121,249,0.10)",
|
||||
444→ 444→ color: wCanClaim
|
||||
445→ 445→ ? "#022c22"
|
||||
446→ 446→ : "rgba(232,121,249,0.55)",
|
||||
447→ 447→ }}
|
||||
448→ 448→ >
|
||||
449→ 449→ {wClaimed
|
||||
450→ 450→ ? "已领取"
|
||||
451→ 451→ : wCanClaim
|
||||
452→ 452→ ? "领取奖励"
|
||||
453→ 453→ : wCompleted
|
||||
454→ 454→ ? "已领取"
|
||||
455→ 455→ : "进行中…"}
|
||||
456→ 456→ </Button>
|
||||
457→ 457→ </div>
|
||||
458→ 458→
|
||||
459→ 459→ {wCompleted && weeklyProgress.durationSec > 0 && (
|
||||
460→ 460→ <div className="mt-1.5 text-[10px] text-muted-foreground/50 text-center">
|
||||
461→ 461→ 完成用时 {Math.floor(weeklyProgress.durationSec / 60)}分{weeklyProgress.durationSec % 60}秒
|
||||
462→ 462→ </div>
|
||||
463→ 463→ )}
|
||||
464→ 464→ </div>
|
||||
465→ 465→
|
||||
466→ 466→ {/* ===== 信标链卡片(amber→rose 渐变) ===== */}
|
||||
467→ 467→ <div
|
||||
468→ 468→ className="relative rounded-xl border p-3 overflow-hidden"
|
||||
469→ 469→ style={{
|
||||
470→ 470→ borderColor: "rgba(251,191,36,0.35)",
|
||||
471→ 471→ background: `linear-gradient(135deg, rgba(251,191,36,0.10), rgba(251,113,133,0.10), rgba(0,0,0,0.4))`,
|
||||
472→ 472→ }}
|
||||
473→ 473→ >
|
||||
474→ 474→ {/* 头部:标题 + 当前连续天数 */}
|
||||
475→ 475→ <div className="flex items-center justify-between mb-2">
|
||||
476→ 476→ <div className="flex items-center gap-1.5">
|
||||
477→ 477→ <Link2 className="h-3.5 w-3.5 text-amber-400" />
|
||||
478→ 478→ <span className="text-xs font-semibold text-amber-200">
|
||||
479→ 479→ 信标链 · CHAIN
|
||||
480→ 480→ </span>
|
||||
481→ 481→ </div>
|
||||
482→ 482→ <div className="flex items-baseline gap-1">
|
||||
483→ 483→ <Flame className="h-3 w-3 text-rose-400" />
|
||||
484→ 484→ <span className="text-[10px] text-muted-foreground/60">连续</span>
|
||||
485→ 485→ <span className="chain-streak-text text-2xl font-bold font-mono leading-none">
|
||||
486→ 486→ {chainState.currentStreak}
|
||||
487→ 487→ </span>
|
||||
488→ 488→ <span className="text-[10px] text-muted-foreground/60">天</span>
|
||||
489→ 489→ </div>
|
||||
490→ 490→ </div>
|
||||
491→ 491→
|
||||
492→ 492→ {/* 今日完成状态 */}
|
||||
493→ 493→ <div className="mb-2 flex items-center justify-between">
|
||||
494→ 494→ <span className="text-[10px] text-muted-foreground/70">
|
||||
495→ 495→ {completedToday
|
||||
496→ 496→ ? "今日已贡献"
|
||||
497→ 497→ : "今日尚未完成日挑战"}
|
||||
498→ 498→ </span>
|
||||
499→ 499→ <span
|
||||
500→ 500→ className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${
|
||||
501→ 501→ completedToday
|
||||
502→ 502→ ? "bg-emerald-500/15 text-emerald-300 border-emerald-500/30"
|
||||
503→ 503→ : "bg-white/5 text-muted-foreground/70 border-white/10"
|
||||
504→ 504→ }`}
|
||||
505→ 505→ >
|
||||
506→ 506→ {completedToday ? "✓ 已记录" : "○ 待完成"}
|
||||
507→ 507→ </span>
|
||||
508→ 508→ </div>
|
||||
509→ 509→
|
||||
510→ 510→ {/* 4 个里程碑节点横向排列 */}
|
||||
511→ 511→ <div className="flex items-center justify-between mb-2 relative">
|
||||
512→ 512→ {/* 节点之间的连线(背景灰) */}
|
||||
513→ 513→ <div className="absolute top-6 left-[12.5%] right-[12.5%] h-[2px] bg-white/10" />
|
||||
514→ 514→ {/* 已达成部分高亮(基于 prev→next 插值) */}
|
||||
515→ 515→ {(() => {
|
||||
516→ 516→ // 节点圆心水平位置(百分比)
|
||||
517→ 517→ const MILESTONE_POS: Record<number, number> = {
|
||||
518→ 518→ 0: 12.5,
|
||||
519→ 519→ 3: 12.5,
|
||||
520→ 520→ 7: 37.5,
|
||||
521→ 521→ 14: 62.5,
|
||||
522→ 522→ 30: 87.5,
|
||||
523→ 523→ };
|
||||
524→ 524→ const prevPos =
|
||||
525→ 525→ MILESTONE_POS[chainProgress.prev] ?? 12.5;
|
||||
526→ 526→ const nextPos =
|
||||
527→ 527→ chainProgress.next !== null
|
||||
528→ 528→ ? MILESTONE_POS[chainProgress.next] ?? 87.5
|
||||
529→ 529→ : 87.5;
|
||||
530→ 530→ const pct = chainProgress.progressPct / 100;
|
||||
531→ 531→ const activeEndPos = prevPos + (nextPos - prevPos) * pct;
|
||||
532→ 532→ const widthPct = Math.max(0, activeEndPos - 12.5);
|
||||
533→ 533→ if (widthPct <= 0) return null;
|
||||
534→ 534→ return (
|
||||
535→ 535→ <div
|
||||
536→ 536→ className="absolute top-6 h-[2px]"
|
||||
537→ 537→ style={{
|
||||
538→ 538→ left: "12.5%",
|
||||
539→ 539→ width: `${widthPct}%`,
|
||||
540→ 540→ background:
|
||||
541→ 541→ "linear-gradient(90deg, #fbbf24, #fb7185)",
|
||||
542→ 542→ boxShadow: "0 0 8px rgba(251,113,133,0.5)",
|
||||
543→ 543→ }}
|
||||
544→ 544→ />
|
||||
545→ 545→ );
|
||||
546→ 546→ })()}
|
||||
547→ 547→
|
||||
548→ 548→ {BEACON_CHAIN_MILESTONES.map((m) => {
|
||||
549→ 549→ const reward = BEACON_CHAIN_REWARDS.find((r) => r.milestone === m);
|
||||
550→ 550→ const claimed = chainState.milestonesClaimed.includes(m);
|
||||
551→ 551→ const reachable =
|
||||
552→ 552→ chainState.currentStreak >= m && !claimed;
|
||||
553→ 553→ const inProgress = chainState.currentStreak > 0 && nextMilestone === m;
|
||||
554→ 554→ // 节点配色
|
||||
555→ 555→ let nodeBg = "rgba(255,255,255,0.05)";
|
||||
556→ 556→ let nodeBorder = "rgba(255,255,255,0.15)";
|
||||
557→ 557→ let textColor = "rgba(255,255,255,0.4)";
|
||||
558→ 558→ if (claimed) {
|
||||
559→ 559→ nodeBg = "rgba(52,211,153,0.25)";
|
||||
560→ 560→ nodeBorder = "#34d399";
|
||||
561→ 561→ textColor = "#34d399";
|
||||
562→ 562→ } else if (reachable) {
|
||||
563→ 563→ nodeBg = "rgba(251,113,133,0.20)";
|
||||
564→ 564→ nodeBorder = "#fb7185";
|
||||
565→ 565→ textColor = "#fb7185";
|
||||
566→ 566→ } else if (inProgress) {
|
||||
567→ 567→ nodeBg = "rgba(251,191,36,0.20)";
|
||||
568→ 568→ nodeBorder = "#fbbf24";
|
||||
569→ 569→ textColor = "#fbbf24";
|
||||
570→ 570→ }
|
||||
571→ 571→
|
||||
572→ 572→ return (
|
||||
573→ 573→ <div
|
||||
574→ 574→ key={m}
|
||||
575→ 575→ className="flex flex-col items-center gap-1 z-10 flex-1"
|
||||
576→ 576→ >
|
||||
577→ 577→ <div
|
||||
578→ 578→ className={`w-12 h-12 rounded-full flex items-center justify-center font-mono font-bold text-sm border-2 ${
|
||||
579→ 579→ reachable ? "chain-milestone-reachable" : ""
|
||||
580→ 580→ }`}
|
||||
581→ 581→ style={{
|
||||
582→ 582→ background: nodeBg,
|
||||
583→ 583→ borderColor: nodeBorder,
|
||||
584→ 584→ color: textColor,
|
||||
585→ 585→ }}
|
||||
586→ 586→ >
|
||||
587→ 587→ {claimed ? (
|
||||
588→ 588→ <Sparkles className="h-4 w-4" />
|
||||
589→ 589→ ) : (
|
||||
590→ 590→ m
|
||||
591→ 591→ )}
|
||||
592→ 592→ </div>
|
||||
593→ 593→ <span
|
||||
594→ 594→ className="text-[9px] text-center leading-tight"
|
||||
595→ 595→ style={{ color: textColor }}
|
||||
596→ 596→ >
|
||||
597→ 597→ {reward?.label}
|
||||
598→ 598→ </span>
|
||||
599→ 599→ {claimed ? (
|
||||
600→ 600→ <span className="text-[9px] text-emerald-400/80 font-mono">
|
||||
601→ 601→ 已领
|
||||
602→ 602→ </span>
|
||||
603→ 603→ ) : reachable ? (
|
||||
604→ 604→ <Button
|
||||
605→ 605→ onClick={() => handleClaimChain(m)}
|
||||
606→ 606→ size="sm"
|
||||
607→ 607→ className="h-5 px-2 text-[9px] py-0 border-0"
|
||||
608→ 608→ style={{
|
||||
609→ 609→ background:
|
||||
610→ 610→ "linear-gradient(90deg, #fb7185, #f43f5e)",
|
||||
611→ 611→ color: "#1c0608",
|
||||
612→ 612→ }}
|
||||
613→ 613→ >
|
||||
614→ 614→ 领取
|
||||
615→ 615→ </Button>
|
||||
616→ 616→ ) : (
|
||||
617→ 617→ <span className="text-[9px] text-muted-foreground/40 font-mono">
|
||||
618→ 618→ +{reward?.rewardInsight}洞
|
||||
619→ 619→ </span>
|
||||
620→ 620→ )}
|
||||
621→ 621→ </div>
|
||||
622→ 622→ );
|
||||
623→ 623→ })}
|
||||
624→ 624→ </div>
|
||||
625→ 625→
|
||||
626→ 626→ {/* 进度条 */}
|
||||
627→ 627→ <div className="mb-2">
|
||||
628→ 628→ <div className="flex items-center justify-between mb-1">
|
||||
629→ 629→ <span className="text-[10px] text-muted-foreground/70">
|
||||
630→ 630→ {chainProgress.next === null
|
||||
631→ 631→ ? "已通关全部里程碑"
|
||||
632→ 632→ : `下一目标:${chainProgress.next} 天`}
|
||||
633→ 633→ </span>
|
||||
634→ 634→ <span className="text-[10px] font-mono text-amber-300">
|
||||
635→ 635→ {chainProgress.current}
|
||||
636→ 636→ {chainProgress.next !== null && ` / ${chainProgress.next}`} 天
|
||||
637→ 637→ </span>
|
||||
638→ 638→ </div>
|
||||
639→ 639→ <Progress
|
||||
640→ 640→ value={chainProgress.progressPct}
|
||||
641→ 641→ className="h-1.5 bg-black/40"
|
||||
642→ 642→ style={{
|
||||
643→ 643→ ["--progress-color" as string]: "#fbbf24",
|
||||
644→ 644→ }}
|
||||
645→ 645→ />
|
||||
646→ 646→ </div>
|
||||
647→ 647→
|
||||
648→ 648→ {/* 底部统计 */}
|
||||
649→ 649→ <div className="flex items-center justify-between gap-2 text-[10px]">
|
||||
650→ 650→ <div className="flex items-center gap-2">
|
||||
651→ 651→ <span className="text-muted-foreground/60">最长</span>
|
||||
652→ 652→ <span className="font-mono text-amber-300">
|
||||
653→ 653→ {chainState.longestStreak}天
|
||||
654→ 654→ </span>
|
||||
655→ 655→ </div>
|
||||
656→ 656→ <div className="flex items-center gap-2">
|
||||
657→ 657→ <span className="text-muted-foreground/60">累计</span>
|
||||
658→ 658→ <span className="font-mono text-rose-300">
|
||||
659→ 659→ {chainState.totalCompletions}次
|
||||
660→ 660→ </span>
|
||||
661→ 661→ </div>
|
||||
662→ 662→ <div className="flex items-center gap-1">
|
||||
663→ 663→ <span className="text-muted-foreground/60">续命</span>
|
||||
664→ 664→ {chainState.graceUsed >= 1 ? (
|
||||
665→ 665→ <span className="font-mono text-rose-400/80">已用</span>
|
||||
666→ 666→ ) : (
|
||||
667→ 667→ <span className="font-mono text-emerald-400/80">可用</span>
|
||||
668→ 668→ )}
|
||||
669→ 669→ </div>
|
||||
670→ 670→ </div>
|
||||
671→ 671→ </div>
|
||||
672→ 672→ </div>
|
||||
673→ 673→
|
||||
674→ 674→ {/* 本地排行榜 */}
|
||||
675→ 675→ <div className="rounded-xl border border-white/10 bg-black/30 p-2.5">
|
||||
676→ 676→ <div className="flex items-center gap-1.5 mb-2">
|
||||
677→ 677→ <Trophy className="h-3.5 w-3.5 text-amber-400" />
|
||||
678→ 678→ <span className="text-xs font-semibold">深空排行榜</span>
|
||||
679→ 679→ <span className="text-[10px] text-muted-foreground/50">本地 · Top {leaderboard.length || 0}</span>
|
||||
680→ 680→ </div>
|
||||
681→ 681→
|
||||
682→ 682→ {leaderboard.length === 0 ? (
|
||||
683→ 683→ <div className="text-center py-4 text-[11px] text-muted-foreground/40">
|
||||
684→ 684→ <Trophy className="h-6 w-6 mx-auto mb-1 opacity-30" />
|
||||
685→ 685→ 尚无记录。完成今日或本周信标即可登榜。
|
||||
686→ 686→ </div>
|
||||
687→ 687→ ) : (
|
||||
688→ 688→ <div className="space-y-0.5 max-h-[180px] overflow-y-auto echo-scroll">
|
||||
689→ 689→ {leaderboard.map((entry, i) => {
|
||||
690→ 690→ const rb = rankBadge(i);
|
||||
691→ 691→ const eDiff = BEACON_DIFFICULTY[entry.difficulty];
|
||||
692→ 692→ const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType];
|
||||
693→ 693→ const isMine = entry.dateKey === getTodayKey() || entry.dateKey === getWeekKey();
|
||||
694→ 694→ return (
|
||||
695→ 695→ <div
|
||||
696→ 696→ key={`${entry.timestamp}-${i}`}
|
||||
697→ 697→ className={`flex items-center gap-2 px-2 py-1 rounded-lg text-[11px] ${
|
||||
698→ 698→ entry.isWeekly
|
||||
699→ 699→ ? "bg-fuchsia-500/10 border border-fuchsia-500/20"
|
||||
700→ 700→ : isMine
|
||||
701→ 701→ ? "bg-fuchsia-500/10 border border-fuchsia-500/20"
|
||||
702→ 702→ : "hover:bg-white/5"
|
||||
703→ 703→ }`}
|
||||
704→ 704→ >
|
||||
705→ 705→ {/* 排名 */}
|
||||
706→ 706→ <span className="w-6 flex items-center justify-center">
|
||||
707→ 707→ {rb ? (
|
||||
708→ 708→ <rb.icon className="h-3.5 w-3.5" style={{ color: rb.color }} />
|
||||
709→ 709→ ) : (
|
||||
710→ 710→ <span className="text-muted-foreground/50 font-mono">{i + 1}</span>
|
||||
711→ 711→ )}
|
||||
712→ 712→ </span>
|
||||
713→ 713→ {/* 类型 + 难度 + 周挑战标记 */}
|
||||
714→ 714→ <span className="flex items-center gap-1 flex-1 min-w-0">
|
||||
715→ 715→ <span style={{ color: eDiff.color }} className="font-mono">{eDiff.icon}</span>
|
||||
716→ 716→ <span className="text-muted-foreground/80 truncate">{eType.label}</span>
|
||||
717→ 717→ {entry.isWeekly && (
|
||||
718→ 718→ <span className="text-[9px] font-mono px-1 rounded bg-fuchsia-500/20 text-fuchsia-300 border border-fuchsia-500/40">
|
||||
719→ 719→ WEEK
|
||||
720→ 720→ </span>
|
||||
721→ 721→ )}
|
||||
722→ 722→ {entry.progress >= 1 && <span className="text-emerald-400/70">✓</span>}
|
||||
723→ 723→ </span>
|
||||
724→ 724→ {/* 用时 */}
|
||||
725→ 725→ <span className="text-muted-foreground/50 font-mono text-[10px] w-16 text-right">
|
||||
726→ 726→ {Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s
|
||||
727→ 727→ </span>
|
||||
728→ 728→ {/* 分数 */}
|
||||
729→ 729→ <span className="font-mono font-semibold w-12 text-right" style={{ color: eDiff.color }}>
|
||||
730→ 730→ {formatNum(entry.score)}
|
||||
731→ 731→ </span>
|
||||
732→ 732→ </div>
|
||||
733→ 733→ );
|
||||
734→ 734→ })}
|
||||
735→ 735→ </div>
|
||||
736→ 736→ )}
|
||||
737→ 737→ </div>
|
||||
738→ 738→
|
||||
739→ 739→ {/* 难度图例 */}
|
||||
740→ 740→ <div className="flex items-center justify-center gap-3 text-[9px] text-muted-foreground/50">
|
||||
741→ 741→ {DIFFICULTY_ORDER.map((d) => {
|
||||
742→ 742→ const m = BEACON_DIFFICULTY[d];
|
||||
743→ 743→ return (
|
||||
744→ 744→ <span key={d} className="flex items-center gap-1">
|
||||
745→ 745→ <span style={{ color: m.color }}>{m.icon}</span>
|
||||
746→ 746→ {m.label} ×{m.mult}
|
||||
747→ 747→ </span>
|
||||
748→ 748→ );
|
||||
749→ 749→ })}
|
||||
750→ 750→ </div>
|
||||
751→ 751→ </div>
|
||||
752→ 752→ );
|
||||
753→ 753→}
|
||||
754→ 754→
|
||||
@@ -0,0 +1,589 @@
|
||||
1→# 回响星核 / Echo Nexus — 开发工作日志
|
||||
2→
|
||||
3→> 持续性任务的工作交接文档。每个阶段更新,避免进度丢失。
|
||||
4→
|
||||
5→---
|
||||
6→
|
||||
7→## 一、项目当前状态描述 / 判断
|
||||
8→
|
||||
9→### 概况
|
||||
10→- **项目**:回响星核 / Echo Nexus — 深空考古放置策略游戏
|
||||
11→- **一句话**:自治无人机采矿 → 解码记忆晶体(原创共振谜题)→ 拼凑碎片叙事 → 飞升多周目。差异化:解码谜题驱动叙事涌现,全息晶体 + 深空粒子美学。
|
||||
12→- **当前版本**:**v0.7**(CrystalOrb Canvas 粒子系统 + 角色属性系统)
|
||||
13→- **在线游玩**:https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
|
||||
14→- **仓库**:https://git.atdunbg.xyz/Super_Z/echo-nexus
|
||||
15→- **技术栈**:Next.js 16 (App Router) + TypeScript + Tailwind + shadcn/ui + Canvas 2D + Zustand + Web Audio API
|
||||
16→- **定时任务**:每 15 分钟一次 `webDevReview`(`fixed_rate` + `"900"` 秒,priority=10,job_id 228266)。正常完成不会被删除,无需自持续机制。
|
||||
17→
|
||||
18→### 状态判断
|
||||
19→- dev 服务器运行正常(HTTP 200,编译 < 250ms)
|
||||
20→- v0.7 两大新功能已完成:CrystalOrb Canvas 粒子系统升级(VLM 8/10)+ 角色属性系统(VLM 7/10)
|
||||
21→- 系统总体稳定,无阻塞性 bug,可继续推进新功能
|
||||
22→
|
||||
23→### 已完成版本里程碑(精简)
|
||||
24→| 版本 | 核心内容 |
|
||||
25→|------|---------|
|
||||
26→| v0.1 | MVP:放置采矿 + 解码共振谜题 + 技术树 + 记忆图谱 + 飞升 + 统计 |
|
||||
27→| v0.1.1 | 解码可解性修复(路径构造法生成器,100% 可解率)|
|
||||
28→| v0.2 | 遗迹探险肉鸽系统(能量/探险力/6 节点/程序化路径/BOSS)|
|
||||
29→| v0.2.1 | 程序化音频(15 种音效)+ 14 项成就 + 视觉打磨 |
|
||||
30→| v0.3 | 星潮事件系统(6 种星潮 + 全屏叠层 + 程序化音效)|
|
||||
31→| v0.3.1 | 星图天文台元进程(6 类 × 3 = 18 天赋 + 飞升后 3 选 1 draft + Canvas 星图)|
|
||||
32→| v0.4 | 回响编年史(5 纪元循环命名 + 模板化叙事 + 时间轴 UI + 6 项新成就 + 飞升 BUG 修复)|
|
||||
33→| v0.5 | 深空信标(每日挑战 + 本地排行榜 Top20 + 7 标签页 + 编年史历史 BUG 修复)|
|
||||
34→| v0.5.1 | 静态部署 Gitea Pages + 新手教程系统(7 步聚光灯引导)+ UI 间距优化 |
|
||||
35→| v0.5.2 | 离线收益报告弹窗 + 标签页 UI 重设计 + persist rehydrate 竞态修复 |
|
||||
36→| v0.6 | 深空巡航 Canvas 2D 实时玩法 + UI 偏移重叠修复 |
|
||||
37→| **v0.7** | **CrystalOrb Canvas 粒子系统升级 + 角色属性系统(四维)** |
|
||||
38→
|
||||
39→### 核心系统清单(8 大系统)
|
||||
40→1. **放置采矿** — 中央晶体球 + 主动脉冲 + 连击加成(`CrystalOrb.tsx`)
|
||||
41→2. **谐振解码** — 原创路径谜题,路径构造法 100% 可解(`DecodeArray.tsx` + `engine.ts`)
|
||||
42→3. **技术树** — 三层科技解锁,提升产能/解码/探险(`TechTree.tsx`)
|
||||
43→4. **遗迹探险** — 肉鸽系统,6 种节点 + 程序化路径 + BOSS(`ExpeditionPanel.tsx` + `expedition.ts`)
|
||||
44→5. **星潮事件** — 6 种动态事件,为放置循环注入变化(`StarTide.tsx` + `starTide.ts`)
|
||||
45→6. **星图天文台** — 飞升后 3 选 1 天赋 draft,跨周目永久生效,18 个天赋(`ConstellationPanel.tsx` + `constellation.ts`)
|
||||
46→7. **回响编年史** — 跨周目叙事时间轴,5 纪元循环命名(`ChronicleDialog.tsx` + `chronicle.ts`)
|
||||
47→8. **深空信标** — 每日挑战 + 本地排行榜 Top20(`BeaconPanel.tsx` + `beacon.ts`)
|
||||
48→9. **深空巡航** — Canvas 2D 实时玩法,飞船操控/陨石躲避/星门通关(`CruiseMode.tsx` + `cruise.ts`)【v0.6】
|
||||
49→10. **角色属性系统** — 四维属性(探索力/智慧/勇气/灵感),飞升获得属性点,影响所有系统(`AttributesPanel.tsx` + `attributes.ts`)【v0.7 新增】
|
||||
50→
|
||||
51→---
|
||||
52→
|
||||
53→## 二、当前目标 / 已完成的修改 / 验证结果
|
||||
54→
|
||||
55→### v0.7 CrystalOrb Canvas 粒子系统升级(已完成)
|
||||
56→**动机**:P0 优先级 — 将中央晶体球从 CSS 动画升级为 Canvas 粒子视觉盛宴,提升视觉震撼。
|
||||
57→
|
||||
58→**重写文件**:`src/components/game/CrystalOrb.tsx`
|
||||
59→- 从纯 CSS 动画升级为 Canvas 2D 粒子系统
|
||||
60→- **多层粒子**:
|
||||
61→ - 环绕能量粒子(3层14/10/8个,emerald/fuchsia/rose三色,不同速度方向环绕晶核,带拖尾)
|
||||
62→ - 环境星尘(40个,缓慢漂移 + 闪烁)
|
||||
63→ - 点击爆发粒子(10-30个径向发散,颜色随连击等级变色)
|
||||
64→ - 冲击波环(点击触发1-3层错峰扩散,连击≥3/5解锁更多层)
|
||||
65→- **晶核升级**:径向渐变辉光 + 3层六边形纹理旋转 + 高光 + 鼠标视差偏移
|
||||
66→- **呼吸动画**:晶核 + 外层辉光随时间呼吸(sin波)
|
||||
67→- **进度环**:SVG渐变环(emerald→fuchsia→rose)保留
|
||||
68→- **交互保留**:点击脉冲、连击Toast、浮动数字、音效、`data-tut="crystal-orb"` 锚点
|
||||
69→- **性能**:DPR cap 2,ResizeObserver 自适应,requestAnimationFrame 60fps
|
||||
70→
|
||||
71→**QA 验证**(agent-browser + VLM):
|
||||
72→- VLM 视觉评分 **8/10**:晶体球清晰可见,多层辉光,环绕粒子动态自然,无渲染问题
|
||||
73→- 点击交互:冲击波环 ✅ + 爆发粒子 ✅,评分 7/10
|
||||
74→- lint 零错误;HTTP 200
|
||||
75→
|
||||
76→### v0.7 角色属性系统(已完成)
|
||||
77→**动机**:P1 优先级 — 增加RPG深度,四维属性影响所有游戏系统。
|
||||
78→
|
||||
79→**新增文件**:
|
||||
80→- `src/lib/game/attributes.ts`(~330行逻辑层):CharacterAttributes/AttributeProgress 类型、ATTRIBUTE_CONFIG 元信息、getAttributeBonus 两区加成公式(0-50线性+0.5%/点,50-100递减+0.2%/点)、getAllBonuses 12项修饰器、levelUpCheck 自动升级、computePrestigeAttrPoints、migrateAttributes 旧存档兼容
|
||||
81→- `src/components/game/AttributesPanel.tsx`(~330行 UI):四维卡片网格(小屏2×2/大屏1×4)、图标+中英文名+Lv.badge+数值/100+加成%+经验条+影响列表+分配按钮、顶部待分配点数badge闪烁、底部总览+12项修饰器明细
|
||||
82→
|
||||
83→**修改文件**:
|
||||
84→- `types.ts`:GameState 新增 attributes/attributeProgress/pendingAttrPoints
|
||||
85→- `config.ts`:INITIAL_STATE 补全默认值
|
||||
86→- `engine.ts`:recomputeStats 聚合属性加成;performPrestige 发放属性点(ascensions×2+1)
|
||||
87→- `gameStore.ts`:新增 allocateAttribute/gainAttributeExp actions;pulse/clickNode/autoDecodeTick/resolveCurrentNode/grantCruiseReward/tickTide 全部接入属性经验;init() 调用 migrateAttributes
|
||||
88→- `achievements.ts`:新增 ach_attr_total_50 + ach_attr_max_100 两项成就
|
||||
89→- `page.tsx`:grid-cols-7→8,新增「角色」标签页(User图标),红点提示,StatsPanel新增5行属性行,版本号 v0.6→v0.7
|
||||
90→
|
||||
91→**四维属性设计**:
|
||||
92→- 探索力(emerald):探险力+X%/巡航飞船速度+X%
|
||||
93→- 智慧(fuchsia):解码步数+X/洞见+X%/自动解码周期-X%
|
||||
94→- 勇气(amber):探险生命+X/BOSS胜率+X%/巡航护盾+X
|
||||
95→- 灵感(rose):接触率+X/星潮概率+X/脉冲连击+X%
|
||||
96→
|
||||
97→**QA 验证**(agent-browser + VLM):
|
||||
98→- VLM 视觉评分 **7/10**(面板)/ **8/10**(待分配状态):四维卡片可见、数值/等级/加成/经验条齐全、四色全息
|
||||
99→- 功能验证:setState pendingAttrPoints=5 → 显示5点待分配 → 点击分配按钮 → exploration 0→1, pending 5→4 ✅
|
||||
100→- lint 零错误;HTTP 200
|
||||
101→
|
||||
102→---
|
||||
103→
|
||||
104→### v0.6 深空巡航 Canvas 2D 实时玩法(历史记录)
|
||||
105→**动机**:回应用户反馈「玩法太单调(全是点点点)」+「视觉震撼不足」——首次引入实时操作玩法。
|
||||
106→
|
||||
107→**新增文件**:
|
||||
108→- `src/lib/game/cruise.ts`(~520 行逻辑层)
|
||||
109→ - 类型系统:CruiseEntity 联合类型(ship/asteroid/storm/crystal/insight/beacon/stargate/particle 共 8 种)
|
||||
110→ - mulberry32 + FNV-1a 种子化 RNG(`cruiseSeed(level)`)
|
||||
111→ - `generateLevel`:程序化生成陨石带(8+lvl×2)、虚空风暴(1+lvl/2)、晶体碎片(5+lvl)、洞见光球(2+lvl/3)、信标(1+lvl/4)、星门
|
||||
112→ - `updateCruise`:物理推进 + 碰撞检测 + 粒子系统 + 震动 + 600ms 无敌帧
|
||||
113→ - `computeRewards`:crystals=碎片×(8+lvl×2)、insights=洞见×(2+lvl×0.5)、contact=信标×(1.5+lvl×0.3);通关×1.5、失败×0.5
|
||||
114→ - 独立 localStorage `echo-nexus-cruise-v1`:最高分/累计通关/最高关卡/累计奖励/最近 20 局
|
||||
115→
|
||||
116→- `src/components/game/CruiseMode.tsx`(~830 行渲染层)
|
||||
117→ - 全屏 fixed inset-0 z-50 Canvas,DPR cap 2,resize 监听
|
||||
118→ - 3 层视差星空:远(100星)/中(60星)/近(30星),独立漂移 + 飞船速度视差 + 闪烁
|
||||
119→ - 8 种实体全部 ctx.shadowBlur 辉光绘制
|
||||
120→ - 粒子系统:尾焰/收集/碰撞/烟花,上限 200
|
||||
121→ - 屏幕震动 280ms 衰减;低护盾(<30%)红色边框脉冲
|
||||
122→ - HUD(HTML 叠层,glass+backdrop-blur,80ms 节流):护盾/能量/分数/用时/收集计数
|
||||
123→ - 控制:桌面 WASD/方向键 8 方向 + 移动端虚拟摇杆 + P 暂停 + Esc 退出 + 失焦自动暂停
|
||||
124→ - 阶段:ready → playing → won/lost(结算明细 + 奖励 + 下一关/重试/返回)
|
||||
125→
|
||||
126→**修改文件**:
|
||||
127→- `src/app/page.tsx`:header 新增 amber 主题「巡航」按钮(Navigation 图标)+ cruiseOpen state + 条件渲染 CruiseMode
|
||||
128→- `src/store/gameStore.ts`:新增 `grantCruiseReward({crystals?, insights?, contact?})` action(crystals 受 crystalCap 限制,contact 受 100 上限)
|
||||
129→- 版本号 v0.5.2 → v0.6
|
||||
130→
|
||||
131→**UI 偏移/重叠 BUG 修复**(3 处):
|
||||
132→- `DecodeArray.tsx`:移除 className 冲突的 `-translate-x-1/2 -translate-y-1/2`,统一用 inline transform 定位+缩放
|
||||
133→- `AchievementsPanel.tsx`:移除内层 `max-h-[300px]`,改 `flex-1 min-h-0` 自适应父容器
|
||||
134→- `page.tsx`:调整三个 section 的 min-h,避免小屏单列时总高度超过手机视口
|
||||
135→
|
||||
136→**色彩规范**:严格遵循 emerald(#34d399) / rose(#fb7185) / amber(#fbbf24) / fuchsia(#e879f9) 四色全息色谱,零蓝色/靛色
|
||||
137→
|
||||
138→**QA 验证**(agent-browser + VLM):
|
||||
139→- lint 零错误;dev 服务器 HTTP 200;编译 < 250ms
|
||||
140→- VLM 确认主界面:巡航按钮存在、晶体球居中、解码区正常、无重叠偏移
|
||||
141→- VLM 确认巡航玩法:飞船可见(绿色三角形)、HUD 完整、陨石+星空背景、粒子特效(黄色尾焰+紫色辉光)
|
||||
142→- WASD 控制飞船移动,按 W 直冲星门 → 通关,奖励 +30 晶体(2×10×1.5=30 计算正确)
|
||||
143→- localStorage 正确记录 highScore/totalRuns/totalWins/bestLevel
|
||||
144→- 奖励同步 gameStore(grantCruiseReward,满仓时 cap 逻辑正确)
|
||||
145→- 退出返回主界面,7 标签页 + 晶体球 + 解码面板均完好
|
||||
146→
|
||||
147→---
|
||||
148→
|
||||
149→## 三、未解决问题或风险 / 下一阶段优先事项
|
||||
150→
|
||||
151→### 已知问题 / 风险
|
||||
152→1. **dev 服务器 Turbopack 缓存偶发损坏**:`Unable to open static sorted file` + 进程被杀,需 `rm -rf .next` 重启。静态导出构建稳定可靠。
|
||||
153→2. **定时任务调度类型**:必须用 `fixed_rate` + `"900"` 秒(15分钟),不要用 cron 表达式。`webDevReview` 类型任务正常完成不会被删除,只有抛出未捕获异常才会被标记失败。
|
||||
154→3. **v0.3 星潮为单机版**:原计划 socket 全局事件,后续可扩展为多人同步。
|
||||
155→4. **v0.5 信标排行榜为本地版**(纯 localStorage):升级云排行榜需后端 API。
|
||||
156→5. **v0.5 每日挑战仅 1 个/天**:可加入"周挑战"或"信标链"(连续完成 N 天奖励)。
|
||||
157→6. **探险 BOSS 战胜率较低**:基础探索力 10 vs BOSS 难度 5-7,胜率约 25%,需玩家投资探险技术。
|
||||
158→7. **探险能量恢复较慢**:45s/点,可加技术提升恢复速度。
|
||||
159→8. **编年史上限 50 条**:超过自动丢弃最早的;v0.4 之前飞升无回填(空状态有提示)。
|
||||
160→9. **5 纪元叙事为模板化生成**:v0.5+ 将加入手写剧情节点。
|
||||
161→10. ~~**CrystalOrb 仍为 CSS 动画**~~ → ✅ **v0.7 已完成 Canvas 粒子系统升级**。
|
||||
162→
|
||||
163→### 下一阶段优先级(v0.7 后修正版)
|
||||
164→> 工单修正:原 P0 CrystalOrb Canvas + P1 角色属性两项均已在 v0.7 完成,现重新排序。
|
||||
165→
|
||||
166→1. **🔴 P0 — 巡航玩法大增强(v0.8 主线)**:BOSS 战关卡(每 5 关一个 BOSS,三阶段攻击模式 + 飞船射击系统)/ 道具掉落(5 种 powerup:护盾/能量/急速/超载/磁吸)/ 事件选择节点(每关通关后 3 选 1 强化牌,10 种强化)。这是当前最高优先级,能显著提升巡航玩法的深度与可玩性。
|
||||
167→2. **🟠 P1 — 探险 BOSS 战胜率平衡 + 探险能量恢复**:修复已知问题 6/7(探险 BOSS 胜率 25% 偏低,能量 45s/点 偏慢),提升探险体验。
|
||||
168→3. **🟠 P1 — 信标系统扩展**:周挑战 / 信标链(连续完成 N 天奖励),修复已知问题 5。
|
||||
169→4. **🟡 P2 — UI 样式细节打磨**:标签页图标/文字比例、视觉层次强化、移动端响应式进一步优化、CrystalOrb 周边留白优化(VLM 指出主界面左侧有空白)。
|
||||
170→5. **🟡 P2 — 云存档 + 云排行榜**:v0.5+ 后端 API(需评估是否引入 mini-service)。
|
||||
171→6. **🟢 P3 — 全 5 纪元手写叙事节点**:替换模板化 lore。
|
||||
172→7. **🟢 P3 — socket 多人同步星潮**:全局事件共享。
|
||||
173→
|
||||
174→### 定时任务(fixed_rate · 900s)
|
||||
175→- **当前 job_id**: **228357**(2026-06-24 13:25 重建)
|
||||
176→- **配置**: `fixed_rate` + `"900"`(15分钟间隔),`priority=10`(高优先级),`payload.kind=webDevReview`,`tz=Asia/Shanghai`
|
||||
177→- **任务名**: Echo Nexus - 任务审查与持续迭代
|
||||
178→- **任务描述强制要求**:每次触发 MUST 先完整阅读 worklog.md 工单,以工单为准,不凭记忆工作
|
||||
179→- **正常完成不会被删除**,只有未捕获异常会标记失败。若任务列表为空,需用 cron create 重建(schedule.kind=fixed_rate, expr="900", priority=10, payload.kind=webDevReview)。
|
||||
180→- **job_id 历史**: 227909(失效) → 228130(失效) → 228172(失效) → 228186(失效) → 228218(删除重建) → 228236(cron版,删除重建) → 228266(fixed_rate,失效) → 228329(失效) → **228357(当前,重建)**
|
||||
181→
|
||||
182→---
|
||||
183→
|
||||
184→## 定时任务描述模板(webDevReview · fixed_rate)
|
||||
185→
|
||||
186→> 重建参数:schedule.kind=`fixed_rate`, schedule.expr=`"900"`, params.priority=`10`, payload.kind=`webDevReview`
|
||||
187→
|
||||
188→```
|
||||
189→请先判断当前项目状态,使用 agent-browser 进行必要测试和 QA,然后自主选择本轮最合适的工作重点并继续开发。
|
||||
190→
|
||||
191→要求:
|
||||
192→1. 开始任务时查看 /home/z/my-project/worklog.md 了解实际进展,拆解详细todo
|
||||
193→2. 使用 agent-browser 进行测试和QA,有bug优先修复
|
||||
194→3. 如果稳定,自主提出新需求继续推进
|
||||
195→4. 样式要越做细节越多!!
|
||||
196→5. 功能要越做越多!!
|
||||
197→6. 工作告一段落更新 /home/z/my-project/worklog.md
|
||||
198→7. 项目结束后一定要把工作记录写在 worklog.md 里
|
||||
199→
|
||||
200→交接文档建议包含三部分:
|
||||
201→- 项目当前状态描述/判断
|
||||
202→- 当前目标/已完成的修改/验证结果
|
||||
203→- 未解决问题或风险,建议下一阶段优先事项
|
||||
204→```
|
||||
205→
|
||||
206→---
|
||||
207→
|
||||
208→## 历史详细版本记录(按需查阅 docs/repo/docs/)
|
||||
209→
|
||||
210→- `04-解码系统修复-v0.1.1.md`
|
||||
211→- `05-遗迹探险系统-v0.2.md`
|
||||
212→- `06-音频与成就系统-v0.2.1.md`
|
||||
213→- `07-星潮事件系统-v0.3.md`
|
||||
214→- `08-星图天文台系统-v0.3.1.md`
|
||||
215→- `09-回响编年史系统-v0.4.md`
|
||||
216→- `10-深空信标系统-v0.5.md`
|
||||
217→
|
||||
218→---
|
||||
219→
|
||||
220→Task ID: 7
|
||||
221→Agent: full-stack-developer
|
||||
222→Task: 角色属性系统(探索力/智慧/勇气/灵感)
|
||||
223→
|
||||
224→Work Log:
|
||||
225→- 阅读 worklog.md 了解 v0.6 项目背景,确认 8 大系统架构与四色全息色彩规范
|
||||
226→- 新建 `src/lib/game/attributes.ts` 逻辑层(~330 行):
|
||||
227→ - 定义 CharacterAttributes/AttributeProgress/AttributeKey 类型 + ATTRIBUTE_CONFIG 元信息
|
||||
228→ - getAttributeBonus 线性+递减两区加成公式(0-50 每 10 点 +5%,50-100 每点 +2%)
|
||||
229→ - getAllBonuses 返回 12 个修饰器(探险力/巡航速度/解码步数/洞见/自动解码周期/探险生命/BOSS 胜率/巡航护盾/接触率/星潮触发/脉冲连击/产能)
|
||||
230→ - levelUpCheck 自动跨多级 + expRequiredForLevel + computePrestigeAttrPoints + migrateAttributes 兼容
|
||||
231→- 扩展 `src/lib/game/types.ts` GameState:新增 attributes/attributeProgress/pendingAttrPoints
|
||||
232→- 扩展 `src/lib/game/config.ts` INITIAL_STATE:补全默认值(0 数值 + 空 progress)
|
||||
233→- 扩展 `src/lib/game/engine.ts`:
|
||||
234→ - recomputeStats 末尾聚合 am.crystalsPerSecMult/insightMultAdd/contactRateMult/decodeStepsBonus
|
||||
235→ - performPrestige 保留 attributes 数值,清空 attributeProgress,发放 ascensions × 2 + 1 点属性点
|
||||
236→ - createInitialState 每次返回全新对象避免引用共享
|
||||
237→- 扩展 `src/store/gameStore.ts`(核心改造,10+ 处 syncStats 调用全部补 attributes 参数):
|
||||
238→ - pulse:连击 ≥3 给灵感经验 + 脉冲威力乘灵感连击加成
|
||||
239→ - clickNode 完成:给智慧经验(tier × 2)
|
||||
240→ - autoDecodeTick:智慧经验 +1 + 自动解码周期受 am.autoDecodeIntervalMult 影响
|
||||
241→ - startExpedition:探险力乘 am.expeditionPowerMult,生命加 am.expeditionHpBonus
|
||||
242→ - resolveCurrentNode:BOSS 节点包装 RNG 提升 +am.bossWinRateBonus 胜率;战斗胜利给勇气+探索力经验
|
||||
243→ - grantCruiseReward:按总奖励量给探索力+勇气经验
|
||||
244→ - tickTide:灵感 am.tideTriggerBonus 缩短星潮间隙(上限 30%)
|
||||
245→ - init() 调用 migrateAttributes 补全旧存档
|
||||
246→ - 新增 allocateAttribute / gainAttributeExp 两个 action
|
||||
247→- 新建 `src/components/game/AttributesPanel.tsx` UI 面板(~330 行):
|
||||
248→ - 四维卡片网格(小屏 2×2,大屏 1×4)
|
||||
249→ - 每张卡片:图标 + 中英文名 + Lv.badge + 数值/100 + 加成% + 经验条 + 影响列表 + 「+分配」按钮
|
||||
250→ - 顶部待分配点数 badge(pendingAttrPoints > 0 时 echo-pending-pulse 闪烁动画)
|
||||
251→ - 底部总等级/总加成概览 + 12 个修饰器明细
|
||||
252→ - 4 色辉光边框(emerald/fuchsia/amber/rose),顶角光晕装饰
|
||||
253→- 接入 `src/app/page.tsx`:
|
||||
254→ - grid-cols-7 → grid-cols-8,新增第 8 个「角色」TabsTrigger(User 图标,渐变主题)
|
||||
255→ - pendingAttrPoints > 0 时显示 rose 红点提示
|
||||
256→ - 渲染 AttributesPanel
|
||||
257→ - StatsPanel 新增 5 行属性行(探索力/智慧/勇气/灵感/待分配点)
|
||||
258→ - 版本号 v0.6 → v0.7
|
||||
259→- 新增 `src/lib/game/achievements.ts` 2 项成就:
|
||||
260→ - ach_attr_total_50(四维觉醒):总属性 ≥ 50 → 产能+6%/洞见+6%
|
||||
261→ - ach_attr_max_100(维度精通):任一属性 100 → 产能+12%/洞见+10%
|
||||
262→- 写入 `agent-ctx/7-full-stack-developer.md` 工作记录
|
||||
263→- 最终验证:lint 零错误 + dev 服务器 HTTP 200 + 编译 < 250ms
|
||||
264→
|
||||
265→Stage Summary:
|
||||
266→- ✅ 角色属性系统 v0.7 P1 全部完成
|
||||
267→- ✅ 新增 2 文件(attributes.ts + AttributesPanel.tsx),修改 6 文件(types/config/engine/gameStore/achievements/page)
|
||||
268→- ✅ 四维属性完整接入游戏各系统(探险/解码/脉冲/巡航/星潮)
|
||||
269→- ✅ 飞升属性点公式 = ascensions × 2 + 1;经验累积自动升级(每级 10×level)
|
||||
270→- ✅ 旧存档兼容(migrateAttributes 补全字段,同步 level 与 attributes)
|
||||
271→- ✅ lint 零错误 + HTTP 200 + 编译 < 250ms
|
||||
272→- ✅ 严格四色全息(emerald/fuchsia/amber/rose),零蓝色/靛色
|
||||
273→- ✅ 8 标签页 + 巡航按钮全部保留,data-tut 锚点未破坏
|
||||
274→- ✅ 待分配点数时标签页 rose 红点提示 + 顶部 badge 闪烁
|
||||
275→
|
||||
276→---
|
||||
277→Task ID: 8-deploy
|
||||
278→Agent: main
|
||||
279→Task: 重建定时任务 + 更新 README + 部署 gh-pages(v0.7)
|
||||
280→
|
||||
281→Work Log:
|
||||
282→- 检查项目状态:CrystalOrb.tsx 已是 v0.7 Canvas 粒子系统(659 行),无活动 cron job
|
||||
283→- 重建定时任务:cron create fixed_rate="900" priority=10 webDevReview → job_id 228329(tz=Asia/Shanghai)
|
||||
284→- 重写 README.md:
|
||||
285→ - 顶部显著位置添加「在线游玩」链接(https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/)
|
||||
286→ - 版本号 v0.1 → v0.7
|
||||
287→ - 新增 10 大系统表格(含 v0.6 巡航 + v0.7 角色属性)
|
||||
288→ - 完整开发路线表(v0.1-v1.0,标记 ✅/🔜 状态)
|
||||
289→ - 新增本地开发命令 + Gitea Pages 部署指南
|
||||
290→ - 文档索引扩展至 10 个版本文档
|
||||
291→- 静态导出:BUILD_EXPORT=true bun run build:static → out/ 1.6M(HTTP 200 编译 4.3s)
|
||||
292→- 推送 gh-pages 分支:force push origin gh-pages(commit 8727fed "deploy: v0.7")
|
||||
293→- 提交 main:README 更新(commit 43dfc2d "docs: README 添加在线游玩链接 + v0.7 状态更新")
|
||||
294→- 验证 Gitea Pages:https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/ → HTTP 200 ✅
|
||||
295→- QA 验证:agent-browser 打开 http://localhost:3000/ → VLM 评分 8/10(主界面 polished,minor 空白)
|
||||
296→
|
||||
297→Stage Summary:
|
||||
298→- ✅ 定时任务重建成功(job_id 228329,每 15 分钟自动审查迭代)
|
||||
299→- ✅ README 重写完成,网站链接显著展示,v0.7 状态完整
|
||||
300→- ✅ gh-pages 部署成功,Gitea Pages HTTP 200 可访问
|
||||
301→- ✅ main 分支已同步推送(README + worklog 更新)
|
||||
302→- ✅ dev 服务器健康(HTTP 200,编译 < 250ms)
|
||||
303→- ✅ CrystalOrb v0.7 Canvas 粒子系统已确认完整(659 行)
|
||||
304→- 下一步:v0.8 巡航玩法增强(BOSS 战 / 道具掉落 / 事件选择节点)
|
||||
305→
|
||||
306→---
|
||||
307→Task ID: 8
|
||||
308→Agent: main
|
||||
309→Task: v0.8 巡航玩法大增强(BOSS 战 + 道具掉落 + 事件选择节点)+ 工单修正 + 审查流程重建
|
||||
310→
|
||||
311→Work Log:
|
||||
312→- 重读 worklog 工单,发现 v0.7 两项(CrystalOrb Canvas + 角色属性)已完成但工单"下一阶段优先级"未更新
|
||||
313→- 修正 worklog 工单:标注 P0/P1 v0.7 已完成,重新排序下一阶段优先级(P0=巡航增强, P1=探险平衡/信标扩展, P2=UI打磨/云存档, P3=叙事/多人)
|
||||
314→- 重建审查流程 cron job:fixed_rate=900s, priority=10, webDevReview, tz=Asia/Shanghai, job_id=228357
|
||||
315→ - 任务描述强制要求:每次触发 MUST 先完整阅读 worklog.md 工单,以工单为准,不凭记忆工作
|
||||
316→- 发现之前被中断的 v0.8 subagent 已写入 2037 行代码(cruise.ts +1487 / CruiseMode.tsx +858 / page.tsx +2)
|
||||
317→- 修复 2 个 lint 错误(react-hooks/set-state-in-effect):
|
||||
318→ - isTouch: useState(false) + useEffect(setIsTouch) → useState initializer
|
||||
319→ - stats: useState(null) + useEffect(setStats(loadCruiseStats)) → useState initializer
|
||||
320→- QA 验证(agent-browser + VLM):
|
||||
321→ - 主界面 VLM 7/10:v0.8 版本号 ✅ + 巡航按钮 ✅
|
||||
322→ - 巡航 READY 阶段 VLM 8/10:BOSS 战说明 ✅(每5关BOSS战/击败BOSS)+ 射击控制说明 ✅(空格/右下角射击按钮)
|
||||
323→ - 巡航第1关游戏 VLM 8/10:飞船(绿色三角)✅ + HUD(护盾/能量/分数/计时)✅ + 陨石/收集物/能量场 ✅ + 无渲染错误
|
||||
324→- 提交 v0.8(commit 494bc5f)+ 推送 main
|
||||
325→- 静态导出 BUILD_EXPORT=true → out/ 1.6M(编译 4.1s)
|
||||
326→- 部署 gh-pages(force push, commit b865922)→ Gitea Pages HTTP 200 ✅
|
||||
327→
|
||||
328→Stage Summary:
|
||||
329→- ✅ 工单修正完成:v0.7 已完成项标注清楚,下一阶段优先级重新排序
|
||||
330→- ✅ 审查流程重建:cron job_id 228357,每15分钟触发,强制读工单
|
||||
331→- ✅ v0.8 巡航玩法大增强全部完成并部署:
|
||||
332→ - BOSS 战系统(每5关,三阶段攻击,HP条,击败奖励×3)
|
||||
333→ - 飞船射击系统(空格/射击按钮,能量消耗,子弹尾焰)
|
||||
334→ - 道具掉落(5种powerup:护盾/能量/急速/超载/磁吸)
|
||||
335→ - 事件选择节点(每关通关后3选1,10种强化牌)
|
||||
336→ - lint 零错误 + dev HTTP 200 + gh-pages HTTP 200
|
||||
337→- ✅ VLM 评分:主界面 7/10 + 巡航READY 8/10 + 巡航游戏 8/10
|
||||
338→- 在线游玩 v0.8:https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
|
||||
339→- 下一阶段工单:P1 探险BOSS胜率平衡 + 探险能量恢复 / 信标系统扩展(周挑战/信标链)
|
||||
340→
|
||||
341→---
|
||||
342→Task ID: 9-b
|
||||
343→Agent: full-stack-developer
|
||||
344→Task: 信标系统扩展(周挑战 + 信标链连续奖励)
|
||||
345→
|
||||
346→Work Log:
|
||||
347→- 阅读现有 `src/lib/game/beacon.ts`(v0.5 每日挑战 + 本地排行榜,358 行)+ `src/components/game/BeaconPanel.tsx` + `src/store/gameStore.ts` 的 `trackBeacon` 函数(约第 173 行)+ worklog v0.8 项目状态,确认四色全息规范与现有架构。
|
||||
348→- **扩展 `src/lib/game/beacon.ts`(358 → 873 行,新增 ~515 行)**:
|
||||
349→ - **周挑战(WEEKLY CHALLENGE)**:
|
||||
350→ - `BeaconWeeklyChallenge` 接口(weekKey YYYY-Www / type / difficulty 强制 anomaly|singular / goal / rewardInsight / rewardContact / seed / title / desc)
|
||||
351→ - `BeaconWeeklyProgress` 接口(weekKey / progress / startedAt / completedAt / claimed / durationSec)
|
||||
352→ - `getWeekKey(now)`:ISO 8601 周键(周一为起点,含首个周四的周为第一周)
|
||||
353→ - `weekKeyToSeed`:FNV-1a 哈希
|
||||
354→ - `generateWeeklyChallenge(now)`:基于 weekKey 种子确定性生成,难度加权 anomaly 60% / singular 40%,goal 为日基准 ×3-5 倍(decode 18-75 / expedition 3-15 / pulse 60-245 / boss 3-12 / insight 120-595)
|
||||
355→ - `loadWeeklyProgress` / `saveWeeklyProgress` / `addWeeklyProgress` / `claimWeeklyReward`(推送排行榜,entry 加 `isWeekly: true`)
|
||||
356→ - `msUntilNextWeek(now)`:距离下周一 UTC 0 点的毫秒数
|
||||
357→ - `BEACON_WEEKLY_KEY = "echo-nexus-beacon-weekly-v1"`
|
||||
358→ - **信标链(BEACON CHAIN)**:
|
||||
359→ - `BeaconChainState` 接口(lastCompletedDateKey / currentStreak / longestStreak / totalCompletions / graceUsed / milestonesClaimed)
|
||||
360→ - `BEACON_CHAIN_KEY = "echo-nexus-beacon-chain-v1"`
|
||||
361→ - `BEACON_CHAIN_MILESTONES = [3, 7, 14, 30]` 常量
|
||||
362→ - `BEACON_CHAIN_REWARDS`:4 个里程碑(三日谐振 +50/+5 / 七日回响 +120/+12 / 半月星潮 +280/+28 / 满月飞升 +680/+68)
|
||||
363→ - `loadChainState` / `saveChainState`(每次返回新对象避免引用共享)
|
||||
364→ - `recordChainCompletion(dateKey)` 核心逻辑:同日重复忽略;次日 streak++;隔一天 miss 且 graceUsed<1 时续命 streak++ graceUsed++;其他断链 streak=1 graceUsed=0;返回 `{ state, newMilestones }`
|
||||
365→ - `claimChainMilestone(milestone)` / `getNextMilestone(streak)` / `getChainProgress(streak)`(返回 current/next/prev/progressPct 用于 UI)
|
||||
366→ - `dateKeyToTimestamp` / `dateKeyDiffDays` 工具函数
|
||||
367→ - `BeaconScoreEntry` 新增可选 `isWeekly?: boolean` 字段
|
||||
368→- **扩展 `src/store/gameStore.ts`**:
|
||||
369→ - import 扩展:generateWeeklyChallenge / loadWeeklyProgress / loadChainState / addWeeklyProgress / recordChainCompletion / claimWeeklyReward / claimChainMilestone / getTodayKey + 类型
|
||||
370→ - `trackBeacon(type, delta)` 函数签名从 `boolean` 改为 `{ dailyJustCompleted, weeklyJustCompleted, newChainMilestones }`:
|
||||
371→ - 日挑战类型匹配且未完成 → addBeaconProgress;若刚完成 → recordChainCompletion(getTodayKey()) 收集 newMilestones
|
||||
372→ - 周挑战类型匹配且未完成 → addWeeklyProgress
|
||||
373→ - 所有调用点(pulse/clickNode/autoDecodeTick/resolveCurrentNode 等 7 处)原本就忽略返回值,向后兼容
|
||||
374→ - 新增 action `claimWeeklyBeacon`:调用 claimWeeklyReward → 发放 insights + contact 到 state,返回 `{ rewardInsight, rewardContact, score }`
|
||||
375→ - 新增 action `claimChainReward(milestone)`:前置校验 currentStreak 与 milestonesClaimed,调用 claimChainMilestone → 发放奖励,返回 `{ rewardInsight, rewardContact, label, ok }`
|
||||
376→ - GameActions 接口同步新增两个 action 签名
|
||||
377→- **重写 `src/components/game/BeaconPanel.tsx`(301 → 638 行)**:
|
||||
378→ - 顶部头部 + 每日挑战卡片(保留 v0.5 完整功能 + 按钮主题色随难度色淡显)
|
||||
379→ - **新增周挑战区块**(fuchsia 主题):标题"周挑战 · WEEKLY" + weekKey + 倒计时 + 挑战卡片(类型/难度标签/标题/描述/进度条/奖励/领取按钮 emerald 主题)+ weekly-glow 动画
|
||||
380→ - **新增信标链区块**(amber→rose 渐变):标题"信标链 · CHAIN" + 大字号 streak(chain-streak-text 渐变流动动画)+ 今日完成状态徽章 + 4 个里程碑节点(w-12 h-12 圆形,claimed=emerald/✓,reachable=rose 脉冲动画+领取按钮,inProgress=amber 半亮,未到达=muted 灰)+ 节点间连线(背景灰 + 已达成部分 amber→rose 渐变 + 辉光,基于 prev→next 插值定位)+ 进度条 + 底部统计(最长/累计/续命状态)
|
||||
381→ - 桌面端 lg:grid-cols-2 让周挑战+信标链并排,移动端单列
|
||||
382→ - 排行榜条目区分日/周:周挑战 entry 显示 "WEEK" 徽章 + fuchsia 高亮背景
|
||||
383→ - 空状态提示扩展为"完成今日或本周信标即可登榜"
|
||||
384→ - 4 个新动画:weekly-glow / chain-milestone-pulse / chain-streak-flux(背景渐变流动)
|
||||
385→- **QA 验证**:
|
||||
386→ - `bun run lint` 零错误(每次修改后均验证)
|
||||
387→ - dev.log 全程无错误,所有编译 < 300ms
|
||||
388→ - **信标链逻辑测试**(bun 直接运行 TS,5 个场景全 PASS):
|
||||
389→ 1. 昨日 streak=1 → 今日完成 → streak=2 ✅
|
||||
390→ 2. 同日重复完成 → 忽略 ✅
|
||||
391→ 3. 明日完成 → streak=3,无需 grace ✅
|
||||
392→ 4. 隔一天 miss(lastCompleted + 2 天)→ streak=4,graceUsed=1 ✅
|
||||
393→ 5. 再次 miss(grace 已用)→ 断链 streak=1,graceUsed=0 ✅
|
||||
394→ - **UI 集成测试**(agent-browser):
|
||||
395→ - localStorage 模拟设置 chain state(lastCompletedDateKey=yesterday, currentStreak=1)→ reload → UI 正确显示 streak=1
|
||||
396→ - 设置 streak=3 + milestonesClaimed=[] → milestone 3 节点显示 rose 脉冲动画 + "领取"按钮
|
||||
397→ - 点击"领取"→ milestonesClaimed=[3] + Toast "✦ 三日谐振 已领取 +50 洞见 · +5.0 接触" + 按钮消失 ✅
|
||||
398→ - **VLM 视觉评分**(目标 ≥7/10):
|
||||
399→ - 首屏截图:8/10(四色一致、布局合理、信标链清晰)
|
||||
400→ - 里程碑可领取状态:8/10(amber→rose 渐变醒目、里程碑层次分明、fuchsia 主题清晰)
|
||||
401→ - 重置后干净状态:8/10(WEEKLY 字体对比度可优化,但整体可读性强)
|
||||
402→ - **不破坏现有功能**:日挑战进度/领奖/排行榜流程全部保留并工作正常
|
||||
403→
|
||||
404→Stage Summary:
|
||||
405→- ✅ beacon.ts 扩展完成(358 → 873 行):周挑战 + 信标链全部函数与类型
|
||||
406→- ✅ gameStore.ts 集成完成:trackBeacon 升级为对象返回值 + claimWeeklyBeacon + claimChainReward 两个新 action
|
||||
407→- ✅ BeaconPanel.tsx UI 完整重写(301 → 638 行):周挑战 fuchsia 卡片 + 信标链 amber→rose 渐变区块
|
||||
408→- ✅ 严格四色全息:周挑战 fuchsia、信标链 amber→rose、领取按钮 emerald、无蓝/紫色违规
|
||||
409→- ✅ localStorage 新增 2 个独立 key(weekly + chain),不污染旧 key
|
||||
410→- ✅ 确定性:周挑战同周同种子同结果(weekKey FNV-1a 种子)
|
||||
411→- ✅ 信标链 grace 续命机制工作正常(5 个场景 bun 测试全 PASS)
|
||||
412→- ✅ lint 零错误 + dev HTTP 200 + VLM 8/10
|
||||
413→- ✅ 现有日挑战 + 排行榜 + 领奖流程全部保留,无回归
|
||||
414→
|
||||
415→---
|
||||
416→Task ID: 9
|
||||
417→Agent: main + subagent(9-b)
|
||||
418→Task: v0.8.1 P1 双任务 — 探险 BOSS 胜率平衡 + 能量恢复 + 信标系统扩展(周挑战 + 信标链)
|
||||
419→
|
||||
420→Work Log:
|
||||
421→- 重读工单确认 P1 两项为下一阶段优先级
|
||||
422→- P1-a 探险平衡(main 自己做,平衡需要精细控制):
|
||||
423→ - expedition.ts: combatDifficultyScale 8→4, combatWinRateFloor 0.25→0.35, BOSS difficulty 5-7→3-5
|
||||
424→ - 新增 computeEnergyRegenInterval(state): exp_2 -30%, exp_3 -20%, 探索力属性 -最高30%, 下限12s
|
||||
425→ - computeEnergyRegen 接受 intervalSec 参数
|
||||
426→ - config.ts: exp_2/exp_3 描述加能量恢复说明
|
||||
427→ - gameStore.ts tick: 传入动态间隔
|
||||
428→ - ExpeditionPanel.tsx: 显示实际恢复速度 + '已加速'标记
|
||||
429→- P1-b 信标系统扩展(dispatch subagent 9-b,并行完成):
|
||||
430→ - beacon.ts 358→873行: 周挑战(getWeekKey ISO 8601 + 确定性生成) + 信标链(4里程碑+grace续命)
|
||||
431→ - BeaconPanel.tsx 301→638行: 周挑战fuchsia主题 + 信标链amber→rose里程碑节点
|
||||
432→ - gameStore.ts: trackBeacon升级 + claimWeeklyBeacon/claimChainReward actions
|
||||
433→- QA 验证:
|
||||
434→ - lint 零错误
|
||||
435→ - 探险面板 VLM 7/10(能量条 + 恢复速度显示正常)
|
||||
436→ - 信标面板 VLM 8/10(周挑战 WEEKLY + 信标链 3/7/14/30 + 排行榜全部可见)
|
||||
437→ - subagent 5场景 bun 测试全 PASS(信标链断链/续命逻辑)
|
||||
438→- 提交 v0.8.1(commit 71ca5b4)+ 推送 main
|
||||
439→- 静态导出 + 部署 gh-pages(commit 97eecaa)→ HTTP 200
|
||||
440→
|
||||
441→Stage Summary:
|
||||
442→- ✅ P1-a 探险平衡完成: BOSS 基础胜率 25%→35-45%, 配合技术/属性可达 55-70%
|
||||
443→- ✅ P1-a 能量恢复: 固定45s → 动态12-45s(技术+属性加速)
|
||||
444→- ✅ P1-b 周挑战: 每周一UTC刷新, fuchsia主题, goal×3-5倍
|
||||
445→- ✅ P1-b 信标链: 4里程碑(3/7/14/30天) + grace续命, amber→rose渐变
|
||||
446→- ✅ 在线游玩 v0.8.1: https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
|
||||
447→- 下一阶段工单: P2 UI样式细节打磨 / P2 云存档+云排行榜 / P3 全纪元叙事
|
||||
448→
|
||||
449→---
|
||||
450→Task ID: 10-a
|
||||
451→Agent: full-stack-developer
|
||||
452→Task: #5 限时挑战 + #3 星潮类型深化
|
||||
453→
|
||||
454→Work Log:
|
||||
455→- 阅读项目上下文:worklog.md(v0.8.1,10 大系统)+ beacon.ts(873行,日+周+链)+ starTide.ts(209行,6种星潮)+ gameStore trackBeacon/tickTide + BeaconPanel.tsx,确认四色全息规范与现有架构
|
||||
456→- **工单 #5 限时挑战**:
|
||||
457→ - beacon.ts(873→1162行)新增限时挑战系统:
|
||||
458→ - BeaconTimedChallenge/BeaconTimedProgress 接口 + isTimed 字段加入 BeaconScoreEntry
|
||||
459→ - getTimedSlotKey(UTC 0/4/8/12/16/20 点切分,"timed_YYYY-MM-DD_HH")+ timedSlotKeyToSeed(FNV-1a)
|
||||
460→ - generateTimedChallenge(确定性,难度 routine,goal=日基准×0.3-0.5,5类型公式)
|
||||
461→ - loadTimedProgress/saveTimedProgress/addTimedProgress/claimTimedReward(排行榜 isTimed)
|
||||
462→ - msUntilNextTimedSlot + TIMED_SLOT_MS=4h + BEACON_TIMED_KEY
|
||||
463→ - gameStore.ts:trackBeacon 返回值新增 timedJustCompleted,三进度同时更新;新增 claimTimedBeacon action
|
||||
464→ - BeaconPanel.tsx(753→944行):日挑战与周挑战之间插入限时挑战区块(amber 主题)
|
||||
465→ - Hourglass图标+TIMED标题+slotKey+倒计时 + 进度+奖励+领取按钮(emerald)
|
||||
466→ - 紧急状态:距时段结束<30min 切 rose 脉冲动画+"即将结束"徽章
|
||||
467→ - 2新动画 timed-glow(amber)/timed-urgent(rose) + 排行榜 TIMED 徽章
|
||||
468→- **工单 #3 星潮深化**:
|
||||
469→ - starTide.ts(209→289行):TideType 新增 surge/eclipse/prism
|
||||
470→ - surge(emerald ⇈): crystalsPerSec×2.5 + targetLenBonus=2(解码目标序列+2,更长更难)
|
||||
471→ - eclipse(rose ◐): crystalsPerSec×0.7 + bossWinRateBonus=0.2(BOSS胜率+20%)
|
||||
472→ - prism(fuchsia ◬): insightMultAdd=1.0(洞见×2) + autoDecodeIntervalMult=0.7(自动解码-30%)
|
||||
473→ - TideModifiers 新增 targetLenBonus/bossWinRateBonus/autoDecodeIntervalMult 可选字段
|
||||
474→ - TIDE_WEIGHTS 新增3种各12(总136)+ getTideModifiers 3新case
|
||||
475→ - decode.ts:generatePuzzle 新增 targetLenBonus 参数(surge用,targetLen+stepLimit同步增加)
|
||||
476→ - gameStore.ts 3处修饰器应用:
|
||||
477→ - startDecode:surge 时传 targetLenBonus → 谜题更长
|
||||
478→ - autoDecodeTick:prism 时 interval×=autoDecodeIntervalMult
|
||||
479→ - resolveCurrentNode:eclipse 时 bossWinRate 加 tideBossBonus
|
||||
480→ - achievements.ts:ach_tides_all 阈值6→9;page.tsx StatsPanel 星潮亲历 /9
|
||||
481→- QA 验证:
|
||||
482→ - lint 零错误(exit 0)+ dev HTTP 200 + 编译<30ms
|
||||
483→ - BeaconPanel VLM 8.5/10(限时区块可见)+ 最终 9/10
|
||||
484→ - 三新星潮 localStorage 注入触发截图 VLM:surge 8/10、prism 7/10、eclipse 8/10
|
||||
485→ - 日/周/信标链/6旧星潮全部保留无回归
|
||||
486→
|
||||
487→Stage Summary:
|
||||
488→- ✅ beacon.ts +289行(限时挑战完整系统:4h时段+确定性+进度+领奖+排行榜)
|
||||
489→- ✅ starTide.ts +80行(3新类型 surge/eclipse/prism + 3新修饰器字段)
|
||||
490→- ✅ decode.ts generatePuzzle 支持 targetLenBonus(surge 谜题+2)
|
||||
491→- ✅ gameStore.ts trackBeacon 三进度同步 + claimTimedBeacon + 3处新星潮修饰器
|
||||
492→- ✅ BeaconPanel.tsx +191行(amber 限时区块 + 紧急状态 + TIMED 徽章)
|
||||
493→- ✅ 严格四色规范:限时amber/surge-emerald/eclipse-rose/prism-fuchsia,零蓝色
|
||||
494→- ✅ 确定性:限时挑战同时段同种子同结果(FNV-1a slotKey 哈希)
|
||||
495→- ✅ lint 零错误 + dev HTTP 200 + VLM 全部≥7/10
|
||||
496→
|
||||
497→---
|
||||
498→Task ID: 10-b
|
||||
499→Agent: full-stack-developer
|
||||
500→Task: P2/#4 云排行榜 mini-service(信标本机榜升级为云端 Top100)
|
||||
501→
|
||||
502→Work Log:
|
||||
503→- 前置阅读:worklog(v0.8.1 项目状态)+ beacon.ts(BeaconScoreEntry / loadLeaderboard / pushLeaderboardEntry / claimBeaconReward / claimWeeklyReward / claimTimedReward)+ BeaconPanel.tsx(排行榜 UI 渲染)+ Caddyfile(?XTransformPort=端口号 转发规则)+ examples/websocket(mini-service 参考)+ .zscripts/dev.sh(mini-service 启动模式)
|
||||
504→- 发现 worklog 信息滞后:beacon.ts 实际已扩到 1161 行(v0.8.2 已加限时挑战 + claimTimedReward),BeaconPanel 已扩到 953 行
|
||||
505→- 创建 mini-service:
|
||||
506→ - `mini-services/leaderboard-service/package.json`:独立 bun 项目,type=module,scripts `dev: bun --hot index.ts` + `start: bun index.ts`,依赖 hono ^4.6.14
|
||||
507→ - `mini-services/leaderboard-service/index.ts`(182 行):
|
||||
508→ - Hono + cors 中间件(origin: * 全开放,跨端口必须)
|
||||
509→ - 内存数组存储(最多 1000 条,按 score 降序,同分按时长→时间戳排序)
|
||||
510→ - 防刷:同 dateKey+challenge 10s 内只接受 1 次提交(Map 记录 lastSubmitAt),返回 429 + retryAfterMs
|
||||
511→ - GET /api/leaderboard → Top100 + total
|
||||
512→ - POST /api/leaderboard body {entry} → 校验 schema → 推入 → 排序 → 返回 {entries, total, rank}
|
||||
513→ - GET /api/leaderboard/stats → {totalSubmissions, uniquePlayers, topScore}
|
||||
514→ - GET / → 健康检查 {service, version, ok, uptime}
|
||||
515→ - 端口固定 3030,`export default { port, fetch }` 标准 bun 模式
|
||||
516→ - `mini-services/leaderboard-service/README.md`:API 文档 + 联调规范
|
||||
517→- mini-service 启动调试:bun --hot 在本沙盒环境下会因文件 watcher 异常被 kill;改用 `bun index.ts`(无 --hot)+ 双 fork `( ... & )` 模式启动,PPID=1,uptime 4+ 分钟稳定
|
||||
518→- eslint.config.mjs 新增 ignores "mini-services/**"(mini-service 独立项目,不参与主项目 lint)
|
||||
519→- beacon.ts 扩展(1161 → 1290 行,+129 行):
|
||||
520→ - 三个 claim 函数返回值新增 `entry: BeaconScoreEntry`(非破坏性扩展):
|
||||
521→ - `claimBeaconReward` 返回 `entry`(daily)
|
||||
522→ - `claimWeeklyReward` 返回 `entry`(weekly)
|
||||
523→ - `claimTimedReward` 返回 `entry`(timed)
|
||||
524→ - 新增云排行榜模块(v0.8.2 P2/#4):
|
||||
525→ - `BEACON_CLOUD_PORT = 3030` 常量
|
||||
526→ - `BEACON_CLOUD_LAST_SUBMIT_KEY = "echo-nexus-beacon-cloud-last-submit-v1"` 本地 key
|
||||
527→ - `CloudLeaderboardResponse` / `CloudSubmitResponse` / `CloudStatsResponse` 接口
|
||||
528→ - `loadLastCloudSubmitTimestamp()` / `saveLastCloudSubmitTimestamp(ts)` 工具
|
||||
529→ - `fetchCloudLeaderboard(): Promise<BeaconScoreEntry[]>` — GET 相对路径 + ?XTransformPort=3030
|
||||
530→ - `submitCloudScore(entry): Promise<number>` — POST,成功返回 rank,失败返回 -1,自动记录 ts 供全球榜高亮
|
||||
531→ - `fetchCloudStats(): Promise<CloudStatsResponse | null>` — 全局统计
|
||||
532→ - 所有 fetch 用相对路径 + `?XTransformPort=3030`,**禁止** localhost:3030
|
||||
533→- gameStore.ts 集成:
|
||||
534→ - import 新增 `submitCloudScore`
|
||||
535→ - `claimWeeklyBeacon` action:领奖成功后 `void submitCloudScore(res.entry)`(fire-and-forget,不 await,不阻塞)
|
||||
536→ - `claimTimedBeacon` action:同上
|
||||
537→ - `claimBeaconReward` (daily) 由 BeaconPanel 直接调用,不通过 gameStore
|
||||
538→- BeaconPanel.tsx 重写(953 → 1170 行,+217 行):
|
||||
539→ - 新增 import: fetchCloudLeaderboard / submitCloudScore / loadLastCloudSubmitTimestamp + 4 个 lucide 图标(RefreshCw/Globe/WifiOff/Loader2)
|
||||
540→ - 新增状态: lbTab ('local'|'global') / cloudEntries / cloudLoading / cloudError / cloudFetched / mySubmitTs / cloudFetchingRef
|
||||
541→ - 新增 `refreshCloudLeaderboard()` callback:拉取云端榜 + 防并发(ref)+ 空 entries 时显示提示
|
||||
542→ - useEffect: 切到全球 tab 自动拉取(仅首次)+ 初始化读取 mySubmitTs
|
||||
543→ - handleClaimDaily: 领奖成功后 `void submitCloudScore(res.entry).then(rank => ...)` — 若用户在全球 tab 自动刷新
|
||||
544→ - handleClaimTimed/Weekly: 领奖后更新 mySubmitTs + 若在全球 tab 自动刷新
|
||||
545→ - 排行榜 UI 重写为双 tab:
|
||||
546→ - 顶部「本地」(amber 主题) / 「全球」(emerald 主题) 切换按钮 + 全球 tab 专属刷新按钮(旋转动画)
|
||||
547→ - 子标题:本地"本机 Top N · 离线可用" / 全球"全球 Top N · 已同步 / 同步中…"
|
||||
548→ - 本地榜保留原 v0.8.2 渲染(max-h-200px overflow-y-auto)
|
||||
549→ - 全球榜:
|
||||
550→ - 加载中:Loader2 spinner + "正在拉取全球榜…"
|
||||
551→ - 空榜+错误:WifiOff 图标 + 错误文案 + 重试按钮
|
||||
552→ - 空榜+无错:Globe 图标 + "尚无全球记录"提示
|
||||
553→ - 有数据:渲染前 100 条,每条带排名(1-3 名用 Crown/Medal/Award 图标)/ 难度色 / WEEK·TIMED 徽章 / 用时 / 分数
|
||||
554→ - 玩家自己的记录通过 timestamp 匹配 mySubmitTs 高亮:emerald 边框 + emerald 阴影 + YOU 徽章 + emerald 分数色 + 加粗排名
|
||||
555→- QA 验证:
|
||||
556→ - `bun run lint` 零错误零警告(eslint.config.mjs 把 mini-services 加入 ignores)
|
||||
557→ - dev.log 全程无错误,所有编译 < 300ms
|
||||
558→ - mini-service curl 全通过:
|
||||
559→ - GET / → 200 {service, version, ok, uptime}
|
||||
560→ - GET /api/leaderboard (空) → 200 {entries:[], total:0}
|
||||
561→ - POST /api/leaderboard → 200 {entries, total, rank:1}
|
||||
562→ - GET /api/leaderboard (有数据) → 200 {entries:[...], total:N}
|
||||
563→ - GET /api/leaderboard/stats → 200 {totalSubmissions, uniquePlayers, topScore}
|
||||
564→ - OPTIONS 预检 → 204 + 完整 CORS 头(Allow-Origin: *)
|
||||
565→ - 重复 POST(同 dateKey+challenge 10s 内)→ 429 {error:"Rate limited", retryAfterMs}
|
||||
566→ - 网关路由验证:Caddy :81 → localhost:3030 通过 ?XTransformPort=3030 正确转发,返回 JSON 200
|
||||
567→ - agent-browser 集成测试(通过 :81 端口访问,绕过 Next.js 404):
|
||||
568→ - 信标 tab 选中(用 KeyboardEvent Enter 激活 Radix Tabs,因 pointerdown 被覆盖层拦截)
|
||||
569→ - 切到「全球」tab → 自动拉取 → 显示"全球 Top 6 · 已同步" → 渲染 6 条记录
|
||||
570→ - 刷新按钮可点击,旋转动画正常
|
||||
571→ - POST 测试:浏览器 fetch 提交 score=9999 → 返回 rank=1 → 刷新后榜首显示 10.00K
|
||||
572→ - YOU 高亮:手动设置 localStorage[BEACON_CLOUD_LAST_SUBMIT_KEY] 后刷新 → 榜首显示 emerald 边框 + YOU 徽章
|
||||
573→ - VLM 视觉评分(目标 ≥7/10):
|
||||
574→ - 全球榜含 YOU 高亮:**8/10**(双tab切换清晰,全球榜记录完整,难度色与标签规范,视觉层次佳)
|
||||
575→ - 本地榜:**8/10**(双tab切换逻辑清晰,本地tab内容完整,四色规范应用,信息层级合理)
|
||||
576→
|
||||
577→Stage Summary:
|
||||
578→- ✅ mini-service 独立项目:自己的 package.json + bun.lock + node_modules,依赖 hono ^4.6.14,不污染主项目
|
||||
579→- ✅ 端口 3030 + Caddy 网关:前端用相对路径 `fetch('/api/leaderboard?XTransformPort=3030')`,**禁止** localhost:3030
|
||||
580→- ✅ CORS 全开放:origin: * + OPTIONS 预检自动处理,前端跨端口无忧
|
||||
581→- ✅ 内存存储 + 防刷:1000 条上限,同 dateKey+challenge 10s 限 1 次
|
||||
582→- ✅ 三个 claim 入口全部接入云提交:claimBeaconReward(BeaconPanel 直接调)/ claimWeeklyBeacon / claimTimedBeacon(gameStore action 内)
|
||||
583→- ✅ 本地榜保留:loadLeaderboard / pushLeaderboardEntry 未删除,作为离线 fallback
|
||||
584→- ✅ 双 tab UI:本地(amber 主题,立即可用)/ 全球(emerald 主题,按需拉取)+ 刷新按钮 + 加载态 + 错误重试
|
||||
585→- ✅ 玩家高亮:通过 timestamp 匹配 localStorage[BEACON_CLOUD_LAST_SUBMIT_KEY],emerald 边框 + YOU 徽章
|
||||
586→- ✅ lint 零错误 + dev HTTP 200 + VLM 8/10
|
||||
587→- ✅ mini-service 启动稳定:uptime 4+ 分钟,PPID=1(双 fork 模式)
|
||||
588→- 下一阶段工单:P3 #10-c 全球星潮同步(star-tide-service 已在 mini-services 目录中)
|
||||
589→
|
||||
@@ -445,3 +445,230 @@ Stage Summary:
|
||||
- ✅ P1-b 信标链: 4里程碑(3/7/14/30天) + grace续命, amber→rose渐变
|
||||
- ✅ 在线游玩 v0.8.1: https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
|
||||
- 下一阶段工单: P2 UI样式细节打磨 / P2 云存档+云排行榜 / P3 全纪元叙事
|
||||
|
||||
---
|
||||
Task ID: 10-a
|
||||
Agent: full-stack-developer
|
||||
Task: #5 限时挑战 + #3 星潮类型深化
|
||||
|
||||
Work Log:
|
||||
- 阅读项目上下文:worklog.md(v0.8.1,10 大系统)+ beacon.ts(873行,日+周+链)+ starTide.ts(209行,6种星潮)+ gameStore trackBeacon/tickTide + BeaconPanel.tsx,确认四色全息规范与现有架构
|
||||
- **工单 #5 限时挑战**:
|
||||
- beacon.ts(873→1162行)新增限时挑战系统:
|
||||
- BeaconTimedChallenge/BeaconTimedProgress 接口 + isTimed 字段加入 BeaconScoreEntry
|
||||
- getTimedSlotKey(UTC 0/4/8/12/16/20 点切分,"timed_YYYY-MM-DD_HH")+ timedSlotKeyToSeed(FNV-1a)
|
||||
- generateTimedChallenge(确定性,难度 routine,goal=日基准×0.3-0.5,5类型公式)
|
||||
- loadTimedProgress/saveTimedProgress/addTimedProgress/claimTimedReward(排行榜 isTimed)
|
||||
- msUntilNextTimedSlot + TIMED_SLOT_MS=4h + BEACON_TIMED_KEY
|
||||
- gameStore.ts:trackBeacon 返回值新增 timedJustCompleted,三进度同时更新;新增 claimTimedBeacon action
|
||||
- BeaconPanel.tsx(753→944行):日挑战与周挑战之间插入限时挑战区块(amber 主题)
|
||||
- Hourglass图标+TIMED标题+slotKey+倒计时 + 进度+奖励+领取按钮(emerald)
|
||||
- 紧急状态:距时段结束<30min 切 rose 脉冲动画+"即将结束"徽章
|
||||
- 2新动画 timed-glow(amber)/timed-urgent(rose) + 排行榜 TIMED 徽章
|
||||
- **工单 #3 星潮深化**:
|
||||
- starTide.ts(209→289行):TideType 新增 surge/eclipse/prism
|
||||
- surge(emerald ⇈): crystalsPerSec×2.5 + targetLenBonus=2(解码目标序列+2,更长更难)
|
||||
- eclipse(rose ◐): crystalsPerSec×0.7 + bossWinRateBonus=0.2(BOSS胜率+20%)
|
||||
- prism(fuchsia ◬): insightMultAdd=1.0(洞见×2) + autoDecodeIntervalMult=0.7(自动解码-30%)
|
||||
- TideModifiers 新增 targetLenBonus/bossWinRateBonus/autoDecodeIntervalMult 可选字段
|
||||
- TIDE_WEIGHTS 新增3种各12(总136)+ getTideModifiers 3新case
|
||||
- decode.ts:generatePuzzle 新增 targetLenBonus 参数(surge用,targetLen+stepLimit同步增加)
|
||||
- gameStore.ts 3处修饰器应用:
|
||||
- startDecode:surge 时传 targetLenBonus → 谜题更长
|
||||
- autoDecodeTick:prism 时 interval×=autoDecodeIntervalMult
|
||||
- resolveCurrentNode:eclipse 时 bossWinRate 加 tideBossBonus
|
||||
- achievements.ts:ach_tides_all 阈值6→9;page.tsx StatsPanel 星潮亲历 /9
|
||||
- QA 验证:
|
||||
- lint 零错误(exit 0)+ dev HTTP 200 + 编译<30ms
|
||||
- BeaconPanel VLM 8.5/10(限时区块可见)+ 最终 9/10
|
||||
- 三新星潮 localStorage 注入触发截图 VLM:surge 8/10、prism 7/10、eclipse 8/10
|
||||
- 日/周/信标链/6旧星潮全部保留无回归
|
||||
|
||||
Stage Summary:
|
||||
- ✅ beacon.ts +289行(限时挑战完整系统:4h时段+确定性+进度+领奖+排行榜)
|
||||
- ✅ starTide.ts +80行(3新类型 surge/eclipse/prism + 3新修饰器字段)
|
||||
- ✅ decode.ts generatePuzzle 支持 targetLenBonus(surge 谜题+2)
|
||||
- ✅ gameStore.ts trackBeacon 三进度同步 + claimTimedBeacon + 3处新星潮修饰器
|
||||
- ✅ BeaconPanel.tsx +191行(amber 限时区块 + 紧急状态 + TIMED 徽章)
|
||||
- ✅ 严格四色规范:限时amber/surge-emerald/eclipse-rose/prism-fuchsia,零蓝色
|
||||
- ✅ 确定性:限时挑战同时段同种子同结果(FNV-1a slotKey 哈希)
|
||||
- ✅ lint 零错误 + dev HTTP 200 + VLM 全部≥7/10
|
||||
|
||||
---
|
||||
Task ID: 10-b
|
||||
Agent: full-stack-developer
|
||||
Task: P2/#4 云排行榜 mini-service(信标本机榜升级为云端 Top100)
|
||||
|
||||
Work Log:
|
||||
- 前置阅读:worklog(v0.8.1 项目状态)+ beacon.ts(BeaconScoreEntry / loadLeaderboard / pushLeaderboardEntry / claimBeaconReward / claimWeeklyReward / claimTimedReward)+ BeaconPanel.tsx(排行榜 UI 渲染)+ Caddyfile(?XTransformPort=端口号 转发规则)+ examples/websocket(mini-service 参考)+ .zscripts/dev.sh(mini-service 启动模式)
|
||||
- 发现 worklog 信息滞后:beacon.ts 实际已扩到 1161 行(v0.8.2 已加限时挑战 + claimTimedReward),BeaconPanel 已扩到 953 行
|
||||
- 创建 mini-service:
|
||||
- `mini-services/leaderboard-service/package.json`:独立 bun 项目,type=module,scripts `dev: bun --hot index.ts` + `start: bun index.ts`,依赖 hono ^4.6.14
|
||||
- `mini-services/leaderboard-service/index.ts`(182 行):
|
||||
- Hono + cors 中间件(origin: * 全开放,跨端口必须)
|
||||
- 内存数组存储(最多 1000 条,按 score 降序,同分按时长→时间戳排序)
|
||||
- 防刷:同 dateKey+challenge 10s 内只接受 1 次提交(Map 记录 lastSubmitAt),返回 429 + retryAfterMs
|
||||
- GET /api/leaderboard → Top100 + total
|
||||
- POST /api/leaderboard body {entry} → 校验 schema → 推入 → 排序 → 返回 {entries, total, rank}
|
||||
- GET /api/leaderboard/stats → {totalSubmissions, uniquePlayers, topScore}
|
||||
- GET / → 健康检查 {service, version, ok, uptime}
|
||||
- 端口固定 3030,`export default { port, fetch }` 标准 bun 模式
|
||||
- `mini-services/leaderboard-service/README.md`:API 文档 + 联调规范
|
||||
- mini-service 启动调试:bun --hot 在本沙盒环境下会因文件 watcher 异常被 kill;改用 `bun index.ts`(无 --hot)+ 双 fork `( ... & )` 模式启动,PPID=1,uptime 4+ 分钟稳定
|
||||
- eslint.config.mjs 新增 ignores "mini-services/**"(mini-service 独立项目,不参与主项目 lint)
|
||||
- beacon.ts 扩展(1161 → 1290 行,+129 行):
|
||||
- 三个 claim 函数返回值新增 `entry: BeaconScoreEntry`(非破坏性扩展):
|
||||
- `claimBeaconReward` 返回 `entry`(daily)
|
||||
- `claimWeeklyReward` 返回 `entry`(weekly)
|
||||
- `claimTimedReward` 返回 `entry`(timed)
|
||||
- 新增云排行榜模块(v0.8.2 P2/#4):
|
||||
- `BEACON_CLOUD_PORT = 3030` 常量
|
||||
- `BEACON_CLOUD_LAST_SUBMIT_KEY = "echo-nexus-beacon-cloud-last-submit-v1"` 本地 key
|
||||
- `CloudLeaderboardResponse` / `CloudSubmitResponse` / `CloudStatsResponse` 接口
|
||||
- `loadLastCloudSubmitTimestamp()` / `saveLastCloudSubmitTimestamp(ts)` 工具
|
||||
- `fetchCloudLeaderboard(): Promise<BeaconScoreEntry[]>` — GET 相对路径 + ?XTransformPort=3030
|
||||
- `submitCloudScore(entry): Promise<number>` — POST,成功返回 rank,失败返回 -1,自动记录 ts 供全球榜高亮
|
||||
- `fetchCloudStats(): Promise<CloudStatsResponse | null>` — 全局统计
|
||||
- 所有 fetch 用相对路径 + `?XTransformPort=3030`,**禁止** localhost:3030
|
||||
- gameStore.ts 集成:
|
||||
- import 新增 `submitCloudScore`
|
||||
- `claimWeeklyBeacon` action:领奖成功后 `void submitCloudScore(res.entry)`(fire-and-forget,不 await,不阻塞)
|
||||
- `claimTimedBeacon` action:同上
|
||||
- `claimBeaconReward` (daily) 由 BeaconPanel 直接调用,不通过 gameStore
|
||||
- BeaconPanel.tsx 重写(953 → 1170 行,+217 行):
|
||||
- 新增 import: fetchCloudLeaderboard / submitCloudScore / loadLastCloudSubmitTimestamp + 4 个 lucide 图标(RefreshCw/Globe/WifiOff/Loader2)
|
||||
- 新增状态: lbTab ('local'|'global') / cloudEntries / cloudLoading / cloudError / cloudFetched / mySubmitTs / cloudFetchingRef
|
||||
- 新增 `refreshCloudLeaderboard()` callback:拉取云端榜 + 防并发(ref)+ 空 entries 时显示提示
|
||||
- useEffect: 切到全球 tab 自动拉取(仅首次)+ 初始化读取 mySubmitTs
|
||||
- handleClaimDaily: 领奖成功后 `void submitCloudScore(res.entry).then(rank => ...)` — 若用户在全球 tab 自动刷新
|
||||
- handleClaimTimed/Weekly: 领奖后更新 mySubmitTs + 若在全球 tab 自动刷新
|
||||
- 排行榜 UI 重写为双 tab:
|
||||
- 顶部「本地」(amber 主题) / 「全球」(emerald 主题) 切换按钮 + 全球 tab 专属刷新按钮(旋转动画)
|
||||
- 子标题:本地"本机 Top N · 离线可用" / 全球"全球 Top N · 已同步 / 同步中…"
|
||||
- 本地榜保留原 v0.8.2 渲染(max-h-200px overflow-y-auto)
|
||||
- 全球榜:
|
||||
- 加载中:Loader2 spinner + "正在拉取全球榜…"
|
||||
- 空榜+错误:WifiOff 图标 + 错误文案 + 重试按钮
|
||||
- 空榜+无错:Globe 图标 + "尚无全球记录"提示
|
||||
- 有数据:渲染前 100 条,每条带排名(1-3 名用 Crown/Medal/Award 图标)/ 难度色 / WEEK·TIMED 徽章 / 用时 / 分数
|
||||
- 玩家自己的记录通过 timestamp 匹配 mySubmitTs 高亮:emerald 边框 + emerald 阴影 + YOU 徽章 + emerald 分数色 + 加粗排名
|
||||
- QA 验证:
|
||||
- `bun run lint` 零错误零警告(eslint.config.mjs 把 mini-services 加入 ignores)
|
||||
- dev.log 全程无错误,所有编译 < 300ms
|
||||
- mini-service curl 全通过:
|
||||
- GET / → 200 {service, version, ok, uptime}
|
||||
- GET /api/leaderboard (空) → 200 {entries:[], total:0}
|
||||
- POST /api/leaderboard → 200 {entries, total, rank:1}
|
||||
- GET /api/leaderboard (有数据) → 200 {entries:[...], total:N}
|
||||
- GET /api/leaderboard/stats → 200 {totalSubmissions, uniquePlayers, topScore}
|
||||
- OPTIONS 预检 → 204 + 完整 CORS 头(Allow-Origin: *)
|
||||
- 重复 POST(同 dateKey+challenge 10s 内)→ 429 {error:"Rate limited", retryAfterMs}
|
||||
- 网关路由验证:Caddy :81 → localhost:3030 通过 ?XTransformPort=3030 正确转发,返回 JSON 200
|
||||
- agent-browser 集成测试(通过 :81 端口访问,绕过 Next.js 404):
|
||||
- 信标 tab 选中(用 KeyboardEvent Enter 激活 Radix Tabs,因 pointerdown 被覆盖层拦截)
|
||||
- 切到「全球」tab → 自动拉取 → 显示"全球 Top 6 · 已同步" → 渲染 6 条记录
|
||||
- 刷新按钮可点击,旋转动画正常
|
||||
- POST 测试:浏览器 fetch 提交 score=9999 → 返回 rank=1 → 刷新后榜首显示 10.00K
|
||||
- YOU 高亮:手动设置 localStorage[BEACON_CLOUD_LAST_SUBMIT_KEY] 后刷新 → 榜首显示 emerald 边框 + YOU 徽章
|
||||
- VLM 视觉评分(目标 ≥7/10):
|
||||
- 全球榜含 YOU 高亮:**8/10**(双tab切换清晰,全球榜记录完整,难度色与标签规范,视觉层次佳)
|
||||
- 本地榜:**8/10**(双tab切换逻辑清晰,本地tab内容完整,四色规范应用,信息层级合理)
|
||||
|
||||
Stage Summary:
|
||||
- ✅ mini-service 独立项目:自己的 package.json + bun.lock + node_modules,依赖 hono ^4.6.14,不污染主项目
|
||||
- ✅ 端口 3030 + Caddy 网关:前端用相对路径 `fetch('/api/leaderboard?XTransformPort=3030')`,**禁止** localhost:3030
|
||||
- ✅ CORS 全开放:origin: * + OPTIONS 预检自动处理,前端跨端口无忧
|
||||
- ✅ 内存存储 + 防刷:1000 条上限,同 dateKey+challenge 10s 限 1 次
|
||||
- ✅ 三个 claim 入口全部接入云提交:claimBeaconReward(BeaconPanel 直接调)/ claimWeeklyBeacon / claimTimedBeacon(gameStore action 内)
|
||||
- ✅ 本地榜保留:loadLeaderboard / pushLeaderboardEntry 未删除,作为离线 fallback
|
||||
- ✅ 双 tab UI:本地(amber 主题,立即可用)/ 全球(emerald 主题,按需拉取)+ 刷新按钮 + 加载态 + 错误重试
|
||||
- ✅ 玩家高亮:通过 timestamp 匹配 localStorage[BEACON_CLOUD_LAST_SUBMIT_KEY],emerald 边框 + YOU 徽章
|
||||
- ✅ lint 零错误 + dev HTTP 200 + VLM 8/10
|
||||
- ✅ mini-service 启动稳定:uptime 4+ 分钟,PPID=1(双 fork 模式)
|
||||
- 下一阶段工单:P3 #10-c 全球星潮同步(star-tide-service 已在 mini-services 目录中)
|
||||
|
||||
---
|
||||
Task ID: 10-c
|
||||
Agent: full-stack-developer
|
||||
Task: #9 手写叙事节点 + P3 socket 多人同步星潮
|
||||
|
||||
Work Log:
|
||||
- 前置阅读:worklog.md(v0.8.1,四色规范 emerald/rose/amber/fuchsia,禁止蓝色,10 大系统)+ chronicle.ts(680 行)+ starTide.ts(297 行,9 种星潮)+ gameStore.ts(1332 行)+ StarTide.tsx(170 行)+ StarTideOverlay.tsx + Caddyfile(:81 网关 ?XTransformPort 转发规则)+ 前序 10-a/10-b 工单(限时挑战/3 新星潮/云排行榜 mini-service)
|
||||
- **状态确认**:本任务为重试,发现上次重试已落地主体代码,本次为验证 + 补 QA + 启动 mini-service + 追加 worklog
|
||||
- **工单 #9 手写叙事节点**(chronicle.ts 已就绪):
|
||||
- EPOCH_LORE 常量:5 纪元 × 3 节点(opening/middle/ending)= 15 段手写叙事,每段 80-150 字文学性
|
||||
- 5 纪元主题:1 觉醒(emerald)/2 谐振(rose)/3 遗迹(amber)/4 飞升(fuchsia)/5 终末
|
||||
- 每段含 fallback + variants(按 ctx 命中条件挑变体):
|
||||
- 第一纪元:解码者(≥15)/学者(techs≥5)/里程碑密集(≥2) + 深度轮回(asc>5)
|
||||
- 第二纪元:探险者(≥3)/解码者(≥15)/觉醒天赋 + 深度轮回
|
||||
- 第三纪元:BOSS 猎手(≥2)/深度探险(≥4)/星潮亲历(≥3)/BOSS 收尾 + 深度轮回
|
||||
- 第四纪元:觉醒天赋/蓝图接近完整(≥5)/里程碑密集 + 深度轮回
|
||||
- 第五纪元:里程碑密集(≥3)/星潮汹涌(≥3)/觉醒天赋 + 深度轮回
|
||||
- 变量替换:{decodedThisRun}/{techsThisRun}/{expThisRun}/{bossKillsThisRun}/{blueprintsAfter}/{milestonesCount}/{milestonesList}/{tideCount}/{tideNames}/{perksList}/{minutes}
|
||||
- buildLore 改造:优先 buildHandwrittenLore,无匹配(ascensionNumber 异常)时 fallback 到 v0.4 模板拼接
|
||||
- 验证:bun 脚本测试 5 纪元 × 2 上下文(minimal/rich)= 10 个 case 全通过,文字长度 233-273 字(含收尾段),变体匹配正确,变量替换无残留 {xxx}
|
||||
- **P3 socket 多人同步星潮**:
|
||||
- **mini-service(mini-services/star-tide-service/)**:
|
||||
- 独立 bun 项目,package.json type=module + scripts dev:bun --hot index.ts + start:bun index.ts,依赖 socket.io ^4.8.3
|
||||
- index.ts(250 行):
|
||||
- 端口固定 3031,path:"/"(Caddy 据此转发)
|
||||
- 6 种星潮类型表(crystal/resonance/ruins/void/core/silence)+ 权重表 + rollTide
|
||||
- DURATION_SEC=60s 全球星潮持续;FIRST_TIDE_DELAY_MS=60s(QA 友好,env 可覆盖);MIN/MAX_GAP_MS=10-15 分钟随机间隔
|
||||
- 调度:startTide → io.emit("global-tide", {type,name,startedAt,endsAt,durationSec}) → 60s 后 endTide → io.emit("tide-ended", {endedAt,nextTideAt}) → scheduleNextTide(10-15 分钟随机)
|
||||
- 客户端连接:socket.emit("tide-state", {current, nextTideAt, serverTime}) 立即推送
|
||||
- 客户端可 emit "get-next-tide" → 服务端响应 "next-tide-info" {current, nextTideAt, serverTime, remainingMs}
|
||||
- dev/QA 用 "admin-trigger-tide":立即触发一次全球星潮(不等待下次定时器)
|
||||
- 优雅退出:SIGTERM/SIGINT → 清 timer → io.close → httpServer.close → exit(0)
|
||||
- README.md:API/事件文档
|
||||
- **前端集成**:
|
||||
- src/hooks/useGlobalTide.ts(127 行):
|
||||
- 单例 socket(sharedSocket + connectRefCount 引用计数,多组件挂载共享同一连接)
|
||||
- io("/?XTransformPort=3031", {transports:["websocket","polling"], reconnection:true, reconnectionAttempts:Infinity})
|
||||
- 监听 "global-tide" → triggerRef.current(type),用 startedAt+type 去重(防重连重复触发),过期 tide 不触发
|
||||
- 监听 "tide-state" → 连接/重连时若服务端正有进行中的全球星潮,补触发
|
||||
- 主动 emit "get-next-tide" 保险
|
||||
- 导出 adminTriggerGlobalTide(dev 控制台 QA 用),window.__adminTriggerGlobalTide 暴露
|
||||
- gameStore.ts 新增 triggerGlobalTide action + globalTide state:
|
||||
- GLOBAL_TIDE_DURATION_MS = 60_000 常量
|
||||
- GlobalTideState 类型 {type, startedAt, endsAt, id}
|
||||
- TideEvent 新增 isGlobal?: boolean 字段
|
||||
- triggerGlobalTide(type):创建 newTide(60s)+ newGlobalTide,覆盖本地 activeTide(即使本地有进行中的星潮也会被替换),加入 _tideEvents 队列 with isGlobal:true
|
||||
- tickTide 改造:globalTide 优先 → 全球星潮进行中本地不触发新星潮;全球星潮结束时设 globalTide:null + activeTide:null + lastTideEnd + silenceCompensation + ended 事件 with isGlobal:true
|
||||
- partialize 排除 globalTide(瞬态运行时状态,不持久化)
|
||||
- page.tsx 顶层挂载 useGlobalTide()
|
||||
- StarTide.tsx UI 升级:
|
||||
- StarTideIndicator:读 globalTide → isGlobal 标记 → 加 🌐 emoji + ring-1 ring-amber-300/60 + 强化 boxShadow + 渐变背景 + title 改为 "🌐 全球星潮 · {meta.desc}";duration 用 globalTide.endsAt-startedAt 计算(60s)避免越界
|
||||
- StarTideOverlay:全球星潮多一道顶部琥珀色光带 + global-tide-sweep 2.5s 扫光动画 + inset boxShadow 加深(160px vs 120px)
|
||||
- StarTideNotifier:toast 文案区分全球/本地("🌐 全球星潮降临:{name}" + "全球玩家同步经历 · {desc}")
|
||||
- **QA 验证**:
|
||||
- `bun run lint` 零错误零警告
|
||||
- dev.log 全程无错误,编译 < 70ms,HTTP 200
|
||||
- mini-service 启动稳定:pid 9456,PPID=1(双 fork 脱离 dev.sh),uptime 19+ 分钟,端口 3031 监听正常
|
||||
- curl http://localhost:3031/ → {"code":0,"message":"Transport unknown"}(socket.io-only 服务预期响应)
|
||||
- curl http://localhost:3031/socket.io/?EIO=4&transport=polling → 正确握手 {"sid":...,"upgrades":["websocket"]}
|
||||
- 网关路由验证:curl http://localhost:81/socket.io/?EIO=4&transport=polling&XTransformPort=3031 → 通过 Caddy 转发到 3031,返回正确握手
|
||||
- socket.io 端到端测试(bun 脚本 /tmp/test-socket.ts):connect ✅ → tide-state ✅ → emit admin-trigger-tide → 504ms 内收到 global-tide {type:ruins, startedAt, endsAt, durationSec:60} ✅
|
||||
- agent-browser 集成测试(通过 http://localhost:81/ 走 Caddy 网关,socket.io 才能正确路由):
|
||||
- 页面加载 200,window.__adminTriggerGlobalTide 已挂载(useGlobalTide hook 已挂载)
|
||||
- 调用 adminTriggerGlobalTide() → 1-3s 内 StarTideIndicator 渲染 🌐 芯片
|
||||
- DOM 验证:芯片文本 "🌐✷虚空低语57s",title "🌐 全球星潮 · 虚空传来回响,洞见获取翻倍"(isGlobal:true 路径生效)
|
||||
- 第二次触发:"🌐⬢遗迹共振59s",title "🌐 全球星潮 · 远古遗迹苏醒,探险力 +5、生命 +30"
|
||||
- 全球星潮结束(60s 后)→ activeTide:null + globalTide:null + lastTideEnd 更新 → 本地 tickTide 恢复正常节奏
|
||||
- VLM 视觉评分:**8/10**(确认 🌐 地球图标 + 汉字名称"遗迹共振" + 倒计时"59s" 全部可见;颜色 emerald/amber/fuchsia/rose 四色规范,零蓝色)
|
||||
- 编年史 buildLore 验证(bun 脚本测试 5 纪元 × 2 上下文 = 10 case):
|
||||
- 全部产出 233-273 字文学性叙事,包含开场+中段+结尾+收尾段
|
||||
- fallback 路径:默认文本,无变量替换
|
||||
- variant 路径:按 ctx 命中条件挑变体,{decodedThisRun}/{bossKillsThisRun}/{milestonesList}/{perksList} 等变量正确替换
|
||||
- 纪元名生成正确:第一纪元·初鸣之夕 / 第二纪元·光谱涌动 / 第三纪元·星辉汇聚 / 第四纪元·以太共振 / 第五纪元·永恒闭环
|
||||
|
||||
Stage Summary:
|
||||
- ✅ 工单 #9:chronicle.ts EPOCH_LORE 5 纪元 × 3 节点 = 15 段手写叙事(每段 80-150 字文学性),buildLore 优先手写节点 + fallback 模板,buildChronicleEntry/regenerateLoreFromEntry/withPerks 全部接入;10 case 测试通过,变体匹配 + 变量替换正确
|
||||
- ✅ P3 socket 多人同步星潮:mini-services/star-tide-service 独立 bun 项目,socket.io 端口 3031,每 10-15 分钟随机广播 global-tide(60s 持续),admin-trigger-tide 供 QA 触发;前端 useGlobalTide hook + gameStore.triggerGlobalTide + globalTide 状态 + tickTide 全球优先 + StarTide UI 🌐 标记 + toast 区分全球/本地
|
||||
- ✅ 网关规范:socket.io 严格用 `io("/?XTransformPort=3031")`,path "/",禁止 localhost:3031 直连;Caddy :81 → localhost:3031 转发正确
|
||||
- ✅ 离线容错:socket.io 自动重连(Infinity attempts),断连期间本地 tickTide 继续按原节奏工作,玩家不会被卡住;globalTide 不持久化(瞬态运行时状态)
|
||||
- ✅ 防重连重复触发:useGlobalTide 用 startedAt+type 去重(Set 上限 32 条 FIFO 清理),过期 tide 不触发
|
||||
- ✅ 本地星潮保留:全球星潮是增强而非替换,全球星潮结束后本地 tickTide 恢复正常节奏
|
||||
- ✅ mini-service 启动稳定:pid 9456,PPID=1,uptime 19+ 分钟,端口 3031 监听正常
|
||||
- ✅ lint 零错误 + dev HTTP 200 + VLM 8/10(🌐 全球星潮标记清晰可见)
|
||||
- ✅ 严格四色规范:全球星潮琥珀色扫光(amber)+ 各星潮本色(emerald/rose/amber/fuchsia),零蓝色
|
||||
- 下一阶段工单:可考虑 v0.9 收尾(全球星潮成就/编年史全球星潮高亮/星图天文台全球星潮增强天赋)
|
||||
|
||||