From 4545fe380bd2f361d3b1080361882ede62a15bb0 Mon Sep 17 00:00:00 2001
From: Super_Z <1401203083@qq.com>
Date: Tue, 23 Jun 2026 21:00:42 +0000
Subject: [PATCH] =?UTF-8?q?v0.7:=20CrystalOrb=20Canvas=20=E7=B2=92?=
=?UTF-8?q?=E5=AD=90=E7=B3=BB=E7=BB=9F=E5=8D=87=E7=BA=A7=20+=20=E8=A7=92?=
=?UTF-8?q?=E8=89=B2=E5=B1=9E=E6=80=A7=E7=B3=BB=E7=BB=9F=EF=BC=88=E5=9B=9B?=
=?UTF-8?q?=E7=BB=B4=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CrystalOrb 升级(P0 视觉震撼):
- 从 CSS 动画重写为 Canvas 2D 粒子系统
- 环绕能量粒子(3层32个,emerald/fuchsia/rose,带拖尾)
- 环境星尘(40个,闪烁漂移)
- 点击爆发粒子 + 冲击波环(连击解锁更多层)
- 晶核辉光呼吸 + 3层六边形纹理 + 鼠标视差
- VLM 视觉评分 8/10
角色属性系统(P1 RPG 深度):
- 新增 attributes.ts 逻辑层 + AttributesPanel UI
- 四维属性:探索力/智慧/勇气/灵感(emerald/fuchsia/amber/rose)
- 两区加成公式(0-50线性,50-100递减)
- 飞升获得属性点(ascensions×2+1),手动分配
- 完成活动获得属性经验,自动升级
- 接入 pulse/clickNode/resolveCurrentNode/grantCruiseReward/tickTide
- 旧存档兼容(migrateAttributes)
- 新增 2 项成就 + 第 8 个标签页「角色」
- VLM 视觉评分 7-8/10
lint 零错误;HTTP 200
---
agent-ctx/7-full-stack-developer.md | 103 ++++
src/app/page.tsx | 24 +-
src/components/game/AttributesPanel.tsx | 334 ++++++++++++
src/components/game/CrystalOrb.tsx | 661 +++++++++++++++++++-----
src/lib/game/achievements.ts | 39 ++
src/lib/game/attributes.ts | 363 +++++++++++++
src/lib/game/config.ts | 14 +
src/lib/game/engine.ts | 44 +-
src/lib/game/types.ts | 5 +
src/store/gameStore.ts | 262 +++++++++-
worklog.md | 122 ++++-
11 files changed, 1812 insertions(+), 159 deletions(-)
create mode 100644 agent-ctx/7-full-stack-developer.md
create mode 100644 src/components/game/AttributesPanel.tsx
create mode 100644 src/lib/game/attributes.ts
diff --git a/agent-ctx/7-full-stack-developer.md b/agent-ctx/7-full-stack-developer.md
new file mode 100644
index 000000000..9cade0389
--- /dev/null
+++ b/agent-ctx/7-full-stack-developer.md
@@ -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] 数值,避免漂移
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 2c1ace376..b165fa51f 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -19,6 +19,7 @@ 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,
@@ -42,6 +43,7 @@ import {
Star,
Radio,
Navigation,
+ User,
} from "lucide-react";
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config";
import { ACHIEVEMENTS } from "@/lib/game/achievements";
@@ -74,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);
@@ -169,7 +172,7 @@ export default function Page() {
回响星核
- ECHO NEXUS · v0.6
+ ECHO NEXUS · v0.7
@@ -257,10 +260,10 @@ export default function Page() {
- {/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 */}
+ {/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 / 角色 */}
-
+
探险
@@ -304,6 +307,13 @@ export default function Page() {
统计
+
+
+ 角色
+ {pendingAttrPoints > 0 && (
+
+ )}
+
@@ -326,6 +336,9 @@ export default function Page() {
+
+
+
@@ -424,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 (
diff --git a/src/components/game/AttributesPanel.tsx b/src/components/game/AttributesPanel.tsx
new file mode 100644
index 000000000..165da8a31
--- /dev/null
+++ b/src/components/game/AttributesPanel.tsx
@@ -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
= {
+ 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 (
+
+
+
+ {/* 顶部:标题 + 待分配点数 */}
+
+
+
+ 驾驶员属性
+
+
+
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}
+
+
+
+ {/* 属性卡片网格:小屏 2×2,大屏 1×4 */}
+
+ {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 (
+
+ {/* 顶部光晕装饰 */}
+
+ {/* 标题行:图标 + 名称 + 等级 badge */}
+
+
+
+
+
+
+ {meta.name}
+
+
+ {meta.enName}
+
+
+
+ Lv.{value}
+
+
+
+ {/* 数值 + 加成 */}
+
+
+ {value}
+
+ /{ATTRIBUTE_HARD_CAP}
+
+
+
+ +{bonusPct.toFixed(1)}%
+
+
+
+ {/* 经验进度条 */}
+
+
+
+
+ {isMax ? "已满级" : `${curExp} / ${nextExp} EXP`}
+
+ {inDiminishing && !isMax && (
+ 递减区
+ )}
+
+
+
+ {/* 加成列表 */}
+
+ {meta.effects.map((eff, i) => (
+
+ ·
+ {eff}
+
+ ))}
+
+
+ {/* 分配按钮 */}
+
+
+ );
+ })}
+
+
+ {/* 底部:总览 */}
+
+
+
+ 总等级
+
+ {totalLvl}
+ /{ATTRIBUTE_HARD_CAP * 4}
+
+
+
+ 总加成
+
+ +{totalBonus.toFixed(1)}%
+
+
+
+
+ {/* 当前生效修饰器(细节展示) */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* 飞升获得属性点提示 */}
+
+ 每次飞升获得 飞升次数 × 2 + 1 点属性点;完成对应活动自动累积经验
+
+
+ );
+}
+
+function DetailLine({
+ label,
+ value,
+ color,
+}: {
+ label: string;
+ value: string;
+ color: string;
+}) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
diff --git a/src/components/game/CrystalOrb.tsx b/src/components/game/CrystalOrb.tsx
index 1af1a5efb..587ddef53 100644
--- a/src/components/game/CrystalOrb.tsx
+++ b/src/components/game/CrystalOrb.tsx
@@ -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([]);
- const [particles, setParticles] = useState([]);
- const [pulseAnim, setPulseAnim] = useState(0);
const idRef = useRef(0);
+ // Canvas refs
+ const canvasRef = useRef(null);
+ const containerRef = useRef(null);
+ const pulseAnimRef = useRef(0);
+ const [pulseAnim, setPulseAnim] = useState(0); // 触发 SVG 环动画
+
+ // 粒子状态 refs(不触发 re-render)
+ const energyParticlesRef = useRef([]);
+ const ambientParticlesRef = useRef([]);
+ const shockRingsRef = useRef([]);
+ const burstParticlesRef = useRef([]);
+
+ 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) => {
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) => {
+ 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 (
{/* 连击显示 */}
{combo > 1 && (
-
+
×{combo} 连击
)}