v0.8.1: 探险平衡 + 信标系统扩展(周挑战 + 信标链)
P1-a 探险 BOSS 胜率平衡 + 能量恢复(expedition.ts + config.ts + gameStore.ts + ExpeditionPanel.tsx): - combatWinRate: scale 8→4, floor 0.25→0.35(基础力10对BOSS胜率 25%→35-45%) - BOSS 难度 5-7→3-5(配合新公式基础胜率达 45-55%) - computeEnergyRegenInterval(state): 动态恢复间隔 - exp_2 解锁 -30%, exp_3 解锁 -20%, 探索力属性 -最高30% - 下限 12s(原固定 45s) - computeEnergyRegen 接受 intervalSec 参数 - gameStore tick 传入动态间隔 - config.ts: exp_2/exp_3 描述加'能量恢复 +30%/+20%' - ExpeditionPanel: 显示实际恢复速度 + '已加速'标记 P1-b 信标系统扩展(beacon.ts + BeaconPanel.tsx + gameStore.ts)[subagent 9-b]: - 周挑战: getWeekKey ISO 8601 + FNV-1a 种子确定性生成 - 难度强制 anomaly 60%/singular 40%, goal 日挑战×3-5倍 - 完整进度/领奖/排行榜推送(isWeekly标记) - 信标链: 4里程碑(3/7/14/30天) + grace续命机制(每链1次) - recordChainCompletion 核心断链/续命逻辑 - 奖励 50→680洞见递增 - BeaconPanel: 周挑战fuchsia主题 + 信标链amber→rose渐变里程碑节点 - gameStore: trackBeacon升级 + claimWeeklyBeacon/claimChainReward actions - VLM 8/10, lint零错误, 5场景bun测试全PASS
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
# Task 9-b · full-stack-developer · 信标系统扩展(周挑战 + 信标链)
|
||||
|
||||
> 本文件为本 agent 在 Task 9-b 的工作记录,供后续 agent 查阅。
|
||||
|
||||
## 任务概述
|
||||
|
||||
为「回响星核 / Echo Nexus」v0.8 扩展深空信标系统,新增两大功能:
|
||||
1. **周挑战(Weekly Challenge)** — 每周一 UTC 0 点刷新,目标更大、奖励更好,与日挑战并行
|
||||
2. **信标链(Beacon Chain)** — 连续完成日挑战形成"链",达成里程碑(3/7/14/30 天)领取递增大奖,含 grace 续命机制
|
||||
|
||||
## 阅读的前置工作
|
||||
|
||||
- `/home/z/my-project/worklog.md`(v0.8 项目状态,10 大系统,四色全息规范)
|
||||
- `/home/z/my-project/src/lib/game/beacon.ts`(v0.5 原版 358 行:每日挑战 + 本地排行榜)
|
||||
- `/home/z/my-project/src/components/game/BeaconPanel.tsx`(原版 301 行)
|
||||
- `/home/z/my-project/src/store/gameStore.ts` 第 173 行 `trackBeacon` 函数 + 7 处调用点(pulse/clickNode/autoDecodeTick/resolveCurrentNode 等)
|
||||
|
||||
## 实现细节
|
||||
|
||||
### 1. beacon.ts 扩展(358 → 873 行)
|
||||
|
||||
**周挑战(WEEKLY CHALLENGE)section**:
|
||||
- `BeaconWeeklyChallenge` 接口 + `BeaconWeeklyProgress` 接口
|
||||
- `getWeekKey(now)`:ISO 8601 周键(YYYY-Www,周一为起点,含首个周四的周为第一周)
|
||||
- `weekKeyToSeed`:FNV-1a 哈希
|
||||
- `generateWeeklyChallenge(now)`:基于 weekKey 种子确定性生成
|
||||
- 难度加权:anomaly 60% / singular 40%
|
||||
- mult = 3 + floor(rng() * 3) → 3-5 倍
|
||||
- goal 范围:decode 18-75 / expedition 3-15 / pulse 60-245 / boss 3-12 / insight 120-595
|
||||
- `loadWeeklyProgress` / `saveWeeklyProgress` / `addWeeklyProgress` / `claimWeeklyReward`
|
||||
- `msUntilNextWeek(now)`
|
||||
- `BEACON_WEEKLY_KEY = "echo-nexus-beacon-weekly-v1"`
|
||||
|
||||
**信标链(BEACON CHAIN)section**:
|
||||
- `BeaconChainState` 接口(lastCompletedDateKey / currentStreak / longestStreak / totalCompletions / graceUsed / milestonesClaimed)
|
||||
- `BEACON_CHAIN_KEY = "echo-nexus-beacon-chain-v1"`
|
||||
- `BEACON_CHAIN_MILESTONES = [3, 7, 14, 30] as const`
|
||||
- `BEACON_CHAIN_REWARDS`:4 个里程碑
|
||||
- 3 天:+50 洞见 / +5 接触 / "三日谐振"
|
||||
- 7 天:+120 洞见 / +12 接触 / "七日回响"
|
||||
- 14 天:+280 洞见 / +28 接触 / "半月星潮"
|
||||
- 30 天:+680 洞见 / +68 接触 / "满月飞升"
|
||||
- `loadChainState` / `saveChainState`(每次返回新对象避免引用共享 bug)
|
||||
- `recordChainCompletion(dateKey)` 核心逻辑:
|
||||
- 同日重复完成 → 忽略
|
||||
- 次日 → streak++
|
||||
- 隔一天 miss 且 graceUsed<1 → 续命 streak++ graceUsed++
|
||||
- 其他 → 断链 streak=1 graceUsed=0
|
||||
- 返回 `{ state, newMilestones }`
|
||||
- `claimChainMilestone(milestone)` / `getNextMilestone(streak)` / `getChainProgress(streak)`
|
||||
- `dateKeyToTimestamp` / `dateKeyDiffDays` 工具
|
||||
|
||||
`BeaconScoreEntry` 新增可选 `isWeekly?: boolean` 字段,向后兼容。
|
||||
|
||||
### 2. gameStore.ts 集成
|
||||
|
||||
- import 扩展:新增 generateWeeklyChallenge / loadWeeklyProgress / loadChainState / addWeeklyProgress / recordChainCompletion / claimWeeklyReward / claimChainMilestone / getTodayKey + 类型
|
||||
- `trackBeacon(type, delta)` 返回值从 `boolean` 升级为 `{ dailyJustCompleted, weeklyJustCompleted, newChainMilestones }`:
|
||||
- 同时更新日挑战进度 + 周挑战进度
|
||||
- 日挑战刚完成时调用 `recordChainCompletion(getTodayKey())`
|
||||
- 所有 7 处调用点原本忽略返回值,向后兼容
|
||||
- 新增 action `claimWeeklyBeacon()`:调用 claimWeeklyReward → 发放奖励到 state
|
||||
- 新增 action `claimChainReward(milestone)`:前置校验 → claimChainMilestone → 发放奖励
|
||||
- GameActions 接口同步扩展
|
||||
|
||||
### 3. BeaconPanel.tsx 重写(301 → 638 行)
|
||||
|
||||
- 头部 + 每日挑战卡片(v0.5 保留)+ 难度色按钮主题
|
||||
- **周挑战区块**(fuchsia 主题):标题 + weekKey + 倒计时 + 卡片(标签/标题/描述/进度/奖励/领取按钮 emerald)+ weekly-glow 动画
|
||||
- **信标链区块**(amber→rose 渐变):
|
||||
- 标题"信标链 · CHAIN" + 大字号 streak(chain-streak-text 流动渐变动画)
|
||||
- 今日完成状态徽章
|
||||
- 4 个里程碑节点(w-12 h-12 圆形):
|
||||
- claimed: emerald 实心 + ✓
|
||||
- reachable: rose 脉冲动画 + "领取"按钮
|
||||
- inProgress (next milestone): amber 半亮
|
||||
- 未到达: muted 灰
|
||||
- 节点间连线:背景灰 + 已达成部分 amber→rose 渐变 + 辉光,基于 prev→next 插值定位
|
||||
- 进度条 + 底部统计(最长链/累计完成/续命状态)
|
||||
- 桌面端 lg:grid-cols-2 让周挑战 + 信标链并排,移动端单列
|
||||
- 排行榜区分日/周:周挑战 entry 显示 "WEEK" 徽章 + fuchsia 高亮背景
|
||||
- 4 个新 CSS 动画:weekly-glow / chain-milestone-pulse / chain-streak-flux
|
||||
|
||||
## QA 验证结果
|
||||
|
||||
### 1. lint
|
||||
- `bun run lint` 零错误(每次修改后均验证)
|
||||
|
||||
### 2. dev 服务器
|
||||
- dev.log 全程无错误,所有编译 < 300ms,HTTP 200
|
||||
|
||||
### 3. 信标链逻辑测试(bun 直接运行 TS,5 个场景全 PASS)
|
||||
1. ✅ 昨日 streak=1 → 今日完成 → streak=2(normal increment)
|
||||
2. ✅ 同日重复完成 → 忽略
|
||||
3. ✅ 明日完成 → streak=3,无需 grace
|
||||
4. ✅ 隔一天 miss(lastCompleted + 2 天)→ streak=4,graceUsed=1(续命触发)
|
||||
5. ✅ 再次 miss(grace 已用)→ 断链 streak=1,graceUsed=0
|
||||
|
||||
### 4. UI 集成测试(agent-browser)
|
||||
- localStorage 模拟设置 chain state(lastCompletedDateKey=yesterday, currentStreak=1)→ reload → UI 正确显示 streak=1
|
||||
- 设置 streak=3 + milestonesClaimed=[] → milestone 3 节点显示 rose 脉冲动画 + "领取"按钮
|
||||
- 点击"领取"→ milestonesClaimed=[3] + Toast "✦ 三日谐振 已领取 +50 洞见 · +5.0 接触" + 按钮消失 ✅
|
||||
|
||||
### 5. VLM 视觉评分(目标 ≥7/10)
|
||||
- 首屏截图:**8/10**(四色一致、布局合理、信标链清晰)
|
||||
- 里程碑可领取状态:**8/10**(amber→rose 渐变醒目、里程碑层次分明、fuchsia 主题清晰)
|
||||
- 重置后干净状态:**8/10**(WEEKLY 字体对比度可优化,但整体可读性强)
|
||||
|
||||
## 修改的文件
|
||||
|
||||
1. `src/lib/game/beacon.ts` — 358 → 873 行(+515 行)
|
||||
2. `src/store/gameStore.ts` — trackBeacon 升级 + 2 个新 action(+~80 行)
|
||||
3. `src/components/game/BeaconPanel.tsx` — 301 → 638 行(+337 行,重写)
|
||||
|
||||
## 不破坏的现有功能
|
||||
|
||||
- 日挑战进度追踪与领奖流程
|
||||
- 本地排行榜 Top20
|
||||
- 现有 5 种挑战类型 + 3 档难度
|
||||
- 现有 localStorage keys(echo-nexus-beacon-lb-v1 / echo-nexus-beacon-prog-v1)
|
||||
|
||||
## 新增的 localStorage keys
|
||||
|
||||
- `echo-nexus-beacon-weekly-v1`(周挑战进度)
|
||||
- `echo-nexus-beacon-chain-v1`(信标链状态)
|
||||
|
||||
## 四色全息规范遵循
|
||||
|
||||
- 周挑战主题:**fuchsia** (#e879f9)
|
||||
- 信标链主题:**amber → rose** 渐变 (#fbbf24 → #fb7185)
|
||||
- 领取按钮:**emerald** (#34d399)
|
||||
- 难度色:routine emerald / anomaly amber / singular rose
|
||||
- **零蓝色/靛色违规**
|
||||
|
||||
## 截图资产
|
||||
|
||||
- `/home/z/my-project/agent-ctx/beacon-panel-v0.8.png` — 初次进入信标页
|
||||
- `/home/z/my-project/agent-ctx/beacon-chain-streak1.png` — chain streak=1 状态
|
||||
- `/home/z/my-project/agent-ctx/beacon-chain-milestone3.png` — chain streak=3 milestone 可领取
|
||||
- `/home/z/my-project/agent-ctx/beacon-milestone3-claimable.png` — milestone 3 领取前
|
||||
- `/home/z/my-project/agent-ctx/beacon-final-fresh.png` — 重置后干净状态
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 299 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 380 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 386 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 383 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 387 KiB |
@@ -1,29 +1,54 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — 深空信标面板(v0.5 每日挑战 + 本地排行榜)
|
||||
// 回响星核 / Echo Nexus — 深空信标面板
|
||||
// v0.5:每日挑战 + 本地排行榜
|
||||
// v0.8:周挑战 + 信标链(连续完成奖励)
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useGameStore } from "@/store/gameStore";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { sfx } from "@/hooks/useAudio";
|
||||
import {
|
||||
generateDailyChallenge,
|
||||
generateWeeklyChallenge,
|
||||
loadDailyProgress,
|
||||
loadWeeklyProgress,
|
||||
loadLeaderboard,
|
||||
loadChainState,
|
||||
claimBeaconReward,
|
||||
msUntilNextDay,
|
||||
formatCountdown,
|
||||
getTodayKey,
|
||||
BEACON_DIFFICULTY,
|
||||
BEACON_TYPE_META,
|
||||
BEACON_CHAIN_MILESTONES,
|
||||
BEACON_CHAIN_REWARDS,
|
||||
getWeekKey,
|
||||
getTodayKey,
|
||||
getNextMilestone,
|
||||
getChainProgress,
|
||||
msUntilNextDay,
|
||||
msUntilNextWeek,
|
||||
formatCountdown,
|
||||
type BeaconDailyChallenge,
|
||||
type BeaconDailyProgress,
|
||||
type BeaconWeeklyChallenge,
|
||||
type BeaconWeeklyProgress,
|
||||
type BeaconScoreEntry,
|
||||
type BeaconChallengeType,
|
||||
type BeaconDifficulty,
|
||||
type BeaconChainState,
|
||||
} from "@/lib/game/beacon";
|
||||
import { formatNum } from "@/lib/game/config";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Radio, Clock, Trophy, Sparkles, Award, Crown, Medal } from "lucide-react";
|
||||
import {
|
||||
Radio,
|
||||
Clock,
|
||||
Trophy,
|
||||
Sparkles,
|
||||
Award,
|
||||
Crown,
|
||||
Medal,
|
||||
Link2,
|
||||
Flame,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
|
||||
const DIFFICULTY_ORDER: BeaconDifficulty[] = ["routine", "anomaly", "singular"];
|
||||
|
||||
@@ -35,34 +60,48 @@ function rankBadge(rank: number) {
|
||||
}
|
||||
|
||||
export function BeaconPanel() {
|
||||
const insights = useGameStore((s) => s.insights);
|
||||
const grantBeaconReward = useGameStore((s) => s.grantBeaconReward);
|
||||
const claimWeeklyBeacon = useGameStore((s) => s.claimWeeklyBeacon);
|
||||
const claimChainReward = useGameStore((s) => s.claimChainReward);
|
||||
const { toast } = useToast();
|
||||
|
||||
const [challenge, setChallenge] = useState<BeaconDailyChallenge | null>(null);
|
||||
const [progress, setProgress] = useState<BeaconDailyProgress | null>(null);
|
||||
const [weeklyChallenge, setWeeklyChallenge] =
|
||||
useState<BeaconWeeklyChallenge | null>(null);
|
||||
const [weeklyProgress, setWeeklyProgress] =
|
||||
useState<BeaconWeeklyProgress | null>(null);
|
||||
const [chainState, setChainState] = useState<BeaconChainState | null>(null);
|
||||
const [leaderboard, setLeaderboard] = useState<BeaconScoreEntry[]>([]);
|
||||
const [countdown, setCountdown] = useState("00:00:00");
|
||||
const [dayCountdown, setDayCountdown] = useState("00:00:00");
|
||||
const [weekCountdown, setWeekCountdown] = useState("00:00:00");
|
||||
const [now, setNow] = useState(Date.now());
|
||||
|
||||
// 初始化 + 每秒刷新(进度 + 倒计时)
|
||||
useEffect(() => {
|
||||
setChallenge(generateDailyChallenge());
|
||||
setProgress(loadDailyProgress());
|
||||
setWeeklyChallenge(generateWeeklyChallenge());
|
||||
setWeeklyProgress(loadWeeklyProgress());
|
||||
setChainState(loadChainState());
|
||||
setLeaderboard(loadLeaderboard());
|
||||
const id = setInterval(() => {
|
||||
setNow(Date.now());
|
||||
setProgress(loadDailyProgress());
|
||||
setWeeklyProgress(loadWeeklyProgress());
|
||||
setChainState(loadChainState());
|
||||
setChallenge((c) => c ?? generateDailyChallenge());
|
||||
setWeeklyChallenge((c) => c ?? generateWeeklyChallenge());
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setCountdown(formatCountdown(msUntilNextDay(new Date(now))));
|
||||
setDayCountdown(formatCountdown(msUntilNextDay(new Date(now))));
|
||||
setWeekCountdown(formatCountdown(msUntilNextWeek(new Date(now))));
|
||||
}, [now]);
|
||||
|
||||
const handleClaim = useCallback(() => {
|
||||
const handleClaimDaily = useCallback(() => {
|
||||
if (!challenge || !progress) return;
|
||||
if (progress.completedAt === null || progress.claimed) return;
|
||||
const res = claimBeaconReward(challenge, progress);
|
||||
@@ -72,12 +111,51 @@ export function BeaconPanel() {
|
||||
grantBeaconReward(res.rewardInsight, res.rewardContact);
|
||||
sfx("achievement");
|
||||
toast({
|
||||
title: "✦ 信标奖励已领取",
|
||||
description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(1)} 接触 · 得分 ${res.score}`,
|
||||
title: "✦ 每日信标奖励已领取",
|
||||
description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
|
||||
1
|
||||
)} 接触 · 得分 ${res.score}`,
|
||||
});
|
||||
}, [challenge, progress, toast, grantBeaconReward]);
|
||||
|
||||
if (!challenge || !progress) {
|
||||
const handleClaimWeekly = useCallback(() => {
|
||||
if (!weeklyChallenge || !weeklyProgress) return;
|
||||
if (weeklyProgress.completedAt === null || weeklyProgress.claimed) return;
|
||||
const res = claimWeeklyBeacon();
|
||||
setLeaderboard(loadLeaderboard());
|
||||
setWeeklyProgress(loadWeeklyProgress());
|
||||
if (res.rewardInsight > 0 || res.rewardContact > 0) {
|
||||
sfx("achievement");
|
||||
toast({
|
||||
title: "✦ 周挑战奖励已领取",
|
||||
description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
|
||||
1
|
||||
)} 接触 · 得分 ${res.score}`,
|
||||
});
|
||||
}
|
||||
}, [weeklyChallenge, weeklyProgress, toast, claimWeeklyBeacon]);
|
||||
|
||||
const handleClaimChain = useCallback(
|
||||
(milestone: number) => {
|
||||
const reward = BEACON_CHAIN_REWARDS.find(
|
||||
(r) => r.milestone === milestone
|
||||
);
|
||||
const res = claimChainReward(milestone);
|
||||
setChainState(loadChainState());
|
||||
if (res.ok) {
|
||||
sfx("achievement");
|
||||
toast({
|
||||
title: `✦ ${res.label || reward?.label || "里程碑"} 已领取`,
|
||||
description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
|
||||
1
|
||||
)} 接触`,
|
||||
});
|
||||
}
|
||||
},
|
||||
[toast, claimChainReward]
|
||||
);
|
||||
|
||||
if (!challenge || !progress || !weeklyChallenge || !weeklyProgress || !chainState) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-xs text-muted-foreground/60">
|
||||
正在校准深空信标…
|
||||
@@ -92,6 +170,20 @@ export function BeaconPanel() {
|
||||
const isClaimed = progress.claimed;
|
||||
const canClaim = isCompleted && !isClaimed;
|
||||
|
||||
// 周挑战派生量
|
||||
const wDiffMeta = BEACON_DIFFICULTY[weeklyChallenge.difficulty];
|
||||
const wTypeMeta = BEACON_TYPE_META[weeklyChallenge.type];
|
||||
const wPct = Math.min(100, (weeklyProgress.progress / weeklyChallenge.goal) * 100);
|
||||
const wCompleted = weeklyProgress.completedAt !== null;
|
||||
const wClaimed = weeklyProgress.claimed;
|
||||
const wCanClaim = wCompleted && !wClaimed;
|
||||
|
||||
// 信标链派生量
|
||||
const chainProgress = getChainProgress(chainState.currentStreak);
|
||||
const nextMilestone = getNextMilestone(chainState.currentStreak);
|
||||
const todayKey = getTodayKey();
|
||||
const completedToday = chainState.lastCompletedDateKey === todayKey;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5 h-full overflow-y-auto echo-scroll pr-0.5">
|
||||
<style jsx global>{`
|
||||
@@ -106,6 +198,29 @@ export function BeaconPanel() {
|
||||
0%, 100% { box-shadow: 0 0 18px ${diffMeta.glow}, inset 0 0 12px ${diffMeta.glow}; }
|
||||
50% { box-shadow: 0 0 32px ${diffMeta.glow}, inset 0 0 20px ${diffMeta.glow}; }
|
||||
}
|
||||
@keyframes weekly-glow {
|
||||
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 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); }
|
||||
}
|
||||
.chain-milestone-reachable {
|
||||
animation: chain-milestone-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes chain-streak-flux {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
.chain-streak-text {
|
||||
background: linear-gradient(90deg, #fbbf24, #fb7185, #fbbf24);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: chain-streak-flux 4s ease-in-out infinite;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* 头部:信标 + 倒计时 */}
|
||||
@@ -117,7 +232,7 @@ export function BeaconPanel() {
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
|
||||
<Clock className="h-3 w-3" />
|
||||
次日重置
|
||||
<span className="font-mono text-fuchsia-300/90">{countdown}</span>
|
||||
<span className="font-mono text-fuchsia-300/90">{dayCountdown}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -205,14 +320,15 @@ export function BeaconPanel() {
|
||||
<span className="text-fuchsia-300 font-mono">+{challenge.rewardContact.toFixed(1)}接触</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleClaim}
|
||||
onClick={handleClaimDaily}
|
||||
disabled={!canClaim}
|
||||
size="sm"
|
||||
className="h-7 px-3 text-[11px] border-0"
|
||||
style={{
|
||||
background: canClaim
|
||||
? `linear-gradient(90deg, ${diffMeta.color}, ${diffMeta.color}cc)`
|
||||
: undefined,
|
||||
: `${diffMeta.color}1a`,
|
||||
color: canClaim ? "#022c22" : `${diffMeta.color}99`,
|
||||
}}
|
||||
>
|
||||
{isClaimed ? "已领取" : canClaim ? "领取奖励" : isCompleted ? "已领取" : "进行中…"}
|
||||
@@ -228,6 +344,333 @@ export function BeaconPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 周挑战 + 信标链(桌面端并排,移动端单列) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
||||
{/* ===== 周挑战卡片(fuchsia 主题) ===== */}
|
||||
<div
|
||||
className="relative rounded-xl border p-3 overflow-hidden"
|
||||
style={{
|
||||
borderColor: "rgba(232,121,249,0.4)",
|
||||
background: `linear-gradient(135deg, rgba(232,121,249,0.12), rgba(0,0,0,0.45))`,
|
||||
animation: wCompleted ? "none" : "weekly-glow 3.5s ease-in-out infinite",
|
||||
}}
|
||||
>
|
||||
{/* 头部:标题 + weekKey + 倒计时 */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Zap className="h-3.5 w-3.5 text-fuchsia-400" />
|
||||
<span className="text-xs font-semibold text-fuchsia-200">
|
||||
周挑战 · WEEKLY
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[9px] text-muted-foreground/70">
|
||||
<span className="font-mono text-fuchsia-300/80">
|
||||
{weeklyChallenge.weekKey}
|
||||
</span>
|
||||
<Clock className="h-2.5 w-2.5" />
|
||||
<span className="font-mono text-fuchsia-300/90">{weekCountdown}</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: `${wDiffMeta.color}22`, color: wDiffMeta.color, border: `1px solid ${wDiffMeta.color}55` }}
|
||||
>
|
||||
<span>{wDiffMeta.icon}</span>
|
||||
{wDiffMeta.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">
|
||||
{wTypeMeta.icon} {wTypeMeta.label}
|
||||
</span>
|
||||
{wCompleted && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 标题 */}
|
||||
<h4
|
||||
className="text-sm font-semibold mb-1"
|
||||
style={{ color: wDiffMeta.color, textShadow: `0 0 10px ${wDiffMeta.glow}` }}
|
||||
>
|
||||
{weeklyChallenge.title}
|
||||
</h4>
|
||||
<p className="text-[10px] text-muted-foreground/80 leading-relaxed mb-2">
|
||||
{weeklyChallenge.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: wDiffMeta.color }}>
|
||||
{Math.min(weeklyProgress.progress, weeklyChallenge.goal)} / {weeklyChallenge.goal} {wTypeMeta.unit}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={wPct}
|
||||
className="h-2 bg-black/40"
|
||||
style={{
|
||||
["--progress-color" as string]: wDiffMeta.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>
|
||||
{weeklyChallenge.rewardInsight > 0 && (
|
||||
<span className="text-amber-300 font-mono">
|
||||
+{formatNum(weeklyChallenge.rewardInsight)}洞见
|
||||
</span>
|
||||
)}
|
||||
<span className="text-fuchsia-300 font-mono">
|
||||
+{weeklyChallenge.rewardContact.toFixed(1)}接触
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleClaimWeekly}
|
||||
disabled={!wCanClaim}
|
||||
size="sm"
|
||||
className="h-7 px-3 text-[11px] border-0"
|
||||
style={{
|
||||
background: wCanClaim
|
||||
? `linear-gradient(90deg, #34d399, #34d399cc)`
|
||||
: "rgba(232,121,249,0.10)",
|
||||
color: wCanClaim
|
||||
? "#022c22"
|
||||
: "rgba(232,121,249,0.55)",
|
||||
}}
|
||||
>
|
||||
{wClaimed
|
||||
? "已领取"
|
||||
: wCanClaim
|
||||
? "领取奖励"
|
||||
: wCompleted
|
||||
? "已领取"
|
||||
: "进行中…"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{wCompleted && weeklyProgress.durationSec > 0 && (
|
||||
<div className="mt-1.5 text-[10px] text-muted-foreground/50 text-center">
|
||||
完成用时 {Math.floor(weeklyProgress.durationSec / 60)}分{weeklyProgress.durationSec % 60}秒
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== 信标链卡片(amber→rose 渐变) ===== */}
|
||||
<div
|
||||
className="relative rounded-xl border p-3 overflow-hidden"
|
||||
style={{
|
||||
borderColor: "rgba(251,191,36,0.35)",
|
||||
background: `linear-gradient(135deg, rgba(251,191,36,0.10), rgba(251,113,133,0.10), rgba(0,0,0,0.4))`,
|
||||
}}
|
||||
>
|
||||
{/* 头部:标题 + 当前连续天数 */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Link2 className="h-3.5 w-3.5 text-amber-400" />
|
||||
<span className="text-xs font-semibold text-amber-200">
|
||||
信标链 · CHAIN
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<Flame className="h-3 w-3 text-rose-400" />
|
||||
<span className="text-[10px] text-muted-foreground/60">连续</span>
|
||||
<span className="chain-streak-text text-2xl font-bold font-mono leading-none">
|
||||
{chainState.currentStreak}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/60">天</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 今日完成状态 */}
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground/70">
|
||||
{completedToday
|
||||
? "今日已贡献"
|
||||
: "今日尚未完成日挑战"}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${
|
||||
completedToday
|
||||
? "bg-emerald-500/15 text-emerald-300 border-emerald-500/30"
|
||||
: "bg-white/5 text-muted-foreground/70 border-white/10"
|
||||
}`}
|
||||
>
|
||||
{completedToday ? "✓ 已记录" : "○ 待完成"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 4 个里程碑节点横向排列 */}
|
||||
<div className="flex items-center justify-between mb-2 relative">
|
||||
{/* 节点之间的连线(背景灰) */}
|
||||
<div className="absolute top-6 left-[12.5%] right-[12.5%] h-[2px] bg-white/10" />
|
||||
{/* 已达成部分高亮(基于 prev→next 插值) */}
|
||||
{(() => {
|
||||
// 节点圆心水平位置(百分比)
|
||||
const MILESTONE_POS: Record<number, number> = {
|
||||
0: 12.5,
|
||||
3: 12.5,
|
||||
7: 37.5,
|
||||
14: 62.5,
|
||||
30: 87.5,
|
||||
};
|
||||
const prevPos =
|
||||
MILESTONE_POS[chainProgress.prev] ?? 12.5;
|
||||
const nextPos =
|
||||
chainProgress.next !== null
|
||||
? MILESTONE_POS[chainProgress.next] ?? 87.5
|
||||
: 87.5;
|
||||
const pct = chainProgress.progressPct / 100;
|
||||
const activeEndPos = prevPos + (nextPos - prevPos) * pct;
|
||||
const widthPct = Math.max(0, activeEndPos - 12.5);
|
||||
if (widthPct <= 0) return null;
|
||||
return (
|
||||
<div
|
||||
className="absolute top-6 h-[2px]"
|
||||
style={{
|
||||
left: "12.5%",
|
||||
width: `${widthPct}%`,
|
||||
background:
|
||||
"linear-gradient(90deg, #fbbf24, #fb7185)",
|
||||
boxShadow: "0 0 8px rgba(251,113,133,0.5)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{BEACON_CHAIN_MILESTONES.map((m) => {
|
||||
const reward = BEACON_CHAIN_REWARDS.find((r) => r.milestone === m);
|
||||
const claimed = chainState.milestonesClaimed.includes(m);
|
||||
const reachable =
|
||||
chainState.currentStreak >= m && !claimed;
|
||||
const inProgress = chainState.currentStreak > 0 && nextMilestone === m;
|
||||
// 节点配色
|
||||
let nodeBg = "rgba(255,255,255,0.05)";
|
||||
let nodeBorder = "rgba(255,255,255,0.15)";
|
||||
let textColor = "rgba(255,255,255,0.4)";
|
||||
if (claimed) {
|
||||
nodeBg = "rgba(52,211,153,0.25)";
|
||||
nodeBorder = "#34d399";
|
||||
textColor = "#34d399";
|
||||
} else if (reachable) {
|
||||
nodeBg = "rgba(251,113,133,0.20)";
|
||||
nodeBorder = "#fb7185";
|
||||
textColor = "#fb7185";
|
||||
} else if (inProgress) {
|
||||
nodeBg = "rgba(251,191,36,0.20)";
|
||||
nodeBorder = "#fbbf24";
|
||||
textColor = "#fbbf24";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m}
|
||||
className="flex flex-col items-center gap-1 z-10 flex-1"
|
||||
>
|
||||
<div
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center font-mono font-bold text-sm border-2 ${
|
||||
reachable ? "chain-milestone-reachable" : ""
|
||||
}`}
|
||||
style={{
|
||||
background: nodeBg,
|
||||
borderColor: nodeBorder,
|
||||
color: textColor,
|
||||
}}
|
||||
>
|
||||
{claimed ? (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
) : (
|
||||
m
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className="text-[9px] text-center leading-tight"
|
||||
style={{ color: textColor }}
|
||||
>
|
||||
{reward?.label}
|
||||
</span>
|
||||
{claimed ? (
|
||||
<span className="text-[9px] text-emerald-400/80 font-mono">
|
||||
已领
|
||||
</span>
|
||||
) : reachable ? (
|
||||
<Button
|
||||
onClick={() => handleClaimChain(m)}
|
||||
size="sm"
|
||||
className="h-5 px-2 text-[9px] py-0 border-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, #fb7185, #f43f5e)",
|
||||
color: "#1c0608",
|
||||
}}
|
||||
>
|
||||
领取
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-[9px] text-muted-foreground/40 font-mono">
|
||||
+{reward?.rewardInsight}洞
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] text-muted-foreground/70">
|
||||
{chainProgress.next === null
|
||||
? "已通关全部里程碑"
|
||||
: `下一目标:${chainProgress.next} 天`}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-amber-300">
|
||||
{chainProgress.current}
|
||||
{chainProgress.next !== null && ` / ${chainProgress.next}`} 天
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={chainProgress.progressPct}
|
||||
className="h-1.5 bg-black/40"
|
||||
style={{
|
||||
["--progress-color" as string]: "#fbbf24",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 底部统计 */}
|
||||
<div className="flex items-center justify-between gap-2 text-[10px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground/60">最长</span>
|
||||
<span className="font-mono text-amber-300">
|
||||
{chainState.longestStreak}天
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground/60">累计</span>
|
||||
<span className="font-mono text-rose-300">
|
||||
{chainState.totalCompletions}次
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground/60">续命</span>
|
||||
{chainState.graceUsed >= 1 ? (
|
||||
<span className="font-mono text-rose-400/80">已用</span>
|
||||
) : (
|
||||
<span className="font-mono text-emerald-400/80">可用</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 本地排行榜 */}
|
||||
<div className="rounded-xl border border-white/10 bg-black/30 p-2.5">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
@@ -239,7 +682,7 @@ export function BeaconPanel() {
|
||||
{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">
|
||||
@@ -247,12 +690,16 @@ export function BeaconPanel() {
|
||||
const rb = rankBadge(i);
|
||||
const eDiff = BEACON_DIFFICULTY[entry.difficulty];
|
||||
const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType];
|
||||
const isMine = entry.dateKey === getTodayKey();
|
||||
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] ${
|
||||
isMine ? "bg-fuchsia-500/10 border border-fuchsia-500/20" : "hover:bg-white/5"
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
{/* 排名 */}
|
||||
@@ -263,10 +710,15 @@ export function BeaconPanel() {
|
||||
<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.progress >= 1 && <span className="text-emerald-400/70">✓</span>}
|
||||
</span>
|
||||
{/* 用时 */}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { useGameStore } from "@/store/gameStore";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { sfx } from "@/hooks/useAudio";
|
||||
import { EXPEDITION_CONFIG, combatWinRate } from "@/lib/game/expedition";
|
||||
import { EXPEDITION_CONFIG, combatWinRate, computeEnergyRegenInterval } from "@/lib/game/expedition";
|
||||
import { formatNum } from "@/lib/game/config";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
@@ -42,6 +42,8 @@ export function ExpeditionPanel() {
|
||||
const lastEnergyTick = useGameStore((s) => s.lastEnergyTick);
|
||||
const expeditionLog = useGameStore((s) => s.expeditionLog);
|
||||
const totalExpeditions = useGameStore((s) => s.totalExpeditions);
|
||||
// v0.8.1 动态能量恢复间隔(技术 + 探索力属性缩短)
|
||||
const regenIntervalSec = useGameStore((s) => computeEnergyRegenInterval(s));
|
||||
const startExpedition = useGameStore((s) => s.startExpedition);
|
||||
const resolveCurrentNode = useGameStore((s) => s.resolveCurrentNode);
|
||||
const advanceNode = useGameStore((s) => s.advanceNode);
|
||||
@@ -49,10 +51,11 @@ export function ExpeditionPanel() {
|
||||
const { toast } = useToast();
|
||||
const [lastLog, setLastLog] = useState<string | null>(null);
|
||||
|
||||
// 能量恢复进度
|
||||
// 能量恢复进度(v0.8.1 动态间隔)
|
||||
const now = Date.now();
|
||||
const regenMs = EXPEDITION_CONFIG.energyRegenSec * 1000;
|
||||
const regenMs = regenIntervalSec * 1000;
|
||||
const regenProgress = energy >= energyMax ? 100 : Math.min(100, ((now - lastEnergyTick) / regenMs) * 100);
|
||||
const regenBoosted = regenIntervalSec < EXPEDITION_CONFIG.energyRegenSec;
|
||||
|
||||
const handleStart = () => {
|
||||
const res = startExpedition();
|
||||
@@ -126,6 +129,9 @@ export function ExpeditionPanel() {
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground/70 mt-1">
|
||||
下一点能量约 {Math.ceil((regenMs - (now - lastEnergyTick)) / 1000)}s 后恢复
|
||||
{regenBoosted && (
|
||||
<span className="text-emerald-400/80 ml-1">⚡ {regenIntervalSec.toFixed(0)}s/点 (已加速)</span>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
|
||||
+521
-4
@@ -1,6 +1,7 @@
|
||||
// 回响星核 / Echo Nexus — 深空信标(v0.5 每日挑战 + 本地排行榜)
|
||||
// 一个自包含的"每日挑战"元系统:基于日期种子的固定挑战 + 本地排行榜。
|
||||
// 不依赖后端,纯 localStorage 持久化,给放置循环注入"今日目标"动机。
|
||||
// 回响星核 / Echo Nexus — 深空信标
|
||||
// v0.5:每日挑战 + 本地排行榜
|
||||
// v0.8:周挑战 + 信标链(连续完成奖励)
|
||||
// 一个自包含的"每日挑战 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。
|
||||
|
||||
/** 每日挑战类型 */
|
||||
export type BeaconChallengeType =
|
||||
@@ -17,7 +18,7 @@ export type BeaconDifficulty = "routine" | "anomaly" | "singular";
|
||||
export interface BeaconScoreEntry {
|
||||
/** 提交时间戳 */
|
||||
timestamp: number;
|
||||
/** 日期 key(YYYY-MM-DD) */
|
||||
/** 日期 key(YYYY-MM-DD)或周 key(YYYY-Www) */
|
||||
dateKey: string;
|
||||
/** 挑战类型 */
|
||||
challenge: BeaconChallengeType;
|
||||
@@ -29,6 +30,8 @@ export interface BeaconScoreEntry {
|
||||
score: number;
|
||||
/** 完成时长(秒),未完成则记 0 */
|
||||
durationSec: number;
|
||||
/** v0.8:是否为周挑战记录(日挑战默认 false / undefined) */
|
||||
isWeekly?: boolean;
|
||||
}
|
||||
|
||||
/** 每日挑战定义 */
|
||||
@@ -355,3 +358,517 @@ export function formatCountdown(ms: number): string {
|
||||
const s = String(total % 60).padStart(2, "0");
|
||||
return `${h}:${m}:${s}`;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 周挑战(WEEKLY CHALLENGE)— v0.8
|
||||
// 每周一 UTC 0 点刷新一个"周挑战",目标更大、奖励更好,与日挑战并行存在。
|
||||
// 同一周同一种子 → 同一挑战(确定性)。难度强制 anomaly/singular(加权 60/40)。
|
||||
// ===========================================================================
|
||||
|
||||
/** 周挑战 localStorage key */
|
||||
export const BEACON_WEEKLY_KEY = "echo-nexus-beacon-weekly-v1";
|
||||
|
||||
/** 周挑战定义 */
|
||||
export interface BeaconWeeklyChallenge {
|
||||
/** ISO 周键(YYYY-Www,如 "2026-W26") */
|
||||
weekKey: string;
|
||||
/** 挑战类型 */
|
||||
type: BeaconChallengeType;
|
||||
/** 难度(固定 anomaly 或 singular) */
|
||||
difficulty: "anomaly" | "singular";
|
||||
/** 目标数值(日挑战的 3-5 倍) */
|
||||
goal: number;
|
||||
/** 奖励:完成时洞见 */
|
||||
rewardInsight: number;
|
||||
/** 奖励:完成时接触进度 */
|
||||
rewardContact: number;
|
||||
/** 使用的种子(可复现) */
|
||||
seed: number;
|
||||
/** 友好标题 */
|
||||
title: string;
|
||||
/** 描述 */
|
||||
desc: string;
|
||||
}
|
||||
|
||||
/** 周挑战进度 */
|
||||
export interface BeaconWeeklyProgress {
|
||||
weekKey: string;
|
||||
progress: number;
|
||||
startedAt: number;
|
||||
completedAt: number | null;
|
||||
claimed: boolean;
|
||||
durationSec: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 ISO 8601 周键(YYYY-Www),如 "2026-W26"。
|
||||
* 规则:周一为一周开始;第一周是该年至少含 4 天的第一周(即包含第一个周四)。
|
||||
*/
|
||||
export function getWeekKey(now: Date = new Date()): string {
|
||||
// 取 UTC 日期,避免时区偏移
|
||||
const tmp = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
||||
);
|
||||
// ISO 周以周一为起点:(getUTCDay + 6) % 7 → 0=Mon, 6=Sun
|
||||
const dayNum = (tmp.getUTCDay() + 6) % 7;
|
||||
// 把日期调整到本周四(ISO 周归属年份由该周四所在的年份决定)
|
||||
tmp.setUTCDate(tmp.getUTCDate() - dayNum + 3);
|
||||
const isoYear = tmp.getUTCFullYear();
|
||||
const yearStart = Date.UTC(isoYear, 0, 1);
|
||||
const weekNum = Math.ceil(((tmp.getTime() - yearStart) / 86400000 + 1) / 7);
|
||||
return `${isoYear}-W${String(weekNum).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** weekKey → 数值种子(FNV-1a 哈希) */
|
||||
function weekKeyToSeed(weekKey: string): number {
|
||||
let h = 2166136261 >>> 0;
|
||||
for (let i = 0; i < weekKey.length; i++) {
|
||||
h ^= weekKey.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619) >>> 0;
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成本周周挑战(确定性:同一周同一种子 → 同一挑战)。
|
||||
* 难度强制 anomaly(60%)或 singular(40%),goal 为日挑战基准 ×3-5 倍。
|
||||
*/
|
||||
export function generateWeeklyChallenge(
|
||||
now: Date = new Date()
|
||||
): BeaconWeeklyChallenge {
|
||||
const weekKey = getWeekKey(now);
|
||||
const seed = weekKeyToSeed(weekKey);
|
||||
const rng = mulberry32(seed);
|
||||
|
||||
const types: BeaconChallengeType[] = [
|
||||
"decode",
|
||||
"expedition",
|
||||
"pulse",
|
||||
"boss",
|
||||
"insight",
|
||||
];
|
||||
const type = types[Math.floor(rng() * types.length)];
|
||||
|
||||
// 难度加权:anomaly 60% / singular 40%
|
||||
const dr = rng();
|
||||
const difficulty: "anomaly" | "singular" = dr < 0.6 ? "anomaly" : "singular";
|
||||
const diffMult = BEACON_DIFFICULTY[difficulty].mult;
|
||||
|
||||
// 3-5 倍
|
||||
const mult = 3 + Math.floor(rng() * 3);
|
||||
|
||||
let goal = 0;
|
||||
let rewardInsight = 0;
|
||||
let rewardContact = 0;
|
||||
let title = "";
|
||||
let desc = "";
|
||||
|
||||
switch (type) {
|
||||
case "decode":
|
||||
// 日基准 6-15 × mult(3-5) → 18-75
|
||||
goal = Math.max(18, Math.round((6 + Math.floor(rng() * 10)) * diffMult * mult));
|
||||
rewardInsight = Math.round(goal * 5 * diffMult);
|
||||
rewardContact = goal * 1.2;
|
||||
title = `周界解码 · ${goal} 颗晶体`;
|
||||
desc = `本周深空信标要求解码 ${goal} 颗记忆晶体,强度为日挑战的 ${mult} 倍。`;
|
||||
break;
|
||||
case "expedition":
|
||||
// 日基准 1-3 × mult(3-5) → 3-15
|
||||
goal = Math.max(3, Math.round((1 + Math.floor(rng() * 3)) * diffMult * mult));
|
||||
rewardInsight = Math.round(goal * 14 * diffMult);
|
||||
rewardContact = goal * 2;
|
||||
title = `周界远征 · ${goal} 次探险`;
|
||||
desc = `本周派出探险队完成 ${goal} 次远征,无论胜负均计入。`;
|
||||
break;
|
||||
case "pulse":
|
||||
// 日基准 20-49 × mult(3-5) → 60-245
|
||||
goal = Math.max(60, Math.round((20 + Math.floor(rng() * 30)) * diffMult * mult));
|
||||
rewardInsight = Math.round(goal * 1.5 * diffMult);
|
||||
rewardContact = goal * 0.4;
|
||||
title = `周界脉冲 · ${goal} 次扫描`;
|
||||
desc = `本周发起 ${goal} 次脉冲扫描。连击可叠加加成。`;
|
||||
break;
|
||||
case "boss":
|
||||
// 日基准 1 × mult(3-5) → 3-12(singular mult=2.4 时上限 12)
|
||||
goal = Math.max(3, Math.round(mult * diffMult));
|
||||
rewardInsight = Math.round(goal * 35 * diffMult);
|
||||
rewardContact = goal * 4;
|
||||
title = `周界猎杀 · ${goal} 处 BOSS`;
|
||||
desc = `本周在探险终点击破 ${goal} 处维度 BOSS。提升探险力后再挑战。`;
|
||||
break;
|
||||
case "insight":
|
||||
// 日基准 40-119 × mult(3-5) → 120-595
|
||||
goal = Math.max(120, Math.round((40 + Math.floor(rng() * 80)) * diffMult * mult));
|
||||
rewardInsight = 0; // 洞见挑战不给洞见,给接触
|
||||
rewardContact = goal * 0.15;
|
||||
title = `周界洞见 · ${goal} 点`;
|
||||
desc = `本周通过解码、探险、星潮等途径累计 ${goal} 洞见。`;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
weekKey,
|
||||
type,
|
||||
difficulty,
|
||||
goal,
|
||||
rewardInsight,
|
||||
rewardContact,
|
||||
seed,
|
||||
title,
|
||||
desc,
|
||||
};
|
||||
}
|
||||
|
||||
/** 读取本周进度(若 weekKey 不匹配则重置) */
|
||||
export function loadWeeklyProgress(now: Date = new Date()): BeaconWeeklyProgress {
|
||||
const weekKey = getWeekKey(now);
|
||||
const empty: BeaconWeeklyProgress = {
|
||||
weekKey,
|
||||
progress: 0,
|
||||
startedAt: Date.now(),
|
||||
completedAt: null,
|
||||
claimed: false,
|
||||
durationSec: 0,
|
||||
};
|
||||
if (typeof localStorage === "undefined") return empty;
|
||||
try {
|
||||
const raw = localStorage.getItem(BEACON_WEEKLY_KEY);
|
||||
if (!raw) return empty;
|
||||
const prog = JSON.parse(raw) as BeaconWeeklyProgress;
|
||||
if (prog.weekKey !== weekKey) return empty; // 新的一周,重置
|
||||
return prog;
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存本周进度 */
|
||||
export function saveWeeklyProgress(prog: BeaconWeeklyProgress): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.setItem(BEACON_WEEKLY_KEY, JSON.stringify(prog));
|
||||
}
|
||||
|
||||
/** 增量更新周挑战进度,返回新进度 + 是否刚完成 */
|
||||
export function addWeeklyProgress(
|
||||
current: BeaconWeeklyProgress,
|
||||
challenge: BeaconWeeklyChallenge,
|
||||
delta: number
|
||||
): { progress: BeaconWeeklyProgress; 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: BeaconWeeklyProgress = {
|
||||
...current,
|
||||
progress: newProgressVal,
|
||||
completedAt,
|
||||
durationSec,
|
||||
};
|
||||
saveWeeklyProgress(next);
|
||||
return { progress: next, justCompleted };
|
||||
}
|
||||
|
||||
/** 领取周挑战奖励:返回奖励数值 + 推送排行榜 */
|
||||
export function claimWeeklyReward(
|
||||
challenge: BeaconWeeklyChallenge,
|
||||
progress: BeaconWeeklyProgress
|
||||
): {
|
||||
rewardInsight: number;
|
||||
rewardContact: number;
|
||||
score: number;
|
||||
leaderboard: BeaconScoreEntry[];
|
||||
} {
|
||||
if (progress.claimed || progress.completedAt === null) {
|
||||
return {
|
||||
rewardInsight: 0,
|
||||
rewardContact: 0,
|
||||
score: 0,
|
||||
leaderboard: loadLeaderboard(),
|
||||
};
|
||||
}
|
||||
// 借用 computeBeaconScore:把 weekly 包装成 daily 接口(dateKey 字段不影响计分逻辑)
|
||||
const score = computeBeaconScore(
|
||||
{ ...(challenge as unknown as BeaconDailyChallenge), dateKey: challenge.weekKey },
|
||||
progress.progress,
|
||||
progress.durationSec
|
||||
);
|
||||
const entry: BeaconScoreEntry = {
|
||||
timestamp: Date.now(),
|
||||
dateKey: challenge.weekKey,
|
||||
challenge: challenge.type,
|
||||
difficulty: challenge.difficulty,
|
||||
progress: progress.progress / challenge.goal,
|
||||
score,
|
||||
durationSec: progress.durationSec,
|
||||
isWeekly: true,
|
||||
};
|
||||
const leaderboard = pushLeaderboardEntry(entry);
|
||||
const updated: BeaconWeeklyProgress = { ...progress, claimed: true };
|
||||
saveWeeklyProgress(updated);
|
||||
return {
|
||||
rewardInsight: challenge.rewardInsight,
|
||||
rewardContact: challenge.rewardContact,
|
||||
score,
|
||||
leaderboard,
|
||||
};
|
||||
}
|
||||
|
||||
/** 距离下周一 UTC 0 点的毫秒数(用于周挑战倒计时) */
|
||||
export function msUntilNextWeek(now: Date = new Date()): number {
|
||||
const dayNum = (now.getUTCDay() + 6) % 7; // 0 = Mon
|
||||
const mondayThisWeek = Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth(),
|
||||
now.getUTCDate() - dayNum,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
);
|
||||
const nextMonday = mondayThisWeek + 7 * 86400000;
|
||||
return Math.max(0, nextMonday - now.getTime());
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 信标链(BEACON CHAIN)— v0.8
|
||||
// 连续完成日挑战形成"链",达成里程碑(3/7/14/30 天)领取递增大奖。
|
||||
// 断链有宽容机制(1 天 miss 不断链,用"信标续命"概念),每条链只能用 1 次。
|
||||
// ===========================================================================
|
||||
|
||||
/** 信标链 localStorage key */
|
||||
export const BEACON_CHAIN_KEY = "echo-nexus-beacon-chain-v1";
|
||||
|
||||
/** 里程碑天数 */
|
||||
export const BEACON_CHAIN_MILESTONES = [3, 7, 14, 30] as const;
|
||||
|
||||
/** 里程碑奖励配置 */
|
||||
export interface BeaconChainReward {
|
||||
milestone: number;
|
||||
rewardInsight: number;
|
||||
rewardContact: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const BEACON_CHAIN_REWARDS: BeaconChainReward[] = [
|
||||
{ milestone: 3, rewardInsight: 50, rewardContact: 5, label: "三日谐振" },
|
||||
{ milestone: 7, rewardInsight: 120, rewardContact: 12, label: "七日回响" },
|
||||
{ milestone: 14, rewardInsight: 280, rewardContact: 28, label: "半月星潮" },
|
||||
{ milestone: 30, rewardInsight: 680, rewardContact: 68, label: "满月飞升" },
|
||||
];
|
||||
|
||||
/** 信标链状态 */
|
||||
export interface BeaconChainState {
|
||||
/** 上次完成日(YYYY-MM-DD) */
|
||||
lastCompletedDateKey: string;
|
||||
/** 当前连续天数 */
|
||||
currentStreak: number;
|
||||
/** 历史最长 */
|
||||
longestStreak: number;
|
||||
/** 累计完成总数 */
|
||||
totalCompletions: number;
|
||||
/** 本周期已用续命数(上限 1) */
|
||||
graceUsed: number;
|
||||
/** 已领取的里程碑数组 */
|
||||
milestonesClaimed: number[];
|
||||
}
|
||||
|
||||
/** 读取信标链状态 */
|
||||
export function loadChainState(): BeaconChainState {
|
||||
const fresh = (): BeaconChainState => ({
|
||||
lastCompletedDateKey: "",
|
||||
currentStreak: 0,
|
||||
longestStreak: 0,
|
||||
totalCompletions: 0,
|
||||
graceUsed: 0,
|
||||
milestonesClaimed: [],
|
||||
});
|
||||
if (typeof localStorage === "undefined") return fresh();
|
||||
try {
|
||||
const raw = localStorage.getItem(BEACON_CHAIN_KEY);
|
||||
if (!raw) return fresh();
|
||||
const s = JSON.parse(raw) as Partial<BeaconChainState>;
|
||||
return {
|
||||
lastCompletedDateKey: s.lastCompletedDateKey ?? "",
|
||||
currentStreak: s.currentStreak ?? 0,
|
||||
longestStreak: s.longestStreak ?? 0,
|
||||
totalCompletions: s.totalCompletions ?? 0,
|
||||
graceUsed: s.graceUsed ?? 0,
|
||||
milestonesClaimed: Array.isArray(s.milestonesClaimed)
|
||||
? [...s.milestonesClaimed]
|
||||
: [],
|
||||
};
|
||||
} catch {
|
||||
return fresh();
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存信标链状态 */
|
||||
export function saveChainState(state: BeaconChainState): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.setItem(BEACON_CHAIN_KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
/** dateKey → UTC 0 点时间戳 */
|
||||
function dateKeyToTimestamp(dateKey: string): number {
|
||||
const [y, m, d] = dateKey.split("-").map(Number);
|
||||
return Date.UTC(y, m - 1, d, 0, 0, 0);
|
||||
}
|
||||
|
||||
/** 计算 b - a 相差的天数(UTC 0 点对齐) */
|
||||
function dateKeyDiffDays(a: string, b: string): number {
|
||||
if (!a || !b) return Number.MAX_SAFE_INTEGER;
|
||||
return Math.round((dateKeyToTimestamp(b) - dateKeyToTimestamp(a)) / 86400000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一次日挑战完成(核心逻辑)。
|
||||
* - dateKey === lastCompletedDateKey:同一天重复完成,忽略,返回 newMilestones: []
|
||||
* - dateKey 是 lastCompletedDateKey 的次日:currentStreak++
|
||||
* - dateKey 是 lastCompletedDateKey 的后两天(隔了一天 miss)且 graceUsed < 1:续命一次,currentStreak++
|
||||
* - 其他:currentStreak = 1(断链重来),graceUsed = 0
|
||||
* 更新 longestStreak 与 totalCompletions。
|
||||
* @returns { state, newMilestones } 刚达成但未领取的里程碑数组
|
||||
*/
|
||||
export function recordChainCompletion(dateKey: string): {
|
||||
state: BeaconChainState;
|
||||
newMilestones: number[];
|
||||
} {
|
||||
const state = loadChainState();
|
||||
|
||||
// 同一天重复完成:忽略
|
||||
if (state.lastCompletedDateKey === dateKey) {
|
||||
return { state, newMilestones: [] };
|
||||
}
|
||||
|
||||
let next: BeaconChainState;
|
||||
|
||||
if (state.lastCompletedDateKey === "") {
|
||||
// 首次完成
|
||||
next = {
|
||||
...state,
|
||||
lastCompletedDateKey: dateKey,
|
||||
currentStreak: 1,
|
||||
longestStreak: Math.max(state.longestStreak, 1),
|
||||
totalCompletions: state.totalCompletions + 1,
|
||||
};
|
||||
} else {
|
||||
const diff = dateKeyDiffDays(state.lastCompletedDateKey, dateKey);
|
||||
if (diff === 1) {
|
||||
// 次日:链 +1
|
||||
const newStreak = state.currentStreak + 1;
|
||||
next = {
|
||||
...state,
|
||||
lastCompletedDateKey: dateKey,
|
||||
currentStreak: newStreak,
|
||||
longestStreak: Math.max(state.longestStreak, newStreak),
|
||||
totalCompletions: state.totalCompletions + 1,
|
||||
};
|
||||
} else if (diff === 2 && state.graceUsed < 1) {
|
||||
// 隔一天 miss,续命一次
|
||||
const newStreak = state.currentStreak + 1;
|
||||
next = {
|
||||
...state,
|
||||
lastCompletedDateKey: dateKey,
|
||||
currentStreak: newStreak,
|
||||
longestStreak: Math.max(state.longestStreak, newStreak),
|
||||
totalCompletions: state.totalCompletions + 1,
|
||||
graceUsed: state.graceUsed + 1,
|
||||
};
|
||||
} else {
|
||||
// 断链重来
|
||||
next = {
|
||||
...state,
|
||||
lastCompletedDateKey: dateKey,
|
||||
currentStreak: 1,
|
||||
totalCompletions: state.totalCompletions + 1,
|
||||
graceUsed: 0,
|
||||
longestStreak: Math.max(state.longestStreak, 1),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 检查新里程碑(刚达成但未领取)
|
||||
const newMilestones: number[] = [];
|
||||
for (const m of BEACON_CHAIN_MILESTONES) {
|
||||
if (
|
||||
next.currentStreak >= m &&
|
||||
!next.milestonesClaimed.includes(m)
|
||||
) {
|
||||
newMilestones.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
saveChainState(next);
|
||||
return { state: next, newMilestones };
|
||||
}
|
||||
|
||||
/** 领取里程碑奖励,加入 milestonesClaimed */
|
||||
export function claimChainMilestone(milestone: number): {
|
||||
rewardInsight: number;
|
||||
rewardContact: number;
|
||||
label: string;
|
||||
state: BeaconChainState;
|
||||
} {
|
||||
const reward = BEACON_CHAIN_REWARDS.find((r) => r.milestone === milestone);
|
||||
const state = loadChainState();
|
||||
if (
|
||||
!reward ||
|
||||
state.milestonesClaimed.includes(milestone) ||
|
||||
state.currentStreak < milestone
|
||||
) {
|
||||
return { rewardInsight: 0, rewardContact: 0, label: "", state };
|
||||
}
|
||||
const next: BeaconChainState = {
|
||||
...state,
|
||||
milestonesClaimed: [...state.milestonesClaimed, milestone],
|
||||
};
|
||||
saveChainState(next);
|
||||
return {
|
||||
rewardInsight: reward.rewardInsight,
|
||||
rewardContact: reward.rewardContact,
|
||||
label: reward.label,
|
||||
state: next,
|
||||
};
|
||||
}
|
||||
|
||||
/** 返回下一个目标里程碑(如 streak=5 → 7;streak=30+ → null) */
|
||||
export function getNextMilestone(streak: number): number | null {
|
||||
for (const m of BEACON_CHAIN_MILESTONES) {
|
||||
if (streak < m) return m;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回信标链进度信息(用于 UI 进度条)。
|
||||
* - current:当前连续天数
|
||||
* - next:下一个目标里程碑(null 表示已通关全部)
|
||||
* - progressPct:当前进度(基于上一个里程碑 → 下一个里程碑)
|
||||
*/
|
||||
export function getChainProgress(streak: number): {
|
||||
current: number;
|
||||
next: number | null;
|
||||
prev: number;
|
||||
progressPct: number;
|
||||
} {
|
||||
const next = getNextMilestone(streak);
|
||||
let prev = 0;
|
||||
for (const m of BEACON_CHAIN_MILESTONES) {
|
||||
if (streak >= m) prev = m;
|
||||
}
|
||||
if (next === null) {
|
||||
return { current: streak, next: null, prev, progressPct: 100 };
|
||||
}
|
||||
const span = next - prev;
|
||||
const done = streak - prev;
|
||||
const pct = span > 0 ? Math.round((done / span) * 100) : 100;
|
||||
return {
|
||||
current: streak,
|
||||
next,
|
||||
prev,
|
||||
progressPct: Math.min(100, Math.max(0, pct)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ export const TECH_TREE: TechNode[] = [
|
||||
branch: "expedition",
|
||||
level: 2,
|
||||
name: "遗迹图谱",
|
||||
desc: "探险力 +5,探险生命 +20",
|
||||
desc: "探险力 +5,探险生命 +20,能量恢复 +30%",
|
||||
cost: 60,
|
||||
effect: { kind: "crystalCap", value: 200 },
|
||||
},
|
||||
@@ -206,7 +206,7 @@ export const TECH_TREE: TechNode[] = [
|
||||
branch: "expedition",
|
||||
level: 3,
|
||||
name: "维度信标",
|
||||
desc: "探险力 +8,接触进度转化率 +50%",
|
||||
desc: "探险力 +8,接触进度转化率 +50%,能量恢复 +20%",
|
||||
cost: 180,
|
||||
effect: { kind: "contactRate", value: 0.5 },
|
||||
},
|
||||
|
||||
@@ -35,10 +35,14 @@ export const EXPEDITION_CONFIG = {
|
||||
baseHp: 100,
|
||||
/** 探险力基础 */
|
||||
basePower: 10,
|
||||
/** 战斗:胜率 = clamp(power / (power + difficulty*8), 0.25, 0.95) */
|
||||
combatDifficultyScale: 8,
|
||||
/** 能量恢复间隔(秒),每 interval 恢复 1 点 */
|
||||
/** 战斗:胜率 = clamp(power / (power + difficulty*scale), floor, 0.95)
|
||||
* v0.8.1 平衡:scale 8→4,floor 0.25→0.35,让基础探险力也能有合理胜率 */
|
||||
combatDifficultyScale: 4,
|
||||
combatWinRateFloor: 0.35,
|
||||
/** 能量恢复基础间隔(秒),可被技术/属性缩短 */
|
||||
energyRegenSec: 45,
|
||||
/** 能量恢复最短间隔(秒,技术+属性全满时) */
|
||||
energyRegenMinSec: 12,
|
||||
};
|
||||
|
||||
/** 节点类型权重(boss 固定末位,其余按权重随机) */
|
||||
@@ -147,7 +151,7 @@ export function generateExpedition(
|
||||
title: flavor.titles[fi],
|
||||
desc: flavor.descs[fi],
|
||||
cleared: false,
|
||||
difficulty: isBoss ? 5 + Math.floor(rng() * 3) : 1 + Math.floor(rng() * 4),
|
||||
difficulty: isBoss ? 3 + Math.floor(rng() * 3) : 1 + Math.floor(rng() * 4),
|
||||
position: i,
|
||||
});
|
||||
}
|
||||
@@ -203,10 +207,13 @@ export function computeExpeditionHp(state: GameState): number {
|
||||
return hp;
|
||||
}
|
||||
|
||||
/** 战斗胜率 */
|
||||
/** 战斗胜率
|
||||
* v0.8.1 平衡:scale 4 + floor 0.35,让基础探险力 10 对 BOSS(diff 3-5) 有 33-45% 胜率,
|
||||
* 配合技术/属性后可达 55-70%,告别"BOSS 必败"体验 */
|
||||
export function combatWinRate(power: number, difficulty: number): number {
|
||||
const scale = EXPEDITION_CONFIG.combatDifficultyScale;
|
||||
return Math.max(0.25, Math.min(0.95, power / (power + difficulty * scale)));
|
||||
const floor = EXPEDITION_CONFIG.combatWinRateFloor;
|
||||
return Math.max(floor, Math.min(0.95, power / (power + difficulty * scale)));
|
||||
}
|
||||
|
||||
/** 结算当前节点(自动结算,返回结果与日志) */
|
||||
@@ -358,14 +365,32 @@ export function advanceExpedition(expedition: Expedition): ExpeditionResult {
|
||||
return { log: `前进至节点 ${expedition.currentNode + 1}`, ended: false };
|
||||
}
|
||||
|
||||
/** 计算能量恢复(基于时间) */
|
||||
/** 计算实际能量恢复间隔(秒),由技术树 + 角色属性缩短
|
||||
* v0.8.1:exp_2 解锁 -30%,exp_3 解锁 -20%,探索力属性 -最高30%,下限 12s */
|
||||
export function computeEnergyRegenInterval(state: GameState): number {
|
||||
const base = EXPEDITION_CONFIG.energyRegenSec;
|
||||
let mult = 1;
|
||||
// 技术:遗迹图谱(exp_2)+ 维度信标(exp_3)
|
||||
if (state.tech?.exp_2) mult *= 0.7;
|
||||
if (state.tech?.exp_3) mult *= 0.8;
|
||||
// 角色属性:探索力(exploration)每点缩短少量,高探索力最高 -30%
|
||||
const exploration = state.attributes?.exploration ?? 0;
|
||||
const explorationBonus = exploration >= 50
|
||||
? 0.15 + (Math.min(100, exploration) - 50) * 0.003 // 50点15%,100点30%
|
||||
: exploration * 0.003;
|
||||
mult *= 1 - explorationBonus;
|
||||
return Math.max(EXPEDITION_CONFIG.energyRegenMinSec, base * mult);
|
||||
}
|
||||
|
||||
/** 计算能量恢复(基于时间,支持动态间隔) */
|
||||
export function computeEnergyRegen(
|
||||
lastTick: number,
|
||||
now: number,
|
||||
current: number,
|
||||
max: number
|
||||
max: number,
|
||||
intervalSec: number = EXPEDITION_CONFIG.energyRegenSec
|
||||
): { energy: number; lastTick: number } {
|
||||
const interval = EXPEDITION_CONFIG.energyRegenSec * 1000;
|
||||
const interval = intervalSec * 1000;
|
||||
const elapsed = now - lastTick;
|
||||
const gained = Math.floor(elapsed / interval);
|
||||
if (gained <= 0) return { energy: current, lastTick };
|
||||
|
||||
+125
-14
@@ -39,6 +39,7 @@ import {
|
||||
computeExpeditionPower,
|
||||
computeExpeditionHp,
|
||||
computeEnergyRegen,
|
||||
computeEnergyRegenInterval,
|
||||
EXPEDITION_CONFIG,
|
||||
} from "@/lib/game/expedition";
|
||||
import { ACHIEVEMENTS, type Achievement } from "@/lib/game/achievements";
|
||||
@@ -61,10 +62,20 @@ import {
|
||||
} from "@/lib/game/chronicle";
|
||||
import {
|
||||
generateDailyChallenge,
|
||||
generateWeeklyChallenge,
|
||||
loadDailyProgress,
|
||||
loadWeeklyProgress,
|
||||
loadChainState,
|
||||
addBeaconProgress,
|
||||
addWeeklyProgress,
|
||||
recordChainCompletion,
|
||||
claimWeeklyReward,
|
||||
claimChainMilestone,
|
||||
getTodayKey,
|
||||
type BeaconDailyChallenge,
|
||||
type BeaconDailyProgress,
|
||||
type BeaconWeeklyChallenge,
|
||||
type BeaconWeeklyProgress,
|
||||
} from "@/lib/game/beacon";
|
||||
import { setPendingOfflineReport } from "@/lib/game/offlineReport";
|
||||
import {
|
||||
@@ -129,6 +140,19 @@ interface GameActions {
|
||||
// 深空信标奖励发放(v0.5)
|
||||
grantBeaconReward: (insights: number, contact: number) => void;
|
||||
|
||||
// 深空信标 · 周挑战领取 + 信标链里程碑领取(v0.8)
|
||||
claimWeeklyBeacon: () => {
|
||||
rewardInsight: number;
|
||||
rewardContact: number;
|
||||
score: number;
|
||||
};
|
||||
claimChainReward: (milestone: number) => {
|
||||
rewardInsight: number;
|
||||
rewardContact: number;
|
||||
label: string;
|
||||
ok: boolean;
|
||||
};
|
||||
|
||||
// 深空巡航奖励发放(v0.6 — 实时 Canvas 玩法)
|
||||
grantCruiseReward: (rewards: { crystals?: number; insights?: number; contact?: number }) => void;
|
||||
|
||||
@@ -165,25 +189,61 @@ function syncStats(state: Partial<GameState>) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 深空信标进度追踪(v0.5)。
|
||||
* 在游戏关键动作(脉冲/解码/探险/BOSS/洞见)后调用,按今日挑战类型增量更新进度。
|
||||
* 进度独立存储于 localStorage(echo-nexus-beacon-prog-v1),不污染 GameState。
|
||||
* @returns 若刚完成则返回 true(供 UI 触发通知)
|
||||
* 深空信标进度追踪(v0.5 → v0.8 扩展)。
|
||||
* 在游戏关键动作(脉冲/解码/探险/BOSS/洞见)后调用,同时更新:
|
||||
* 1. 日挑战进度(按今日挑战类型增量)
|
||||
* 2. 周挑战进度(按本周挑战类型增量)
|
||||
* 3. 信标链:日挑战刚完成时记录一次完成(含 grace 续命逻辑)
|
||||
* 进度独立存储于 localStorage,不污染 GameState。
|
||||
* @returns 三类状态变更供 UI 触发通知
|
||||
*/
|
||||
function trackBeacon(
|
||||
type: "pulse" | "decode" | "expedition" | "boss" | "insight",
|
||||
delta: number
|
||||
): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
): {
|
||||
dailyJustCompleted: boolean;
|
||||
weeklyJustCompleted: boolean;
|
||||
newChainMilestones: number[];
|
||||
} {
|
||||
const result = {
|
||||
dailyJustCompleted: false,
|
||||
weeklyJustCompleted: false,
|
||||
newChainMilestones: [] as number[],
|
||||
};
|
||||
if (typeof window === "undefined") return result;
|
||||
try {
|
||||
// ---- 日挑战 ----
|
||||
const challenge: BeaconDailyChallenge = generateDailyChallenge();
|
||||
if (challenge.type !== type) return false;
|
||||
const current: BeaconDailyProgress = loadDailyProgress();
|
||||
if (current.completedAt !== null) return false; // 已完成不再累加
|
||||
const { justCompleted } = addBeaconProgress(current, challenge, delta);
|
||||
return justCompleted;
|
||||
if (challenge.type === type) {
|
||||
const current: BeaconDailyProgress = loadDailyProgress();
|
||||
if (current.completedAt === null) {
|
||||
const { justCompleted } = addBeaconProgress(current, challenge, delta);
|
||||
result.dailyJustCompleted = justCompleted;
|
||||
// 日挑战刚完成 → 更新信标链
|
||||
if (justCompleted) {
|
||||
const { newMilestones } = recordChainCompletion(getTodayKey());
|
||||
result.newChainMilestones = newMilestones;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 周挑战 ----
|
||||
const wChallenge: BeaconWeeklyChallenge = generateWeeklyChallenge();
|
||||
if (wChallenge.type === type) {
|
||||
const wCurrent: BeaconWeeklyProgress = loadWeeklyProgress();
|
||||
if (wCurrent.completedAt === null) {
|
||||
const { justCompleted } = addWeeklyProgress(
|
||||
wCurrent,
|
||||
wChallenge,
|
||||
delta
|
||||
);
|
||||
result.weeklyJustCompleted = justCompleted;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch {
|
||||
return false;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,11 +463,12 @@ export const useGameStore = create<Store>()(
|
||||
lastSpawn = now;
|
||||
}
|
||||
|
||||
// 能量恢复(探险系统)
|
||||
// 能量恢复(探险系统,v0.8.1 动态间隔)
|
||||
let energy = s.energy;
|
||||
let lastEnergyTick = s.lastEnergyTick;
|
||||
if (energy < s.energyMax) {
|
||||
const regen = computeEnergyRegen(lastEnergyTick, now, energy, s.energyMax);
|
||||
const intervalSec = computeEnergyRegenInterval(s);
|
||||
const regen = computeEnergyRegen(lastEnergyTick, now, energy, s.energyMax, intervalSec);
|
||||
energy = regen.energy;
|
||||
lastEnergyTick = regen.lastTick;
|
||||
} else {
|
||||
@@ -948,6 +1009,56 @@ export const useGameStore = create<Store>()(
|
||||
});
|
||||
},
|
||||
|
||||
// 深空信标:领取周挑战奖励(v0.8)
|
||||
claimWeeklyBeacon: () => {
|
||||
const challenge = generateWeeklyChallenge();
|
||||
const progress = loadWeeklyProgress();
|
||||
const res = claimWeeklyReward(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),
|
||||
});
|
||||
}
|
||||
return {
|
||||
rewardInsight: res.rewardInsight,
|
||||
rewardContact: res.rewardContact,
|
||||
score: res.score,
|
||||
};
|
||||
},
|
||||
|
||||
// 深空信标:领取信标链里程碑奖励(v0.8)
|
||||
claimChainReward: (milestone) => {
|
||||
// loadChainState 仅用于前置校验,真正的状态修改由 claimChainMilestone 完成
|
||||
const pre = loadChainState();
|
||||
if (
|
||||
pre.currentStreak < milestone ||
|
||||
pre.milestonesClaimed.includes(milestone)
|
||||
) {
|
||||
return {
|
||||
rewardInsight: 0,
|
||||
rewardContact: 0,
|
||||
label: "",
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
const res = claimChainMilestone(milestone);
|
||||
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),
|
||||
});
|
||||
}
|
||||
return {
|
||||
rewardInsight: res.rewardInsight,
|
||||
rewardContact: res.rewardContact,
|
||||
label: res.label,
|
||||
ok: res.rewardInsight > 0 || res.rewardContact > 0,
|
||||
};
|
||||
},
|
||||
|
||||
// 深空巡航:发放实时玩法奖励(v0.6)
|
||||
grantCruiseReward: (rewards) => {
|
||||
const s = get();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+74
@@ -337,3 +337,77 @@ Stage Summary:
|
||||
- ✅ VLM 评分:主界面 7/10 + 巡航READY 8/10 + 巡航游戏 8/10
|
||||
- 在线游玩 v0.8:https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
|
||||
- 下一阶段工单:P1 探险BOSS胜率平衡 + 探险能量恢复 / 信标系统扩展(周挑战/信标链)
|
||||
|
||||
---
|
||||
Task ID: 9-b
|
||||
Agent: full-stack-developer
|
||||
Task: 信标系统扩展(周挑战 + 信标链连续奖励)
|
||||
|
||||
Work Log:
|
||||
- 阅读现有 `src/lib/game/beacon.ts`(v0.5 每日挑战 + 本地排行榜,358 行)+ `src/components/game/BeaconPanel.tsx` + `src/store/gameStore.ts` 的 `trackBeacon` 函数(约第 173 行)+ worklog v0.8 项目状态,确认四色全息规范与现有架构。
|
||||
- **扩展 `src/lib/game/beacon.ts`(358 → 873 行,新增 ~515 行)**:
|
||||
- **周挑战(WEEKLY CHALLENGE)**:
|
||||
- `BeaconWeeklyChallenge` 接口(weekKey YYYY-Www / type / difficulty 强制 anomaly|singular / goal / rewardInsight / rewardContact / seed / title / desc)
|
||||
- `BeaconWeeklyProgress` 接口(weekKey / progress / startedAt / completedAt / claimed / durationSec)
|
||||
- `getWeekKey(now)`:ISO 8601 周键(周一为起点,含首个周四的周为第一周)
|
||||
- `weekKeyToSeed`:FNV-1a 哈希
|
||||
- `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)
|
||||
- `loadWeeklyProgress` / `saveWeeklyProgress` / `addWeeklyProgress` / `claimWeeklyReward`(推送排行榜,entry 加 `isWeekly: true`)
|
||||
- `msUntilNextWeek(now)`:距离下周一 UTC 0 点的毫秒数
|
||||
- `BEACON_WEEKLY_KEY = "echo-nexus-beacon-weekly-v1"`
|
||||
- **信标链(BEACON CHAIN)**:
|
||||
- `BeaconChainState` 接口(lastCompletedDateKey / currentStreak / longestStreak / totalCompletions / graceUsed / milestonesClaimed)
|
||||
- `BEACON_CHAIN_KEY = "echo-nexus-beacon-chain-v1"`
|
||||
- `BEACON_CHAIN_MILESTONES = [3, 7, 14, 30]` 常量
|
||||
- `BEACON_CHAIN_REWARDS`:4 个里程碑(三日谐振 +50/+5 / 七日回响 +120/+12 / 半月星潮 +280/+28 / 满月飞升 +680/+68)
|
||||
- `loadChainState` / `saveChainState`(每次返回新对象避免引用共享)
|
||||
- `recordChainCompletion(dateKey)` 核心逻辑:同日重复忽略;次日 streak++;隔一天 miss 且 graceUsed<1 时续命 streak++ graceUsed++;其他断链 streak=1 graceUsed=0;返回 `{ state, newMilestones }`
|
||||
- `claimChainMilestone(milestone)` / `getNextMilestone(streak)` / `getChainProgress(streak)`(返回 current/next/prev/progressPct 用于 UI)
|
||||
- `dateKeyToTimestamp` / `dateKeyDiffDays` 工具函数
|
||||
- `BeaconScoreEntry` 新增可选 `isWeekly?: boolean` 字段
|
||||
- **扩展 `src/store/gameStore.ts`**:
|
||||
- import 扩展:generateWeeklyChallenge / loadWeeklyProgress / loadChainState / addWeeklyProgress / recordChainCompletion / claimWeeklyReward / claimChainMilestone / getTodayKey + 类型
|
||||
- `trackBeacon(type, delta)` 函数签名从 `boolean` 改为 `{ dailyJustCompleted, weeklyJustCompleted, newChainMilestones }`:
|
||||
- 日挑战类型匹配且未完成 → addBeaconProgress;若刚完成 → recordChainCompletion(getTodayKey()) 收集 newMilestones
|
||||
- 周挑战类型匹配且未完成 → addWeeklyProgress
|
||||
- 所有调用点(pulse/clickNode/autoDecodeTick/resolveCurrentNode 等 7 处)原本就忽略返回值,向后兼容
|
||||
- 新增 action `claimWeeklyBeacon`:调用 claimWeeklyReward → 发放 insights + contact 到 state,返回 `{ rewardInsight, rewardContact, score }`
|
||||
- 新增 action `claimChainReward(milestone)`:前置校验 currentStreak 与 milestonesClaimed,调用 claimChainMilestone → 发放奖励,返回 `{ rewardInsight, rewardContact, label, ok }`
|
||||
- GameActions 接口同步新增两个 action 签名
|
||||
- **重写 `src/components/game/BeaconPanel.tsx`(301 → 638 行)**:
|
||||
- 顶部头部 + 每日挑战卡片(保留 v0.5 完整功能 + 按钮主题色随难度色淡显)
|
||||
- **新增周挑战区块**(fuchsia 主题):标题"周挑战 · WEEKLY" + weekKey + 倒计时 + 挑战卡片(类型/难度标签/标题/描述/进度条/奖励/领取按钮 emerald 主题)+ weekly-glow 动画
|
||||
- **新增信标链区块**(amber→rose 渐变):标题"信标链 · CHAIN" + 大字号 streak(chain-streak-text 渐变流动动画)+ 今日完成状态徽章 + 4 个里程碑节点(w-12 h-12 圆形,claimed=emerald/✓,reachable=rose 脉冲动画+领取按钮,inProgress=amber 半亮,未到达=muted 灰)+ 节点间连线(背景灰 + 已达成部分 amber→rose 渐变 + 辉光,基于 prev→next 插值定位)+ 进度条 + 底部统计(最长/累计/续命状态)
|
||||
- 桌面端 lg:grid-cols-2 让周挑战+信标链并排,移动端单列
|
||||
- 排行榜条目区分日/周:周挑战 entry 显示 "WEEK" 徽章 + fuchsia 高亮背景
|
||||
- 空状态提示扩展为"完成今日或本周信标即可登榜"
|
||||
- 4 个新动画:weekly-glow / chain-milestone-pulse / chain-streak-flux(背景渐变流动)
|
||||
- **QA 验证**:
|
||||
- `bun run lint` 零错误(每次修改后均验证)
|
||||
- dev.log 全程无错误,所有编译 < 300ms
|
||||
- **信标链逻辑测试**(bun 直接运行 TS,5 个场景全 PASS):
|
||||
1. 昨日 streak=1 → 今日完成 → streak=2 ✅
|
||||
2. 同日重复完成 → 忽略 ✅
|
||||
3. 明日完成 → streak=3,无需 grace ✅
|
||||
4. 隔一天 miss(lastCompleted + 2 天)→ streak=4,graceUsed=1 ✅
|
||||
5. 再次 miss(grace 已用)→ 断链 streak=1,graceUsed=0 ✅
|
||||
- **UI 集成测试**(agent-browser):
|
||||
- localStorage 模拟设置 chain state(lastCompletedDateKey=yesterday, currentStreak=1)→ reload → UI 正确显示 streak=1
|
||||
- 设置 streak=3 + milestonesClaimed=[] → milestone 3 节点显示 rose 脉冲动画 + "领取"按钮
|
||||
- 点击"领取"→ milestonesClaimed=[3] + Toast "✦ 三日谐振 已领取 +50 洞见 · +5.0 接触" + 按钮消失 ✅
|
||||
- **VLM 视觉评分**(目标 ≥7/10):
|
||||
- 首屏截图:8/10(四色一致、布局合理、信标链清晰)
|
||||
- 里程碑可领取状态:8/10(amber→rose 渐变醒目、里程碑层次分明、fuchsia 主题清晰)
|
||||
- 重置后干净状态:8/10(WEEKLY 字体对比度可优化,但整体可读性强)
|
||||
- **不破坏现有功能**:日挑战进度/领奖/排行榜流程全部保留并工作正常
|
||||
|
||||
Stage Summary:
|
||||
- ✅ beacon.ts 扩展完成(358 → 873 行):周挑战 + 信标链全部函数与类型
|
||||
- ✅ gameStore.ts 集成完成:trackBeacon 升级为对象返回值 + claimWeeklyBeacon + claimChainReward 两个新 action
|
||||
- ✅ BeaconPanel.tsx UI 完整重写(301 → 638 行):周挑战 fuchsia 卡片 + 信标链 amber→rose 渐变区块
|
||||
- ✅ 严格四色全息:周挑战 fuchsia、信标链 amber→rose、领取按钮 emerald、无蓝/紫色违规
|
||||
- ✅ localStorage 新增 2 个独立 key(weekly + chain),不污染旧 key
|
||||
- ✅ 确定性:周挑战同周同种子同结果(weekKey FNV-1a 种子)
|
||||
- ✅ 信标链 grace 续命机制工作正常(5 个场景 bun 测试全 PASS)
|
||||
- ✅ lint 零错误 + dev HTTP 200 + VLM 8/10
|
||||
- ✅ 现有日挑战 + 排行榜 + 领奖流程全部保留,无回归
|
||||
|
||||
Reference in New Issue
Block a user