Compare commits
6
Commits
fcf94b6c3f
...
4545fe380b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4545fe380b | ||
|
|
8474f4caa7 | ||
|
|
c0535e6ebe | ||
|
|
7111e154b2 | ||
|
|
72e1f3fc3c | ||
|
|
fafa2ee20a |
@@ -0,0 +1,52 @@
|
||||
# Task 6: 开发 Canvas 2D 深空巡航实时玩法
|
||||
|
||||
## Agent: full-stack-developer
|
||||
|
||||
## 完成状态: ✅ 全部完成
|
||||
|
||||
## 创建的文件
|
||||
1. `/home/z/my-project/src/lib/game/cruise.ts` — 逻辑层(~520 行纯 TS)
|
||||
2. `/home/z/my-project/src/components/game/CruiseMode.tsx` — Canvas 2D 渲染层(~830 行)
|
||||
|
||||
## 修改的文件
|
||||
1. `/home/z/my-project/src/app/page.tsx` — 新增巡航入口按钮 + CruiseMode 渲染
|
||||
|
||||
## 关键实现细节
|
||||
|
||||
### cruise.ts 逻辑层
|
||||
- **类型系统**:CruiseEntity 联合类型(8 种实体:ship/asteroid/storm/crystal/insight/beacon/stargate/particle),完整 CruiseState/Phase/RunResult/Stats/Input
|
||||
- **关卡生成**:mulberry32 + FNV-1a 种子化 RNG;陨石(8+level×2)、风暴(1+level/2)、晶体(5+level)、洞见(2+level/3)、信标(1+level/4)、星门;时长 60-90s
|
||||
- **物理**:飞船 8 方向加速度+摩擦+限速+边界反弹;陨石碰撞扣盾+击退+600ms 无敌;风暴持续 DPS;收集物吸引半径 90px 自动吸入
|
||||
- **奖励**:crystals=碎片×(8+lvl×2)、insights=洞见×(2+lvl×0.5)、contact=信标×(1.5+lvl×0.3);通关×1.5、失败×0.5
|
||||
- **localStorage**:独立 key `echo-nexus-cruise-v1`,记录最高分/通关数/最高关卡/累计奖励/最近 20 局
|
||||
|
||||
### CruiseMode.tsx 渲染层
|
||||
- **全屏 Canvas**:fixed inset-0 z-50,DPR cap 2,resize 监听
|
||||
- **3 层视差星空**:远/中/近 100/60/30 星,独立漂移+飞船速度视差+闪烁
|
||||
- **实体辉光绘制**:全部使用 ctx.shadowBlur;飞船三角形+尾焰渐变;陨石不规则多边形;风暴 5 层云团+闪电;晶体菱形+脉冲环;洞见球体+漩涡;信标光束+旋转环;星门 4 层旋涡+吸入粒子
|
||||
- **粒子系统**:尾焰/收集/碰撞/烟花,上限 200
|
||||
- **屏幕震动**:受击 280ms 衰减抖动
|
||||
- **低护盾警告**:shield<30% 屏幕边缘 rose 脉冲边框
|
||||
- **HUD**:HTML 叠层(非 canvas),glass+backdrop-blur;护盾/能量/分数/用时/收集计数;80ms 节流更新
|
||||
- **控制**:桌面 WASD/方向键;移动端 floating 虚拟摇杆;P 暂停;Esc 退出;失焦自动暂停
|
||||
- **奖励同步**:结算时 grantCruiseReward + recordRun,rewardGrantedRef 防重
|
||||
|
||||
### page.tsx 集成
|
||||
- header 按钮区新增「巡航」按钮(Navigation 图标,amber 主题)
|
||||
- 新增 cruiseOpen state
|
||||
- 组件末尾渲染 {cruiseOpen && <CruiseMode onClose={...} />}
|
||||
- 未破坏现有 7 标签页和其他功能
|
||||
|
||||
## QA 验证结果
|
||||
- ✅ lint 零错误
|
||||
- ✅ 巡航按钮出现在顶部 header
|
||||
- ✅ 点击进入全屏 Canvas,ready 界面完整
|
||||
- ✅ WASD 控制飞船移动,按 W 直冲星门通关
|
||||
- ✅ 收集 2 晶体 → 奖励 +30(2×10×1.5=30 计算正确)
|
||||
- ✅ localStorage 正确记录 highScore/totalWins/bestLevel
|
||||
- ✅ 奖励同步到 gameStore
|
||||
- ✅ 退出返回主界面,7 标签页完好
|
||||
- ✅ 移动端 viewport 测试通过
|
||||
|
||||
## 色彩遵循
|
||||
emerald(#34d399) / rose(#fb7185) / amber(#fbbf24) / fuchsia(#e879f9) 四色全息色谱,零蓝色/靛色
|
||||
@@ -0,0 +1,103 @@
|
||||
# Task 7: 角色属性系统(探索力/智慧/勇气/灵感)
|
||||
|
||||
## Agent: full-stack-developer
|
||||
|
||||
## 完成状态: ✅ 全部完成
|
||||
|
||||
## 创建的文件
|
||||
1. `/home/z/my-project/src/lib/game/attributes.ts` — 属性逻辑层(~330 行)
|
||||
2. `/home/z/my-project/src/components/game/AttributesPanel.tsx` — 属性 UI 面板(~330 行)
|
||||
|
||||
## 修改的文件
|
||||
1. `/home/z/my-project/src/lib/game/types.ts` — GameState 新增 attributes/attributeProgress/pendingAttrPoints
|
||||
2. `/home/z/my-project/src/lib/game/config.ts` — INITIAL_STATE 补全新字段默认值
|
||||
3. `/home/z/my-project/src/lib/game/engine.ts` — recomputeStats 聚合属性加成;performPrestige 发放属性点
|
||||
4. `/home/z/my-project/src/store/gameStore.ts` — 新增 allocateAttribute/gainAttributeExp action;pulse/clickNode/autoDecodeTick/resolveCurrentNode/grantCruiseReward/tickTide/init 接入属性逻辑;旧存档兼容
|
||||
5. `/home/z/my-project/src/lib/game/achievements.ts` — 新增 2 项属性成就
|
||||
6. `/home/z/my-project/src/app/page.tsx` — 新增第 8 个「角色」标签页 + grid-cols-7→8 + 红点提示 + 统计面板新增属性行 + 版本号 v0.6→v0.7
|
||||
|
||||
## 关键实现细节
|
||||
|
||||
### attributes.ts 逻辑层
|
||||
- **类型系统**:AttributeKey(4 个键)/ CharacterAttributes(0-100 数值)/ AttributeProgressEntry(exp + level)
|
||||
- **加成公式**:
|
||||
- 0-50 线性区:每点 +0.5% 加成(0..50 → 0..25%)
|
||||
- 50-100 递减区:每点 +0.2% 加成(50..100 → 25..35%)
|
||||
- 超过 100 仍按 100 计算加成(软上限)
|
||||
- **getAttributeBonus(attr)** 单属性加成百分比
|
||||
- **getAllBonuses(attrs)** 返回 12 个修饰器:探险力倍率/巡航速度/解码步数/洞见倍率/自动解码周期/探险生命/BOSS 胜率/巡航护盾/接触率/星潮触发/脉冲连击/产能加成
|
||||
- **levelUpCheck(progress)** 自动跨多级升级,安全上限 200 次循环
|
||||
- **expRequiredForLevel(level) = 10 × level**(最低 10)
|
||||
- **computePrestigeAttrPoints(ascensions) = ascensions × 2 + 1**(飞升前次数计算)
|
||||
- **migrateAttributes(state)** 旧存档兼容:补全缺失字段、夹紧越界值、同步 level 与 attributes
|
||||
|
||||
### engine.ts 改造
|
||||
- recomputeStats 末尾追加 `getAllBonuses(state.attributes ?? {})` 聚合:
|
||||
- `crystalsPerSec *= am.crystalsPerSecMult`
|
||||
- `insightMult += am.insightMultAdd`
|
||||
- `contactRateMult *= am.contactRateMult`
|
||||
- `decodeStepsBonus += am.decodeStepsBonus`
|
||||
- performPrestige:
|
||||
- 保留 attributes 数值(跨周目永久)
|
||||
- 清空 attributeProgress(新周目重新累积经验)
|
||||
- pendingAttrPoints += computePrestigeAttrPoints(ascensions) = ascensions × 2 + 1
|
||||
- createInitialState:每次返回全新 attributes/attributeProgress 对象,避免引用共享
|
||||
|
||||
### gameStore.ts 集成(核心)
|
||||
- **pulse**:连击 ≥3 给灵感经验(expGain = 1 + floor(combo/2));脉冲威力乘以灵感连击加成
|
||||
- **clickNode** 完成:给智慧经验(expGain = puzzle.tier × 2)
|
||||
- **autoDecodeTick**:自动解码也给智慧经验 +1;自动解码周期受智慧 am.autoDecodeIntervalMult 影响
|
||||
- **startExpedition**:探险力乘 am.expeditionPowerMult;探险生命加 am.expeditionHpBonus
|
||||
- **resolveCurrentNode**:
|
||||
- BOSS 节点用包装 RNG 提升 +am.bossWinRateBonus 胜率(单次 rng 调用,B% 概率返回 0,其余返回 r-B 保持均匀分布)
|
||||
- 战斗胜利给勇气+探索力经验(中途战斗 +2/+1,BOSS 击破 +8/+6,探险胜利 +4)
|
||||
- **grantCruiseReward**:按总奖励量缩放给探索力+勇气经验(expBase = max(2, totalReward/30))
|
||||
- **tickTide**:灵感 am.tideTriggerBonus 缩短星潮间隙(gap × (1 - bonus)),上限 30%
|
||||
- **doPrestige**:performPrestige 后用 next.attributes 重算 stats
|
||||
- **allocateAttribute(attr, points=1)**:分配属性点,同步 attributeProgress[attr].level
|
||||
- **gainAttributeExp(attr, amount)**:通用经验获取(自动升级)
|
||||
- **init()**:调用 migrateAttributes 补全旧存档字段,并传 attributes 到 syncStats
|
||||
- 所有 syncStats 调用点(10+ 处)都补充 `attributes: ...` 参数
|
||||
|
||||
### AttributesPanel.tsx UI
|
||||
- 四维属性卡片网格(小屏 2×2,大屏 1×4):
|
||||
- 图标(Compass/Brain/Swords/Sparkles)+ 中文名 + 英文名 + Lv.{value} badge
|
||||
- 数值 /100 + 加成百分比
|
||||
- 经验进度条(gradient + glow)+ "递减区"标记
|
||||
- 加成影响列表(3 条)
|
||||
- 「+分配」按钮(pendingAttrPoints > 0 时可点,hover scale 105)
|
||||
- 顶部:标题 + 待分配点数 badge(pendingAttrPoints > 0 时 echo-pending-pulse 动画)
|
||||
- 底部:总等级 + 总加成概览 + 12 个修饰器明细行
|
||||
- 配色:4 色全息(emerald/fuchsia/amber/rose),辉光边框 + 顶角光晕装饰
|
||||
- 完全响应式(2 列 → 4 列),overflow-y-auto + 自定义 scrollbar
|
||||
|
||||
### page.tsx 集成
|
||||
- 新增 `User` 图标导入
|
||||
- 新增 `pendingAttrPoints` store selector
|
||||
- TabsList: grid-cols-7 → grid-cols-8
|
||||
- 新增第 8 个 TabsTrigger「角色」(value="attributes"),主题色用 emerald→fuchsia→rose 渐变
|
||||
- pendingAttrPoints > 0 时显示 rose 红点
|
||||
- 新增 TabsContent 渲染 AttributesPanel
|
||||
- StatsPanel 新增 5 行属性行(探索力/智慧/勇气/灵感/待分配属性点)
|
||||
- 版本号 v0.6 → v0.7
|
||||
|
||||
### achievements.ts 新增
|
||||
- `ach_attr_total_50`(四维觉醒):四维属性总和 ≥ 50 → 产能+6%/洞见+6%
|
||||
- `ach_attr_max_100`(维度精通):任一属性 ≥ 100 → 产能+12%/洞见+10%
|
||||
|
||||
## QA 验证结果
|
||||
- ✅ `bun run lint` 零错误
|
||||
- ✅ dev 服务器 HTTP 200
|
||||
- ✅ 编译 < 250ms(177ms 实测)
|
||||
- ✅ 7 标签页 + 巡航按钮完好保留,新增第 8 个「角色」标签页
|
||||
- ✅ data-tut 锚点(tab-expedition/tab-tech/prestige-btn/crystal-orb/decode-panel)保留
|
||||
- ✅ 色彩规范:严格 emerald/fuchsia/amber/rose 四色全息,零蓝色/靛色
|
||||
- ✅ 旧存档兼容:migrateAttributes 补全 attributes/attributeProgress/pendingAttrPoints 字段
|
||||
- ✅ 飞升后 pendingAttrPoints = ascensions × 2 + 1(飞升前次数)
|
||||
- ✅ pulse/clickNode/resolveCurrentNode/grantCruiseReward/autoDecodeTick 均接入属性经验获取
|
||||
|
||||
## 注意事项
|
||||
- BOSS 胜率 RNG 包装:仅在 boss 节点生效,单次 rng 调用保持均匀分布;+am.bossWinRateBonus 上限 +30%
|
||||
- 灵感星潮触发:通过缩短 gap 间接提升触发频率(上限 30%)
|
||||
- 属性加成与现有所有系统(技术树/蓝图/成就/星图/星潮)叠加,不冲突
|
||||
- migrateAttributes 同步 attributeProgress[attr].level 与 attributes[attr] 数值,避免漂移
|
||||
+40
-7
@@ -18,6 +18,8 @@ import { ChronicleDialog } from "@/components/game/ChronicleDialog";
|
||||
import { BeaconPanel } from "@/components/game/BeaconPanel";
|
||||
import { TutorialOverlay } from "@/components/game/TutorialOverlay";
|
||||
import { OfflineReportDialog } from "@/components/game/OfflineReportDialog";
|
||||
import { CruiseMode } from "@/components/game/CruiseMode";
|
||||
import { AttributesPanel } from "@/components/game/AttributesPanel";
|
||||
import {
|
||||
StarTideNotifier,
|
||||
StarTideIndicator,
|
||||
@@ -40,6 +42,8 @@ import {
|
||||
Trophy,
|
||||
Star,
|
||||
Radio,
|
||||
Navigation,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config";
|
||||
import { ACHIEVEMENTS } from "@/lib/game/achievements";
|
||||
@@ -54,6 +58,7 @@ export default function Page() {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [constellationOpen, setConstellationOpen] = useState(false);
|
||||
const [chronicleOpen, setChronicleOpen] = useState(false);
|
||||
const [cruiseOpen, setCruiseOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const contact = useGameStore((s) => s.contact);
|
||||
@@ -71,6 +76,7 @@ export default function Page() {
|
||||
const energy = useGameStore((s) => s.energy);
|
||||
const hasActiveExpedition = useGameStore((s) => !!s.activeExpedition && !s.activeExpedition.finished);
|
||||
const chronicleCount = useGameStore((s) => s.chronicle?.length ?? 0);
|
||||
const pendingAttrPoints = useGameStore((s) => s.pendingAttrPoints ?? 0);
|
||||
|
||||
// 深空信标:检测是否有可领取的奖励(独立 localStorage)
|
||||
const [beaconClaimable, setBeaconClaimable] = useState(false);
|
||||
@@ -166,12 +172,23 @@ export default function Page() {
|
||||
<div className="leading-tight">
|
||||
<h1 className="text-base sm:text-lg font-bold text-gradient">回响星核</h1>
|
||||
<p className="text-[9px] sm:text-[10px] text-muted-foreground/70 -mt-0.5 tracking-wider">
|
||||
ECHO NEXUS · v0.5.2
|
||||
ECHO NEXUS · v0.7
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<StarTideIndicator />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setCruiseOpen(true)}
|
||||
className="border-amber-400/50 text-amber-200 hover:bg-amber-500/10 h-8 px-2.5"
|
||||
aria-label="深空巡航"
|
||||
title="深空巡航 · 实时玩法"
|
||||
>
|
||||
<Navigation className="h-3.5 w-3.5 mr-1" />
|
||||
巡航
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -224,11 +241,11 @@ export default function Page() {
|
||||
<ResourceBar onPrestige={() => setPrestigeOpen(true)} />
|
||||
</header>
|
||||
|
||||
{/* 主体 */}
|
||||
{/* 主体 — 小屏允许自然滚动,避免 min-h 总和超过视口导致挤压重叠 */}
|
||||
<main className="flex-1 px-3 sm:px-5 pb-3 min-h-0">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_minmax(360px,420px)] gap-3 h-full">
|
||||
{/* 左侧:脉冲晶体 */}
|
||||
<section className="glass rounded-2xl p-4 sm:p-6 flex flex-col items-center justify-center min-h-[420px] relative overflow-hidden">
|
||||
<section className="glass rounded-2xl p-4 sm:p-6 flex flex-col items-center justify-center min-h-[340px] sm:min-h-[420px] relative overflow-hidden">
|
||||
{/* 装饰光圈 */}
|
||||
<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" />
|
||||
@@ -240,13 +257,13 @@ export default function Page() {
|
||||
{/* 右侧:解码 + 标签面板 */}
|
||||
<section className="flex flex-col gap-3 min-h-0">
|
||||
{/* 解码面板 */}
|
||||
<div data-tut="decode-panel" className="glass rounded-2xl p-4 flex-1 min-h-[360px] max-h-[560px]">
|
||||
<div data-tut="decode-panel" className="glass rounded-2xl p-4 flex-1 min-h-[300px] sm:min-h-[360px] max-h-[560px]">
|
||||
<DecodePanel />
|
||||
</div>
|
||||
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 */}
|
||||
<div className="glass rounded-2xl p-3 min-h-[320px] max-h-[440px]">
|
||||
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 / 角色 */}
|
||||
<div className="glass rounded-2xl p-3 min-h-[280px] sm:min-h-[320px] max-h-[440px] flex flex-col">
|
||||
<Tabs defaultValue={hasActiveExpedition ? "expedition" : hasPendingPerk ? "constellation" : "tech"} className="h-full flex flex-col">
|
||||
<TabsList className="grid grid-cols-7 h-9 bg-black/30 gap-0.5 p-1">
|
||||
<TabsList className="grid grid-cols-8 h-9 bg-black/30 gap-0.5 p-1">
|
||||
<TabsTrigger value="expedition" data-tut="tab-expedition" className="text-[11px] gap-1 relative px-0.5 flex-col h-7 data-[state=active]:bg-amber-500/15 data-[state=active]:shadow-[0_0_12px_rgba(251,191,36,0.3)] transition-all">
|
||||
<Rocket className="h-3.5 w-3.5" />
|
||||
<span className="leading-none">探险</span>
|
||||
@@ -290,6 +307,13 @@ export default function Page() {
|
||||
<BarChart3 className="h-3.5 w-3.5" />
|
||||
<span className="leading-none">统计</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="attributes" className="text-[11px] gap-1 relative px-0.5 flex-col h-7 data-[state=active]:bg-gradient-to-br data-[state=active]:from-emerald-500/15 data-[state=active]:via-fuchsia-500/15 data-[state=active]:to-rose-500/15 data-[state=active]:shadow-[0_0_12px_rgba(232,121,249,0.3)] transition-all">
|
||||
<User className="h-3.5 w-3.5" />
|
||||
<span className="leading-none">角色</span>
|
||||
{pendingAttrPoints > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-rose-400 animate-pulse ring-1 ring-black/50" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="expedition" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
|
||||
<ExpeditionPanel />
|
||||
@@ -312,6 +336,9 @@ export default function Page() {
|
||||
<TabsContent value="stats" className="flex-1 mt-2 min-h-0">
|
||||
<StatsPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="attributes" className="flex-1 mt-2 min-h-0 data-[state=active]:flex data-[state=active]:flex-col">
|
||||
<AttributesPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</section>
|
||||
@@ -373,6 +400,7 @@ export default function Page() {
|
||||
<StarTideNotifier />
|
||||
<TutorialOverlay />
|
||||
<OfflineReportDialog />
|
||||
{cruiseOpen && <CruiseMode onClose={() => setCruiseOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -409,6 +437,11 @@ function StatsPanel() {
|
||||
{ label: "编年史条目", value: `${(s.chronicle ?? []).length} 纪元` },
|
||||
{ label: "探险能量", value: `${Math.floor(s.energy)} / ${s.energyMax}` },
|
||||
{ label: "信标最高分", value: beaconBest !== null ? formatNum(beaconBest) : "—" },
|
||||
{ label: "探索力", value: `${s.attributes?.exploration ?? 0} / 100` },
|
||||
{ label: "智慧", value: `${s.attributes?.wisdom ?? 0} / 100` },
|
||||
{ label: "勇气", value: `${s.attributes?.courage ?? 0} / 100` },
|
||||
{ label: "灵感", value: `${s.attributes?.inspiration ?? 0} / 100` },
|
||||
{ label: "待分配属性点", value: `${s.pendingAttrPoints ?? 0}` },
|
||||
];
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1.5 text-xs">
|
||||
|
||||
@@ -37,8 +37,8 @@ export function AchievementsPanel() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 成就网格 */}
|
||||
<div className="flex-1 grid grid-cols-1 sm:grid-cols-2 gap-1.5 content-start overflow-y-auto echo-scroll pr-1 max-h-[300px]">
|
||||
{/* 成就网格 — 移除固定 max-h,用 flex-1 min-h-0 自适应父容器,避免小屏挤压重叠 */}
|
||||
<div className="flex-1 min-h-0 grid grid-cols-1 sm:grid-cols-2 gap-1.5 content-start overflow-y-auto echo-scroll pr-1">
|
||||
{ACHIEVEMENTS.map((a) => {
|
||||
const unlocked = !!achievements[a.id];
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — 角色属性面板(v0.7 P1)
|
||||
//
|
||||
// 无人机驾驶员四维属性可视化:
|
||||
// • 探索力 emerald / 智慧 fuchsia / 勇气 amber / 灵感 rose
|
||||
// • 显示数值(0-100)、加成百分比、经验进度条、等级 badge
|
||||
// • 待分配属性点时高亮 + 闪烁提示,「+」按钮可分配 1 点
|
||||
// • 顶部待分配点数提示;底部总览
|
||||
// • 小屏 2×2 网格,大屏 1×4 横排
|
||||
import { useMemo } from "react";
|
||||
import { useGameStore } from "@/store/gameStore";
|
||||
import {
|
||||
ATTRIBUTE_CONFIG,
|
||||
ATTRIBUTE_KEYS,
|
||||
ATTRIBUTE_HARD_CAP,
|
||||
ATTRIBUTE_LINEAR_CAP,
|
||||
getAttributeBonus,
|
||||
getAllBonuses,
|
||||
totalAttributeLevel,
|
||||
totalAttributeBonusPct,
|
||||
expRequiredForLevel,
|
||||
type AttributeKey,
|
||||
} from "@/lib/game/attributes";
|
||||
import {
|
||||
Compass,
|
||||
Brain,
|
||||
Swords,
|
||||
Sparkles,
|
||||
Plus,
|
||||
ChevronUp,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Compass,
|
||||
Brain,
|
||||
Swords,
|
||||
Sparkles,
|
||||
};
|
||||
|
||||
/** 主题色 → Tailwind/CSS 颜色映射 */
|
||||
const COLOR_STYLES: Record<
|
||||
AttributeKey,
|
||||
{
|
||||
text: string;
|
||||
border: string;
|
||||
borderActive: string;
|
||||
bg: string;
|
||||
ring: string;
|
||||
barFrom: string;
|
||||
barTo: string;
|
||||
glow: string;
|
||||
}
|
||||
> = {
|
||||
exploration: {
|
||||
text: "text-emerald-300",
|
||||
border: "border-emerald-400/30",
|
||||
borderActive: "border-emerald-400/70",
|
||||
bg: "bg-emerald-500/10",
|
||||
ring: "ring-emerald-400/40",
|
||||
barFrom: "from-emerald-400",
|
||||
barTo: "to-emerald-500",
|
||||
glow: "rgba(52,211,153,0.45)",
|
||||
},
|
||||
wisdom: {
|
||||
text: "text-fuchsia-300",
|
||||
border: "border-fuchsia-400/30",
|
||||
borderActive: "border-fuchsia-400/70",
|
||||
bg: "bg-fuchsia-500/10",
|
||||
ring: "ring-fuchsia-400/40",
|
||||
barFrom: "from-fuchsia-400",
|
||||
barTo: "to-fuchsia-500",
|
||||
glow: "rgba(232,121,249,0.45)",
|
||||
},
|
||||
courage: {
|
||||
text: "text-amber-300",
|
||||
border: "border-amber-400/30",
|
||||
borderActive: "border-amber-400/70",
|
||||
bg: "bg-amber-500/10",
|
||||
ring: "ring-amber-400/40",
|
||||
barFrom: "from-amber-400",
|
||||
barTo: "to-amber-500",
|
||||
glow: "rgba(251,191,36,0.45)",
|
||||
},
|
||||
inspiration: {
|
||||
text: "text-rose-300",
|
||||
border: "border-rose-400/30",
|
||||
borderActive: "border-rose-400/70",
|
||||
bg: "bg-rose-500/10",
|
||||
ring: "ring-rose-400/40",
|
||||
barFrom: "from-rose-400",
|
||||
barTo: "to-rose-500",
|
||||
glow: "rgba(251,113,133,0.45)",
|
||||
},
|
||||
};
|
||||
|
||||
export function AttributesPanel() {
|
||||
const attributes = useGameStore((s) => s.attributes);
|
||||
const attributeProgress = useGameStore((s) => s.attributeProgress);
|
||||
const pendingAttrPoints = useGameStore((s) => s.pendingAttrPoints ?? 0);
|
||||
const allocateAttribute = useGameStore((s) => s.allocateAttribute);
|
||||
|
||||
const bonuses = useMemo(() => getAllBonuses(attributes), [attributes]);
|
||||
const totalLvl = useMemo(() => totalAttributeLevel(attributes), [attributes]);
|
||||
const totalBonus = useMemo(
|
||||
() => totalAttributeBonusPct(attributes),
|
||||
[attributes]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5 h-full overflow-y-auto echo-scroll pr-0.5">
|
||||
<style jsx global>{`
|
||||
.echo-scroll::-webkit-scrollbar { width: 4px; }
|
||||
.echo-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 2px; }
|
||||
.echo-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||
@keyframes echo-pending-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(232,121,249,0.5); }
|
||||
50% { box-shadow: 0 0 14px 4px rgba(232,121,249,0.65); }
|
||||
}
|
||||
.echo-pending-pulse {
|
||||
animation: echo-pending-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes echo-card-glow {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* 顶部:标题 + 待分配点数 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-1.5">
|
||||
<span className="bg-gradient-to-br from-emerald-300 via-fuchsia-300 to-rose-300 bg-clip-text text-transparent">
|
||||
驾驶员属性
|
||||
</span>
|
||||
</h3>
|
||||
<div
|
||||
className={`text-[10px] px-2 py-0.5 rounded-full border ${
|
||||
pendingAttrPoints > 0
|
||||
? "border-fuchsia-400/60 bg-fuchsia-500/15 text-fuchsia-200 echo-pending-pulse font-bold"
|
||||
: "border-white/15 bg-black/25 text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
待分配 {pendingAttrPoints}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 属性卡片网格:小屏 2×2,大屏 1×4 */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2">
|
||||
{ATTRIBUTE_KEYS.map((key) => {
|
||||
const meta = ATTRIBUTE_CONFIG[key];
|
||||
const cs = COLOR_STYLES[key];
|
||||
const Icon = ICON_MAP[meta.icon] ?? Sparkles;
|
||||
const value = attributes?.[key] ?? 0;
|
||||
const prog = attributeProgress?.[key] ?? { exp: 0, level: value };
|
||||
const bonusPct = getAttributeBonus(value) * 100;
|
||||
const nextExp = expRequiredForLevel(value);
|
||||
const curExp = prog.exp;
|
||||
const expPct =
|
||||
nextExp > 0 ? Math.min(100, (curExp / nextExp) * 100) : 100;
|
||||
const isMax = value >= ATTRIBUTE_HARD_CAP;
|
||||
const canAllocate = pendingAttrPoints > 0 && !isMax;
|
||||
// 区域标记
|
||||
const inDiminishing = value > ATTRIBUTE_LINEAR_CAP;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`relative rounded-xl border ${cs.border} ${cs.bg} p-2.5 flex flex-col gap-1.5 overflow-hidden transition-all duration-300`}
|
||||
style={{
|
||||
boxShadow: `inset 0 0 18px ${cs.glow}`,
|
||||
}}
|
||||
>
|
||||
{/* 顶部光晕装饰 */}
|
||||
<div
|
||||
className="pointer-events-none absolute -top-8 -right-8 h-20 w-20 rounded-full blur-2xl opacity-50"
|
||||
style={{ background: meta.hex }}
|
||||
/>
|
||||
{/* 标题行:图标 + 名称 + 等级 badge */}
|
||||
<div className="relative flex items-center gap-1.5">
|
||||
<div
|
||||
className={`shrink-0 h-7 w-7 rounded-lg flex items-center justify-center ${cs.bg} border ${cs.border}`}
|
||||
style={{ boxShadow: `0 0 8px ${cs.glow}` }}
|
||||
>
|
||||
<Icon className={`h-3.5 w-3.5 ${cs.text}`} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 leading-tight">
|
||||
<div className={`text-[11px] font-semibold ${cs.text} truncate`}>
|
||||
{meta.name}
|
||||
</div>
|
||||
<div className="text-[8px] text-muted-foreground/70 uppercase tracking-wider truncate">
|
||||
{meta.enName}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`shrink-0 text-[9px] px-1.5 py-0.5 rounded font-mono font-bold ${
|
||||
isMax
|
||||
? "bg-amber-400/20 text-amber-200 border border-amber-400/40"
|
||||
: "bg-black/30 text-white/80 border border-white/10"
|
||||
}`}
|
||||
>
|
||||
Lv.{value}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数值 + 加成 */}
|
||||
<div className="relative flex items-end justify-between">
|
||||
<span className={`text-lg font-mono font-bold ${cs.text} leading-none`}>
|
||||
{value}
|
||||
<span className="text-[9px] text-muted-foreground/60 ml-0.5">
|
||||
/{ATTRIBUTE_HARD_CAP}
|
||||
</span>
|
||||
</span>
|
||||
<span className={`text-[10px] ${cs.text} font-mono`}>
|
||||
+{bonusPct.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 经验进度条 */}
|
||||
<div className="relative">
|
||||
<div className="h-1.5 rounded-full bg-black/40 overflow-hidden border border-white/5">
|
||||
<div
|
||||
className={`h-full bg-gradient-to-r ${cs.barFrom} ${cs.barTo} transition-all duration-500`}
|
||||
style={{
|
||||
width: `${isMax ? 100 : expPct}%`,
|
||||
boxShadow: `0 0 6px ${cs.glow}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-0.5 text-[8px] text-muted-foreground/70 font-mono">
|
||||
<span>
|
||||
{isMax ? "已满级" : `${curExp} / ${nextExp} EXP`}
|
||||
</span>
|
||||
{inDiminishing && !isMax && (
|
||||
<span className="text-amber-300/70">递减区</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 加成列表 */}
|
||||
<div className="relative flex flex-col gap-0.5 mt-0.5">
|
||||
{meta.effects.map((eff, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-[8.5px] text-muted-foreground/70 leading-tight flex items-center gap-1"
|
||||
>
|
||||
<span className={cs.text}>·</span>
|
||||
<span className="truncate">{eff}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分配按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canAllocate}
|
||||
onClick={() => allocateAttribute(key, 1)}
|
||||
aria-label={`分配 1 点到 ${meta.name}`}
|
||||
className={`relative mt-auto rounded-md border px-1.5 py-1 flex items-center justify-center gap-0.5 text-[10px] font-bold transition-all ${
|
||||
canAllocate
|
||||
? `${cs.borderActive} ${cs.bg} ${cs.text} hover:scale-105 cursor-pointer`
|
||||
: "border-white/5 bg-black/20 text-muted-foreground/30 cursor-not-allowed"
|
||||
}`}
|
||||
style={
|
||||
canAllocate
|
||||
? { boxShadow: `0 0 8px ${cs.glow}` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
<span>分配</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 底部:总览 */}
|
||||
<div className="mt-auto rounded-lg border border-white/10 bg-black/30 px-2.5 py-1.5 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-[10px]">
|
||||
<ChevronUp className="h-3 w-3 text-fuchsia-300" />
|
||||
<span className="text-muted-foreground">总等级</span>
|
||||
<span className="font-mono font-bold text-fuchsia-200">
|
||||
{totalLvl}
|
||||
<span className="text-muted-foreground/60 text-[9px]">/{ATTRIBUTE_HARD_CAP * 4}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px]">
|
||||
<span className="text-muted-foreground">总加成</span>
|
||||
<span className="font-mono font-bold text-emerald-200">
|
||||
+{totalBonus.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 当前生效修饰器(细节展示) */}
|
||||
<div className="grid grid-cols-2 gap-1 text-[9px]">
|
||||
<DetailLine label="探险力" value={`×${bonuses.expeditionPowerMult.toFixed(2)}`} color="text-emerald-300" />
|
||||
<DetailLine label="解码步数" value={`+${bonuses.decodeStepsBonus}`} color="text-fuchsia-300" />
|
||||
<DetailLine label="洞见倍率" value={`+${(bonuses.insightMultAdd * 100).toFixed(1)}%`} color="text-fuchsia-300" />
|
||||
<DetailLine label="自动解码周期" value={`×${bonuses.autoDecodeIntervalMult.toFixed(2)}`} color="text-fuchsia-300" />
|
||||
<DetailLine label="探险生命" value={`+${bonuses.expeditionHpBonus}`} color="text-amber-300" />
|
||||
<DetailLine label="BOSS 胜率" value={`+${(bonuses.bossWinRateBonus * 100).toFixed(1)}%`} color="text-amber-300" />
|
||||
<DetailLine label="巡航护盾" value={`+${bonuses.cruiseShieldBonus}`} color="text-amber-300" />
|
||||
<DetailLine label="接触率" value={`×${bonuses.contactRateMult.toFixed(2)}`} color="text-rose-300" />
|
||||
<DetailLine label="星潮触发" value={`+${(bonuses.tideTriggerBonus * 100).toFixed(1)}%`} color="text-rose-300" />
|
||||
<DetailLine label="脉冲连击" value={`+${(bonuses.pulseComboBonus * 100).toFixed(1)}%`} color="text-rose-300" />
|
||||
<DetailLine label="巡航速度" value={`×${bonuses.cruiseShipSpeedMult.toFixed(2)}`} color="text-emerald-300" />
|
||||
<DetailLine label="产能加成" value={`×${bonuses.crystalsPerSecMult.toFixed(2)}`} color="text-emerald-300" />
|
||||
</div>
|
||||
|
||||
{/* 飞升获得属性点提示 */}
|
||||
<div className="text-[9px] text-muted-foreground/60 text-center leading-tight">
|
||||
每次飞升获得 <span className="text-fuchsia-300 font-mono">飞升次数 × 2 + 1</span> 点属性点;完成对应活动自动累积经验
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailLine({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded bg-black/25 border border-white/5 px-1.5 py-0.5">
|
||||
<span className="text-muted-foreground/80 truncate">{label}</span>
|
||||
<span className={`font-mono ${color}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+532
-129
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — 中央晶体集群 + 主动脉冲
|
||||
import { useRef, useState, useCallback } from "react";
|
||||
// 回响星核 / Echo Nexus — 中央晶体集群 + 主动脉冲(v0.7 Canvas 粒子系统)
|
||||
import { useRef, useState, useCallback, useEffect } from "react";
|
||||
import { useGameStore } from "@/store/gameStore";
|
||||
import { formatNum } from "@/lib/game/config";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
@@ -15,12 +15,62 @@ interface FloatNum {
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface Particle {
|
||||
id: number;
|
||||
// ===== Canvas 粒子系统类型 =====
|
||||
interface EnergyParticle {
|
||||
// 环绕能量粒子(持续)
|
||||
angle: number;
|
||||
dist: number;
|
||||
radius: number;
|
||||
baseRadius: number;
|
||||
speed: number;
|
||||
size: number;
|
||||
color: string;
|
||||
alpha: number;
|
||||
phase: number;
|
||||
}
|
||||
|
||||
interface AmbientParticle {
|
||||
// 环境星尘(缓慢漂浮)
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
size: number;
|
||||
alpha: number;
|
||||
twinkle: number;
|
||||
}
|
||||
|
||||
interface ShockRing {
|
||||
// 冲击波环(点击触发)
|
||||
born: number;
|
||||
color: string;
|
||||
maxRadius: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface BurstParticle {
|
||||
// 爆发粒子(点击触发,径向发散)
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
size: number;
|
||||
color: string;
|
||||
born: number;
|
||||
life: number;
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
emerald: "#34d399",
|
||||
fuchsia: "#e879f9",
|
||||
rose: "#fb7185",
|
||||
amber: "#fbbf24",
|
||||
white: "#ffffff",
|
||||
};
|
||||
|
||||
function comboColor(combo: number): string {
|
||||
if (combo >= 5) return COLORS.amber;
|
||||
if (combo >= 3) return COLORS.fuchsia;
|
||||
return COLORS.emerald;
|
||||
}
|
||||
|
||||
export function CrystalOrb() {
|
||||
@@ -30,11 +80,141 @@ export function CrystalOrb() {
|
||||
const crystalCap = useGameStore((s) => s.crystalCap);
|
||||
const combo = useGameStore((s) => s._combo);
|
||||
const { toast } = useToast();
|
||||
|
||||
const [floats, setFloats] = useState<FloatNum[]>([]);
|
||||
const [particles, setParticles] = useState<Particle[]>([]);
|
||||
const [pulseAnim, setPulseAnim] = useState(0);
|
||||
const idRef = useRef(0);
|
||||
|
||||
// Canvas refs
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const pulseAnimRef = useRef(0);
|
||||
const [pulseAnim, setPulseAnim] = useState(0); // 触发 SVG 环动画
|
||||
|
||||
// 粒子状态 refs(不触发 re-render)
|
||||
const energyParticlesRef = useRef<EnergyParticle[]>([]);
|
||||
const ambientParticlesRef = useRef<AmbientParticle[]>([]);
|
||||
const shockRingsRef = useRef<ShockRing[]>([]);
|
||||
const burstParticlesRef = useRef<BurstParticle[]>([]);
|
||||
|
||||
const comboRef = useRef(combo);
|
||||
const fillPctRef = useRef(0);
|
||||
const crystalsRef = useRef(crystals);
|
||||
const crystalCapRef = useRef(crystalCap);
|
||||
useEffect(() => {
|
||||
comboRef.current = combo;
|
||||
crystalsRef.current = crystals;
|
||||
crystalCapRef.current = crystalCap;
|
||||
}, [combo, crystals, crystalCap]);
|
||||
|
||||
const mouseRef = useRef<{ x: number; y: number; inside: boolean }>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
inside: false,
|
||||
});
|
||||
|
||||
// ===== 初始化粒子 =====
|
||||
const initParticles = useCallback((w: number, h: number) => {
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const baseR = Math.min(w, h) * 0.28;
|
||||
|
||||
// 环绕能量粒子(3 层,每层不同速度方向)
|
||||
const energy: EnergyParticle[] = [];
|
||||
const layers = [
|
||||
{ count: 14, rMul: 0.95, speed: 0.6, color: COLORS.emerald },
|
||||
{ count: 10, rMul: 1.15, speed: -0.4, color: COLORS.fuchsia },
|
||||
{ count: 8, rMul: 1.35, speed: 0.3, color: COLORS.rose },
|
||||
];
|
||||
layers.forEach((layer) => {
|
||||
for (let i = 0; i < layer.count; i++) {
|
||||
energy.push({
|
||||
angle: (Math.PI * 2 * i) / layer.count + Math.random() * 0.3,
|
||||
radius: baseR * layer.rMul,
|
||||
baseRadius: baseR * layer.rMul,
|
||||
speed: layer.speed * (0.8 + Math.random() * 0.4),
|
||||
size: 1.5 + Math.random() * 2,
|
||||
color: layer.color,
|
||||
alpha: 0.5 + Math.random() * 0.4,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
});
|
||||
}
|
||||
});
|
||||
energyParticlesRef.current = energy;
|
||||
|
||||
// 环境星尘
|
||||
const ambient: AmbientParticle[] = [];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
ambient.push({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
vx: (Math.random() - 0.5) * 8,
|
||||
vy: (Math.random() - 0.5) * 8,
|
||||
size: 0.5 + Math.random() * 1.5,
|
||||
alpha: 0.2 + Math.random() * 0.5,
|
||||
twinkle: Math.random() * Math.PI * 2,
|
||||
});
|
||||
}
|
||||
ambientParticlesRef.current = ambient;
|
||||
void cx;
|
||||
void cy;
|
||||
}, []);
|
||||
|
||||
// ===== 触发脉冲效果(Canvas 部分)=====
|
||||
const triggerPulseEffect = useCallback(
|
||||
(x: number, y: number, res: { combo: number; gain: number }) => {
|
||||
const color = comboColor(res.combo);
|
||||
const now = performance.now();
|
||||
|
||||
// 冲击波环(多层错峰)
|
||||
shockRingsRef.current.push({
|
||||
born: now,
|
||||
color: COLORS.emerald,
|
||||
maxRadius: 140,
|
||||
duration: 700,
|
||||
});
|
||||
if (res.combo >= 3) {
|
||||
shockRingsRef.current.push({
|
||||
born: now + 100,
|
||||
color: COLORS.fuchsia,
|
||||
maxRadius: 170,
|
||||
duration: 800,
|
||||
});
|
||||
}
|
||||
if (res.combo >= 5) {
|
||||
shockRingsRef.current.push({
|
||||
born: now + 200,
|
||||
color: COLORS.amber,
|
||||
maxRadius: 200,
|
||||
duration: 900,
|
||||
});
|
||||
}
|
||||
|
||||
// 径向爆发粒子
|
||||
const pcount = 10 + Math.min(10, res.combo) * 2;
|
||||
for (let i = 0; i < pcount; i++) {
|
||||
const angle = (Math.PI * 2 * i) / pcount + Math.random() * 0.4;
|
||||
const speed = 120 + Math.random() * 180;
|
||||
burstParticlesRef.current.push({
|
||||
x,
|
||||
y,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
size: 2 + Math.random() * 3,
|
||||
color,
|
||||
born: now,
|
||||
life: 600 + Math.random() * 300,
|
||||
});
|
||||
}
|
||||
|
||||
// 让能量粒子被"推开"再回弹
|
||||
energyParticlesRef.current.forEach((p) => {
|
||||
p.radius = p.baseRadius * (1.25 + Math.random() * 0.15);
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// ===== 点击处理 =====
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const res = pulse();
|
||||
@@ -43,33 +223,24 @@ export function CrystalOrb() {
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const id = idRef.current++;
|
||||
const floatColor = res.combo >= 5 ? "#fbbf24" : res.combo >= 3 ? "#e879f9" : "#34d399";
|
||||
const floatColor = comboColor(res.combo);
|
||||
setFloats((f) => [
|
||||
...f,
|
||||
{ id, x, y, text: `+${res.gain.toFixed(1)}`, born: Date.now(), color: floatColor },
|
||||
]);
|
||||
// 粒子爆发
|
||||
const newParticles: Particle[] = [];
|
||||
const pcount = 6 + Math.min(6, res.combo);
|
||||
for (let i = 0; i < pcount; i++) {
|
||||
newParticles.push({
|
||||
id: idRef.current++,
|
||||
angle: (Math.PI * 2 * i) / pcount + Math.random() * 0.3,
|
||||
dist: 0,
|
||||
{
|
||||
id,
|
||||
x,
|
||||
y,
|
||||
text: `+${res.gain.toFixed(1)}`,
|
||||
born: Date.now(),
|
||||
color: floatColor,
|
||||
});
|
||||
}
|
||||
setParticles((p) => [...p, ...newParticles]);
|
||||
},
|
||||
]);
|
||||
triggerPulseEffect(x, y, res);
|
||||
setPulseAnim((n) => n + 1);
|
||||
// 音效
|
||||
sfx(res.combo >= 3 ? "pulseCombo" : "pulse", { combo: res.combo });
|
||||
setTimeout(() => {
|
||||
setFloats((f) => f.filter((it) => it.id !== id));
|
||||
}, 900);
|
||||
setTimeout(() => {
|
||||
setParticles((p) => p.filter((it) => !newParticles.includes(it)));
|
||||
}, 700);
|
||||
if (res.combo >= 5 && res.combo % 5 === 0) {
|
||||
toast({
|
||||
title: `×${res.combo} 连击!`,
|
||||
@@ -77,126 +248,349 @@ export function CrystalOrb() {
|
||||
});
|
||||
}
|
||||
},
|
||||
[pulse, toast]
|
||||
[pulse, toast, triggerPulseEffect]
|
||||
);
|
||||
|
||||
// 进度比例
|
||||
// ===== Canvas 动画循环 =====
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
let rafId = 0;
|
||||
let lastTime = performance.now();
|
||||
let dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
|
||||
const resize = () => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
canvas.width = Math.max(1, Math.floor(rect.width * dpr));
|
||||
canvas.height = Math.max(1, Math.floor(rect.height * dpr));
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
initParticles(rect.width, rect.height);
|
||||
};
|
||||
resize();
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(container);
|
||||
|
||||
const draw = (now: number) => {
|
||||
const dt = Math.min(0.05, (now - lastTime) / 1000);
|
||||
lastTime = now;
|
||||
const w = canvas.width / dpr;
|
||||
const h = canvas.height / dpr;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const baseR = Math.min(w, h) * 0.28;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// === 1. 环境星尘 ===
|
||||
ambientParticlesRef.current.forEach((p) => {
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
p.twinkle += dt * 2;
|
||||
if (p.x < 0) p.x = w;
|
||||
if (p.x > w) p.x = 0;
|
||||
if (p.y < 0) p.y = h;
|
||||
if (p.y > h) p.y = 0;
|
||||
const tw = 0.5 + 0.5 * Math.sin(p.twinkle);
|
||||
ctx.globalAlpha = p.alpha * tw;
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// === 2. 外层辉光(呼吸)===
|
||||
const breath = 0.85 + 0.15 * Math.sin(now * 0.001);
|
||||
const glowGrad = ctx.createRadialGradient(
|
||||
cx,
|
||||
cy,
|
||||
baseR * 0.3,
|
||||
cx,
|
||||
cy,
|
||||
baseR * 2.2
|
||||
);
|
||||
glowGrad.addColorStop(0, `rgba(52,211,153,${0.18 * breath})`);
|
||||
glowGrad.addColorStop(0.5, `rgba(232,121,249,${0.1 * breath})`);
|
||||
glowGrad.addColorStop(1, "rgba(0,0,0,0)");
|
||||
ctx.fillStyle = glowGrad;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// === 3. 进度填充环(背景 + 进度)===
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.06)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, baseR * 1.45, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
const fp = Math.min(
|
||||
100,
|
||||
(crystalsRef.current / Math.max(1, crystalCapRef.current)) * 100
|
||||
);
|
||||
fillPctRef.current = fp;
|
||||
const ringR = baseR * 1.45;
|
||||
const grad = ctx.createLinearGradient(
|
||||
cx - ringR,
|
||||
cy - ringR,
|
||||
cx + ringR,
|
||||
cy + ringR
|
||||
);
|
||||
grad.addColorStop(0, COLORS.emerald);
|
||||
grad.addColorStop(0.5, COLORS.fuchsia);
|
||||
grad.addColorStop(1, COLORS.rose);
|
||||
ctx.strokeStyle = grad;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.lineCap = "round";
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.shadowColor = COLORS.emerald;
|
||||
ctx.beginPath();
|
||||
ctx.arc(
|
||||
cx,
|
||||
cy,
|
||||
ringR,
|
||||
-Math.PI / 2,
|
||||
-Math.PI / 2 + (Math.PI * 2 * fp) / 100
|
||||
);
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// === 4. 环绕能量粒子 ===
|
||||
energyParticlesRef.current.forEach((p) => {
|
||||
p.angle += p.speed * dt;
|
||||
// 回弹到 baseRadius
|
||||
p.radius += (p.baseRadius - p.radius) * Math.min(1, dt * 4);
|
||||
const px = cx + Math.cos(p.angle) * p.radius;
|
||||
const py = cy + Math.sin(p.angle) * p.radius;
|
||||
const flicker = 0.7 + 0.3 * Math.sin(now * 0.005 + p.phase);
|
||||
ctx.globalAlpha = p.alpha * flicker;
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = p.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, p.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// 拖尾(向心方向的小线段)
|
||||
ctx.globalAlpha = p.alpha * flicker * 0.4;
|
||||
ctx.strokeStyle = p.color;
|
||||
ctx.lineWidth = p.size * 0.6;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px, py);
|
||||
ctx.lineTo(
|
||||
cx + Math.cos(p.angle - p.speed * 0.08) * (p.radius - 2),
|
||||
cy + Math.sin(p.angle - p.speed * 0.08) * (p.radius - 2)
|
||||
);
|
||||
ctx.stroke();
|
||||
});
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// === 5. 中央晶核 ===
|
||||
const coreR = baseR * 0.95;
|
||||
// 鼠标接近时晶核轻微偏移(视差感)
|
||||
let coreOffsetX = 0;
|
||||
let coreOffsetY = 0;
|
||||
if (mouseRef.current.inside) {
|
||||
const dx = mouseRef.current.x - cx;
|
||||
const dy = mouseRef.current.y - cy;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist > 0.1) {
|
||||
coreOffsetX = (dx / dist) * Math.min(8, dist * 0.05);
|
||||
coreOffsetY = (dy / dist) * Math.min(8, dist * 0.05);
|
||||
}
|
||||
}
|
||||
const ccx = cx + coreOffsetX;
|
||||
const ccy = cy + coreOffsetY;
|
||||
|
||||
// 晶核主体(径向渐变)
|
||||
const coreGrad = ctx.createRadialGradient(
|
||||
ccx - coreR * 0.25,
|
||||
ccy - coreR * 0.3,
|
||||
coreR * 0.1,
|
||||
ccx,
|
||||
ccy,
|
||||
coreR
|
||||
);
|
||||
coreGrad.addColorStop(0, "rgba(255,255,255,0.95)");
|
||||
coreGrad.addColorStop(0.3, `rgba(52,211,153,${0.85 * breath})`);
|
||||
coreGrad.addColorStop(0.65, "rgba(232,121,249,0.55)");
|
||||
coreGrad.addColorStop(1, "rgba(251,113,133,0.2)");
|
||||
ctx.fillStyle = coreGrad;
|
||||
ctx.shadowBlur = 30;
|
||||
ctx.shadowColor = COLORS.emerald;
|
||||
ctx.beginPath();
|
||||
ctx.arc(ccx, ccy, coreR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// 晶核内部六边形纹理
|
||||
ctx.save();
|
||||
ctx.translate(ccx, ccy);
|
||||
ctx.globalAlpha = 0.25;
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.6)";
|
||||
ctx.lineWidth = 0.8;
|
||||
const hexR = coreR * 0.7;
|
||||
for (let layer = 0; layer < 3; layer++) {
|
||||
const r = hexR * (1 - layer * 0.3);
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const a = (Math.PI / 3) * i + now * 0.0002 * (layer % 2 ? 1 : -1);
|
||||
const px = Math.cos(a) * r;
|
||||
const py = Math.sin(a) * r;
|
||||
if (i === 0) ctx.moveTo(px, py);
|
||||
else ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
// 高光
|
||||
ctx.globalAlpha = 0.4;
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(
|
||||
ccx - coreR * 0.25,
|
||||
ccy - coreR * 0.35,
|
||||
coreR * 0.35,
|
||||
coreR * 0.18,
|
||||
-0.4,
|
||||
0,
|
||||
Math.PI * 2
|
||||
);
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// === 6. 冲击波环 ===
|
||||
shockRingsRef.current = shockRingsRef.current.filter((ring) => {
|
||||
const elapsed = now - ring.born;
|
||||
if (elapsed < 0 || elapsed > ring.duration) return elapsed <= ring.duration + 50;
|
||||
const t = elapsed / ring.duration;
|
||||
const r = ring.maxRadius * t;
|
||||
const alpha = (1 - t) * 0.8;
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.strokeStyle = ring.color;
|
||||
ctx.lineWidth = 2.5 * (1 - t * 0.5);
|
||||
ctx.shadowBlur = 12;
|
||||
ctx.shadowColor = ring.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
return true;
|
||||
});
|
||||
shockRingsRef.current = shockRingsRef.current.filter(
|
||||
(r) => now - r.born < r.duration
|
||||
);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// === 7. 爆发粒子 ===
|
||||
burstParticlesRef.current = burstParticlesRef.current.filter((p) => {
|
||||
const age = now - p.born;
|
||||
if (age > p.life) return false;
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
p.vx *= 0.96;
|
||||
p.vy *= 0.96;
|
||||
const t = age / p.life;
|
||||
const alpha = 1 - t;
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.shadowColor = p.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size * (1 - t * 0.5), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
return true;
|
||||
});
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
rafId = requestAnimationFrame(draw);
|
||||
};
|
||||
rafId = requestAnimationFrame(draw);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [initParticles]);
|
||||
|
||||
// ===== 鼠标追踪(视差)=====
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
mouseRef.current = {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
inside: true,
|
||||
};
|
||||
},
|
||||
[]
|
||||
);
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
mouseRef.current.inside = false;
|
||||
}, []);
|
||||
|
||||
// 进度比例(用于显示)
|
||||
const fillPct = Math.min(100, (crystals / Math.max(1, crystalCap)) * 100);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-center justify-center gap-4 select-none">
|
||||
{/* 连击显示 */}
|
||||
{combo > 1 && (
|
||||
<div className="absolute -top-2 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-rose-500/20 border border-rose-400/40 text-rose-200 text-xs font-mono animate-pulse">
|
||||
<div className="absolute -top-2 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-rose-500/20 border border-rose-400/40 text-rose-200 text-xs font-mono animate-pulse z-10">
|
||||
×{combo} 连击
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleClick}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className="relative h-52 w-52 sm:h-64 sm:w-64 rounded-full focus:outline-none group"
|
||||
aria-label="脉冲扫描,获取记忆晶体"
|
||||
>
|
||||
{/* 外层光晕 */}
|
||||
<div className="absolute inset-0 rounded-full bg-emerald-500/10 blur-2xl group-hover:bg-emerald-500/20 transition-colors" />
|
||||
{/* 旋转外环 */}
|
||||
{/* Canvas 粒子层 */}
|
||||
<div
|
||||
className="absolute inset-2 rounded-full border border-emerald-400/30"
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 rounded-full overflow-hidden"
|
||||
>
|
||||
<canvas ref={canvasRef} className="block w-full h-full" />
|
||||
</div>
|
||||
|
||||
{/* CSS 旋转外环装饰(与 Canvas 叠加)*/}
|
||||
<div
|
||||
className="absolute inset-2 rounded-full border border-emerald-400/30 pointer-events-none"
|
||||
style={{ animation: "echo-spin 18s linear infinite" }}
|
||||
>
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-2 rounded-full bg-emerald-300 shadow-[0_0_8px_#34d399]" />
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 h-1.5 w-1.5 rounded-full bg-rose-300 shadow-[0_0_8px_#fb7185]" />
|
||||
</div>
|
||||
{/* 反向旋转内环 */}
|
||||
{/* 反向旋转虚线内环 */}
|
||||
<div
|
||||
className="absolute inset-6 rounded-full border border-fuchsia-400/20 border-dashed"
|
||||
className="absolute inset-6 rounded-full border border-fuchsia-400/20 border-dashed pointer-events-none"
|
||||
style={{ animation: "echo-spin 24s linear infinite reverse" }}
|
||||
/>
|
||||
{/* 进度填充环 */}
|
||||
<svg className="absolute inset-0 -rotate-90" viewBox="0 0 100 100">
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="44"
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.06)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="44"
|
||||
fill="none"
|
||||
stroke="url(#echoGrad)"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${2 * Math.PI * 44}`}
|
||||
strokeDashoffset={`${2 * Math.PI * 44 * (1 - fillPct / 100)}`}
|
||||
style={{ transition: "stroke-dashoffset 0.3s ease", filter: "drop-shadow(0 0 4px rgba(52,211,153,0.6))" }}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="echoGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#34d399" />
|
||||
<stop offset="50%" stopColor="#e879f9" />
|
||||
<stop offset="100%" stopColor="#fb7185" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
{/* 脉冲扩散环(点击时) */}
|
||||
|
||||
{/* 脉冲扩散环(点击时,CSS 叠加 Canvas 冲击波)*/}
|
||||
<div
|
||||
key={`ring-${pulseAnim}`}
|
||||
className="absolute inset-8 rounded-full border-2 border-emerald-400/60 pointer-events-none"
|
||||
style={{ animation: "echo-ring 0.7s ease-out forwards" }}
|
||||
/>
|
||||
{/* 中央晶体 */}
|
||||
<div
|
||||
key={pulseAnim}
|
||||
className="absolute inset-12 rounded-full flex items-center justify-center"
|
||||
style={{ animation: "echo-ping 0.4s ease-out" }}
|
||||
>
|
||||
<div className="relative h-full w-full">
|
||||
{/* 晶核 */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle at 35% 30%, rgba(255,255,255,0.9), rgba(52,211,153,0.7) 35%, rgba(232,121,249,0.5) 70%, rgba(251,113,133,0.3))",
|
||||
boxShadow:
|
||||
"0 0 30px rgba(52,211,153,0.6), 0 0 60px rgba(232,121,249,0.4), inset 0 0 20px rgba(255,255,255,0.3)",
|
||||
}}
|
||||
/>
|
||||
{/* 高光 */}
|
||||
<div className="absolute top-3 left-6 h-6 w-10 rounded-full bg-white/40 blur-sm rotate-[-20deg]" />
|
||||
{/* 内部六边形纹理 */}
|
||||
<div
|
||||
className="absolute inset-4 rounded-full opacity-30"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(60deg, rgba(255,255,255,0.4) 0 1px, transparent 1px 8px), repeating-linear-gradient(-60deg, rgba(255,255,255,0.4) 0 1px, transparent 1px 8px)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 粒子爆发 */}
|
||||
{particles.map((p) => (
|
||||
<span
|
||||
key={p.id}
|
||||
className="absolute left-1/2 top-1/2 rounded-full pointer-events-none"
|
||||
style={{
|
||||
width: 4,
|
||||
height: 4,
|
||||
background: p.color,
|
||||
boxShadow: `0 0 6px ${p.color}`,
|
||||
["--angle" as string]: `${p.angle}rad`,
|
||||
animation: "echo-burst 0.7s ease-out forwards",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* 浮动数字 */}
|
||||
|
||||
{/* 浮动数字(保留 HTML,便于清晰文字)*/}
|
||||
{floats.map((f) => (
|
||||
<span
|
||||
key={f.id}
|
||||
className="absolute pointer-events-none font-mono font-bold text-sm"
|
||||
className="absolute pointer-events-none font-mono font-bold text-sm z-10"
|
||||
style={{
|
||||
left: f.x,
|
||||
top: f.y,
|
||||
@@ -213,39 +607,48 @@ export function CrystalOrb() {
|
||||
|
||||
{/* 数值显示 */}
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-mono font-bold text-emerald-300" style={{ textShadow: "0 0 12px rgba(52,211,153,0.5)" }}>
|
||||
<div
|
||||
className="text-2xl font-mono font-bold text-emerald-300"
|
||||
style={{ textShadow: "0 0 12px rgba(52,211,153,0.5)" }}
|
||||
>
|
||||
{formatNum(crystals)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
记忆晶体 · {crystalsPerSec.toFixed(1)}/s · 上限 {formatNum(crystalCap)}
|
||||
记忆晶体 · {crystalsPerSec.toFixed(1)}/s · 上限{" "}
|
||||
{formatNum(crystalCap)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground/70 mt-2">
|
||||
点击晶体发起<span className="text-emerald-300">脉冲扫描</span>,连击叠加加成
|
||||
点击晶体发起
|
||||
<span className="text-emerald-300">脉冲扫描</span>,连击叠加加成
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground/50 mt-1 tabular-nums">
|
||||
仓库 {fillPct.toFixed(1)}% · 连击 ×{Math.max(1, combo)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes echo-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes echo-ping {
|
||||
0% { transform: scale(0.92); }
|
||||
50% { transform: scale(1.04); }
|
||||
100% { transform: scale(1); }
|
||||
@keyframes echo-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@keyframes echo-float {
|
||||
0% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||
100% { opacity: 0; transform: translate(-50%, -180%) scale(1.3); }
|
||||
}
|
||||
@keyframes echo-ring {
|
||||
0% { transform: scale(0.8); opacity: 0.8; }
|
||||
100% { transform: scale(1.6); opacity: 0; }
|
||||
}
|
||||
@keyframes echo-burst {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) rotate(var(--angle)) translateX(0) rotate(calc(-1 * var(--angle)));
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) rotate(var(--angle)) translateX(80px) rotate(calc(-1 * var(--angle)));
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -180%) scale(1.3);
|
||||
}
|
||||
}
|
||||
@keyframes echo-ring {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.6);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,11 +192,11 @@ export function DecodeArray() {
|
||||
onMouseEnter={() => setHoverId(node.id)}
|
||||
onMouseLeave={() => setHoverId(null)}
|
||||
disabled={used}
|
||||
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full transition-all duration-200 focus:outline-none disabled:cursor-not-allowed"
|
||||
className="absolute rounded-full transition-[box-shadow,background,border,opacity] duration-200 focus:outline-none disabled:cursor-not-allowed"
|
||||
style={{
|
||||
left: `${cx}%`,
|
||||
top: `${cy}%`,
|
||||
width: `min(${100 / cols * 0.62}%, 56px)`,
|
||||
width: `min(${(100 / cols) * 0.62}%, 56px)`,
|
||||
aspectRatio: "1",
|
||||
background: used
|
||||
? "rgba(255,255,255,0.04)"
|
||||
@@ -214,6 +214,7 @@ export function DecodeArray() {
|
||||
? `1.5px solid ${v.hex}aa`
|
||||
: "1px solid rgba(255,255,255,0.12)",
|
||||
opacity: used ? 0.35 : isDeadStart ? 0.5 : 1,
|
||||
// 统一用 inline transform 定位居中 + 缩放,避免与 Tailwind translate 冲突导致左上偏移
|
||||
transform: `translate(-50%, -50%) scale(${
|
||||
isHover && !used ? 1.12 : isFlash ? (flash?.ok ? 1.25 : 0.8) : 1
|
||||
})`,
|
||||
|
||||
@@ -251,6 +251,45 @@ export const ACHIEVEMENTS: Achievement[] = [
|
||||
reward: { crystalsPerSecPct: 8, insightPct: 8 },
|
||||
rewardText: "产能 +8% · 洞见 +8%",
|
||||
},
|
||||
{
|
||||
id: "ach_attr_total_50",
|
||||
name: "四维觉醒",
|
||||
desc: "角色四维属性总和达到 50",
|
||||
icon: "🧭",
|
||||
color: "#e879f9",
|
||||
check: (s) => {
|
||||
const a = s.attributes;
|
||||
if (!a) return false;
|
||||
return (
|
||||
(a.exploration || 0) +
|
||||
(a.wisdom || 0) +
|
||||
(a.courage || 0) +
|
||||
(a.inspiration || 0) >=
|
||||
50
|
||||
);
|
||||
},
|
||||
reward: { crystalsPerSecPct: 6, insightPct: 6 },
|
||||
rewardText: "产能 +6% · 洞见 +6%",
|
||||
},
|
||||
{
|
||||
id: "ach_attr_max_100",
|
||||
name: "维度精通",
|
||||
desc: "任一角色属性达到满级 100",
|
||||
icon: "💫",
|
||||
color: "#fbbf24",
|
||||
check: (s) => {
|
||||
const a = s.attributes;
|
||||
if (!a) return false;
|
||||
return (
|
||||
a.exploration >= 100 ||
|
||||
a.wisdom >= 100 ||
|
||||
a.courage >= 100 ||
|
||||
a.inspiration >= 100
|
||||
);
|
||||
},
|
||||
reward: { crystalsPerSecPct: 12, insightPct: 10 },
|
||||
rewardText: "产能 +12% · 洞见 +10%",
|
||||
},
|
||||
];
|
||||
|
||||
/** 计算成就提供的永久加成(跨周目保留) */
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
// 回响星核 / Echo Nexus — 角色属性系统 (v0.7 P1)
|
||||
//
|
||||
// 无人机驾驶员的四维属性,为放置循环注入 RPG 深度:
|
||||
// • 探索力 Exploration(翠 emerald)— 探险力 / 飞船速度 / 星图发现
|
||||
// • 智慧 Wisdom (玫 fuchsia)— 解码步数 / 洞见 / 自动解码周期
|
||||
// • 勇气 Courage (琥 amber) — 探险生命 / BOSS 战胜率 / 巡航护盾
|
||||
// • 灵感 Inspiration (玫红 rose) — 接触率 / 星潮触发 / 脉冲连击加成
|
||||
//
|
||||
// 数值规则:
|
||||
// • 0-100 软上限(超过后收益递减)
|
||||
// • 0-50 线性区:每 10 点 = +5% 加成 → 即 0.5%/点
|
||||
// • 50-100 递减区:每点 = +2% 加成
|
||||
// • 属性通过「属性点」升级(飞升获得),亦可由「经验值」自动累积升级
|
||||
// • 每级经验需求 = 10 × 当前等级
|
||||
import type { GameState } from "./types";
|
||||
|
||||
/** 四维属性键 */
|
||||
export type AttributeKey = "exploration" | "wisdom" | "courage" | "inspiration";
|
||||
|
||||
/** 角色四维属性数值(0-100) */
|
||||
export interface CharacterAttributes extends Record<AttributeKey, number> {
|
||||
exploration: number;
|
||||
wisdom: number;
|
||||
courage: number;
|
||||
inspiration: number;
|
||||
}
|
||||
|
||||
/** 单个属性的经验进度 */
|
||||
export interface AttributeProgressEntry {
|
||||
/** 当前累计经验值 */
|
||||
exp: number;
|
||||
/** 当前等级(与 attributes[key] 同步) */
|
||||
level: number;
|
||||
}
|
||||
|
||||
/** 经验进度集合 */
|
||||
export type AttributeProgress = Record<AttributeKey, AttributeProgressEntry>;
|
||||
|
||||
/** 单属性元信息(供 UI 渲染) */
|
||||
export interface AttributeMeta {
|
||||
key: AttributeKey;
|
||||
name: string;
|
||||
enName: string;
|
||||
/** 主题色(Tailwind class 前缀,避免使用蓝/靛色) */
|
||||
color: "emerald" | "fuchsia" | "amber" | "rose";
|
||||
hex: string;
|
||||
glow: string;
|
||||
/** lucide-react 图标名 */
|
||||
icon: string;
|
||||
desc: string;
|
||||
/** 影响的游戏系统列表(用于 UI 展示) */
|
||||
effects: string[];
|
||||
}
|
||||
|
||||
/** 属性配置常量 */
|
||||
export const ATTRIBUTE_CONFIG: Record<AttributeKey, AttributeMeta> = {
|
||||
exploration: {
|
||||
key: "exploration",
|
||||
name: "探索力",
|
||||
enName: "Exploration",
|
||||
color: "emerald",
|
||||
hex: "#34d399",
|
||||
glow: "rgba(52,211,153,0.55)",
|
||||
icon: "Compass",
|
||||
desc: "足迹所至,皆是矿脉。提升探险力与巡航飞船速度。",
|
||||
effects: ["探险力 +X%", "巡航飞船速度 +X%", "星图发现概率"],
|
||||
},
|
||||
wisdom: {
|
||||
key: "wisdom",
|
||||
name: "智慧",
|
||||
enName: "Wisdom",
|
||||
color: "fuchsia",
|
||||
hex: "#e879f9",
|
||||
glow: "rgba(232,121,249,0.55)",
|
||||
icon: "Brain",
|
||||
desc: "谐振的回声唯有智者能解。强化解码与洞见。",
|
||||
effects: ["解码步数 +X", "洞见产出 +X%", "自动解码周期 -X%"],
|
||||
},
|
||||
courage: {
|
||||
key: "courage",
|
||||
name: "勇气",
|
||||
enName: "Courage",
|
||||
color: "amber",
|
||||
hex: "#fbbf24",
|
||||
glow: "rgba(251,191,36,0.55)",
|
||||
icon: "Swords",
|
||||
desc: "直面虚空者,虚空亦为之让路。增益战斗与护盾。",
|
||||
effects: ["探险生命 +X", "BOSS 战胜率 +X%", "巡航护盾 +X"],
|
||||
},
|
||||
inspiration: {
|
||||
key: "inspiration",
|
||||
name: "灵感",
|
||||
enName: "Inspiration",
|
||||
color: "rose",
|
||||
hex: "#fb7185",
|
||||
glow: "rgba(251,113,133,0.55)",
|
||||
icon: "Sparkles",
|
||||
desc: "在不期而至的回响之间,照见更高的维度。",
|
||||
effects: ["接触率 +X", "星潮触发概率 +X%", "脉冲连击加成 +X%"],
|
||||
},
|
||||
};
|
||||
|
||||
/** 属性键的固定顺序(UI 渲染用) */
|
||||
export const ATTRIBUTE_KEYS: AttributeKey[] = [
|
||||
"exploration",
|
||||
"wisdom",
|
||||
"courage",
|
||||
"inspiration",
|
||||
];
|
||||
|
||||
/** 属性数值上限(软上限,超过后收益递减但仍可继续投资) */
|
||||
export const ATTRIBUTE_HARD_CAP = 100;
|
||||
/** 线性区与递减区交界点 */
|
||||
export const ATTRIBUTE_LINEAR_CAP = 50;
|
||||
|
||||
/**
|
||||
* 计算单个属性当前的加成百分比(小数形式,1.0 = 100%)。
|
||||
* - 0-50 线性区:每点 +0.5% → 0-50 点对应 0-25%
|
||||
* - 50-100 递减区:每点 +0.2% → 50-100 点对应 25-35%
|
||||
* - >100 仍按 100 计算加成(软上限)
|
||||
*/
|
||||
export function getAttributeBonus(attr: number): number {
|
||||
const v = Math.max(0, Math.min(ATTRIBUTE_HARD_CAP, attr));
|
||||
if (v <= ATTRIBUTE_LINEAR_CAP) {
|
||||
// 0..50 → 0..25%
|
||||
return v * 0.005;
|
||||
}
|
||||
// 25% + (v - 50) * 0.2%
|
||||
return 0.25 + (v - ATTRIBUTE_LINEAR_CAP) * 0.002;
|
||||
}
|
||||
|
||||
/** 计算单个属性对脉冲连击的额外加成百分比(线性,灵感专属) */
|
||||
export function getInspirationComboBonus(attr: number): number {
|
||||
// 每点 +0.5% 连击伤害加成(无递减)
|
||||
return Math.max(0, Math.min(ATTRIBUTE_HARD_CAP, attr)) * 0.005;
|
||||
}
|
||||
|
||||
/** 属性聚合修饰器(聚合到 recomputeStats 与具体动作) */
|
||||
export interface AttributeModifiers {
|
||||
/** 探险力 +X% (探索力) */
|
||||
expeditionPowerMult: number;
|
||||
/** 巡航飞船速度 +X% (探索力) */
|
||||
cruiseShipSpeedMult: number;
|
||||
/** 解码步数 +X (智慧) — 每点 +0.1 步 → 0-10 步加成 */
|
||||
decodeStepsBonus: number;
|
||||
/** 洞见倍率 +X% (智慧) */
|
||||
insightMultAdd: number;
|
||||
/** 自动解码周期 -X% (智慧) */
|
||||
autoDecodeIntervalMult: number;
|
||||
/** 探险生命 +X (勇气) — 每点 +2 HP */
|
||||
expeditionHpBonus: number;
|
||||
/** BOSS 战胜率 +X% (勇气) */
|
||||
bossWinRateBonus: number;
|
||||
/** 巡航护盾 +X (勇气) — 每点 +1 护盾 */
|
||||
cruiseShieldBonus: number;
|
||||
/** 接触率倍率 (灵感) */
|
||||
contactRateMult: number;
|
||||
/** 星潮触发概率 +X% (灵感) */
|
||||
tideTriggerBonus: number;
|
||||
/** 脉冲连击加成 +X% (灵感) */
|
||||
pulseComboBonus: number;
|
||||
/** 综合产能加成(探索力 + 灵感 0.2%/点) */
|
||||
crystalsPerSecMult: number;
|
||||
}
|
||||
|
||||
/** 由四维属性计算所有修饰器 */
|
||||
export function getAllBonuses(attrs: Partial<CharacterAttributes>): AttributeModifiers {
|
||||
const exploration = attrs.exploration ?? 0;
|
||||
const wisdom = attrs.wisdom ?? 0;
|
||||
const courage = attrs.courage ?? 0;
|
||||
const inspiration = attrs.inspiration ?? 0;
|
||||
|
||||
return {
|
||||
expeditionPowerMult: 1 + getAttributeBonus(exploration),
|
||||
cruiseShipSpeedMult: 1 + getAttributeBonus(exploration) * 0.6, // 飞船速度加成减半
|
||||
decodeStepsBonus: Math.floor(wisdom * 0.1), // 每点 +0.1 步
|
||||
insightMultAdd: getAttributeBonus(wisdom),
|
||||
autoDecodeIntervalMult: 1 - Math.min(0.5, wisdom * 0.004), // 每点 -0.4%,上限 -50%
|
||||
expeditionHpBonus: Math.round(courage * 2),
|
||||
bossWinRateBonus: Math.min(0.3, courage * 0.003), // 每点 +0.3%,上限 +30%
|
||||
cruiseShieldBonus: Math.round(courage * 1),
|
||||
contactRateMult: 1 + getAttributeBonus(inspiration),
|
||||
tideTriggerBonus: Math.min(0.3, inspiration * 0.003),
|
||||
pulseComboBonus: getInspirationComboBonus(inspiration),
|
||||
crystalsPerSecMult:
|
||||
1 +
|
||||
getAttributeBonus(exploration) * 0.4 + // 探索力 0.4 倍系数加成产能
|
||||
getAttributeBonus(inspiration) * 0.2, // 灵感 0.2 倍系数加成产能
|
||||
};
|
||||
}
|
||||
|
||||
/** 每级所需经验 = 10 × 当前等级 */
|
||||
export function expRequiredForLevel(level: number): number {
|
||||
return Math.max(10, 10 * Math.max(0, level));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查属性经验是否足以升级。
|
||||
* 返回新的进度(可能跨多级)。
|
||||
*/
|
||||
export function levelUpCheck(
|
||||
progress: AttributeProgressEntry,
|
||||
maxValue: number = ATTRIBUTE_HARD_CAP
|
||||
): { leveledUp: boolean; newProgress: AttributeProgressEntry; levelsGained: number } {
|
||||
let { exp, level } = progress;
|
||||
let leveledUp = false;
|
||||
let levelsGained = 0;
|
||||
// 安全上限:避免异常数据循环
|
||||
let safety = 0;
|
||||
while (level < maxValue && exp >= expRequiredForLevel(level) && safety < 200) {
|
||||
exp -= expRequiredForLevel(level);
|
||||
level += 1;
|
||||
leveledUp = true;
|
||||
levelsGained += 1;
|
||||
safety += 1;
|
||||
}
|
||||
// 已达上限:保留多余经验但不升级
|
||||
if (level >= maxValue) {
|
||||
level = maxValue;
|
||||
}
|
||||
return {
|
||||
leveledUp,
|
||||
levelsGained,
|
||||
newProgress: { exp, level },
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建初始的属性进度集合 */
|
||||
export function createInitialAttributeProgress(): AttributeProgress {
|
||||
return {
|
||||
exploration: { exp: 0, level: 0 },
|
||||
wisdom: { exp: 0, level: 0 },
|
||||
courage: { exp: 0, level: 0 },
|
||||
inspiration: { exp: 0, level: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建初始的属性数值集合 */
|
||||
export function createInitialAttributes(): CharacterAttributes {
|
||||
return {
|
||||
exploration: 0,
|
||||
wisdom: 0,
|
||||
courage: 0,
|
||||
inspiration: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧存档兼容:补全 attributes / attributeProgress / pendingAttrPoints 字段。
|
||||
* 若字段缺失或结构异常,使用初始值;若数值越界则夹紧。
|
||||
*/
|
||||
export function migrateAttributes(state: Partial<GameState>): {
|
||||
attributes: CharacterAttributes;
|
||||
attributeProgress: AttributeProgress;
|
||||
pendingAttrPoints: number;
|
||||
} {
|
||||
const init = createInitialAttributes();
|
||||
let attributes: CharacterAttributes;
|
||||
if (state.attributes && typeof state.attributes === "object") {
|
||||
attributes = {
|
||||
exploration: clampAttr(state.attributes.exploration),
|
||||
wisdom: clampAttr(state.attributes.wisdom),
|
||||
courage: clampAttr(state.attributes.courage),
|
||||
inspiration: clampAttr(state.attributes.inspiration),
|
||||
};
|
||||
} else {
|
||||
attributes = init;
|
||||
}
|
||||
let attributeProgress: AttributeProgress;
|
||||
if (state.attributeProgress && typeof state.attributeProgress === "object") {
|
||||
const base = createInitialAttributeProgress();
|
||||
attributeProgress = {
|
||||
exploration: normalizeProgress(
|
||||
state.attributeProgress.exploration,
|
||||
attributes.exploration
|
||||
),
|
||||
wisdom: normalizeProgress(
|
||||
state.attributeProgress.wisdom,
|
||||
attributes.wisdom
|
||||
),
|
||||
courage: normalizeProgress(
|
||||
state.attributeProgress.courage,
|
||||
attributes.courage
|
||||
),
|
||||
inspiration: normalizeProgress(
|
||||
state.attributeProgress.inspiration,
|
||||
attributes.inspiration
|
||||
),
|
||||
};
|
||||
void base;
|
||||
} else {
|
||||
// 同步 level 与 attributes 数值(保证一致)
|
||||
attributeProgress = {
|
||||
exploration: { exp: 0, level: attributes.exploration },
|
||||
wisdom: { exp: 0, level: attributes.wisdom },
|
||||
courage: { exp: 0, level: attributes.courage },
|
||||
inspiration: { exp: 0, level: attributes.inspiration },
|
||||
};
|
||||
}
|
||||
const pendingAttrPoints =
|
||||
typeof state.pendingAttrPoints === "number" && state.pendingAttrPoints >= 0
|
||||
? Math.floor(state.pendingAttrPoints)
|
||||
: 0;
|
||||
return { attributes, attributeProgress, pendingAttrPoints };
|
||||
}
|
||||
|
||||
function clampAttr(v: unknown): number {
|
||||
const n = typeof v === "number" && isFinite(v) ? v : 0;
|
||||
return Math.max(0, Math.min(ATTRIBUTE_HARD_CAP, Math.floor(n)));
|
||||
}
|
||||
|
||||
function normalizeProgress(
|
||||
p: unknown,
|
||||
level: number
|
||||
): AttributeProgressEntry {
|
||||
if (p && typeof p === "object") {
|
||||
const obj = p as { exp?: unknown; level?: unknown };
|
||||
const exp =
|
||||
typeof obj.exp === "number" && isFinite(obj.exp) && obj.exp >= 0
|
||||
? obj.exp
|
||||
: 0;
|
||||
const lvl =
|
||||
typeof obj.level === "number" && isFinite(obj.level) && obj.level >= 0
|
||||
? Math.floor(obj.level)
|
||||
: level;
|
||||
// level 字段以 attributes 数值为准(attributes 是事实之源)
|
||||
return { exp, level };
|
||||
}
|
||||
return { exp: 0, level };
|
||||
}
|
||||
|
||||
/** 计算本次飞升可获得的属性点 = 飞升次数 × 2 + 1(飞升前 ascensions) */
|
||||
export function computePrestigeAttrPoints(ascensionsBefore: number): number {
|
||||
return ascensionsBefore * 2 + 1;
|
||||
}
|
||||
|
||||
/** 计算四维属性总和 */
|
||||
export function totalAttributeLevel(attrs: CharacterAttributes): number {
|
||||
return (
|
||||
attrs.exploration + attrs.wisdom + attrs.courage + attrs.inspiration
|
||||
);
|
||||
}
|
||||
|
||||
/** 计算四维属性总加成百分比(用于 UI 概览) */
|
||||
export function totalAttributeBonusPct(attrs: CharacterAttributes): number {
|
||||
return (
|
||||
(getAttributeBonus(attrs.exploration) +
|
||||
getAttributeBonus(attrs.wisdom) +
|
||||
getAttributeBonus(attrs.courage) +
|
||||
getAttributeBonus(attrs.inspiration)) *
|
||||
100
|
||||
);
|
||||
}
|
||||
|
||||
/** 是否任一属性已达 100(用于成就) */
|
||||
export function anyAttributeMaxed(attrs: CharacterAttributes): boolean {
|
||||
return (
|
||||
attrs.exploration >= ATTRIBUTE_HARD_CAP ||
|
||||
attrs.wisdom >= ATTRIBUTE_HARD_CAP ||
|
||||
attrs.courage >= ATTRIBUTE_HARD_CAP ||
|
||||
attrs.inspiration >= ATTRIBUTE_HARD_CAP
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,20 @@ export const INITIAL_STATE = {
|
||||
starTidesEncountered: [] as string[],
|
||||
crystalsDecoded: 0,
|
||||
},
|
||||
// v0.7 角色属性系统
|
||||
attributes: {
|
||||
exploration: 0,
|
||||
wisdom: 0,
|
||||
courage: 0,
|
||||
inspiration: 0,
|
||||
} as import("./attributes").CharacterAttributes,
|
||||
attributeProgress: {
|
||||
exploration: { exp: 0, level: 0 },
|
||||
wisdom: { exp: 0, level: 0 },
|
||||
courage: { exp: 0, level: 0 },
|
||||
inspiration: { exp: 0, level: 0 },
|
||||
} as import("./attributes").AttributeProgress,
|
||||
pendingAttrPoints: 0,
|
||||
theme: "dark" as const,
|
||||
soundOn: true,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+42
-2
@@ -14,6 +14,12 @@ import {
|
||||
buildChronicleEntry,
|
||||
createRunStartSnapshot,
|
||||
} from "./chronicle";
|
||||
import {
|
||||
getAllBonuses,
|
||||
migrateAttributes,
|
||||
computePrestigeAttrPoints,
|
||||
createInitialAttributeProgress,
|
||||
} from "./attributes";
|
||||
|
||||
/** 由技术树 + 飞升蓝图 + 成就 + 星图天赋 + 星潮聚合计算产能字段 */
|
||||
export function recomputeStats(state: Partial<GameState>): {
|
||||
@@ -96,6 +102,13 @@ export function recomputeStats(state: Partial<GameState>): {
|
||||
insightMult += tideMod.insightMultAdd;
|
||||
contactRateMult *= tideMod.contactRateMult;
|
||||
|
||||
// 角色属性加成(v0.7 P1 — 与现有所有加成叠加)
|
||||
const am = getAllBonuses(state.attributes ?? {});
|
||||
crystalsPerSec *= am.crystalsPerSecMult;
|
||||
insightMult += am.insightMultAdd;
|
||||
contactRateMult *= am.contactRateMult;
|
||||
decodeStepsBonus += am.decodeStepsBonus;
|
||||
|
||||
return {
|
||||
crystalsPerSec,
|
||||
crystalCap,
|
||||
@@ -142,11 +155,20 @@ export function performPrestige(state: GameState): GameState {
|
||||
const newChronicle = [...(state.chronicle || []), chronicleEntry].slice(-50); // 上限 50 条
|
||||
const freshRunStart = createRunStartSnapshot(state);
|
||||
|
||||
// v0.7 角色属性:保留 attributes 数值(跨周目永久),清空当前周目经验进度,
|
||||
// 发放待分配属性点 = 飞升次数 × 2 + 1(用飞升前的次数计算)
|
||||
const migrated = migrateAttributes(state);
|
||||
const attributes = migrated.attributes; // 保留数值
|
||||
const attributeProgress = createInitialAttributeProgress(); // 清空经验
|
||||
const earnedAttrPoints = computePrestigeAttrPoints(state.ascensions ?? 0);
|
||||
const pendingAttrPoints = migrated.pendingAttrPoints + earnedAttrPoints;
|
||||
|
||||
// 保留:fragments, totalDecoded(累计), ascensions+1, blueprints, achievements, constellation, theme/sound, expeditionLog
|
||||
// chronicle(新增), bossKills(累计), starTidesEncountered(累计), runStart(重置为新周目)
|
||||
// 重置:资源、技术、产能、pendingCrystals、activePuzzle、activeExpedition、contact、lastTick、energy、星潮
|
||||
// attributes(永久保留), pendingAttrPoints(累加)
|
||||
// 重置:资源、技术、产能、pendingCrystals、activePuzzle、activeExpedition、contact、lastTick、energy、星潮、attributeProgress
|
||||
const fresh = createInitialState();
|
||||
const stats = recomputeStats({ tech: {}, blueprints, achievements: state.achievements, constellation: state.constellation });
|
||||
const stats = recomputeStats({ tech: {}, blueprints, achievements: state.achievements, constellation: state.constellation, attributes });
|
||||
return {
|
||||
...fresh,
|
||||
fragments: state.fragments,
|
||||
@@ -174,6 +196,10 @@ export function performPrestige(state: GameState): GameState {
|
||||
// 星潮:飞升后清空,lastTideEnd 设为现在,使首次星潮在 firstDelay 后触发
|
||||
activeTide: null,
|
||||
lastTideEnd: Date.now(),
|
||||
// v0.7 角色属性
|
||||
attributes,
|
||||
attributeProgress,
|
||||
pendingAttrPoints,
|
||||
...stats,
|
||||
};
|
||||
}
|
||||
@@ -194,6 +220,20 @@ export function createInitialState(): GameState {
|
||||
lastEnergyTick: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
lastTick: Date.now(),
|
||||
// v0.7 角色属性:每次创建都生成全新对象,避免引用共享
|
||||
attributes: {
|
||||
exploration: 0,
|
||||
wisdom: 0,
|
||||
courage: 0,
|
||||
inspiration: 0,
|
||||
},
|
||||
attributeProgress: {
|
||||
exploration: { exp: 0, level: 0 },
|
||||
wisdom: { exp: 0, level: 0 },
|
||||
courage: { exp: 0, level: 0 },
|
||||
inspiration: { exp: 0, level: 0 },
|
||||
},
|
||||
pendingAttrPoints: 0,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
|
||||
@@ -142,6 +142,11 @@ export interface GameState {
|
||||
chronicle: ChronicleEntry[];
|
||||
runStart: RunStartSnapshot;
|
||||
|
||||
// 角色属性(v0.7 P1 — 四维属性系统)
|
||||
attributes: import("./attributes").CharacterAttributes;
|
||||
attributeProgress: import("./attributes").AttributeProgress;
|
||||
pendingAttrPoints: number;
|
||||
|
||||
// 元
|
||||
lastTick: number;
|
||||
createdAt: number;
|
||||
|
||||
+260
-18
@@ -67,6 +67,17 @@ import {
|
||||
type BeaconDailyProgress,
|
||||
} from "@/lib/game/beacon";
|
||||
import { setPendingOfflineReport } from "@/lib/game/offlineReport";
|
||||
import {
|
||||
ATTRIBUTE_HARD_CAP,
|
||||
migrateAttributes,
|
||||
levelUpCheck,
|
||||
getAllBonuses,
|
||||
createInitialAttributes,
|
||||
createInitialAttributeProgress,
|
||||
type AttributeKey,
|
||||
type CharacterAttributes,
|
||||
type AttributeProgress,
|
||||
} from "@/lib/game/attributes";
|
||||
|
||||
interface GameActions {
|
||||
// 生命周期
|
||||
@@ -118,6 +129,13 @@ interface GameActions {
|
||||
// 深空信标奖励发放(v0.5)
|
||||
grantBeaconReward: (insights: number, contact: number) => void;
|
||||
|
||||
// 深空巡航奖励发放(v0.6 — 实时 Canvas 玩法)
|
||||
grantCruiseReward: (rewards: { crystals?: number; insights?: number; contact?: number }) => void;
|
||||
|
||||
// 角色属性(v0.7 P1)
|
||||
allocateAttribute: (attr: AttributeKey, points?: number) => { ok: boolean; leveledUp?: number };
|
||||
gainAttributeExp: (attr: AttributeKey, amount: number) => { leveledUp: number; newLevel: number };
|
||||
|
||||
// 派生
|
||||
canPrestige: () => boolean;
|
||||
}
|
||||
@@ -217,6 +235,8 @@ export const useGameStore = create<Store>()(
|
||||
const pendingPerkChoices = s.pendingPerkChoices ?? null;
|
||||
// v0.4 编年史兼容:补全 chronicle / runStart / bossKills / starTidesEncountered
|
||||
const migrated = migrateChronicleFields(s);
|
||||
// v0.7 角色属性兼容:补全 attributes / attributeProgress / pendingAttrPoints
|
||||
const attrMigrated = migrateAttributes(s);
|
||||
// 星图「能量共振」天赋 +1 能量上限
|
||||
const cm = constellationBonuses(constellation);
|
||||
const energyMax = (INITIAL_STATE.energyMax) + cm.energyMaxBonus;
|
||||
@@ -258,16 +278,19 @@ export const useGameStore = create<Store>()(
|
||||
runStart: migrated.runStart,
|
||||
bossKills: migrated.bossKills,
|
||||
starTidesEncountered: migrated.starTidesEncountered,
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation }),
|
||||
attributes: attrMigrated.attributes,
|
||||
attributeProgress: attrMigrated.attributeProgress,
|
||||
pendingAttrPoints: attrMigrated.pendingAttrPoints,
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation, attributes: attrMigrated.attributes }),
|
||||
});
|
||||
} else {
|
||||
set({ lastTick: now, activePuzzle, achievements, activeTide: tide, lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw, constellation, pendingPerkChoices, energyMax, chronicle: migrated.chronicle, runStart: migrated.runStart, bossKills: migrated.bossKills, starTidesEncountered: migrated.starTidesEncountered, ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation }) });
|
||||
set({ lastTick: now, activePuzzle, achievements, activeTide: tide, lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw, constellation, pendingPerkChoices, energyMax, chronicle: migrated.chronicle, runStart: migrated.runStart, bossKills: migrated.bossKills, starTidesEncountered: migrated.starTidesEncountered, attributes: attrMigrated.attributes, attributeProgress: attrMigrated.attributeProgress, pendingAttrPoints: attrMigrated.pendingAttrPoints, ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation, attributes: attrMigrated.attributes }) });
|
||||
}
|
||||
},
|
||||
|
||||
loadOnline: () => {
|
||||
const s = get();
|
||||
set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation }) });
|
||||
set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }) });
|
||||
},
|
||||
|
||||
hardReset: () => {
|
||||
@@ -279,7 +302,10 @@ export const useGameStore = create<Store>()(
|
||||
const tide = s.activeTide;
|
||||
// 星图「星潮引导」减少间隙
|
||||
const cm = constellationBonuses(s.constellation ?? []);
|
||||
const gap = Math.max(15000, TIDE_CONFIG.gap + cm.tideGapDeltaSec * 1000);
|
||||
// v0.7 灵感:星潮触发概率 +X%(缩短间隙)
|
||||
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) 检查当前星潮是否结束
|
||||
if (tide && now >= tide.endsAt) {
|
||||
const endedType = tide.type;
|
||||
@@ -296,7 +322,7 @@ export const useGameStore = create<Store>()(
|
||||
lastTideEnd: now,
|
||||
insights: newInsights,
|
||||
// 星潮结束后重算 stats(移除 contactRate/insight 修饰)
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation }),
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation, attributes: s.attributes }),
|
||||
_tideEvents: [...s._tideEvents, event],
|
||||
});
|
||||
return event;
|
||||
@@ -324,7 +350,7 @@ export const useGameStore = create<Store>()(
|
||||
activeTide: newTide,
|
||||
starTidesEncountered: newTidesAll,
|
||||
// 星潮开始后重算 stats(应用 contactRate/insight 修饰)
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation }),
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation, attributes: s.attributes }),
|
||||
_tideEvents: [...s._tideEvents, event],
|
||||
});
|
||||
return event;
|
||||
@@ -409,7 +435,10 @@ export const useGameStore = create<Store>()(
|
||||
const mult = 1 + (combo - 1) * 0.15;
|
||||
// 星潮脉冲威力修饰
|
||||
const tideMod = getTideModifiers(s.activeTide);
|
||||
const gain = s.pulsePower * mult * tideMod.pulsePowerMult;
|
||||
// v0.7 灵感:脉冲连击加成 +X%
|
||||
const am = getAllBonuses(s.attributes ?? {});
|
||||
const comboBonusMult = 1 + am.pulseComboBonus * Math.max(0, combo - 1);
|
||||
const gain = s.pulsePower * mult * tideMod.pulsePowerMult * comboBonusMult;
|
||||
set({
|
||||
crystals: Math.min(s.crystalCap, s.crystals + gain),
|
||||
_combo: combo,
|
||||
@@ -417,6 +446,30 @@ export const useGameStore = create<Store>()(
|
||||
});
|
||||
// 深空信标:脉冲任务进度 +1
|
||||
trackBeacon("pulse", 1);
|
||||
// v0.7 角色属性:连击 ≥3 给灵感经验
|
||||
if (combo >= 3) {
|
||||
const expGain = 1 + Math.floor(combo / 2); // 3 连击=2, 5 连击=3, 10 连击=6
|
||||
// 内联经验获取(避免递归调用 set)
|
||||
const prog = s.attributeProgress?.inspiration ?? { exp: 0, level: s.attributes?.inspiration ?? 0 };
|
||||
const nextExp = prog.exp + expGain;
|
||||
const lvlResult = levelUpCheck(
|
||||
{ exp: nextExp, level: s.attributes?.inspiration ?? 0 },
|
||||
ATTRIBUTE_HARD_CAP
|
||||
);
|
||||
const newAttributes: CharacterAttributes = {
|
||||
...(s.attributes ?? createInitialAttributes()),
|
||||
inspiration: lvlResult.newProgress.level,
|
||||
};
|
||||
const newProgress: AttributeProgress = {
|
||||
...(s.attributeProgress ?? createInitialAttributeProgress()),
|
||||
inspiration: lvlResult.newProgress,
|
||||
};
|
||||
set({
|
||||
attributes: newAttributes,
|
||||
attributeProgress: newProgress,
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
|
||||
});
|
||||
}
|
||||
return { gain, combo };
|
||||
},
|
||||
|
||||
@@ -456,6 +509,23 @@ export const useGameStore = create<Store>()(
|
||||
// 解锁碎片
|
||||
const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } };
|
||||
const unlocked = checkFragments(tentative);
|
||||
// v0.7 角色属性:完成解码给智慧经验(tier 越高经验越多)
|
||||
const wisdomExpGain = puzzle.tier * 2;
|
||||
const curAttrs = s.attributes ?? createInitialAttributes();
|
||||
const curProg = s.attributeProgress ?? createInitialAttributeProgress();
|
||||
const progEntry = curProg.wisdom ?? { exp: 0, level: curAttrs.wisdom };
|
||||
const wisdomLvl = levelUpCheck(
|
||||
{ exp: progEntry.exp + wisdomExpGain, level: curAttrs.wisdom },
|
||||
ATTRIBUTE_HARD_CAP
|
||||
);
|
||||
const newAttributes: CharacterAttributes = {
|
||||
...curAttrs,
|
||||
wisdom: wisdomLvl.newProgress.level,
|
||||
};
|
||||
const newProgress: AttributeProgress = {
|
||||
...curProg,
|
||||
wisdom: wisdomLvl.newProgress,
|
||||
};
|
||||
set({
|
||||
activePuzzle: null,
|
||||
crystals: newCrystals,
|
||||
@@ -463,6 +533,9 @@ export const useGameStore = create<Store>()(
|
||||
contact: newContact,
|
||||
totalDecoded: newTotal,
|
||||
fragments: tentative.fragments,
|
||||
attributes: newAttributes,
|
||||
attributeProgress: newProgress,
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
|
||||
});
|
||||
// 深空信标:解码 +1,洞见累计
|
||||
trackBeacon("decode", 1);
|
||||
@@ -517,7 +590,10 @@ export const useGameStore = create<Store>()(
|
||||
const now = Date.now();
|
||||
// 星图「自动校准」减少自动解码周期
|
||||
const cm = constellationBonuses(s.constellation ?? []);
|
||||
const interval = Math.max(5000, 12000 + cm.autoDecodeIntervalDeltaSec * 1000);
|
||||
// v0.7 智慧:自动解码周期 -X%
|
||||
const am = getAllBonuses(s.attributes ?? {});
|
||||
const baseInterval = 12000 + cm.autoDecodeIntervalDeltaSec * 1000;
|
||||
const interval = Math.max(5000, baseInterval * am.autoDecodeIntervalMult);
|
||||
if (now - s._lastAutoDecode < interval) return;
|
||||
// 找一颗 T1 晶体自动解码
|
||||
const idx = s.pendingCrystals.findIndex((c) => c.tier === 1);
|
||||
@@ -534,6 +610,22 @@ export const useGameStore = create<Store>()(
|
||||
const newTotal = s.totalDecoded + 1;
|
||||
const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } };
|
||||
checkFragments(tentative);
|
||||
// v0.7 角色属性:自动解码给智慧经验(少量)
|
||||
const curAttrs = s.attributes ?? createInitialAttributes();
|
||||
const curProg = s.attributeProgress ?? createInitialAttributeProgress();
|
||||
const progEntry = curProg.wisdom ?? { exp: 0, level: curAttrs.wisdom };
|
||||
const wisdomLvl = levelUpCheck(
|
||||
{ exp: progEntry.exp + 1, level: curAttrs.wisdom },
|
||||
ATTRIBUTE_HARD_CAP
|
||||
);
|
||||
const newAttributes: CharacterAttributes = {
|
||||
...curAttrs,
|
||||
wisdom: wisdomLvl.newProgress.level,
|
||||
};
|
||||
const newProgress: AttributeProgress = {
|
||||
...curProg,
|
||||
wisdom: wisdomLvl.newProgress,
|
||||
};
|
||||
set({
|
||||
pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystal.id),
|
||||
crystals: s.crystals + rewards.crystals,
|
||||
@@ -542,6 +634,9 @@ export const useGameStore = create<Store>()(
|
||||
totalDecoded: newTotal,
|
||||
fragments: tentative.fragments,
|
||||
_lastAutoDecode: now,
|
||||
attributes: newAttributes,
|
||||
attributeProgress: newProgress,
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
|
||||
});
|
||||
// 深空信标:自动解码也算进度
|
||||
trackBeacon("decode", 1);
|
||||
@@ -559,7 +654,7 @@ export const useGameStore = create<Store>()(
|
||||
set({
|
||||
insights: s.insights - node.cost,
|
||||
tech: newTech,
|
||||
...syncStats({ tech: newTech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation }),
|
||||
...syncStats({ tech: newTech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
@@ -574,8 +669,12 @@ export const useGameStore = create<Store>()(
|
||||
return { ok: false, reason: "能量不足" };
|
||||
}
|
||||
const tideMod = getTideModifiers(s.activeTide);
|
||||
const power = computeExpeditionPower(s) + tideMod.expeditionPowerBonus;
|
||||
const hp = computeExpeditionHp(s) + tideMod.expeditionHpBonus;
|
||||
// v0.7 角色属性:探索力 +X% 探险力,勇气 +X 探险生命
|
||||
const am = getAllBonuses(s.attributes ?? {});
|
||||
const basePower = computeExpeditionPower(s) + tideMod.expeditionPowerBonus;
|
||||
const baseHp = computeExpeditionHp(s) + tideMod.expeditionHpBonus;
|
||||
const power = Math.round(basePower * am.expeditionPowerMult);
|
||||
const hp = baseHp + am.expeditionHpBonus;
|
||||
const seed = Math.floor(Math.random() * 1e9);
|
||||
const expedition = generateExpedition(seed, power, hp);
|
||||
set({
|
||||
@@ -591,7 +690,18 @@ export const useGameStore = create<Store>()(
|
||||
if (!s.activeExpedition || s.activeExpedition.finished) return null;
|
||||
// 深拷贝
|
||||
const exp = JSON.parse(JSON.stringify(s.activeExpedition));
|
||||
const result = resolveNode(exp);
|
||||
// v0.7 勇气:BOSS 战胜率 +X%(动态提高 RNG 阈值)
|
||||
const nodeBefore = exp.nodes[exp.currentNode];
|
||||
const isBossNode = nodeBefore?.type === "boss";
|
||||
const am = getAllBonuses(s.attributes ?? {});
|
||||
const result = isBossNode
|
||||
? resolveNode(exp, () => {
|
||||
// 单次 rng() 调用:B% 概率返回 0(必胜),其余情况返回 r-B(保持均匀分布)
|
||||
const r = Math.random();
|
||||
const b = Math.min(0.95, am.bossWinRateBonus);
|
||||
return r < b ? 0 : Math.min(1, r - b);
|
||||
})
|
||||
: resolveNode(exp);
|
||||
// 累计奖励
|
||||
if (result.crystals) exp.rewards.crystals += result.crystals;
|
||||
if (result.insights) exp.rewards.insights += result.insights;
|
||||
@@ -610,7 +720,7 @@ export const useGameStore = create<Store>()(
|
||||
// 日志
|
||||
const logEntry = {
|
||||
expeditionId: exp.id,
|
||||
nodeType: exp.nodes[exp.currentNode]?.type || "combat",
|
||||
nodeType: nodeBefore?.type || "combat",
|
||||
result: result.log,
|
||||
rewards: [
|
||||
result.crystals ? `+${result.crystals}晶体` : "",
|
||||
@@ -629,12 +739,48 @@ export const useGameStore = create<Store>()(
|
||||
|
||||
// v0.4 编年史:击破 BOSS 时累计计数
|
||||
let bossKills = s.bossKills ?? 0;
|
||||
let bossKilledThisNode = false;
|
||||
if (
|
||||
result.ended &&
|
||||
result.endReason === "victory" &&
|
||||
exp.nodes[exp.currentNode]?.type === "boss"
|
||||
nodeBefore?.type === "boss"
|
||||
) {
|
||||
bossKills = bossKills + 1;
|
||||
bossKilledThisNode = true;
|
||||
}
|
||||
|
||||
// v0.7 角色属性:战斗胜利给勇气+探索力经验;BOSS 额外奖励
|
||||
let newAttributes = s.attributes ?? createInitialAttributes();
|
||||
let newProgress = s.attributeProgress ?? createInitialAttributeProgress();
|
||||
let statsNeedResync = false;
|
||||
// 战斗类节点(combat/boss)且胜利:勇气 + 探索力经验
|
||||
const isCombatVictory =
|
||||
(nodeBefore?.type === "combat" || nodeBefore?.type === "boss") &&
|
||||
!result.ended; // 中途战斗胜利(未结束探险)
|
||||
const isExpeditionVictory =
|
||||
result.ended && result.endReason === "victory";
|
||||
if (isCombatVictory || bossKilledThisNode || isExpeditionVictory) {
|
||||
const courageGain = bossKilledThisNode ? 8 : 2;
|
||||
const explorationGain = bossKilledThisNode ? 6 : isExpeditionVictory ? 4 : 1;
|
||||
const courageLvl = levelUpCheck(
|
||||
{ exp: newProgress.courage.exp + courageGain, level: newAttributes.courage },
|
||||
ATTRIBUTE_HARD_CAP
|
||||
);
|
||||
const explLvl = levelUpCheck(
|
||||
{ exp: newProgress.exploration.exp + explorationGain, level: newAttributes.exploration },
|
||||
ATTRIBUTE_HARD_CAP
|
||||
);
|
||||
newAttributes = {
|
||||
...newAttributes,
|
||||
courage: courageLvl.newProgress.level,
|
||||
exploration: explLvl.newProgress.level,
|
||||
};
|
||||
newProgress = {
|
||||
...newProgress,
|
||||
courage: courageLvl.newProgress,
|
||||
exploration: explLvl.newProgress,
|
||||
};
|
||||
statsNeedResync = true;
|
||||
}
|
||||
|
||||
set({
|
||||
@@ -645,11 +791,16 @@ export const useGameStore = create<Store>()(
|
||||
fragments: newFragments,
|
||||
expeditionLog: newLog,
|
||||
bossKills,
|
||||
attributes: newAttributes,
|
||||
attributeProgress: newProgress,
|
||||
...(statsNeedResync
|
||||
? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes })
|
||||
: {}),
|
||||
});
|
||||
// 深空信标:探险完成(胜负都计)+ 洞见累计 + BOSS 击破
|
||||
if (result.ended) {
|
||||
trackBeacon("expedition", 1);
|
||||
if (result.endReason === "victory" && exp.nodes[exp.currentNode]?.type === "boss") {
|
||||
if (bossKilledThisNode) {
|
||||
trackBeacon("boss", 1);
|
||||
}
|
||||
}
|
||||
@@ -697,7 +848,7 @@ export const useGameStore = create<Store>()(
|
||||
set({
|
||||
...next,
|
||||
energyMax,
|
||||
...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements, activeTide: next.activeTide, constellation: next.constellation }),
|
||||
...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements, activeTide: next.activeTide, constellation: next.constellation, attributes: next.attributes }),
|
||||
_lastAutoDecode: Date.now(),
|
||||
_lastSpawn: Date.now(),
|
||||
_combo: 0,
|
||||
@@ -729,7 +880,7 @@ export const useGameStore = create<Store>()(
|
||||
pendingPerkChoices: null,
|
||||
energyMax,
|
||||
chronicle: newChronicle,
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: newConstellation }),
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: newConstellation, attributes: s.attributes }),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
@@ -768,7 +919,7 @@ export const useGameStore = create<Store>()(
|
||||
insights,
|
||||
contact,
|
||||
...(statsDirty
|
||||
? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated, activeTide: s.activeTide, constellation: s.constellation })
|
||||
? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes })
|
||||
: {}),
|
||||
_achievementQueue: [...s._achievementQueue, ...newlyUnlocked],
|
||||
});
|
||||
@@ -796,6 +947,97 @@ export const useGameStore = create<Store>()(
|
||||
contact: Math.min(100, s.contact + contact),
|
||||
});
|
||||
},
|
||||
|
||||
// 深空巡航:发放实时玩法奖励(v0.6)
|
||||
grantCruiseReward: (rewards) => {
|
||||
const s = get();
|
||||
const addCrystals = rewards.crystals ?? 0;
|
||||
const addInsights = rewards.insights ?? 0;
|
||||
const addContact = rewards.contact ?? 0;
|
||||
// v0.7 角色属性:巡航通关给探索力+勇气经验(按晶体奖励量缩放)
|
||||
const totalReward = addCrystals + addInsights * 10 + addContact * 10;
|
||||
const expBase = Math.max(2, Math.floor(totalReward / 30));
|
||||
const curAttrs = s.attributes ?? createInitialAttributes();
|
||||
const curProg = s.attributeProgress ?? createInitialAttributeProgress();
|
||||
const explLvl = levelUpCheck(
|
||||
{ exp: curProg.exploration.exp + expBase, level: curAttrs.exploration },
|
||||
ATTRIBUTE_HARD_CAP
|
||||
);
|
||||
const courageLvl = levelUpCheck(
|
||||
{ exp: curProg.courage.exp + Math.floor(expBase * 0.6), level: curAttrs.courage },
|
||||
ATTRIBUTE_HARD_CAP
|
||||
);
|
||||
const newAttributes: CharacterAttributes = {
|
||||
...curAttrs,
|
||||
exploration: explLvl.newProgress.level,
|
||||
courage: courageLvl.newProgress.level,
|
||||
};
|
||||
const newProgress: AttributeProgress = {
|
||||
...curProg,
|
||||
exploration: explLvl.newProgress,
|
||||
courage: courageLvl.newProgress,
|
||||
};
|
||||
set({
|
||||
crystals: Math.min(s.crystalCap, s.crystals + addCrystals),
|
||||
insights: s.insights + Math.round(addInsights),
|
||||
contact: Math.min(100, s.contact + addContact),
|
||||
attributes: newAttributes,
|
||||
attributeProgress: newProgress,
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
|
||||
});
|
||||
},
|
||||
|
||||
// ============ 角色属性系统(v0.7 P1) ============
|
||||
allocateAttribute: (attr, points = 1) => {
|
||||
const s = get();
|
||||
const cur = s.attributes ?? createInitialAttributes();
|
||||
const curVal = cur[attr] ?? 0;
|
||||
if (curVal >= ATTRIBUTE_HARD_CAP) {
|
||||
return { ok: false, leveledUp: 0 };
|
||||
}
|
||||
if ((s.pendingAttrPoints ?? 0) < points) {
|
||||
return { ok: false, leveledUp: 0 };
|
||||
}
|
||||
const alloc = Math.min(points, ATTRIBUTE_HARD_CAP - curVal, s.pendingAttrPoints);
|
||||
const newVal = curVal + alloc;
|
||||
const newAttributes: CharacterAttributes = { ...cur, [attr]: newVal };
|
||||
// 同步经验进度 level 字段(保持一致)
|
||||
const curProg = s.attributeProgress ?? createInitialAttributeProgress();
|
||||
const oldProg = curProg[attr] ?? { exp: 0, level: curVal };
|
||||
const newProgress: AttributeProgress = {
|
||||
...curProg,
|
||||
[attr]: { exp: oldProg.exp, level: newVal },
|
||||
};
|
||||
set({
|
||||
attributes: newAttributes,
|
||||
attributeProgress: newProgress,
|
||||
pendingAttrPoints: s.pendingAttrPoints - alloc,
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
|
||||
});
|
||||
return { ok: true, leveledUp: alloc };
|
||||
},
|
||||
|
||||
gainAttributeExp: (attr, amount) => {
|
||||
const s = get();
|
||||
const cur = s.attributes ?? createInitialAttributes();
|
||||
const curProg = s.attributeProgress ?? createInitialAttributeProgress();
|
||||
const progEntry = curProg[attr] ?? { exp: 0, level: cur[attr] };
|
||||
const result = levelUpCheck(
|
||||
{ exp: progEntry.exp + amount, level: cur[attr] },
|
||||
ATTRIBUTE_HARD_CAP
|
||||
);
|
||||
const newAttributes: CharacterAttributes = { ...cur, [attr]: result.newProgress.level };
|
||||
const newProgress: AttributeProgress = {
|
||||
...curProg,
|
||||
[attr]: result.newProgress,
|
||||
};
|
||||
set({
|
||||
attributes: newAttributes,
|
||||
attributeProgress: newProgress,
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
|
||||
});
|
||||
return { leveledUp: result.levelsGained, newLevel: result.newProgress.level };
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "echo-nexus-save-v1",
|
||||
|
||||
@@ -0,0 +1,843 @@
|
||||
1→"use client";
|
||||
2→// 回响星核 / Echo Nexus — Zustand 游戏状态管理
|
||||
3→import { create } from "zustand";
|
||||
4→import { persist, createJSONStorage } from "zustand/middleware";
|
||||
5→import type {
|
||||
6→ GameState,
|
||||
7→ Crystal,
|
||||
8→ CrystalTier,
|
||||
9→ DecodePuzzle,
|
||||
10→ ExpeditionResult,
|
||||
11→} from "@/lib/game/types";
|
||||
12→import {
|
||||
13→ INITIAL_STATE,
|
||||
14→ TECH_TREE,
|
||||
15→ CRYSTAL_VALUE,
|
||||
16→ CONTACT,
|
||||
17→ CRYSTAL_SPAWN,
|
||||
18→ FRAGMENTS,
|
||||
19→ PRESTIGE,
|
||||
20→} from "@/lib/game/config";
|
||||
21→import {
|
||||
22→ createInitialState,
|
||||
23→ recomputeStats,
|
||||
24→ decodeRewards,
|
||||
25→ rollCrystalTierWithBonus,
|
||||
26→ computeNewBlueprints,
|
||||
27→ performPrestige,
|
||||
28→} from "@/lib/game/engine";
|
||||
29→import {
|
||||
30→ generatePuzzle,
|
||||
31→ tryClickNode,
|
||||
32→ isSolvable,
|
||||
33→ resetPuzzle as resetPuz,
|
||||
34→} from "@/lib/game/decode";
|
||||
35→import {
|
||||
36→ generateExpedition,
|
||||
37→ resolveNode,
|
||||
38→ advanceExpedition,
|
||||
39→ computeExpeditionPower,
|
||||
40→ computeExpeditionHp,
|
||||
41→ computeEnergyRegen,
|
||||
42→ EXPEDITION_CONFIG,
|
||||
43→} from "@/lib/game/expedition";
|
||||
44→import { ACHIEVEMENTS, type Achievement } from "@/lib/game/achievements";
|
||||
45→import {
|
||||
46→ TIDE_CONFIG,
|
||||
47→ rollTide,
|
||||
48→ getTideModifiers,
|
||||
49→ computeSilenceCompensation,
|
||||
50→ type StarTide,
|
||||
51→ type TideType,
|
||||
52→} from "@/lib/game/starTide";
|
||||
53→import {
|
||||
54→ getPerk,
|
||||
55→ constellationBonuses,
|
||||
56→ rollPerkChoices,
|
||||
57→} from "@/lib/game/constellation";
|
||||
58→import {
|
||||
59→ migrateChronicleFields,
|
||||
60→ withPerks,
|
||||
61→} from "@/lib/game/chronicle";
|
||||
62→import {
|
||||
63→ generateDailyChallenge,
|
||||
64→ loadDailyProgress,
|
||||
65→ addBeaconProgress,
|
||||
66→ type BeaconDailyChallenge,
|
||||
67→ type BeaconDailyProgress,
|
||||
68→} from "@/lib/game/beacon";
|
||||
69→import { setPendingOfflineReport } from "@/lib/game/offlineReport";
|
||||
70→
|
||||
71→interface GameActions {
|
||||
72→ // 生命周期
|
||||
73→ init: () => void;
|
||||
74→ loadOnline: () => void;
|
||||
75→ hardReset: () => void;
|
||||
76→
|
||||
77→ // 主循环
|
||||
78→ tick: (now: number) => void;
|
||||
79→ pulse: () => { gain: number; combo: number } | null;
|
||||
80→
|
||||
81→ // 星潮
|
||||
82→ tickTide: (now: number) => { started?: TideType; ended?: TideType; silenceCompensation?: number } | null;
|
||||
83→ consumeTideEvents: () => { started?: TideType; ended?: TideType; silenceCompensation?: number }[];
|
||||
84→
|
||||
85→ // 解码
|
||||
86→ startDecode: (crystalId: string) => void;
|
||||
87→ clickNode: (nodeId: number) => { ok: boolean; finished: boolean; solvable?: boolean; failReason?: string };
|
||||
88→ undoStep: () => void;
|
||||
89→ retryPuzzle: () => void;
|
||||
90→ abandonPuzzle: () => void;
|
||||
91→ /** 自动解码 T1(技术解锁后由 tick 调用) */
|
||||
92→ autoDecodeTick: () => void;
|
||||
93→
|
||||
94→ // 探险
|
||||
95→ startExpedition: () => { ok: boolean; reason?: string };
|
||||
96→ resolveCurrentNode: () => ExpeditionResult | null;
|
||||
97→ advanceNode: () => void;
|
||||
98→ abortExpedition: () => void;
|
||||
99→
|
||||
100→ // 技术
|
||||
101→ buyTech: (techId: string) => boolean;
|
||||
102→
|
||||
103→ // 飞升
|
||||
104→ doPrestige: () => { newBp: number } | null;
|
||||
105→
|
||||
106→ // 星图天文台
|
||||
107→ chooseConstellationPerk: (perkId: string) => boolean;
|
||||
108→ rerollPerkChoices: () => void;
|
||||
109→
|
||||
110→ // 成就
|
||||
111→ checkAchievements: () => Achievement[];
|
||||
112→ consumeAchievementQueue: () => Achievement[];
|
||||
113→
|
||||
114→ // 设置
|
||||
115→ toggleTheme: () => void;
|
||||
116→ toggleSound: () => void;
|
||||
117→
|
||||
118→ // 深空信标奖励发放(v0.5)
|
||||
119→ grantBeaconReward: (insights: number, contact: number) => void;
|
||||
120→
|
||||
121→ // 深空巡航奖励发放(v0.6 — 实时 Canvas 玩法)
|
||||
122→ grantCruiseReward: (rewards: { crystals?: number; insights?: number; contact?: number }) => void;
|
||||
123→
|
||||
124→ // 派生
|
||||
125→ canPrestige: () => boolean;
|
||||
126→}
|
||||
127→
|
||||
128→type Store = GameState & GameActions & {
|
||||
129→ _lastAutoDecode: number;
|
||||
130→ _lastSpawn: number;
|
||||
131→ _combo: number;
|
||||
132→ _lastPulse: number;
|
||||
133→ _achievementQueue: Achievement[];
|
||||
134→ _tideEvents: { started?: TideType; ended?: TideType; silenceCompensation?: number }[];
|
||||
135→};
|
||||
136→
|
||||
137→/** 计算并写回产能字段 */
|
||||
138→function syncStats(state: Partial<GameState>) {
|
||||
139→ const s = recomputeStats(state);
|
||||
140→ return {
|
||||
141→ crystalsPerSec: s.crystalsPerSec,
|
||||
142→ crystalCap: s.crystalCap,
|
||||
143→ pulsePower: s.pulsePower,
|
||||
144→ offlineEff: s.offlineEff,
|
||||
145→ insightMult: s.insightMult,
|
||||
146→ contactRateMult: s.contactRateMult,
|
||||
147→ autoDecode: s.autoDecode,
|
||||
148→ decodeStepsBonus: s.decodeStepsBonus,
|
||||
149→ };
|
||||
150→}
|
||||
151→
|
||||
152→/**
|
||||
153→ * 深空信标进度追踪(v0.5)。
|
||||
154→ * 在游戏关键动作(脉冲/解码/探险/BOSS/洞见)后调用,按今日挑战类型增量更新进度。
|
||||
155→ * 进度独立存储于 localStorage(echo-nexus-beacon-prog-v1),不污染 GameState。
|
||||
156→ * @returns 若刚完成则返回 true(供 UI 触发通知)
|
||||
157→ */
|
||||
158→function trackBeacon(
|
||||
159→ type: "pulse" | "decode" | "expedition" | "boss" | "insight",
|
||||
160→ delta: number
|
||||
161→): boolean {
|
||||
162→ if (typeof window === "undefined") return false;
|
||||
163→ try {
|
||||
164→ const challenge: BeaconDailyChallenge = generateDailyChallenge();
|
||||
165→ if (challenge.type !== type) return false;
|
||||
166→ const current: BeaconDailyProgress = loadDailyProgress();
|
||||
167→ if (current.completedAt !== null) return false; // 已完成不再累加
|
||||
168→ const { justCompleted } = addBeaconProgress(current, challenge, delta);
|
||||
169→ return justCompleted;
|
||||
170→ } catch {
|
||||
171→ return false;
|
||||
172→ }
|
||||
173→}
|
||||
174→
|
||||
175→/** 检查并解锁叙事碎片 */
|
||||
176→function checkFragments(state: GameState): string[] {
|
||||
177→ const unlocked: string[] = [];
|
||||
178→ for (const f of FRAGMENTS) {
|
||||
179→ if (!state.fragments[f.id] && state.totalDecoded >= f.threshold) {
|
||||
180→ state.fragments[f.id] = true;
|
||||
181→ unlocked.push(f.id);
|
||||
182→ }
|
||||
183→ }
|
||||
184→ return unlocked;
|
||||
185→}
|
||||
186→
|
||||
187→export const useGameStore = create<Store>()(
|
||||
188→ persist(
|
||||
189→ (set, get) => ({
|
||||
190→ ...createInitialState(),
|
||||
191→ _lastAutoDecode: Date.now(),
|
||||
192→ _lastSpawn: Date.now(),
|
||||
193→ _combo: 0,
|
||||
194→ _lastPulse: 0,
|
||||
195→ _achievementQueue: [],
|
||||
196→ _tideEvents: [],
|
||||
197→
|
||||
198→ init: () => {
|
||||
199→ const s = get();
|
||||
200→ const now = Date.now();
|
||||
201→ // 兼容旧存档:若存在不可解的活跃谜题(旧算法生成),直接清掉
|
||||
202→ let activePuzzle = s.activePuzzle;
|
||||
203→ if (activePuzzle && !isSolvable(activePuzzle)) {
|
||||
204→ // 把晶体放回队列,避免玩家卡死
|
||||
205→ const crystal: Crystal = {
|
||||
206→ id: `c_${now}_rec`,
|
||||
207→ tier: activePuzzle.tier,
|
||||
208→ value: CRYSTAL_VALUE[activePuzzle.tier].crystals,
|
||||
209→ createdAt: now,
|
||||
210→ };
|
||||
211→ activePuzzle = null;
|
||||
212→ set({
|
||||
213→ pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending),
|
||||
214→ });
|
||||
215→ }
|
||||
216→ // 兼容旧存档:补全 achievements / 星潮 / 星图 字段
|
||||
217→ const achievements = s.achievements ?? {};
|
||||
218→ const activeTide = s.activeTide ?? null;
|
||||
219→ const constellation = s.constellation ?? [];
|
||||
220→ const pendingPerkChoices = s.pendingPerkChoices ?? null;
|
||||
221→ // v0.4 编年史兼容:补全 chronicle / runStart / bossKills / starTidesEncountered
|
||||
222→ const migrated = migrateChronicleFields(s);
|
||||
223→ // 星图「能量共振」天赋 +1 能量上限
|
||||
224→ const cm = constellationBonuses(constellation);
|
||||
225→ const energyMax = (INITIAL_STATE.energyMax) + cm.energyMaxBonus;
|
||||
226→ // lastTideEnd:0 表示从未触发过星潮(首次游玩),保留 0 让 firstDelay 生效
|
||||
227→ const lastTideEndRaw = s.lastTideEnd ?? 0;
|
||||
228→ // 若旧存档有已过期的星潮,清掉
|
||||
229→ const tide = activeTide && activeTide.endsAt > now ? activeTide : null;
|
||||
230→ // 首次进入:补发离线收益
|
||||
231→ const elapsed = Math.max(0, (now - s.lastTick) / 1000);
|
||||
232→ if (elapsed > 5) {
|
||||
233→ const cap = 8 * 3600;
|
||||
234→ const secs = Math.min(elapsed, cap);
|
||||
235→ const gain = s.crystalsPerSec * secs * s.offlineEff;
|
||||
236→ const crystalsBefore = s.crystals;
|
||||
237→ const crystalsAfter = Math.min(s.crystalCap, s.crystals + gain);
|
||||
238→ // 设置离线报告(v0.5.2):通知 UI 弹出"欢迎回来"对话框
|
||||
239→ setPendingOfflineReport({
|
||||
240→ elapsedSec: secs,
|
||||
241→ rawElapsedSec: elapsed,
|
||||
242→ gain: crystalsAfter - crystalsBefore,
|
||||
243→ rate: s.crystalsPerSec,
|
||||
244→ eff: s.offlineEff,
|
||||
245→ capped: elapsed > cap,
|
||||
246→ crystalsBefore,
|
||||
247→ crystalsAfter,
|
||||
248→ crystalCap: s.crystalCap,
|
||||
249→ });
|
||||
250→ set({
|
||||
251→ crystals: crystalsAfter,
|
||||
252→ lastTick: now,
|
||||
253→ activePuzzle,
|
||||
254→ achievements,
|
||||
255→ activeTide: tide,
|
||||
256→ lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw,
|
||||
257→ constellation,
|
||||
258→ pendingPerkChoices,
|
||||
259→ energyMax,
|
||||
260→ chronicle: migrated.chronicle,
|
||||
261→ runStart: migrated.runStart,
|
||||
262→ bossKills: migrated.bossKills,
|
||||
263→ starTidesEncountered: migrated.starTidesEncountered,
|
||||
264→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation }),
|
||||
265→ });
|
||||
266→ } else {
|
||||
267→ set({ lastTick: now, activePuzzle, achievements, activeTide: tide, lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw, constellation, pendingPerkChoices, energyMax, chronicle: migrated.chronicle, runStart: migrated.runStart, bossKills: migrated.bossKills, starTidesEncountered: migrated.starTidesEncountered, ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation }) });
|
||||
268→ }
|
||||
269→ },
|
||||
270→
|
||||
271→ loadOnline: () => {
|
||||
272→ const s = get();
|
||||
273→ set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation }) });
|
||||
274→ },
|
||||
275→
|
||||
276→ hardReset: () => {
|
||||
277→ set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0, _achievementQueue: [], _tideEvents: [] });
|
||||
278→ },
|
||||
279→
|
||||
280→ tickTide: (now) => {
|
||||
281→ const s = get();
|
||||
282→ const tide = s.activeTide;
|
||||
283→ // 星图「星潮引导」减少间隙
|
||||
284→ const cm = constellationBonuses(s.constellation ?? []);
|
||||
285→ const gap = Math.max(15000, TIDE_CONFIG.gap + cm.tideGapDeltaSec * 1000);
|
||||
286→ // 1) 检查当前星潮是否结束
|
||||
287→ if (tide && now >= tide.endsAt) {
|
||||
288→ const endedType = tide.type;
|
||||
289→ // 寂静期补偿洞见
|
||||
290→ let silenceCompensation = 0;
|
||||
291→ if (tide.type === "silence") {
|
||||
292→ silenceCompensation = computeSilenceCompensation(tide);
|
||||
293→ }
|
||||
294→ const newInsights = s.insights + silenceCompensation;
|
||||
295→ const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { ended: endedType };
|
||||
296→ if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation;
|
||||
297→ set({
|
||||
298→ activeTide: null,
|
||||
299→ lastTideEnd: now,
|
||||
300→ insights: newInsights,
|
||||
301→ // 星潮结束后重算 stats(移除 contactRate/insight 修饰)
|
||||
302→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation }),
|
||||
303→ _tideEvents: [...s._tideEvents, event],
|
||||
304→ });
|
||||
305→ return event;
|
||||
306→ }
|
||||
307→ // 2) 检查是否该触发新星潮(间隙已过)
|
||||
308→ if (!tide) {
|
||||
309→ const since = now - s.lastTideEnd;
|
||||
310→ // 首次:以 createdAt 起算 firstDelay;之后以 lastTideEnd 起算 gap
|
||||
311→ const firstStart = s.lastTideEnd === 0 || s.lastTideEnd <= s.createdAt + 1000;
|
||||
312→ const need = firstStart ? TIDE_CONFIG.firstDelay : gap;
|
||||
313→ if (since >= need) {
|
||||
314→ const type = rollTide();
|
||||
315→ const newTide: StarTide = {
|
||||
316→ id: `tide_${now}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
317→ type,
|
||||
318→ startedAt: now,
|
||||
319→ endsAt: now + TIDE_CONFIG.duration,
|
||||
320→ };
|
||||
321→ const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { started: type };
|
||||
322→ // v0.4 编年史:累计遇到的星潮 ID(去重)
|
||||
323→ const tidesAll = s.starTidesEncountered ?? [];
|
||||
324→ const tideId = `tide_${type}`;
|
||||
325→ const newTidesAll = tidesAll.includes(tideId) ? tidesAll : [...tidesAll, tideId];
|
||||
326→ set({
|
||||
327→ activeTide: newTide,
|
||||
328→ starTidesEncountered: newTidesAll,
|
||||
329→ // 星潮开始后重算 stats(应用 contactRate/insight 修饰)
|
||||
330→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation }),
|
||||
331→ _tideEvents: [...s._tideEvents, event],
|
||||
332→ });
|
||||
333→ return event;
|
||||
334→ }
|
||||
335→ }
|
||||
336→ return null;
|
||||
337→ },
|
||||
338→
|
||||
339→ consumeTideEvents: () => {
|
||||
340→ const s = get();
|
||||
341→ if (s._tideEvents.length === 0) return [];
|
||||
342→ const items = s._tideEvents;
|
||||
343→ set({ _tideEvents: [] });
|
||||
344→ return items;
|
||||
345→ },
|
||||
346→
|
||||
347→ tick: (now) => {
|
||||
348→ const s = get();
|
||||
349→ const dt = Math.max(0, (now - s.lastTick) / 1000);
|
||||
350→ if (dt <= 0) return;
|
||||
351→
|
||||
352→ // 星潮产能修饰(即时乘)
|
||||
353→ const tideMod = getTideModifiers(s.activeTide);
|
||||
354→ const effCps = s.crystalsPerSec * tideMod.crystalsPerSecMult;
|
||||
355→ // 产能累加(仅闲置产能受仓库上限;探险奖励可超出)
|
||||
356→ const newCrystals =
|
||||
357→ s.crystals >= s.crystalCap
|
||||
358→ ? s.crystals // 已达/超上限,不再自动产出
|
||||
359→ : Math.min(s.crystalCap, s.crystals + effCps * dt);
|
||||
360→
|
||||
361→ // 自动产出待解码晶体(每 baseInterval 秒一颗,受技术/飞升略提速)
|
||||
362→ const bpBoost = 1 + s.blueprints.length * 0.03;
|
||||
363→ const spawnInterval = (CRYSTAL_SPAWN.baseIntervalSec / bpBoost) * 1000;
|
||||
364→ let pending = s.pendingCrystals;
|
||||
365→ let lastSpawn = s._lastSpawn;
|
||||
366→ if (
|
||||
367→ now - lastSpawn > spawnInterval &&
|
||||
368→ pending.length < CRYSTAL_SPAWN.maxPending
|
||||
369→ ) {
|
||||
370→ // 星图「晶体富集」提升 T2/T3 概率
|
||||
371→ const cm = constellationBonuses(s.constellation ?? []);
|
||||
372→ const tier = rollCrystalTierWithBonus(cm.t2t3BonusRate);
|
||||
373→ const crystal: Crystal = {
|
||||
374→ id: `c_${now}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
375→ tier,
|
||||
376→ value: CRYSTAL_VALUE[tier].crystals,
|
||||
377→ createdAt: now,
|
||||
378→ };
|
||||
379→ pending = [...pending, crystal];
|
||||
380→ lastSpawn = now;
|
||||
381→ }
|
||||
382→
|
||||
383→ // 能量恢复(探险系统)
|
||||
384→ let energy = s.energy;
|
||||
385→ let lastEnergyTick = s.lastEnergyTick;
|
||||
386→ if (energy < s.energyMax) {
|
||||
387→ const regen = computeEnergyRegen(lastEnergyTick, now, energy, s.energyMax);
|
||||
388→ energy = regen.energy;
|
||||
389→ lastEnergyTick = regen.lastTick;
|
||||
390→ } else {
|
||||
391→ lastEnergyTick = now;
|
||||
392→ }
|
||||
393→
|
||||
394→ set({
|
||||
395→ crystals: newCrystals,
|
||||
396→ lastTick: now,
|
||||
397→ pendingCrystals: pending,
|
||||
398→ _lastSpawn: lastSpawn,
|
||||
399→ energy,
|
||||
400→ lastEnergyTick,
|
||||
401→ });
|
||||
402→ },
|
||||
403→
|
||||
404→ pulse: () => {
|
||||
405→ const s = get();
|
||||
406→ const now = Date.now();
|
||||
407→ // 连击
|
||||
408→ let combo = 1;
|
||||
409→ if (now - s._lastPulse < 1500) {
|
||||
410→ combo = Math.min(10, s._combo + 1);
|
||||
411→ }
|
||||
412→ const mult = 1 + (combo - 1) * 0.15;
|
||||
413→ // 星潮脉冲威力修饰
|
||||
414→ const tideMod = getTideModifiers(s.activeTide);
|
||||
415→ const gain = s.pulsePower * mult * tideMod.pulsePowerMult;
|
||||
416→ set({
|
||||
417→ crystals: Math.min(s.crystalCap, s.crystals + gain),
|
||||
418→ _combo: combo,
|
||||
419→ _lastPulse: now,
|
||||
420→ });
|
||||
421→ // 深空信标:脉冲任务进度 +1
|
||||
422→ trackBeacon("pulse", 1);
|
||||
423→ return { gain, combo };
|
||||
424→ },
|
||||
425→
|
||||
426→ startDecode: (crystalId) => {
|
||||
427→ const s = get();
|
||||
428→ const crystal = s.pendingCrystals.find((c) => c.id === crystalId);
|
||||
429→ if (!crystal) return;
|
||||
430→ const puzzle = generatePuzzle(crystal.tier);
|
||||
431→ set({
|
||||
432→ activePuzzle: puzzle,
|
||||
433→ pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystalId),
|
||||
434→ });
|
||||
435→ },
|
||||
436→
|
||||
437→ clickNode: (nodeId) => {
|
||||
438→ const s = get();
|
||||
439→ if (!s.activePuzzle) return { ok: false, finished: false, failReason: "无活跃谜题" };
|
||||
440→ // 深拷贝谜题
|
||||
441→ const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle));
|
||||
442→ const res = tryClickNode(puzzle, nodeId);
|
||||
443→ if (res.ok) {
|
||||
444→ if (res.finished) {
|
||||
445→ // 结算奖励(星潮解码奖励修饰 + 星图「二周目经验」)
|
||||
446→ const tideMod = getTideModifiers(s.activeTide);
|
||||
447→ const cm = constellationBonuses(s.constellation ?? []);
|
||||
448→ const base = decodeRewards(puzzle.tier, s.insightMult, s.contactRateMult);
|
||||
449→ const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult;
|
||||
450→ const rewards = {
|
||||
451→ crystals: Math.round(base.crystals * finalMult),
|
||||
452→ insights: Math.round(base.insights * finalMult),
|
||||
453→ contact: +(base.contact * finalMult).toFixed(2),
|
||||
454→ };
|
||||
455→ const newTotal = s.totalDecoded + 1;
|
||||
456→ const newContact = Math.min(100, s.contact + rewards.contact);
|
||||
457→ const newInsights = s.insights + rewards.insights;
|
||||
458→ const newCrystals = s.crystals + rewards.crystals;
|
||||
459→ // 解锁碎片
|
||||
460→ const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } };
|
||||
461→ const unlocked = checkFragments(tentative);
|
||||
462→ set({
|
||||
463→ activePuzzle: null,
|
||||
464→ crystals: newCrystals,
|
||||
465→ insights: newInsights,
|
||||
466→ contact: newContact,
|
||||
467→ totalDecoded: newTotal,
|
||||
468→ fragments: tentative.fragments,
|
||||
469→ });
|
||||
470→ // 深空信标:解码 +1,洞见累计
|
||||
471→ trackBeacon("decode", 1);
|
||||
472→ trackBeacon("insight", rewards.insights);
|
||||
473→ return { ok: true, finished: true, failReason: unlocked.join(",") || undefined };
|
||||
474→ }
|
||||
475→ // 点击成功但未完成:检测当前局面是否仍可解
|
||||
476→ const solvable = isSolvable(puzzle);
|
||||
477→ set({ activePuzzle: puzzle });
|
||||
478→ return { ok: true, finished: false, solvable };
|
||||
479→ }
|
||||
480→ return res;
|
||||
481→ },
|
||||
482→
|
||||
483→ undoStep: () => {
|
||||
484→ const s = get();
|
||||
485→ if (!s.activePuzzle || s.activePuzzle.path.length === 0) return;
|
||||
486→ const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle));
|
||||
487→ const lastId = puzzle.path.pop();
|
||||
488→ if (lastId !== undefined) {
|
||||
489→ const node = puzzle.grid.find((n) => n.id === lastId);
|
||||
490→ if (node) node.used = false;
|
||||
491→ }
|
||||
492→ set({ activePuzzle: puzzle });
|
||||
493→ },
|
||||
494→
|
||||
495→ retryPuzzle: () => {
|
||||
496→ const s = get();
|
||||
497→ if (!s.activePuzzle) return;
|
||||
498→ set({ activePuzzle: resetPuz(s.activePuzzle) });
|
||||
499→ },
|
||||
500→
|
||||
501→ abandonPuzzle: () => {
|
||||
502→ const s = get();
|
||||
503→ if (!s.activePuzzle) return;
|
||||
504→ // 晶体放回队列末尾
|
||||
505→ const crystal: Crystal = {
|
||||
506→ id: `c_${Date.now()}_ret`,
|
||||
507→ tier: s.activePuzzle.tier,
|
||||
508→ value: CRYSTAL_VALUE[s.activePuzzle.tier].crystals,
|
||||
509→ createdAt: Date.now(),
|
||||
510→ };
|
||||
511→ set({
|
||||
512→ activePuzzle: null,
|
||||
513→ pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending),
|
||||
514→ });
|
||||
515→ },
|
||||
516→
|
||||
517→ autoDecodeTick: () => {
|
||||
518→ const s = get();
|
||||
519→ if (!s.autoDecode) return;
|
||||
520→ const now = Date.now();
|
||||
521→ // 星图「自动校准」减少自动解码周期
|
||||
522→ const cm = constellationBonuses(s.constellation ?? []);
|
||||
523→ const interval = Math.max(5000, 12000 + cm.autoDecodeIntervalDeltaSec * 1000);
|
||||
524→ if (now - s._lastAutoDecode < interval) return;
|
||||
525→ // 找一颗 T1 晶体自动解码
|
||||
526→ const idx = s.pendingCrystals.findIndex((c) => c.tier === 1);
|
||||
527→ if (idx < 0) return;
|
||||
528→ const crystal = s.pendingCrystals[idx];
|
||||
529→ const tideMod = getTideModifiers(s.activeTide);
|
||||
530→ const base = decodeRewards(1, s.insightMult, s.contactRateMult);
|
||||
531→ const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult;
|
||||
532→ const rewards = {
|
||||
533→ crystals: Math.round(base.crystals * finalMult),
|
||||
534→ insights: Math.round(base.insights * finalMult),
|
||||
535→ contact: +(base.contact * finalMult).toFixed(2),
|
||||
536→ };
|
||||
537→ const newTotal = s.totalDecoded + 1;
|
||||
538→ const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } };
|
||||
539→ checkFragments(tentative);
|
||||
540→ set({
|
||||
541→ pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystal.id),
|
||||
542→ crystals: s.crystals + rewards.crystals,
|
||||
543→ insights: s.insights + rewards.insights,
|
||||
544→ contact: Math.min(100, s.contact + rewards.contact),
|
||||
545→ totalDecoded: newTotal,
|
||||
546→ fragments: tentative.fragments,
|
||||
547→ _lastAutoDecode: now,
|
||||
548→ });
|
||||
549→ // 深空信标:自动解码也算进度
|
||||
550→ trackBeacon("decode", 1);
|
||||
551→ trackBeacon("insight", rewards.insights);
|
||||
552→ },
|
||||
553→
|
||||
554→ buyTech: (techId) => {
|
||||
555→ const s = get();
|
||||
556→ const node = TECH_TREE.find((t) => t.id === techId);
|
||||
557→ if (!node) return false;
|
||||
558→ const cur = s.tech[techId] ?? 0;
|
||||
559→ if (cur >= 1) return false; // v0.1 每节点 1 级
|
||||
560→ if (s.insights < node.cost) return false;
|
||||
561→ const newTech = { ...s.tech, [techId]: 1 };
|
||||
562→ set({
|
||||
563→ insights: s.insights - node.cost,
|
||||
564→ tech: newTech,
|
||||
565→ ...syncStats({ tech: newTech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation }),
|
||||
566→ });
|
||||
567→ return true;
|
||||
568→ },
|
||||
569→
|
||||
570→ // ============ 探险系统 ============
|
||||
571→ startExpedition: () => {
|
||||
572→ const s = get();
|
||||
573→ if (s.activeExpedition && !s.activeExpedition.finished) {
|
||||
574→ return { ok: false, reason: "已有进行中的探险" };
|
||||
575→ }
|
||||
576→ if (s.energy < EXPEDITION_CONFIG.energyCost) {
|
||||
577→ return { ok: false, reason: "能量不足" };
|
||||
578→ }
|
||||
579→ const tideMod = getTideModifiers(s.activeTide);
|
||||
580→ const power = computeExpeditionPower(s) + tideMod.expeditionPowerBonus;
|
||||
581→ const hp = computeExpeditionHp(s) + tideMod.expeditionHpBonus;
|
||||
582→ const seed = Math.floor(Math.random() * 1e9);
|
||||
583→ const expedition = generateExpedition(seed, power, hp);
|
||||
584→ set({
|
||||
585→ activeExpedition: expedition,
|
||||
586→ energy: s.energy - EXPEDITION_CONFIG.energyCost,
|
||||
587→ totalExpeditions: s.totalExpeditions + 1,
|
||||
588→ });
|
||||
589→ return { ok: true };
|
||||
590→ },
|
||||
591→
|
||||
592→ resolveCurrentNode: () => {
|
||||
593→ const s = get();
|
||||
594→ if (!s.activeExpedition || s.activeExpedition.finished) return null;
|
||||
595→ // 深拷贝
|
||||
596→ const exp = JSON.parse(JSON.stringify(s.activeExpedition));
|
||||
597→ const result = resolveNode(exp);
|
||||
598→ // 累计奖励
|
||||
599→ if (result.crystals) exp.rewards.crystals += result.crystals;
|
||||
600→ if (result.insights) exp.rewards.insights += result.insights;
|
||||
601→ if (result.contact) exp.rewards.contact += result.contact;
|
||||
602→ if (result.fragments) exp.rewards.fragments.push(...result.fragments);
|
||||
603→ if (result.hpDelta) exp.hp = Math.max(0, Math.min(exp.maxHp, exp.hp + result.hpDelta));
|
||||
604→ // 实时入账(玩家立即获得)
|
||||
605→ const newCrystals = s.crystals + (result.crystals || 0);
|
||||
606→ const newInsights = s.insights + (result.insights || 0);
|
||||
607→ const newContact = Math.min(100, s.contact + (result.contact || 0));
|
||||
608→ // 碎片解锁
|
||||
609→ const newFragments = { ...s.fragments };
|
||||
610→ if (result.fragments) {
|
||||
611→ for (const fid of result.fragments) newFragments[fid] = true;
|
||||
612→ }
|
||||
613→ // 日志
|
||||
614→ const logEntry = {
|
||||
615→ expeditionId: exp.id,
|
||||
616→ nodeType: exp.nodes[exp.currentNode]?.type || "combat",
|
||||
617→ result: result.log,
|
||||
618→ rewards: [
|
||||
619→ result.crystals ? `+${result.crystals}晶体` : "",
|
||||
620→ result.insights ? `+${result.insights}洞见` : "",
|
||||
621→ result.contact ? `+${result.contact.toFixed(1)}接触` : "",
|
||||
622→ result.hpDelta ? `${result.hpDelta > 0 ? "+" : ""}${result.hpDelta}生命` : "",
|
||||
623→ ].filter(Boolean).join(" "),
|
||||
624→ timestamp: Date.now(),
|
||||
625→ };
|
||||
626→ const newLog = [logEntry, ...s.expeditionLog].slice(0, 30);
|
||||
627→
|
||||
628→ if (result.ended) {
|
||||
629→ // 探险结束(胜利或失败)
|
||||
630→ exp.finished = true;
|
||||
631→ }
|
||||
632→
|
||||
633→ // v0.4 编年史:击破 BOSS 时累计计数
|
||||
634→ let bossKills = s.bossKills ?? 0;
|
||||
635→ if (
|
||||
636→ result.ended &&
|
||||
637→ result.endReason === "victory" &&
|
||||
638→ exp.nodes[exp.currentNode]?.type === "boss"
|
||||
639→ ) {
|
||||
640→ bossKills = bossKills + 1;
|
||||
641→ }
|
||||
642→
|
||||
643→ set({
|
||||
644→ activeExpedition: exp,
|
||||
645→ crystals: newCrystals,
|
||||
646→ insights: newInsights,
|
||||
647→ contact: newContact,
|
||||
648→ fragments: newFragments,
|
||||
649→ expeditionLog: newLog,
|
||||
650→ bossKills,
|
||||
651→ });
|
||||
652→ // 深空信标:探险完成(胜负都计)+ 洞见累计 + BOSS 击破
|
||||
653→ if (result.ended) {
|
||||
654→ trackBeacon("expedition", 1);
|
||||
655→ if (result.endReason === "victory" && exp.nodes[exp.currentNode]?.type === "boss") {
|
||||
656→ trackBeacon("boss", 1);
|
||||
657→ }
|
||||
658→ }
|
||||
659→ if (result.insights) trackBeacon("insight", result.insights);
|
||||
660→ return result;
|
||||
661→ },
|
||||
662→
|
||||
663→ advanceNode: () => {
|
||||
664→ const s = get();
|
||||
665→ if (!s.activeExpedition || s.activeExpedition.finished) return;
|
||||
666→ const exp = JSON.parse(JSON.stringify(s.activeExpedition));
|
||||
667→ const node = exp.nodes[exp.currentNode];
|
||||
668→ if (!node || !node.cleared) return; // 当前节点未结算不能前进
|
||||
669→ if (exp.currentNode >= exp.nodes.length - 1) return;
|
||||
670→ exp.currentNode++;
|
||||
671→ set({ activeExpedition: exp });
|
||||
672→ },
|
||||
673→
|
||||
674→ abortExpedition: () => {
|
||||
675→ const s = get();
|
||||
676→ if (!s.activeExpedition) return;
|
||||
677→ const exp = JSON.parse(JSON.stringify(s.activeExpedition));
|
||||
678→ exp.finished = true;
|
||||
679→ const logEntry = {
|
||||
680→ expeditionId: exp.id,
|
||||
681→ nodeType: "rest" as const,
|
||||
682→ result: "探险队主动撤退,保留已获奖励。",
|
||||
683→ rewards: "",
|
||||
684→ timestamp: Date.now(),
|
||||
685→ };
|
||||
686→ set({
|
||||
687→ activeExpedition: exp,
|
||||
688→ expeditionLog: [logEntry, ...s.expeditionLog].slice(0, 30),
|
||||
689→ });
|
||||
690→ },
|
||||
691→
|
||||
692→ doPrestige: () => {
|
||||
693→ const s = get();
|
||||
694→ if (s.contact < CONTACT.prestigeMin) return null;
|
||||
695→ const newBp = computeNewBlueprints(s);
|
||||
696→ const next = performPrestige(s);
|
||||
697→ // 星图「能量共振」提升上限
|
||||
698→ const cm = constellationBonuses(next.constellation ?? []);
|
||||
699→ const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus;
|
||||
700→ set({
|
||||
701→ ...next,
|
||||
702→ energyMax,
|
||||
703→ ...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements, activeTide: next.activeTide, constellation: next.constellation }),
|
||||
704→ _lastAutoDecode: Date.now(),
|
||||
705→ _lastSpawn: Date.now(),
|
||||
706→ _combo: 0,
|
||||
707→ _lastPulse: 0,
|
||||
708→ _tideEvents: [],
|
||||
709→ });
|
||||
710→ return { newBp };
|
||||
711→ },
|
||||
712→
|
||||
713→ chooseConstellationPerk: (perkId) => {
|
||||
714→ const s = get();
|
||||
715→ if (!s.pendingPerkChoices || !s.pendingPerkChoices.includes(perkId)) return false;
|
||||
716→ const perk = getPerk(perkId);
|
||||
717→ if (!perk) return false;
|
||||
718→ if (s.constellation?.includes(perkId)) return false;
|
||||
719→ const newConstellation = [...(s.constellation ?? []), perkId];
|
||||
720→ const cm = constellationBonuses(newConstellation);
|
||||
721→ const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus;
|
||||
722→ // v0.4 编年史:回填最近一条 entry 的 perksThisAscension
|
||||
723→ const chronicle = s.chronicle ?? [];
|
||||
724→ let newChronicle = chronicle;
|
||||
725→ if (chronicle.length > 0) {
|
||||
726→ const lastEntry = chronicle[chronicle.length - 1];
|
||||
727→ const updatedLast = withPerks(lastEntry, [perkId]);
|
||||
728→ newChronicle = [...chronicle.slice(0, -1), updatedLast];
|
||||
729→ }
|
||||
730→ set({
|
||||
731→ constellation: newConstellation,
|
||||
732→ pendingPerkChoices: null,
|
||||
733→ energyMax,
|
||||
734→ chronicle: newChronicle,
|
||||
735→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: newConstellation }),
|
||||
736→ });
|
||||
737→ return true;
|
||||
738→ },
|
||||
739→
|
||||
740→ rerollPerkChoices: () => {
|
||||
741→ const s = get();
|
||||
742→ if (!s.pendingPerkChoices) return;
|
||||
743→ const choices = rollPerkChoices(s.constellation ?? []);
|
||||
744→ if (choices.length > 0) set({ pendingPerkChoices: choices });
|
||||
745→ },
|
||||
746→
|
||||
747→ checkAchievements: () => {
|
||||
748→ const s = get();
|
||||
749→ const newlyUnlocked: Achievement[] = [];
|
||||
750→ const updated = { ...s.achievements };
|
||||
751→ let crystals = s.crystals;
|
||||
752→ let insights = s.insights;
|
||||
753→ let contact = s.contact;
|
||||
754→ let statsDirty = false;
|
||||
755→ for (const a of ACHIEVEMENTS) {
|
||||
756→ if (updated[a.id]) continue;
|
||||
757→ if (a.check(s)) {
|
||||
758→ updated[a.id] = true;
|
||||
759→ newlyUnlocked.push(a);
|
||||
760→ // 发放即时奖励
|
||||
761→ if (a.reward.crystals) crystals += a.reward.crystals;
|
||||
762→ if (a.reward.insights) insights += a.reward.insights;
|
||||
763→ if (a.reward.contact) contact = Math.min(100, contact + a.reward.contact);
|
||||
764→ if (a.reward.crystalsPerSecPct || a.reward.insightPct) statsDirty = true;
|
||||
765→ }
|
||||
766→ }
|
||||
767→ if (newlyUnlocked.length === 0) return [];
|
||||
768→ set({
|
||||
769→ achievements: updated,
|
||||
770→ crystals,
|
||||
771→ insights,
|
||||
772→ contact,
|
||||
773→ ...(statsDirty
|
||||
774→ ? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated, activeTide: s.activeTide, constellation: s.constellation })
|
||||
775→ : {}),
|
||||
776→ _achievementQueue: [...s._achievementQueue, ...newlyUnlocked],
|
||||
777→ });
|
||||
778→ return newlyUnlocked;
|
||||
779→ },
|
||||
780→
|
||||
781→ consumeAchievementQueue: () => {
|
||||
782→ const s = get();
|
||||
783→ if (s._achievementQueue.length === 0) return [];
|
||||
784→ const items = s._achievementQueue;
|
||||
785→ set({ _achievementQueue: [] });
|
||||
786→ return items;
|
||||
787→ },
|
||||
788→
|
||||
789→ canPrestige: () => get().contact >= CONTACT.prestigeMin,
|
||||
790→
|
||||
791→ toggleTheme: () => set({ theme: get().theme === "dark" ? "light" : "dark" }),
|
||||
792→ toggleSound: () => set({ soundOn: !get().soundOn }),
|
||||
793→
|
||||
794→ // 深空信标:发放每日挑战奖励(v0.5)
|
||||
795→ grantBeaconReward: (insights, contact) => {
|
||||
796→ const s = get();
|
||||
797→ set({
|
||||
798→ insights: s.insights + Math.round(insights),
|
||||
799→ contact: Math.min(100, s.contact + contact),
|
||||
800→ });
|
||||
801→ },
|
||||
802→
|
||||
803→ // 深空巡航:发放实时玩法奖励(v0.6)
|
||||
804→ grantCruiseReward: (rewards) => {
|
||||
805→ const s = get();
|
||||
806→ const addCrystals = rewards.crystals ?? 0;
|
||||
807→ const addInsights = rewards.insights ?? 0;
|
||||
808→ const addContact = rewards.contact ?? 0;
|
||||
809→ set({
|
||||
810→ crystals: Math.min(s.crystalCap, s.crystals + addCrystals),
|
||||
811→ insights: s.insights + Math.round(addInsights),
|
||||
812→ contact: Math.min(100, s.contact + addContact),
|
||||
813→ });
|
||||
814→ },
|
||||
815→ }),
|
||||
816→ {
|
||||
817→ name: "echo-nexus-save-v1",
|
||||
818→ storage: createJSONStorage(() => localStorage),
|
||||
819→ // 不持久化临时字段
|
||||
820→ partialize: (s) => {
|
||||
821→ const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, ...rest } = s;
|
||||
822→ void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue; void _tideEvents;
|
||||
823→ return rest as GameState;
|
||||
824→ },
|
||||
825→ }
|
||||
826→ )
|
||||
827→);
|
||||
828→
|
||||
829→/** 选择器:未解锁碎片中下一个门槛 */
|
||||
830→export function nextFragmentThreshold(totalDecoded: number): number | null {
|
||||
831→ for (const f of FRAGMENTS) {
|
||||
832→ if (totalDecoded < f.threshold) return f.threshold;
|
||||
833→ }
|
||||
834→ return null;
|
||||
835→}
|
||||
836→
|
||||
837→export { FRAGMENTS, PRESTIGE, TECH_TREE };
|
||||
838→
|
||||
839→// 开发期调试:暴露 store 到 window,便于 QA 测试
|
||||
840→if (typeof window !== "undefined" && process.env.NODE_ENV !== "production") {
|
||||
841→ (window as unknown as { __gameStore?: typeof useGameStore }).__gameStore = useGameStore;
|
||||
842→}
|
||||
843→
|
||||
+258
-257
@@ -4,267 +4,268 @@
|
||||
|
||||
---
|
||||
|
||||
## 项目当前状态描述 / 判断
|
||||
## 一、项目当前状态描述 / 判断
|
||||
|
||||
- **阶段**:v0.5.1 已完成(**游戏已上线 Gitea Pages** + 新手教程系统 + UI 优化)
|
||||
### 概况
|
||||
- **项目**:回响星核 / Echo Nexus — 深空考古放置策略游戏
|
||||
- **一句话**:自治无人机采矿 → 解码记忆晶体(原创共振谜题)→ 拼凑碎片叙事 → 飞升多周目。差异化:解码谜题驱动叙事涌现,全息晶体 + 深空粒子美学。
|
||||
- **当前版本**:**v0.7**(CrystalOrb Canvas 粒子系统 + 角色属性系统)
|
||||
- **在线游玩**:https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
|
||||
- **已完成**:
|
||||
- 游戏市场调研(多源交叉验证,2024-2026 数据)
|
||||
- GDD 游戏设计文档(世界观、核心循环、六大系统、MVP 范围)
|
||||
- 技术架构设计(栈选型、目录结构、状态模型)
|
||||
- Gitea 仓库 `Super_Z/echo-nexus` 已创建,文档已推送
|
||||
- v0.1 MVP:放置采矿 + 解码共振谜题 + 技术树 + 记忆图谱 + 飞升 + 统计
|
||||
- v0.1.1:解码可解性修复(路径构造法生成器)
|
||||
- v0.2:遗迹探险肉鸽系统(能量/探险力/6 节点/程序化路径/BOSS)
|
||||
- v0.2.1:程序化音频 + 14 项成就 + 视觉打磨
|
||||
- v0.3:星潮事件系统(6 种星潮 + 全屏叠层 + 程序化音效)
|
||||
- v0.3.1:星图天文台元进程(6 类 × 3 = 18 天赋 + 飞升后 3 选 1 draft + Canvas 星图)
|
||||
- v0.4:回响编年史(5 纪元循环命名 + 模板化叙事 + 时间轴 UI + 6 项新成就 + 飞升 BUG 修复)
|
||||
- v0.5:深空信标(每日挑战 + 本地排行榜 Top20 + 7 标签页 + 编年史历史 BUG 修复)
|
||||
- **v0.5.1:静态部署 Gitea Pages + 新手教程系统(7 步聚光灯引导)+ UI 间距优化**
|
||||
- **当前目标**:回复 Issue #1 #28 + UI 进一步优化 + 云存档/云排行榜 + 全 5 纪元叙事
|
||||
- **技术栈**:Next.js 16 + TypeScript + Tailwind + shadcn/ui + Canvas + Zustand
|
||||
- **仓库**:https://git.atdunbg.xyz/Super_Z/echo-nexus
|
||||
- **技术栈**:Next.js 16 (App Router) + TypeScript + Tailwind + shadcn/ui + Canvas 2D + Zustand + Web Audio API
|
||||
- **定时任务**:每 15 分钟一次 `webDevReview`(`fixed_rate` + `"900"` 秒,priority=10,job_id 228266)。正常完成不会被删除,无需自持续机制。
|
||||
|
||||
## 游戏核心概念(一句话)
|
||||
### 状态判断
|
||||
- dev 服务器运行正常(HTTP 200,编译 < 250ms)
|
||||
- v0.7 两大新功能已完成:CrystalOrb Canvas 粒子系统升级(VLM 8/10)+ 角色属性系统(VLM 7/10)
|
||||
- 系统总体稳定,无阻塞性 bug,可继续推进新功能
|
||||
|
||||
深空考古放置策略游戏:自治无人机采矿→解码记忆晶体(原创共振谜题)→拼凑碎片叙事→飞升多周目。差异化:解码谜题驱动叙事涌现,全息晶体+深空粒子美学。
|
||||
### 已完成版本里程碑(精简)
|
||||
| 版本 | 核心内容 |
|
||||
|------|---------|
|
||||
| v0.1 | MVP:放置采矿 + 解码共振谜题 + 技术树 + 记忆图谱 + 飞升 + 统计 |
|
||||
| v0.1.1 | 解码可解性修复(路径构造法生成器,100% 可解率)|
|
||||
| v0.2 | 遗迹探险肉鸽系统(能量/探险力/6 节点/程序化路径/BOSS)|
|
||||
| v0.2.1 | 程序化音频(15 种音效)+ 14 项成就 + 视觉打磨 |
|
||||
| v0.3 | 星潮事件系统(6 种星潮 + 全屏叠层 + 程序化音效)|
|
||||
| v0.3.1 | 星图天文台元进程(6 类 × 3 = 18 天赋 + 飞升后 3 选 1 draft + Canvas 星图)|
|
||||
| v0.4 | 回响编年史(5 纪元循环命名 + 模板化叙事 + 时间轴 UI + 6 项新成就 + 飞升 BUG 修复)|
|
||||
| v0.5 | 深空信标(每日挑战 + 本地排行榜 Top20 + 7 标签页 + 编年史历史 BUG 修复)|
|
||||
| v0.5.1 | 静态部署 Gitea Pages + 新手教程系统(7 步聚光灯引导)+ UI 间距优化 |
|
||||
| v0.5.2 | 离线收益报告弹窗 + 标签页 UI 重设计 + persist rehydrate 竞态修复 |
|
||||
| v0.6 | 深空巡航 Canvas 2D 实时玩法 + UI 偏移重叠修复 |
|
||||
| **v0.7** | **CrystalOrb Canvas 粒子系统升级 + 角色属性系统(四维)** |
|
||||
|
||||
## 当前目标 / 已完成的修改 / 验证结果
|
||||
|
||||
### 已完成
|
||||
- 调研报告、GDD、技术架构文档 → 已提交 Gitea
|
||||
- v0.1 MVP 实现:放置采矿 + 解码共振谜题 + 技术树 + 记忆图谱 + 飞升 + 统计
|
||||
- 仓库地址:https://git.atdunbg.xyz/Super_Z/echo-nexus
|
||||
|
||||
### v0.1.1 解码系统修复(用户反馈「大量无法通过」)
|
||||
- **根因**:generatePuzzle 随机填色未保证有解,产生大量死局
|
||||
- **修复**:路径构造法生成器(100% 可解率)+ isSolvable 精确检测 + undoStep 撤销 + 卡死提示横幅 + canStartFrom 可行起点高亮 + 旧存档兼容
|
||||
- **验证**:3000/3000 可解;agent-browser T1/T2 端到端通过;死路检测/撤销/可行起点高亮全部准确
|
||||
- 详见 docs/repo/docs/04-解码系统修复-v0.1.1.md
|
||||
|
||||
### v0.2 遗迹探险肉鸽系统(本轮完成)
|
||||
- **新功能**:能量系统(3/5, 45s恢复) + 探险力/生命 + 6种节点类型(战斗/宝藏/抉择/解谜/休整/BOSS) + 程序化路径(5-7节点) + 实时奖励 + 失败保留 + 主动撤退 + 探险日志
|
||||
- **配套修复**:HP clamp + 探险奖励绕过仓库上限 + 仓库满仓UX警告 + 闲置产能上限优化
|
||||
- **UI**:探险标签页(入口态/进行态) + 节点路径图可视化 + 事件卡 + 生命/能量条
|
||||
- **验证**:全流程通过(出发→探索→前进→BOSS→结束),奖励绕过cap验证(101>50),HP clamp验证
|
||||
- 详见 docs/repo/docs/05-遗迹探险系统-v0.2.md
|
||||
|
||||
### v0.2.1 程序化音频 + 成就系统 + 视觉打磨(本轮完成)
|
||||
- **QA 结论**:v0.2 全系统稳定,解码/探险/技术树/通知均正常,无 bug,故推进新功能
|
||||
- **程序化音频系统**(`src/lib/game/audio.ts`):Web Audio API 零资源文件,15 种音效(脉冲/解码按色变调/探险/飞升/成就/界面),受 soundOn 开关控制,首次交互后初始化 AudioContext
|
||||
- **成就系统**(`src/lib/game/achievements.ts`):14 项成就,跨周目永久产能/洞见加成,Toast+音效通知,每秒由主循环检测
|
||||
- `GameState.achievements` 新字段;`recomputeStats` 聚合三层加成(技术+蓝图+成就)
|
||||
- 旧存档兼容:init() 补全 achievements={}
|
||||
- UI:5 列标签栏新增「成就」页 + AchievementsPanel + AchievementNotifier
|
||||
- **视觉打磨**:脉冲粒子爆发(6-12粒子径向发散) + 扩散环 + 浮动数字按连击变色(绿→紫→琥)
|
||||
- **Gitea 工单**:处理 Issue #1「就一直连连看?」——回复说明 6 大玩法层 + 路线图
|
||||
- **验证**:agent-browser 全流程通过;成就 3/14 解锁;lint 零错误;HTTP 200
|
||||
- 详见 docs/repo/docs/06-音频与成就系统-v0.2.1.md
|
||||
|
||||
### v0.3 星潮事件系统(本轮完成)
|
||||
- **动机**:回应 Issue #1「玩法单一」反馈,为放置循环注入动态变化
|
||||
- **6 种星潮**(`src/lib/game/starTide.ts`):晶体潮(产能×2)/谐振风暴(解码×1.6)/遗迹共振(探险+5力+30血)/虚空低语(洞见×2)/星核悸动(接触×3脉冲×2)/寂静期(产能×0.5,结束补偿洞见)
|
||||
- **触发**:首次 30s,常规间隙 40s,持续 75s,权重抽取,飞升重置
|
||||
- **修饰器应用**:产能/脉冲/解码奖励即时乘;洞见/接触率进 recomputeStats 缓存;探险力/血量即时加
|
||||
- **UI**(`src/components/game/StarTide.tsx`):顶部指示器芯片(图标+名称+倒计时) + 全屏背景叠层(色带+呼吸光) + Toast通知 + Footer联动
|
||||
- **音效**:tideStart(神秘扫频) + tideEnd(柔和消退),程序化合成
|
||||
- **QA**:agent-browser 全流程通过(触发/指示器/叠层/寂静期补偿 50→81 洞见);VLM 视觉确认;lint 零错误
|
||||
- 详见 docs/repo/docs/07-星潮事件系统-v0.3.md
|
||||
|
||||
### v0.3.1 星图天文台 · 元进程天赋系统(本轮完成)
|
||||
- **动机**:再次回应 Issue #1「就一直连连看?」——加入 Slay the Spire 式飞升后 3 选 1 天赋 draft,跨周目永久生效,增加策略深度 + 视觉变化
|
||||
- **核心模块**(`src/lib/game/constellation.ts`):
|
||||
- 6 大星座 × 3 颗星 = **18 个天赋**:永动矿脉(翠)/光谱矩阵(玫)/远征星图(琥)/接触回响(紫)/虚空市场(灰)/宇宙回响(金)
|
||||
- `constellationBonuses()` 聚合 19 项修饰器(产能/上限/脉冲/洞见/解码步数/自动解码周期/探险生命/探险力/能量上限/接触率/飞升门槛/蓝图全属性/离线效率/T2T3概率/星潮间隙/飞升礼包/解码奖励/全局产能)
|
||||
- `rollPerkChoices()` 智能抽取:保证 3 个来自不同类别
|
||||
- `getConstellationLayout()` 六边形布局 + `getStarsInCategory()` 三角形排布
|
||||
- **状态扩展**(`types.ts` + `config.ts`):`GameState.constellation: string[]` + `pendingPerkChoices: string[] | null`
|
||||
- **引擎接入**(`engine.ts`):`recomputeStats` 聚合三层加成(技术+蓝图+成就+星图);`performPrestige` 触发天赋选择 + 飞升礼包补偿;新增 `rollCrystalTierWithBonus()`
|
||||
- **探险系统接入**(`expedition.ts`):`computeExpeditionPower` + `computeExpeditionHp` 应用星座修饰器
|
||||
- **Store 接入**(`gameStore.ts`):
|
||||
- 新增 `chooseConstellationPerk(perkId)` + `rerollPerkChoices()` 动作
|
||||
- `tickTide` 应用星潮间隙缩减;`autoDecodeTick` 应用周期缩减;`tick` 应用 T2/T3 概率补偿;`clickNode`/`autoDecodeTick` 应用解码奖励倍率
|
||||
- 旧存档兼容:init() 补全 constellation=[] / pendingPerkChoices=null / energyMax 加成
|
||||
- **UI 组件**:
|
||||
- `ConstellationPanel.tsx`:Canvas 动态星图(六边形 6 星座 × 3 星点,闪烁/光晕/十字光线/连接线/中心星核呼吸)+ Hover 提示 + 类别图例 + 已解锁列表
|
||||
- `ConstellationDialog.tsx`:飞升后自动弹出,3 卡片 draft + 重新抽取 + 选中粒子动画
|
||||
- `PrestigeDialog.tsx`:增加「星图觉醒预告」卡片
|
||||
- `page.tsx`:6 列标签栏(探险/技术/星图/图谱/成就/统计)+ 顶部「觉醒」按钮(pendingPerkChoices 存在时脉冲提示)+ 统计面板加星图天赋条目
|
||||
- **音频**:新增 `constellation` SFX(上升琶音 C5→E5→G5→C6 + 高频闪光 2093Hz)
|
||||
- **成就**:新增 2 项 —— `ach_constellation_1`「星图初绘」(+30 洞见·产能+5%) / `ach_constellation_6`「六分星辉」(产能+12%·洞见+12%)
|
||||
- **QA 验证**(agent-browser + VLM):
|
||||
- 星图标签渲染正确,6 类别图例齐全
|
||||
- 设置 pendingPerkChoices 后对话框自动弹出,3 卡片显示不同类别
|
||||
- 点击天赋 → constellation 数组增加,crystalsPerSec 提升,pending 清空,对话框关闭
|
||||
- 重新抽取 → 3 张新卡片(不同类别)
|
||||
- 多天赋叠加:c_min_1 + c_con_1 → crystalsPerSec 1.416, contactRateMult 1.2
|
||||
- 6 类别各 1 天赋 → 自动解锁「六分星辉」成就(Toast 弹出)
|
||||
- VLM 视觉确认:星图 Canvas 渲染正确,单星点亮(绿色永动矿脉)
|
||||
- **lint 零错误;HTTP 200;编译 < 200ms**
|
||||
- 详见 docs/repo/docs/08-星图天文台系统-v0.3.1.md
|
||||
|
||||
### v0.3.1 收尾(本轮提交)
|
||||
- 推送 Gitea:3 个 commit(submodule 文档 + 主仓功能 + 清理)
|
||||
- 回复 Issue #1:列出 6 大玩法层 + v0.3.1 新增 + 路线图
|
||||
- 暴露 dev 期 store 调试钩子 `window.__gameStore`(仅 non-production)
|
||||
- 目标提示增加星图天赋引导(飞升后觉醒时显示)
|
||||
|
||||
### v0.4 回响编年史 · 跨周目叙事时间轴(本轮完成)
|
||||
- **QA 发现关键 BUG 并修复**:PrestigeDialog 的 `disabled={newBp <= 0 || confirming}` 逻辑错误——`confirming=true` 后按钮被永久禁用,玩家**永远无法完成飞升**!修复为 `disabled={confirming && !canReconfirm}` + 900ms 冷却窗口
|
||||
- **修复 UX**:`newBp=0` 时不再永久禁用飞升按钮(v0.3.1 星图+v0.4 编年史已为飞升提供动机),按钮文字改为「飞升(无新蓝图)」
|
||||
- **新功能:回响编年史**(`src/lib/game/chronicle.ts` + `src/components/game/ChronicleDialog.tsx`)
|
||||
- 每次飞升自动铭刻一条 ChronicleEntry,记录纪元名/持续时间/解码数/技术数/探险数/BOSS 击破/星潮列表/天赋觉醒/里程碑
|
||||
- 5 纪元循环命名(第一纪元·觉醒之晨 → 第五纪元·起源重述),朝 v0.5 全 5 纪元叙事推进
|
||||
- 模板化 lore 生成:开场 → 核心活动 → 星潮段落 → 觉醒段落 → 里程碑 → 收尾
|
||||
- 9 种里程碑检测:首次飞升/首杀 BOSS/单周目解码 10+/30+/技术狂人/星潮亲历者/星图六分/星图十二宫
|
||||
- RunStartSnapshot 机制:飞升时快照当前累计计数,新周目 delta = 当前 - runStart
|
||||
- **状态扩展**(`types.ts` + `config.ts`):`GameState.chronicle: ChronicleEntry[]` + `runStart: RunStartSnapshot` + `bossKills: number`(累计)+ `starTidesEncountered: string[]`(累计去重)
|
||||
- **引擎接入**(`engine.ts`):`performPrestige` 在重置前构建编年史条目,追加到 chronicle(上限 50),重置 runStart 为新周目快照
|
||||
- **Store 接入**(`gameStore.ts`):
|
||||
- `init()` 调用 `migrateChronicleFields` 补全旧存档
|
||||
- `tickTide` 新星潮触发时追加 `tide_${type}` 到 starTidesEncountered(去重)
|
||||
- `resolveCurrentNode` BOSS 战胜利时 `bossKills++`
|
||||
- `chooseConstellationPerk` 调用 `withPerks` 回填最近一条 entry 的 perksThisAscension 并重新生成 lore
|
||||
- **UI 组件**(`ChronicleDialog.tsx`):
|
||||
- 顶部 BookOpen 图标按钮 + badge 显示条目数
|
||||
- 全屏对话框,深紫黑渐变 + 顶部三色装饰条
|
||||
- 顶部总览统计:飞升次数/BOSS 击破/星潮亲历
|
||||
- 垂直时间轴:左侧渐变线 + 5 纪元循环色节点(翠/玫/琥/紫/金)+ 最新条目 ping 动画
|
||||
- 卡片含:#编号 / 纪元名(textShadow 光晕)/ 时间戳+持续时长 / 衬线叙事文本 / 6 项统计芯片 / 星潮徽章 / 天赋徽章 / 里程碑徽章
|
||||
- 入场动画:fade-in + slide-up,错峰 80ms
|
||||
- 空状态:根据 ascensions 是否 > 0 显示不同提示
|
||||
- **音频**:新增 `chronicle`(G3+C4+G4+C5+G5 多层泛音深沉钟声)+ `chronicleOpen`(A4→D5→A5 翻页声);飞升成功延迟 600ms 触发 chronicle sfx
|
||||
- **成就**:新增 6 项 —— `ach_chronicle_1`「首部编年」(+40洞见·产能+6%) / `ach_chronicle_3`「三纪元回响」(产能+10%·洞见+10%) / `ach_chronicle_5`「五纪元闭环」(产能+15%·洞见+15%) / `ach_boss_1`「首杀维度」(+25洞见·产能+4%) / `ach_boss_5`「维度猎手」(产能+10%·洞见+8%) / `ach_tides_all`「星潮亲历者」(产能+8%·洞见+8%)
|
||||
- **统计面板**:新增 BOSS 击破/星潮亲历/编年史条目 3 行
|
||||
- **QA 验证**(agent-browser + VLM):
|
||||
- 飞升 BUG 修复验证:第一次点飞升 → 900ms 冷却 → 第二次点击成功触发,星图觉醒对话框弹出
|
||||
- 编年史条目创建验证:ascensions 1→2, chronicle 0→1;选天赋后 perksThisAscension=["c_cos_2"] 正确回填
|
||||
- 多条目验证:第二次飞升后 chronicle 1→2,新条目 lore 正确使用「星核悸动」中文名(修复了 tide_ruins 显示 bug)
|
||||
- 5 纪元循环色正确显示(第二纪元玫色 / 第三纪元紫色)
|
||||
- VLM 视觉评估 8/10:时间轴清晰、纪元颜色区分明显、信息密度合理、无瑕疵
|
||||
- 成就解锁:ach_chronicle_1 + ach_prestige_3 自动检测通过
|
||||
- **lint 零错误;HTTP 200;编译 < 250ms**
|
||||
- **Gitea 推送**:2 个 commit(主仓功能 + submodule 文档)
|
||||
- 详见 docs/repo/docs/09-回响编年史系统-v0.4.md
|
||||
|
||||
### v0.5 深空信标 · 每日挑战 + 本地排行榜(本轮完成)
|
||||
- **QA 发现并修复编年史历史 BUG**:v0.4 之前创建的编年史条目 lore 中显示 `tide_ruins` 等原始键名而非「遗迹共振」(最新条目已修复,旧条目 lore 文本一次性生成未回填)。修复方案:新增 `regenerateLoreFromEntry(entry)`,在 ChronicleDialog 显示时从 entry 结构化数据重新生成 lore,保证历史与未来条目命名一致
|
||||
- **新功能:深空信标系统**(`src/lib/game/beacon.ts` ~290 行 + `src/components/game/BeaconPanel.tsx` ~280 行)
|
||||
- **每日挑战**:基于 UTC 日期 key 的 FNV-1a 种子 → mulberry32 PRNG,确定性生成(同一天全球同一挑战)
|
||||
- **5 种挑战类型**:解码协议 / 远征指令 / 脉冲任务 / 猎杀契约 / 洞见采集
|
||||
- **3 档难度**(加权):常规信标 ×1.0(55%) / 异常波动 ×1.6(33%) / 奇点回响 ×2.4(12%)
|
||||
- **得分公式**:完成度 × 1000 × 难度倍率 + 速度奖励 max(0, 500 - 用时秒×0.5)
|
||||
- **进度追踪**:独立 localStorage(`echo-nexus-beacon-prog-v1`),不污染 GameState;在 pulse/clickNode/autoDecodeTick/resolveCurrentNode 后调用 `trackBeacon(type, delta)`
|
||||
- **本地排行榜**:Top 20,按得分降序,奖牌图标(金/银/铜)+ 难度色点 + 今日记录高亮
|
||||
- **倒计时**:距 UTC 次日 0 点 HH:MM:SS,每秒更新
|
||||
- **Store 接入**(`gameStore.ts`):
|
||||
- 新增 `trackBeacon()` 模块级辅助函数(按今日挑战类型增量更新)
|
||||
- 新增 `grantBeaconReward(insights, contact)` action(领取时发放奖励到 GameState)
|
||||
- pulse / clickNode 完成 / autoDecodeTick / resolveCurrentNode(探险结束/BOSS击破/洞见获取)后接入 trackBeacon
|
||||
- **UI 接入**(`page.tsx`):
|
||||
- 第 7 个标签页「信标」(grid-cols-6 → grid-cols-7)
|
||||
- `beaconClaimable` 状态(2s 轮询,可领取时标签页显示绿点)
|
||||
- StatsPanel 新增「信标最高分」行
|
||||
- 版本号 v0.4 → v0.5
|
||||
- **UI 设计细节**:
|
||||
- 挑战卡片:难度色渐变背景 + 呼吸光动画(beacon-glow keyframes)+ 双层脉冲环动画(beacon-pulse-ring)
|
||||
- 进度条带难度色填充
|
||||
- 排行榜行:奖牌图标 + 难度色点 + 类型名 + 完成标记✓ + 用时 + 得分(难度色)
|
||||
- 难度图例三档横排
|
||||
- **QA 验证**(agent-browser + VLM):
|
||||
- 编年史历史 BUG 修复:第二纪元 lore 现正确显示「遗迹共振、晶体潮、虚空低语、谐振风暴」
|
||||
- 信标标签页渲染:挑战卡片 + 倒计时 08:47:12 + 难度图例齐全
|
||||
- 进度追踪:解码 1 颗晶体 → 洞见采集进度 0→12
|
||||
- 领取流程:完成 → 领取按钮亮 → 点击 → contact +5.1(13.47→18.57)→ 排行榜生成条目「1m30s 1.46K」→ 按钮变「已领取」
|
||||
- 统计面板「信标最高分」行显示 1.46K
|
||||
- 可领取时标签页绿点提示
|
||||
- VLM 视觉评估:高美观度,颜色搭配佳,无重叠 bug
|
||||
- 全系统回归:探险/解码/星潮/成就/星图/编年史均正常
|
||||
- **lint 零错误;HTTP 200;编译 < 250ms**
|
||||
- 详见 docs/repo/docs/10-深空信标系统-v0.5.md
|
||||
|
||||
### v0.5.1 静态部署 + 新手教程系统 + UI 优化(本轮完成)
|
||||
- **里程碑:游戏正式上线 Gitea Pages!** 玩家无需下载编译,直接访问 https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/ 即可游玩
|
||||
- **背景**:回应 Issue #1 评论 #28(atdunbg 反馈"如何游玩?需要教程?UI 太紧凑遮挡字体")
|
||||
- **静态导出配置**(`next.config.ts`):
|
||||
- 环境变量 `BUILD_EXPORT=true` 切换 `output: export`(dev 不受影响)
|
||||
- `basePath: /Super_Z/echo-nexus` + `trailingSlash: true` 适配 Gitea Pages 子路径
|
||||
- `images.unoptimized: true`(静态导出禁用图片优化)
|
||||
- 移除 `src/app/api/route.ts`(静态导出不支持 API routes)
|
||||
- 新增 `bun run build:static` 脚本
|
||||
- `out/.nojekyll` 防 Jekyll 忽略 `_next` 目录
|
||||
- **Gitea Pages 部署流程**:`bun run build:static` → `cd out && git init -b gh-pages` → `git push -f origin gh-pages`
|
||||
- **新功能:新手教程系统**(`src/lib/game/tutorial.ts` + `src/components/game/TutorialOverlay.tsx`)
|
||||
- **7 步引导**:欢迎 → 脉冲扫描 → 谐振解码 → 技术树 → 遗迹探险 → 飞升 → 完成
|
||||
- **聚光灯高亮**:4 块 div 挖洞遮罩 + 目标元素呼吸光边框(`tut-pulse` keyframes)
|
||||
- **动作检测**:rAF 轮询 store 状态,检测 pulse/decode-start/tech-buy/expedition-start 自动推进
|
||||
- **提示气泡**:紫粉渐变边框 + 顶部三色装饰条 + 步骤进度条(1/7)+ 上一步/跳过/下一步按钮
|
||||
- **首次自动弹窗**:localStorage `echo-nexus-tutorial-v1` 标记,600ms 延迟等 mount
|
||||
- **设置重看**:SettingsDialog 新增"重看教程"按钮,dispatch `echo-nexus-tutorial-restart` 事件
|
||||
- **UI 优化**(基于 VLM 视觉分析):
|
||||
- **ResourceBar 间距**:`gap-2 → gap-x-3/4`,分隔线改渐变透明→白→透明,`shrink-0` 防图标挤压,`whitespace-nowrap` 防文字断行,`tabular-nums` 数字等宽对齐
|
||||
- **SettingsDialog**:新增"在线游玩"gitea-pages 链接(绿色高亮)+ 教程重看卡片
|
||||
- **globals.css**:新增 `tut-pulse` + `tut-pop-in` 动画 keyframes
|
||||
- **data-tut 属性锚点**:crystal-orb / decode-panel / tab-expedition / tab-tech / prestige-btn
|
||||
- **QA 验证**(agent-browser + VLM,部署站点):
|
||||
- 静态站点 HTTP 200,HTML 完整,资源路径含 basePath 正确
|
||||
- 首次访问教程自动弹出(hasWelcome:true, hasNext:true)
|
||||
- 点击"下一步"→ 步骤 2/7,高亮聚焦框框住中央晶体球(VLM 确认 8/10)
|
||||
- 点击"跳过"→ 教程关闭,游戏正常加载
|
||||
- 设置→重看教程→ 弹窗重新打开回 1/7
|
||||
- VLM 确认弹窗位置合理、遮罩半透明、紫色高亮边框、按钮清晰
|
||||
- 核心交互(点击晶体 +8)正常
|
||||
- **lint 零错误**(修复 2 处 react-hooks/set-state-in-effect,改用 rAF 异步轮询)
|
||||
- **Gitea 推送**:2 个 commit(main 源码 + gh-pages 静态资源)
|
||||
- **dev 服务器 Turbopack 缓存不稳定问题**:偶发 `Unable to open static sorted file` + 进程被杀,需 `rm -rf .next` 重启。静态导出构建稳定可靠
|
||||
|
||||
### v0.5.2 离线收益系统 + 标签页 UI 重设计 + persist 竞态修复(本轮完成)
|
||||
- **新功能:离线收益报告弹窗**(`src/lib/game/offlineReport.ts` + `src/components/game/OfflineReportDialog.tsx`)
|
||||
- 玩家离开后回来,自动弹出"欢迎回来,无人机"弹窗
|
||||
- 显示:离线采集获得的晶体数(+1.44K)+ 离线时长(1小时2分)+ 采集速率(0.4/s)+ 离线效率(50%)
|
||||
- 当前晶体进度条(1.45K / 5.00K)+ 仓库满仓警告
|
||||
- 领取收益按钮 + 程序化音效
|
||||
- 模块级存储 + 自定义事件(`echo-nexus-offline-report`),不污染 GameState schema
|
||||
- **关键 BUG 修复:persist rehydrate 竞态**(`src/hooks/useGameLoop.ts`)
|
||||
- **根因**:Zustand persist 即使用同步 localStorage,rehydrate 也是异步(`Promise.resolve` 包装)。init 在 useEffect 中执行时,rehydrate 可能未完成,`s.lastTick` 是 INITIAL_STATE 的 undefined,`elapsed = NaN`,不进入离线补发分支 → `setPendingOfflineReport` 不调用 → 弹窗不弹
|
||||
- **修复**:useGameLoop 用 `useGameStore.persist.hasHydrated()` 检查,若未 rehydrated 则 `onFinishHydration(doInit)` + 500ms 兜底 setTimeout
|
||||
- **验证**:通过 5 轮调试(window.__initTrace / __lastOfflineReport / __offlineCheck / __loopMounted 标记法)定位
|
||||
- **UI 优化:标签页重设计**(`src/app/page.tsx`)
|
||||
- 图标+文字纵向排列(`flex-col`),图标 h-3.5 w-3.5,文字 leading-none
|
||||
- 每个标签独立主题色 glow:探险(琥珀)/技术(翠)/星图(玫)/图谱(青)/成就(琥珀)/信标(翠)/统计(灰)
|
||||
- 通知圆点增大到 h-2 w-2 + `ring-1 ring-black/50` 描边
|
||||
- active 状态阴影 `shadow-[0_0_12px_rgba(...)]`
|
||||
- **QA 验证**(agent-browser + VLM):
|
||||
- 离线弹窗 UI 视觉评分 9/10(标题/晶体数/时长/速率/按钮/进度条全部正确)
|
||||
- 标签页 UI 评分 7/10(VLM 建议强化颜色区分,已用 per-tab glow 实现)
|
||||
- lint 零错误
|
||||
- **Gitea 推送**:main 分支 1 commit + gh-pages 分支静态资源
|
||||
- **定时任务重置**:job_id 227933(cron `0 0/15 * * * ?`,每15分钟 webDevReview)
|
||||
|
||||
### 进行中
|
||||
- [ ] 持续迭代:云存档+云排行榜(v0.5+)、全5纪元手写叙事节点(v0.5+)、socket 多人同步星潮(后续)
|
||||
- [ ] 回复 Issue #1 #28:告知已上线 Gitea Pages + 教程系统 + UI 优化(v0.5.1 已回复 #29)
|
||||
|
||||
## 未解决问题或风险 / 下一阶段优先事项
|
||||
|
||||
- v0.3 星潮为单机版(原计划 socket 全局事件),后续可扩展为多人同步
|
||||
- v0.5 信标排行榜为本地版(纯 localStorage),后续 v0.5+ 升级为云排行榜需后端 API
|
||||
- v0.5 每日挑战仅 1 个/天,后续可加入"周挑战"或"信标链"(连续完成 N 天奖励)
|
||||
- Issue #1 玩家反馈已通过 v0.3 星潮 + v0.3.1 星图 + v0.4 编年史 + v0.5 信标 + v0.5.1 教程+上线 五层回应
|
||||
- v0.2 探险 BOSS 战胜率较低(基础探索力 10 vs BOSS 难度 5-7,胜率约 25%),需玩家投资探险技术
|
||||
- 探险能量恢复较慢(45s/点),后续可加技术提升恢复速度
|
||||
- 编年史上限 50 条,超过自动丢弃最早的;v0.4 之前的飞升无回填(空状态有对应提示)
|
||||
- 5 纪元叙事内容目前为模板化生成,v0.5+ 将加入手写剧情节点
|
||||
- dev 服务器 Turbopack 缓存偶发损坏,静态导出构建更稳定
|
||||
- **下一阶段优先**:
|
||||
1. 回复 Issue #1 #28(atdunbg 等待游玩方式答复)
|
||||
2. UI 进一步优化(标签页图标/文字比例、视觉层次强化)
|
||||
3. 云存档+云排行榜(v0.5+)
|
||||
4. 全 5 纪元手写叙事节点(v0.5+)
|
||||
5. socket 多人同步星潮
|
||||
|
||||
## 定时任务
|
||||
- 已设置:每 15 分钟 webDevReview(自动 QA + 迭代开发,job_id: 227933,cron `0 0/15 * * * ?`,tz Asia/Shanghai)
|
||||
- 上一轮 job_id 227909 已失效(列表为空),已重新创建
|
||||
### 核心系统清单(8 大系统)
|
||||
1. **放置采矿** — 中央晶体球 + 主动脉冲 + 连击加成(`CrystalOrb.tsx`)
|
||||
2. **谐振解码** — 原创路径谜题,路径构造法 100% 可解(`DecodeArray.tsx` + `engine.ts`)
|
||||
3. **技术树** — 三层科技解锁,提升产能/解码/探险(`TechTree.tsx`)
|
||||
4. **遗迹探险** — 肉鸽系统,6 种节点 + 程序化路径 + BOSS(`ExpeditionPanel.tsx` + `expedition.ts`)
|
||||
5. **星潮事件** — 6 种动态事件,为放置循环注入变化(`StarTide.tsx` + `starTide.ts`)
|
||||
6. **星图天文台** — 飞升后 3 选 1 天赋 draft,跨周目永久生效,18 个天赋(`ConstellationPanel.tsx` + `constellation.ts`)
|
||||
7. **回响编年史** — 跨周目叙事时间轴,5 纪元循环命名(`ChronicleDialog.tsx` + `chronicle.ts`)
|
||||
8. **深空信标** — 每日挑战 + 本地排行榜 Top20(`BeaconPanel.tsx` + `beacon.ts`)
|
||||
9. **深空巡航** — Canvas 2D 实时玩法,飞船操控/陨石躲避/星门通关(`CruiseMode.tsx` + `cruise.ts`)【v0.6】
|
||||
10. **角色属性系统** — 四维属性(探索力/智慧/勇气/灵感),飞升获得属性点,影响所有系统(`AttributesPanel.tsx` + `attributes.ts`)【v0.7 新增】
|
||||
|
||||
---
|
||||
|
||||
## 二、当前目标 / 已完成的修改 / 验证结果
|
||||
|
||||
### v0.7 CrystalOrb Canvas 粒子系统升级(已完成)
|
||||
**动机**:P0 优先级 — 将中央晶体球从 CSS 动画升级为 Canvas 粒子视觉盛宴,提升视觉震撼。
|
||||
|
||||
**重写文件**:`src/components/game/CrystalOrb.tsx`
|
||||
- 从纯 CSS 动画升级为 Canvas 2D 粒子系统
|
||||
- **多层粒子**:
|
||||
- 环绕能量粒子(3层14/10/8个,emerald/fuchsia/rose三色,不同速度方向环绕晶核,带拖尾)
|
||||
- 环境星尘(40个,缓慢漂移 + 闪烁)
|
||||
- 点击爆发粒子(10-30个径向发散,颜色随连击等级变色)
|
||||
- 冲击波环(点击触发1-3层错峰扩散,连击≥3/5解锁更多层)
|
||||
- **晶核升级**:径向渐变辉光 + 3层六边形纹理旋转 + 高光 + 鼠标视差偏移
|
||||
- **呼吸动画**:晶核 + 外层辉光随时间呼吸(sin波)
|
||||
- **进度环**:SVG渐变环(emerald→fuchsia→rose)保留
|
||||
- **交互保留**:点击脉冲、连击Toast、浮动数字、音效、`data-tut="crystal-orb"` 锚点
|
||||
- **性能**:DPR cap 2,ResizeObserver 自适应,requestAnimationFrame 60fps
|
||||
|
||||
**QA 验证**(agent-browser + VLM):
|
||||
- VLM 视觉评分 **8/10**:晶体球清晰可见,多层辉光,环绕粒子动态自然,无渲染问题
|
||||
- 点击交互:冲击波环 ✅ + 爆发粒子 ✅,评分 7/10
|
||||
- lint 零错误;HTTP 200
|
||||
|
||||
### v0.7 角色属性系统(已完成)
|
||||
**动机**:P1 优先级 — 增加RPG深度,四维属性影响所有游戏系统。
|
||||
|
||||
**新增文件**:
|
||||
- `src/lib/game/attributes.ts`(~330行逻辑层):CharacterAttributes/AttributeProgress 类型、ATTRIBUTE_CONFIG 元信息、getAttributeBonus 两区加成公式(0-50线性+0.5%/点,50-100递减+0.2%/点)、getAllBonuses 12项修饰器、levelUpCheck 自动升级、computePrestigeAttrPoints、migrateAttributes 旧存档兼容
|
||||
- `src/components/game/AttributesPanel.tsx`(~330行 UI):四维卡片网格(小屏2×2/大屏1×4)、图标+中英文名+Lv.badge+数值/100+加成%+经验条+影响列表+分配按钮、顶部待分配点数badge闪烁、底部总览+12项修饰器明细
|
||||
|
||||
**修改文件**:
|
||||
- `types.ts`:GameState 新增 attributes/attributeProgress/pendingAttrPoints
|
||||
- `config.ts`:INITIAL_STATE 补全默认值
|
||||
- `engine.ts`:recomputeStats 聚合属性加成;performPrestige 发放属性点(ascensions×2+1)
|
||||
- `gameStore.ts`:新增 allocateAttribute/gainAttributeExp actions;pulse/clickNode/autoDecodeTick/resolveCurrentNode/grantCruiseReward/tickTide 全部接入属性经验;init() 调用 migrateAttributes
|
||||
- `achievements.ts`:新增 ach_attr_total_50 + ach_attr_max_100 两项成就
|
||||
- `page.tsx`:grid-cols-7→8,新增「角色」标签页(User图标),红点提示,StatsPanel新增5行属性行,版本号 v0.6→v0.7
|
||||
|
||||
**四维属性设计**:
|
||||
- 探索力(emerald):探险力+X%/巡航飞船速度+X%
|
||||
- 智慧(fuchsia):解码步数+X/洞见+X%/自动解码周期-X%
|
||||
- 勇气(amber):探险生命+X/BOSS胜率+X%/巡航护盾+X
|
||||
- 灵感(rose):接触率+X/星潮概率+X/脉冲连击+X%
|
||||
|
||||
**QA 验证**(agent-browser + VLM):
|
||||
- VLM 视觉评分 **7/10**(面板)/ **8/10**(待分配状态):四维卡片可见、数值/等级/加成/经验条齐全、四色全息
|
||||
- 功能验证:setState pendingAttrPoints=5 → 显示5点待分配 → 点击分配按钮 → exploration 0→1, pending 5→4 ✅
|
||||
- lint 零错误;HTTP 200
|
||||
|
||||
---
|
||||
|
||||
### v0.6 深空巡航 Canvas 2D 实时玩法(历史记录)
|
||||
**动机**:回应用户反馈「玩法太单调(全是点点点)」+「视觉震撼不足」——首次引入实时操作玩法。
|
||||
|
||||
**新增文件**:
|
||||
- `src/lib/game/cruise.ts`(~520 行逻辑层)
|
||||
- 类型系统:CruiseEntity 联合类型(ship/asteroid/storm/crystal/insight/beacon/stargate/particle 共 8 种)
|
||||
- mulberry32 + FNV-1a 种子化 RNG(`cruiseSeed(level)`)
|
||||
- `generateLevel`:程序化生成陨石带(8+lvl×2)、虚空风暴(1+lvl/2)、晶体碎片(5+lvl)、洞见光球(2+lvl/3)、信标(1+lvl/4)、星门
|
||||
- `updateCruise`:物理推进 + 碰撞检测 + 粒子系统 + 震动 + 600ms 无敌帧
|
||||
- `computeRewards`:crystals=碎片×(8+lvl×2)、insights=洞见×(2+lvl×0.5)、contact=信标×(1.5+lvl×0.3);通关×1.5、失败×0.5
|
||||
- 独立 localStorage `echo-nexus-cruise-v1`:最高分/累计通关/最高关卡/累计奖励/最近 20 局
|
||||
|
||||
- `src/components/game/CruiseMode.tsx`(~830 行渲染层)
|
||||
- 全屏 fixed inset-0 z-50 Canvas,DPR cap 2,resize 监听
|
||||
- 3 层视差星空:远(100星)/中(60星)/近(30星),独立漂移 + 飞船速度视差 + 闪烁
|
||||
- 8 种实体全部 ctx.shadowBlur 辉光绘制
|
||||
- 粒子系统:尾焰/收集/碰撞/烟花,上限 200
|
||||
- 屏幕震动 280ms 衰减;低护盾(<30%)红色边框脉冲
|
||||
- HUD(HTML 叠层,glass+backdrop-blur,80ms 节流):护盾/能量/分数/用时/收集计数
|
||||
- 控制:桌面 WASD/方向键 8 方向 + 移动端虚拟摇杆 + P 暂停 + Esc 退出 + 失焦自动暂停
|
||||
- 阶段:ready → playing → won/lost(结算明细 + 奖励 + 下一关/重试/返回)
|
||||
|
||||
**修改文件**:
|
||||
- `src/app/page.tsx`:header 新增 amber 主题「巡航」按钮(Navigation 图标)+ cruiseOpen state + 条件渲染 CruiseMode
|
||||
- `src/store/gameStore.ts`:新增 `grantCruiseReward({crystals?, insights?, contact?})` action(crystals 受 crystalCap 限制,contact 受 100 上限)
|
||||
- 版本号 v0.5.2 → v0.6
|
||||
|
||||
**UI 偏移/重叠 BUG 修复**(3 处):
|
||||
- `DecodeArray.tsx`:移除 className 冲突的 `-translate-x-1/2 -translate-y-1/2`,统一用 inline transform 定位+缩放
|
||||
- `AchievementsPanel.tsx`:移除内层 `max-h-[300px]`,改 `flex-1 min-h-0` 自适应父容器
|
||||
- `page.tsx`:调整三个 section 的 min-h,避免小屏单列时总高度超过手机视口
|
||||
|
||||
**色彩规范**:严格遵循 emerald(#34d399) / rose(#fb7185) / amber(#fbbf24) / fuchsia(#e879f9) 四色全息色谱,零蓝色/靛色
|
||||
|
||||
**QA 验证**(agent-browser + VLM):
|
||||
- lint 零错误;dev 服务器 HTTP 200;编译 < 250ms
|
||||
- VLM 确认主界面:巡航按钮存在、晶体球居中、解码区正常、无重叠偏移
|
||||
- VLM 确认巡航玩法:飞船可见(绿色三角形)、HUD 完整、陨石+星空背景、粒子特效(黄色尾焰+紫色辉光)
|
||||
- WASD 控制飞船移动,按 W 直冲星门 → 通关,奖励 +30 晶体(2×10×1.5=30 计算正确)
|
||||
- localStorage 正确记录 highScore/totalRuns/totalWins/bestLevel
|
||||
- 奖励同步 gameStore(grantCruiseReward,满仓时 cap 逻辑正确)
|
||||
- 退出返回主界面,7 标签页 + 晶体球 + 解码面板均完好
|
||||
|
||||
---
|
||||
|
||||
## 三、未解决问题或风险 / 下一阶段优先事项
|
||||
|
||||
### 已知问题 / 风险
|
||||
1. **dev 服务器 Turbopack 缓存偶发损坏**:`Unable to open static sorted file` + 进程被杀,需 `rm -rf .next` 重启。静态导出构建稳定可靠。
|
||||
2. **定时任务调度类型**:必须用 `fixed_rate` + `"900"` 秒(15分钟),不要用 cron 表达式。`webDevReview` 类型任务正常完成不会被删除,只有抛出未捕获异常才会被标记失败。
|
||||
3. **v0.3 星潮为单机版**:原计划 socket 全局事件,后续可扩展为多人同步。
|
||||
4. **v0.5 信标排行榜为本地版**(纯 localStorage):升级云排行榜需后端 API。
|
||||
5. **v0.5 每日挑战仅 1 个/天**:可加入"周挑战"或"信标链"(连续完成 N 天奖励)。
|
||||
6. **探险 BOSS 战胜率较低**:基础探索力 10 vs BOSS 难度 5-7,胜率约 25%,需玩家投资探险技术。
|
||||
7. **探险能量恢复较慢**:45s/点,可加技术提升恢复速度。
|
||||
8. **编年史上限 50 条**:超过自动丢弃最早的;v0.4 之前飞升无回填(空状态有提示)。
|
||||
9. **5 纪元叙事为模板化生成**:v0.5+ 将加入手写剧情节点。
|
||||
10. **CrystalOrb 仍为 CSS 动画**:v0.6 未完成 Canvas 粒子系统升级(上下文截断),是下一阶段第一优先级。
|
||||
|
||||
### 下一阶段优先级
|
||||
1. **🔴 P0 — CrystalOrb Canvas 粒子系统升级**:中央晶体球从 CSS 动画升级为 Canvas 粒子视觉(能量流动/晶体共振/脉冲冲击波/环境粒子),提升视觉震撼。文件:`src/components/game/CrystalOrb.tsx`,保留对 `data-tut="crystal-orb"` 锚点。
|
||||
2. **🟠 P1 — 角色属性系统**:探索力/智慧/勇气/灵感四维属性,影响探险/解码/星潮/巡航,增加 RPG 深度。
|
||||
3. **🟠 P1 — 巡航玩法增强**:BOSS 战关卡 / 事件选择节点 / 更多关卡类型 / 道具掉落。
|
||||
4. **🟡 P2 — UI 样式细节打磨**:标签页图标/文字比例、视觉层次强化、移动端响应式进一步优化。
|
||||
5. **🟡 P2 — 云存档 + 云排行榜**:v0.5+ 后端 API(需评估是否引入 mini-service)。
|
||||
6. **🟢 P3 — 全 5 纪元手写叙事节点**:替换模板化 lore。
|
||||
7. **🟢 P3 — socket 多人同步星潮**:全局事件共享。
|
||||
|
||||
### 定时任务(fixed_rate · 900s)
|
||||
- **当前 job_id**: 228266(2026-06-24 04:31 创建)
|
||||
- **配置**: `fixed_rate` + `"900"`(15分钟间隔),`priority=10`(高优先级),`payload.kind=webDevReview`
|
||||
- **任务名**: Echo Nexus - 任务审查与持续迭代
|
||||
- **正常完成不会被删除**,只有未捕获异常会标记失败。若任务列表为空,需用 cron create 重建(schedule.kind=fixed_rate, expr="900", priority=10, payload.kind=webDevReview)。
|
||||
- **job_id 历史**: 227909(失效) → 228130(失效) → 228172(失效) → 228186(失效) → 228218(删除重建) → 228236(cron版,删除重建) → 228266(当前,fixed_rate版)
|
||||
|
||||
---
|
||||
|
||||
## 定时任务描述模板(webDevReview · fixed_rate)
|
||||
|
||||
> 重建参数:schedule.kind=`fixed_rate`, schedule.expr=`"900"`, params.priority=`10`, payload.kind=`webDevReview`
|
||||
|
||||
```
|
||||
请先判断当前项目状态,使用 agent-browser 进行必要测试和 QA,然后自主选择本轮最合适的工作重点并继续开发。
|
||||
|
||||
要求:
|
||||
1. 开始任务时查看 /home/z/my-project/worklog.md 了解实际进展,拆解详细todo
|
||||
2. 使用 agent-browser 进行测试和QA,有bug优先修复
|
||||
3. 如果稳定,自主提出新需求继续推进
|
||||
4. 样式要越做细节越多!!
|
||||
5. 功能要越做越多!!
|
||||
6. 工作告一段落更新 /home/z/my-project/worklog.md
|
||||
7. 项目结束后一定要把工作记录写在 worklog.md 里
|
||||
|
||||
交接文档建议包含三部分:
|
||||
- 项目当前状态描述/判断
|
||||
- 当前目标/已完成的修改/验证结果
|
||||
- 未解决问题或风险,建议下一阶段优先事项
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 历史详细版本记录(按需查阅 docs/repo/docs/)
|
||||
|
||||
- `04-解码系统修复-v0.1.1.md`
|
||||
- `05-遗迹探险系统-v0.2.md`
|
||||
- `06-音频与成就系统-v0.2.1.md`
|
||||
- `07-星潮事件系统-v0.3.md`
|
||||
- `08-星图天文台系统-v0.3.1.md`
|
||||
- `09-回响编年史系统-v0.4.md`
|
||||
- `10-深空信标系统-v0.5.md`
|
||||
|
||||
---
|
||||
|
||||
Task ID: 7
|
||||
Agent: full-stack-developer
|
||||
Task: 角色属性系统(探索力/智慧/勇气/灵感)
|
||||
|
||||
Work Log:
|
||||
- 阅读 worklog.md 了解 v0.6 项目背景,确认 8 大系统架构与四色全息色彩规范
|
||||
- 新建 `src/lib/game/attributes.ts` 逻辑层(~330 行):
|
||||
- 定义 CharacterAttributes/AttributeProgress/AttributeKey 类型 + ATTRIBUTE_CONFIG 元信息
|
||||
- getAttributeBonus 线性+递减两区加成公式(0-50 每 10 点 +5%,50-100 每点 +2%)
|
||||
- getAllBonuses 返回 12 个修饰器(探险力/巡航速度/解码步数/洞见/自动解码周期/探险生命/BOSS 胜率/巡航护盾/接触率/星潮触发/脉冲连击/产能)
|
||||
- levelUpCheck 自动跨多级 + expRequiredForLevel + computePrestigeAttrPoints + migrateAttributes 兼容
|
||||
- 扩展 `src/lib/game/types.ts` GameState:新增 attributes/attributeProgress/pendingAttrPoints
|
||||
- 扩展 `src/lib/game/config.ts` INITIAL_STATE:补全默认值(0 数值 + 空 progress)
|
||||
- 扩展 `src/lib/game/engine.ts`:
|
||||
- recomputeStats 末尾聚合 am.crystalsPerSecMult/insightMultAdd/contactRateMult/decodeStepsBonus
|
||||
- performPrestige 保留 attributes 数值,清空 attributeProgress,发放 ascensions × 2 + 1 点属性点
|
||||
- createInitialState 每次返回全新对象避免引用共享
|
||||
- 扩展 `src/store/gameStore.ts`(核心改造,10+ 处 syncStats 调用全部补 attributes 参数):
|
||||
- pulse:连击 ≥3 给灵感经验 + 脉冲威力乘灵感连击加成
|
||||
- clickNode 完成:给智慧经验(tier × 2)
|
||||
- autoDecodeTick:智慧经验 +1 + 自动解码周期受 am.autoDecodeIntervalMult 影响
|
||||
- startExpedition:探险力乘 am.expeditionPowerMult,生命加 am.expeditionHpBonus
|
||||
- resolveCurrentNode:BOSS 节点包装 RNG 提升 +am.bossWinRateBonus 胜率;战斗胜利给勇气+探索力经验
|
||||
- grantCruiseReward:按总奖励量给探索力+勇气经验
|
||||
- tickTide:灵感 am.tideTriggerBonus 缩短星潮间隙(上限 30%)
|
||||
- init() 调用 migrateAttributes 补全旧存档
|
||||
- 新增 allocateAttribute / gainAttributeExp 两个 action
|
||||
- 新建 `src/components/game/AttributesPanel.tsx` UI 面板(~330 行):
|
||||
- 四维卡片网格(小屏 2×2,大屏 1×4)
|
||||
- 每张卡片:图标 + 中英文名 + Lv.badge + 数值/100 + 加成% + 经验条 + 影响列表 + 「+分配」按钮
|
||||
- 顶部待分配点数 badge(pendingAttrPoints > 0 时 echo-pending-pulse 闪烁动画)
|
||||
- 底部总等级/总加成概览 + 12 个修饰器明细
|
||||
- 4 色辉光边框(emerald/fuchsia/amber/rose),顶角光晕装饰
|
||||
- 接入 `src/app/page.tsx`:
|
||||
- grid-cols-7 → grid-cols-8,新增第 8 个「角色」TabsTrigger(User 图标,渐变主题)
|
||||
- pendingAttrPoints > 0 时显示 rose 红点提示
|
||||
- 渲染 AttributesPanel
|
||||
- StatsPanel 新增 5 行属性行(探索力/智慧/勇气/灵感/待分配点)
|
||||
- 版本号 v0.6 → v0.7
|
||||
- 新增 `src/lib/game/achievements.ts` 2 项成就:
|
||||
- ach_attr_total_50(四维觉醒):总属性 ≥ 50 → 产能+6%/洞见+6%
|
||||
- ach_attr_max_100(维度精通):任一属性 100 → 产能+12%/洞见+10%
|
||||
- 写入 `agent-ctx/7-full-stack-developer.md` 工作记录
|
||||
- 最终验证:lint 零错误 + dev 服务器 HTTP 200 + 编译 < 250ms
|
||||
|
||||
Stage Summary:
|
||||
- ✅ 角色属性系统 v0.7 P1 全部完成
|
||||
- ✅ 新增 2 文件(attributes.ts + AttributesPanel.tsx),修改 6 文件(types/config/engine/gameStore/achievements/page)
|
||||
- ✅ 四维属性完整接入游戏各系统(探险/解码/脉冲/巡航/星潮)
|
||||
- ✅ 飞升属性点公式 = ascensions × 2 + 1;经验累积自动升级(每级 10×level)
|
||||
- ✅ 旧存档兼容(migrateAttributes 补全字段,同步 level 与 attributes)
|
||||
- ✅ lint 零错误 + HTTP 200 + 编译 < 250ms
|
||||
- ✅ 严格四色全息(emerald/fuchsia/amber/rose),零蓝色/靛色
|
||||
- ✅ 8 标签页 + 巡航按钮全部保留,data-tut 锚点未破坏
|
||||
- ✅ 待分配点数时标签页 rose 红点提示 + 顶部 badge 闪烁
|
||||
|
||||
Reference in New Issue
Block a user