v0.7: CrystalOrb Canvas 粒子系统升级 + 角色属性系统(四维)

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
This commit is contained in:
2026-06-23 21:00:42 +00:00
parent 8474f4caa7
commit 4545fe380b
11 changed files with 1812 additions and 159 deletions
+244 -18
View File
@@ -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 {
// 生命周期
@@ -121,6 +132,10 @@ interface GameActions {
// 深空巡航奖励发放(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;
}
@@ -220,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;
@@ -261,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: () => {
@@ -282,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;
@@ -299,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;
@@ -327,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;
@@ -412,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,
@@ -420,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 };
},
@@ -459,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,
@@ -466,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);
@@ -520,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);
@@ -537,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,
@@ -545,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);
@@ -562,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;
},
@@ -577,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({
@@ -594,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;
@@ -613,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}晶体` : "",
@@ -632,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({
@@ -648,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);
}
}
@@ -700,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,
@@ -732,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;
},
@@ -771,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],
});
@@ -806,12 +954,90 @@ export const useGameStore = create<Store>()(
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",