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} 连击
)}