- {phase === "won" ? "MISSION COMPLETE" : "MISSION FAILED"}
+ {phase === "won" ? (result.isBossLevel ? "BOSS DEFEATED" : "MISSION COMPLETE") : "MISSION FAILED"}
关卡 L{result.level} · 用时 {Math.floor(result.durationSec / 60)}:
@@ -1277,7 +1838,9 @@ export function CruiseMode({ onClose }: { onClose: () => void }) {
{/* 奖励明细 */}
- {phase === "won" ? "通关奖励 (+50%)" : "残骸回收 (×50%)"}
+ {phase === "won"
+ ? result.isBossLevel ? "BOSS 击败奖励 (×3)" : "通关奖励 (+50%)"
+ : "残骸回收 (×50%)"}
@@ -1314,11 +1877,11 @@ export function CruiseMode({ onClose }: { onClose: () => void }) {
{phase === "won" && (
)}
)}
+
+ {/* ============ 升级选择阶段 ============ */}
+ {phase === "choosing" && (
+
+
+
+
REINFORCEMENT PROTOCOL
+
选择强化
+
星门共鸣释放能量,3 选 1 永久强化(本局有效)
+
+
+ {pendingChoices.map((card, idx) => {
+ const Icon = UPGRADE_ICON_MAP[card.icon] ?? Sparkles;
+ const isSelected = selectedChoice === idx;
+ const isFaded = selectedChoice !== null && !isSelected;
+ return (
+
+ );
+ })}
+
+ {/* 已选列表 */}
+ {appliedUpgradesList.length > 0 && (
+
+
本局已激活强化
+
+ {appliedUpgradesList.map((u, i) => {
+ const Icon = UPGRADE_ICON_MAP[u.icon] ?? Sparkles;
+ return (
+
+
+ {u.title}
+
+ );
+ })}
+
+
+ )}
+
+
+
+
+
+ )}
);
}
+
+// 暴露 UPGRADE_POOL(虽然组件没用到,但保持 export 完整性,便于其他模块引用)
+export { UPGRADE_POOL };
diff --git a/src/lib/game/cruise.ts b/src/lib/game/cruise.ts
index ca37e8ef3..1d8aed4bc 100644
--- a/src/lib/game/cruise.ts
+++ b/src/lib/game/cruise.ts
@@ -1,6 +1,7 @@
-// 回响星核 / Echo Nexus — 深空巡航(v0.6 Canvas 2D 实时玩法)
+// 回响星核 / Echo Nexus — 深空巡航(v0.8 Canvas 2D 实时玩法)
// 一个自包含的"实时操作"玩法模块:操控飞船穿越陨石带、虚空风暴,
// 收集晶体碎片/洞见光球/信标,最终抵达星门通关。奖励同步到主游戏。
+// v0.8 新增:BOSS 战(每 5 关)/ 飞船射击 / 道具掉落 / 事件选择节点。
// 独立 localStorage(echo-nexus-cruise-v1),不污染 GameState schema。
// ============================================================
@@ -8,7 +9,7 @@
// ============================================================
/** 游戏阶段 */
-export type CruisePhase = "ready" | "playing" | "won" | "lost";
+export type CruisePhase = "ready" | "playing" | "won" | "lost" | "choosing";
/** 实体类型枚举 */
export type CruiseEntityType =
@@ -19,7 +20,10 @@ export type CruiseEntityType =
| "insight"
| "beacon"
| "stargate"
- | "particle";
+ | "particle"
+ | "boss"
+ | "bullet"
+ | "powerup";
/** 基础实体 */
export interface BaseEntity {
@@ -49,6 +53,8 @@ export interface AsteroidEntity extends BaseEntity {
rotationSpeed: number;
vertices: number[]; // 归一化多边形顶点(0.7-1.2)
damage: number;
+ /** 是否为 BOSS 召唤的小陨石(一击碎) */
+ summoned?: boolean;
}
/** 虚空风暴 */
@@ -88,6 +94,8 @@ export interface BeaconEntity extends BaseEntity {
rotationSpeed: number;
pulsePhase: number;
collected: boolean;
+ /** 是否已掉落过道具(防止重复掉落) */
+ powerupDropped?: boolean;
}
/** 星门(到达即通关) */
@@ -110,6 +118,137 @@ export interface ParticleEntity extends BaseEntity {
shrink: boolean; // 是否随生命缩小
}
+/** BOSS 攻击阶段 */
+export type BossPhase = 1 | 2 | 3;
+
+/** BOSS 实体(每 5 关一个) */
+export interface BossEntity extends BaseEntity {
+ type: "boss";
+ hp: number;
+ maxHp: number;
+ radius: number;
+ rotation: number;
+ rotationSpeed: number;
+ pulsePhase: number;
+ /** 当前攻击阶段(1/2/3,根据 HP 自动切换) */
+ attackPhase: BossPhase;
+ /** 直射 / 散射 计时器(秒) */
+ directTimer: number;
+ /** 追踪弹计时器 */
+ homingTimer: number;
+ /** 召唤计时器 */
+ summonTimer: number;
+ /** 漂浮目标点(居中缓慢移动) */
+ driftAngle: number;
+ /** 已掉落道具阶段(0=未掉 25% / 1=未掉 50% / 2=未掉 75% / 3=全部掉完) */
+ dropStage: number;
+ /** 内核呼吸相位 */
+ breathPhase: number;
+ /** 触手动画相位 */
+ tentaclePhase: number;
+}
+
+/** 子弹归属 */
+export type BulletOwner = "ship" | "boss";
+
+/** 子弹实体 */
+export interface BulletEntity extends BaseEntity {
+ type: "bullet";
+ owner: BulletOwner;
+ radius: number;
+ damage: number;
+ life: number; // 剩余生命(秒),超时自动消失
+ /** 是否追踪(仅 BOSS 阶段3 追踪弹) */
+ homing: boolean;
+ /** 追踪剩余时间 */
+ homingTime: number;
+ /** 颜色(来自四色全息色谱) */
+ color: string;
+}
+
+/** 道具类型 */
+export type PowerupType =
+ | "shield"
+ | "energy"
+ | "rapid"
+ | "overdrive"
+ | "magnet";
+
+/** 道具实体 */
+export interface PowerupEntity extends BaseEntity {
+ type: "powerup";
+ powerupType: PowerupType;
+ radius: number;
+ rotation: number;
+ rotationSpeed: number;
+ pulsePhase: number;
+ /** 剩余生命(秒)—— 超时自动消失,BOSS 战掉落物寿命较长 */
+ life: number;
+ maxLife: number;
+ collected: boolean;
+}
+
+/** 激活的临时增益 */
+export interface ActiveBuff {
+ type: PowerupType;
+ remaining: number; // 剩余秒数
+ total: number; // 总时长
+}
+
+/** 升级卡牌 ID(事件选择节点) */
+export type UpgradeId =
+ | "speed"
+ | "accel"
+ | "friction"
+ | "shield_max"
+ | "shield_regen"
+ | "energy_max"
+ | "energy_regen"
+ | "firepower"
+ | "fire_rate"
+ | "double_shot"
+ | "attract_radius"
+ | "collect_radius"
+ | "invuln_time"
+ | "damage_reduce"
+ | "boss_bonus"
+ | "normal_score"
+ | "start_shield"
+ | "start_energy";
+
+/** 升级卡牌元信息 */
+export interface UpgradeCard {
+ id: UpgradeId;
+ title: string;
+ desc: string;
+ /** 主题色(四色之一) */
+ color: string;
+ /** lucide 图标 key(前端映射) */
+ icon: string;
+}
+
+/** 当前 run 内累计的升级修饰值 */
+export interface CruiseUpgrades {
+ speedMult: number; // 飞船最大速度乘子
+ accelMult: number; // 飞船加速度乘子
+ frictionBonus: number; // 摩擦优化(减少衰减,更灵活)
+ shieldMaxBonus: number; // 护盾上限加成
+ shieldRegenMult: number; // 护盾恢复乘子
+ energyMaxBonus: number; // 能量上限加成
+ energyRegenMult: number; // 能量恢复乘子
+ firepowerMult: number; // 火力乘子(每发伤害)
+ fireRateMult: number; // 射速乘子(越小越快)
+ doubleShot: boolean; // 双发
+ attractRadiusMult: number; // 吸引半径乘子
+ collectRadiusMult: number; // 收集半径乘子
+ invulnBonusMs: number; // 受击无敌额外时间
+ damageReduceMult: number; // 受击伤害乘子(<1 减伤)
+ bossBonusMult: number; // BOSS 战奖励乘子
+ normalScoreMult: number; // 普通关卡分数乘子
+ startShieldBonus: number; // 起始护盾加成
+ startEnergyBonus: number; // 起始能量加成
+}
+
/** 联合实体类型 */
export type CruiseEntity =
| ShipEntity
@@ -119,7 +258,10 @@ export type CruiseEntity =
| InsightEntity
| BeaconEntity
| StargateEntity
- | ParticleEntity;
+ | ParticleEntity
+ | BossEntity
+ | BulletEntity
+ | PowerupEntity;
/** 巡航关卡状态 */
export interface CruiseState {
@@ -127,8 +269,12 @@ export interface CruiseState {
level: number;
seed: number;
ship: ShipEntity;
- entities: CruiseEntity[]; // 不含 ship 与 particles
+ entities: CruiseEntity[]; // 不含 ship 与 particles 与 bullets 与 powerups
particles: ParticleEntity[];
+ bullets: BulletEntity[];
+ powerups: PowerupEntity[];
+ boss: BossEntity | null;
+ isBossLevel: boolean;
score: number;
timeSec: number;
durationSec: number;
@@ -141,19 +287,34 @@ export interface CruiseState {
shakeAmount: number;
shakeTimer: number;
flashLowShield: boolean;
+ /** 道具拾取屏幕闪光(颜色 + 剩余时间 ms) */
+ pickupFlash: { color: string; timer: number } | null;
worldWidth: number;
worldHeight: number;
nextEntityId: number;
/** 受击无敌计时(ms),避免连续撞击瞬间清空护盾 */
invulnTimer: number;
+ /** 射击冷却计时(ms) */
+ shootCooldown: number;
/** 结算时间戳 */
endedAt: number | null;
+ /** 当前 run 累计的升级修饰值 */
+ upgrades: CruiseUpgrades;
+ /** 已选择的升级列表(按顺序) */
+ appliedUpgrades: UpgradeCard[];
+ /** 待玩家选择的 3 张升级卡(choosing 阶段使用) */
+ pendingUpgradeChoice: UpgradeCard[];
+ /** 当前激活的临时增益 */
+ activeBuffs: ActiveBuff[];
+ /** BOSS 战击败标志(用于奖励乘子) */
+ bossDefeated: boolean;
}
/** 单局结算 */
export interface CruiseRunResult {
level: number;
phase: "won" | "lost";
+ isBossLevel: boolean;
durationSec: number;
collectedCrystals: number;
collectedInsights: number;
@@ -177,15 +338,17 @@ export interface CruiseStats {
totalCrystals: number;
totalInsights: number;
totalContact: number;
+ bossKills: number;
lastResult: CruiseRunResult | null;
recentResults: CruiseRunResult[]; // 最近 20 局
}
-/** 玩家输入(归一化方向向量 + 是否激活) */
+/** 玩家输入(归一化方向向量 + 是否激活 + 是否射击) */
export interface CruiseInput {
dx: number; // -1 ~ 1
dy: number; // -1 ~ 1
active: boolean;
+ shoot: boolean;
}
// ============================================================
@@ -225,6 +388,10 @@ export const CRUISE_CONFIG = {
shakeIntensity: 14,
/** 粒子上限 */
maxParticles: 200,
+ /** 子弹粒子上限 */
+ maxBullets: 80,
+ /** 道具上限 */
+ maxPowerups: 12,
/** 离屏剔除余量(px) */
cullMargin: 100,
/** 基础关卡时长(秒) */
@@ -233,12 +400,183 @@ export const CRUISE_CONFIG = {
durationPerLevel: 3,
/** 关卡时长上限(秒) */
maxDuration: 90,
+ /** BOSS 关卡时长(秒,更长) */
+ bossDuration: 120,
/** 视差星空层数 */
parallaxLayers: 3,
/** 最大关卡(用于 stats 显示) */
maxLevel: 99,
+
+ // ---- 射击 ----
+ /** 子弹速度(px/s) */
+ bulletSpeed: 540,
+ /** 子弹伤害(飞船) */
+ bulletDamage: 8,
+ /** 每发消耗能量 */
+ bulletEnergyCost: 5,
+ /** 射击冷却(ms) */
+ shootCooldownMs: 150,
+ /** 子弹生命(秒) */
+ bulletLife: 1.4,
+ /** 子弹半径 */
+ bulletRadius: 4,
+
+ // ---- BOSS ----
+ /** BOSS 基础 HP */
+ bossBaseHp: 100,
+ /** 每关 HP 增量 */
+ bossHpPerLevel: 20,
+ /** BOSS 半径 */
+ bossRadius: 70,
+ /** BOSS 子弹伤害 */
+ bossBulletDamage: 14,
+ /** BOSS 直射间隔(秒) */
+ bossDirectInterval: 1.5,
+ /** BOSS 散射间隔(秒) */
+ bossScatterInterval: 2.2,
+ /** BOSS 追踪弹间隔(秒) */
+ bossHomingInterval: 2.0,
+ /** BOSS 召唤间隔(秒) */
+ bossSummonInterval: 4.5,
+ /** BOSS 漂移速度 */
+ bossDriftSpeed: 18,
+ /** BOSS 接触伤害 */
+ bossContactDamage: 22,
+
+ // ---- 道具 ----
+ /** 道具拾取半径 */
+ powerupPickupRadius: 26,
+ /** 道具吸引半径(基础,受 magnet buff 加成) */
+ powerupAttractRadius: 120,
+ /** BOSS 战掉落物寿命(秒) */
+ powerupLifeBoss: 18,
+ /** 普通关卡掉落物寿命(秒) */
+ powerupLifeNormal: 10,
+
+ // ---- Buff 时长(秒) ----
+ buffRapidDuration: 8,
+ buffOverdriveDuration: 8,
+ buffMagnetDuration: 6,
} as const;
+/** 升级池(10 种基础强化 + 8 种衍生,共 18 张,每次随机抽 3) */
+export const UPGRADE_POOL: UpgradeCard[] = [
+ { id: "speed", title: "速度 +12%", desc: "飞船最大速度提升", color: "#34d399", icon: "Wind" },
+ { id: "accel", title: "加速度 +15%", desc: "更灵活的转向响应", color: "#34d399", icon: "Gauge" },
+ { id: "friction", title: "摩擦优化 +5%", desc: "惯性更小,转向更利落", color: "#34d399", icon: "Feather" },
+ { id: "shield_max", title: "护盾上限 +25", desc: "最高护盾值提升", color: "#34d399", icon: "Shield" },
+ { id: "shield_regen", title: "护盾恢复 +50%", desc: "未受击时护盾回血更快", color: "#34d399", icon: "HeartPulse" },
+ { id: "energy_max", title: "能量上限 +25", desc: "更多射击储备", color: "#fbbf24", icon: "Battery" },
+ { id: "energy_regen", title: "能量恢复 +30%", desc: "能量回充加速", color: "#fbbf24", icon: "Zap" },
+ { id: "firepower", title: "火力 +25%", desc: "每发子弹伤害更高", color: "#fb7185", icon: "Flame" },
+ { id: "fire_rate", title: "射速 +20%", desc: "射击间隔更短", color: "#fb7185", icon: "Timer" },
+ { id: "double_shot", title: "双发", desc: "每次射击发射 2 发(仅一次)", color: "#e879f9", icon: "Split" },
+ { id: "attract_radius", title: "吸引半径 +40%", desc: "更远距离吸取收集物", color: "#e879f9", icon: "Magnet" },
+ { id: "collect_radius", title: "收集半径 +50%", desc: "更易拾取道具与晶体", color: "#e879f9", icon: "Circle" },
+ { id: "invuln_time", title: "无敌时间 +200ms", desc: "受击后更多喘息", color: "#34d399", icon: "ShieldCheck" },
+ { id: "damage_reduce", title: "受击伤害 -20%", desc: "减少承受的伤害", color: "#34d399", icon: "ShieldHalf" },
+ { id: "boss_bonus", title: "BOSS 战金币 +50%", desc: "击败 BOSS 奖励翻倍加成", color: "#fbbf24", icon: "Crown" },
+ { id: "normal_score", title: "普通关卡分数 +30%", desc: "通关得分提升", color: "#fbbf24", icon: "Star" },
+ { id: "start_shield", title: "起始护盾 +20", desc: "每局开局更高护盾", color: "#34d399", icon: "ShieldPlus" },
+ { id: "start_energy", title: "起始能量 +20", desc: "每局开局更多能量", color: "#fbbf24", icon: "BatteryCharging" },
+];
+
+// ============================================================
+// 升级修饰器
+// ============================================================
+
+/** 创建一份初始的 CruiseUpgrades(无任何加成) */
+export function defaultUpgrades(): CruiseUpgrades {
+ return {
+ speedMult: 1,
+ accelMult: 1,
+ frictionBonus: 0,
+ shieldMaxBonus: 0,
+ shieldRegenMult: 1,
+ energyMaxBonus: 0,
+ energyRegenMult: 1,
+ firepowerMult: 1,
+ fireRateMult: 1,
+ doubleShot: false,
+ attractRadiusMult: 1,
+ collectRadiusMult: 1,
+ invulnBonusMs: 0,
+ damageReduceMult: 1,
+ bossBonusMult: 1,
+ normalScoreMult: 1,
+ startShieldBonus: 0,
+ startEnergyBonus: 0,
+ };
+}
+
+/** 应用一张升级卡到 upgrades(原地修改) */
+export function applyUpgradeCard(upgrades: CruiseUpgrades, card: UpgradeCard): void {
+ switch (card.id) {
+ case "speed":
+ upgrades.speedMult *= 1.12;
+ break;
+ case "accel":
+ upgrades.accelMult *= 1.15;
+ break;
+ case "friction":
+ // 摩擦优化 = 减少 friction 衰减,用正值表示优化程度
+ upgrades.frictionBonus = Math.min(0.2, upgrades.frictionBonus + 0.05);
+ break;
+ case "shield_max":
+ upgrades.shieldMaxBonus += 25;
+ break;
+ case "shield_regen":
+ upgrades.shieldRegenMult *= 1.5;
+ break;
+ case "energy_max":
+ upgrades.energyMaxBonus += 25;
+ break;
+ case "energy_regen":
+ upgrades.energyRegenMult *= 1.3;
+ break;
+ case "firepower":
+ upgrades.firepowerMult *= 1.25;
+ break;
+ case "fire_rate":
+ upgrades.fireRateMult *= 0.8; // 间隔更短 = 更快
+ break;
+ case "double_shot":
+ upgrades.doubleShot = true;
+ break;
+ case "attract_radius":
+ upgrades.attractRadiusMult *= 1.4;
+ break;
+ case "collect_radius":
+ upgrades.collectRadiusMult *= 1.5;
+ break;
+ case "invuln_time":
+ upgrades.invulnBonusMs += 200;
+ break;
+ case "damage_reduce":
+ upgrades.damageReduceMult *= 0.8;
+ break;
+ case "boss_bonus":
+ upgrades.bossBonusMult *= 1.5;
+ break;
+ case "normal_score":
+ upgrades.normalScoreMult *= 1.3;
+ break;
+ case "start_shield":
+ upgrades.startShieldBonus += 20;
+ break;
+ case "start_energy":
+ upgrades.startEnergyBonus += 20;
+ break;
+ }
+}
+
+/** 随机抽取 n 张不重复升级卡 */
+export function rollUpgradeChoices(count = 3, exclude: UpgradeId[] = []): UpgradeCard[] {
+ const pool = UPGRADE_POOL.filter((c) => !exclude.includes(c.id));
+ const shuffled = [...pool].sort(() => Math.random() - 0.5);
+ return shuffled.slice(0, Math.min(count, shuffled.length));
+}
+
// ============================================================
// 随机数(mulberry32,与 beacon 模块风格一致)
// ============================================================
@@ -270,6 +608,51 @@ export function cruiseSeed(level: number, salt = Date.now()): number {
return fnv1a(`cruise-l${level}-s${salt}`);
}
+// ============================================================
+// 工具:根据 upgrades 计算实时数值
+// ============================================================
+
+function effectiveShieldMax(state: CruiseState): number {
+ return CRUISE_CONFIG.shieldMax + state.upgrades.shieldMaxBonus;
+}
+function effectiveEnergyMax(state: CruiseState): number {
+ return CRUISE_CONFIG.energyMax + state.upgrades.energyMaxBonus;
+}
+function effectiveShipMaxSpeed(state: CruiseState): number {
+ let mult = state.upgrades.speedMult;
+ // overdrive buff 期间 ×1.5
+ if (state.activeBuffs.some((b) => b.type === "overdrive")) mult *= 1.5;
+ return CRUISE_CONFIG.shipMaxSpeed * mult;
+}
+function effectiveShipAccel(state: CruiseState): number {
+ return CRUISE_CONFIG.shipAccel * state.upgrades.accelMult;
+}
+function effectiveFriction(state: CruiseState): number {
+ // 摩擦优化让 friction 衰减更慢(更接近 1)
+ return Math.min(0.98, CRUISE_CONFIG.shipFriction + state.upgrades.frictionBonus);
+}
+function effectiveAttractRadius(state: CruiseState): number {
+ let mult = state.upgrades.attractRadiusMult;
+ // magnet buff 期间 ×3
+ if (state.activeBuffs.some((b) => b.type === "magnet")) mult *= 3;
+ return CRUISE_CONFIG.attractRadius * mult;
+}
+function effectiveCollectRadius(state: CruiseState): number {
+ return CRUISE_CONFIG.collectRadius * state.upgrades.collectRadiusMult;
+}
+function effectiveShootCooldownMs(state: CruiseState): number {
+ let mult = state.upgrades.fireRateMult;
+ // rapid buff 期间 ×0.5
+ if (state.activeBuffs.some((b) => b.type === "rapid")) mult *= 0.5;
+ return CRUISE_CONFIG.shootCooldownMs * mult;
+}
+function effectiveBulletDamage(state: CruiseState): number {
+ return CRUISE_CONFIG.bulletDamage * state.upgrades.firepowerMult;
+}
+function effectiveInvulnMs(state: CruiseState): number {
+ return CRUISE_CONFIG.invulnMs + state.upgrades.invulnBonusMs;
+}
+
// ============================================================
// 关卡生成
// ============================================================
@@ -280,19 +663,32 @@ export function cruiseSeed(level: number, salt = Date.now()): number {
* @param seed 随机种子
* @param worldWidth 画布宽度
* @param worldHeight 画布高度
+ * @param upgrades 已有升级(新 run 时为 defaultUpgrades())
+ * @param appliedUpgrades 已选卡牌列表(用于 UI 显示)
*/
export function generateLevel(
level: number,
seed: number,
worldWidth: number,
- worldHeight: number
+ worldHeight: number,
+ upgrades: CruiseUpgrades = defaultUpgrades(),
+ appliedUpgrades: UpgradeCard[] = []
): CruiseState {
const rng = mulberry32(seed);
const W = worldWidth;
const H = worldHeight;
let nextId = 1;
+ const isBossLevel = level > 0 && level % 5 === 0;
- // 飞船:底部中央
+ // 飞船:底部中央,起始护盾/能量受 upgrades 加成
+ const startShield = Math.min(
+ CRUISE_CONFIG.shieldMax + upgrades.shieldMaxBonus,
+ CRUISE_CONFIG.shieldMax + upgrades.shieldMaxBonus + upgrades.startShieldBonus
+ );
+ const startEnergy = Math.min(
+ CRUISE_CONFIG.energyMax + upgrades.energyMaxBonus,
+ CRUISE_CONFIG.energyMax + upgrades.energyMaxBonus + upgrades.startEnergyBonus
+ );
const ship: ShipEntity = {
id: nextId++,
type: "ship",
@@ -301,184 +697,263 @@ export function generateLevel(
vx: 0,
vy: 0,
heading: -Math.PI / 2,
- shield: CRUISE_CONFIG.shieldMax,
- energy: CRUISE_CONFIG.energyMax,
+ shield: startShield,
+ energy: startEnergy,
radius: CRUISE_CONFIG.shipRadius,
thrust: 0,
};
const entities: CruiseEntity[] = [];
+ let boss: BossEntity | null = null;
+ let crystalCount = 0;
+ let insightCount = 0;
+ let beaconCount = 0;
+ let durationSec: number;
- // 星门:顶部中央
- const stargate: StargateEntity = {
- id: nextId++,
- type: "stargate",
- x: W / 2,
- y: 70,
- vx: 0,
- vy: 0,
- radius: 42,
- rotation: 0,
- rotationSpeed: 0.6,
- pulsePhase: 0,
- active: false,
- };
- entities.push(stargate);
+ if (isBossLevel) {
+ // ---- BOSS 关卡:只生成 BOSS + 少量环境装饰(无星门) ----
+ const bossHp = CRUISE_CONFIG.bossBaseHp + level * CRUISE_CONFIG.bossHpPerLevel;
+ boss = {
+ id: nextId++,
+ type: "boss",
+ x: W / 2,
+ y: H * 0.3,
+ vx: 0,
+ vy: 0,
+ hp: bossHp,
+ maxHp: bossHp,
+ radius: CRUISE_CONFIG.bossRadius,
+ rotation: 0,
+ rotationSpeed: 0.3,
+ pulsePhase: 0,
+ attackPhase: 1,
+ directTimer: 1.5,
+ homingTimer: 2,
+ summonTimer: 4,
+ driftAngle: 0,
+ dropStage: 0,
+ breathPhase: 0,
+ tentaclePhase: 0,
+ };
+ durationSec = CRUISE_CONFIG.bossDuration;
- // 陨石带:数量随关卡递增
- const asteroidCount = Math.min(8 + level * 2, 32);
- for (let i = 0; i < asteroidCount; i++) {
- const radius = 16 + rng() * 28;
- // 在中部区域生成,避开飞船起点和星门
- let x = 0,
- y = 0,
- tries = 0;
- do {
- x = 40 + rng() * (W - 80);
- y = 140 + rng() * (H - 260);
- tries++;
- } while (
- tries < 20 &&
- (Math.hypot(x - ship.x, y - ship.y) < 120 ||
- Math.hypot(x - stargate.x, y - stargate.y) < 100)
- );
- const speed = 30 + rng() * 60 + level * 4;
- const angle = rng() * Math.PI * 2;
- const vertCount = 7 + Math.floor(rng() * 4);
- const vertices: number[] = [];
- for (let v = 0; v < vertCount; v++) {
- vertices.push(0.75 + rng() * 0.4);
+ // 少量装饰性小陨石(缓慢飘动,增加视觉层次)
+ const decorAsteroids = 4;
+ for (let i = 0; i < decorAsteroids; i++) {
+ const radius = 14 + rng() * 16;
+ const x = 60 + rng() * (W - 120);
+ const y = 60 + rng() * (H - 120);
+ const speed = 20 + rng() * 30;
+ const angle = rng() * Math.PI * 2;
+ const vertCount = 7 + Math.floor(rng() * 4);
+ const vertices: number[] = [];
+ for (let v = 0; v < vertCount; v++) vertices.push(0.75 + rng() * 0.4);
+ entities.push({
+ id: nextId++,
+ type: "asteroid",
+ x,
+ y,
+ vx: Math.cos(angle) * speed,
+ vy: Math.sin(angle) * speed,
+ radius,
+ rotation: rng() * Math.PI * 2,
+ rotationSpeed: (rng() - 0.5) * 1.2,
+ vertices,
+ damage: CRUISE_CONFIG.asteroidBaseDamage + level * 1.5,
+ });
}
- entities.push({
- id: nextId++,
- type: "asteroid",
- x,
- y,
- vx: Math.cos(angle) * speed,
- vy: Math.sin(angle) * speed,
- radius,
- rotation: rng() * Math.PI * 2,
- rotationSpeed: (rng() - 0.5) * 1.2,
- vertices,
- damage: CRUISE_CONFIG.asteroidBaseDamage + level * 1.5,
- });
- }
+ // 几枚晶体作为补给
+ crystalCount = 4;
+ for (let i = 0; i < crystalCount; i++) {
+ const x = 80 + rng() * (W - 160);
+ const y = 120 + rng() * (H - 240);
+ entities.push({
+ id: nextId++,
+ type: "crystal",
+ x,
+ y,
+ vx: 0,
+ vy: 0,
+ radius: 12,
+ rotation: rng() * Math.PI * 2,
+ rotationSpeed: (rng() - 0.5) * 2,
+ pulsePhase: rng() * Math.PI * 2,
+ collected: false,
+ });
+ }
+ } else {
+ // ---- 普通关卡:星门 + 陨石带 + 风暴 + 收集物 ----
- // 虚空风暴:随关卡递增(最多 5 个)
- const stormCount = Math.min(1 + Math.floor(level / 2), 5);
- for (let i = 0; i < stormCount; i++) {
- const radius = 60 + rng() * 50;
- let x = 0,
- y = 0,
- tries = 0;
- do {
- x = 100 + rng() * (W - 200);
- y = 180 + rng() * (H - 320);
- tries++;
- } while (
- tries < 20 &&
- (Math.hypot(x - ship.x, y - ship.y) < 180 ||
- Math.hypot(x - stargate.x, y - stargate.y) < 150)
+ // 星门:顶部中央
+ const stargate: StargateEntity = {
+ id: nextId++,
+ type: "stargate",
+ x: W / 2,
+ y: 70,
+ vx: 0,
+ vy: 0,
+ radius: 42,
+ rotation: 0,
+ rotationSpeed: 0.6,
+ pulsePhase: 0,
+ active: false,
+ };
+ entities.push(stargate);
+
+ // 陨石带:数量随关卡递增
+ const asteroidCount = Math.min(8 + level * 2, 32);
+ for (let i = 0; i < asteroidCount; i++) {
+ const radius = 16 + rng() * 28;
+ let x = 0,
+ y = 0,
+ tries = 0;
+ do {
+ x = 40 + rng() * (W - 80);
+ y = 140 + rng() * (H - 260);
+ tries++;
+ } while (
+ tries < 20 &&
+ (Math.hypot(x - ship.x, y - ship.y) < 120 ||
+ Math.hypot(x - stargate.x, y - stargate.y) < 100)
+ );
+ const speed = 30 + rng() * 60 + level * 4;
+ const angle = rng() * Math.PI * 2;
+ const vertCount = 7 + Math.floor(rng() * 4);
+ const vertices: number[] = [];
+ for (let v = 0; v < vertCount; v++) {
+ vertices.push(0.75 + rng() * 0.4);
+ }
+ entities.push({
+ id: nextId++,
+ type: "asteroid",
+ x,
+ y,
+ vx: Math.cos(angle) * speed,
+ vy: Math.sin(angle) * speed,
+ radius,
+ rotation: rng() * Math.PI * 2,
+ rotationSpeed: (rng() - 0.5) * 1.2,
+ vertices,
+ damage: CRUISE_CONFIG.asteroidBaseDamage + level * 1.5,
+ });
+ }
+
+ // 虚空风暴:随关卡递增(最多 5 个)
+ const stormCount = Math.min(1 + Math.floor(level / 2), 5);
+ for (let i = 0; i < stormCount; i++) {
+ const radius = 60 + rng() * 50;
+ let x = 0,
+ y = 0,
+ tries = 0;
+ do {
+ x = 100 + rng() * (W - 200);
+ y = 180 + rng() * (H - 320);
+ tries++;
+ } while (
+ tries < 20 &&
+ (Math.hypot(x - ship.x, y - ship.y) < 180 ||
+ Math.hypot(x - stargate.x, y - stargate.y) < 150)
+ );
+ const speed = 12 + rng() * 18;
+ const angle = rng() * Math.PI * 2;
+ entities.push({
+ id: nextId++,
+ type: "storm",
+ x,
+ y,
+ vx: Math.cos(angle) * speed,
+ vy: Math.sin(angle) * speed,
+ radius,
+ rotation: rng() * Math.PI * 2,
+ rotationSpeed: (rng() - 0.5) * 0.4,
+ lightningTimer: rng() * 0.5,
+ cloudSeed: Math.floor(rng() * 10000),
+ });
+ }
+
+ // 晶体碎片:5 + level
+ crystalCount = 5 + level;
+ for (let i = 0; i < crystalCount; i++) {
+ let x = 0,
+ y = 0,
+ tries = 0;
+ do {
+ x = 50 + rng() * (W - 100);
+ y = 120 + rng() * (H - 220);
+ tries++;
+ } while (tries < 20 && Math.hypot(x - ship.x, y - ship.y) < 90);
+ entities.push({
+ id: nextId++,
+ type: "crystal",
+ x,
+ y,
+ vx: 0,
+ vy: 0,
+ radius: 12,
+ rotation: rng() * Math.PI * 2,
+ rotationSpeed: (rng() - 0.5) * 2,
+ pulsePhase: rng() * Math.PI * 2,
+ collected: false,
+ });
+ }
+
+ // 洞见光球:2 + floor(level/3)
+ insightCount = 2 + Math.floor(level / 3);
+ for (let i = 0; i < insightCount; i++) {
+ let x = 0,
+ y = 0,
+ tries = 0;
+ do {
+ x = 60 + rng() * (W - 120);
+ y = 140 + rng() * (H - 240);
+ tries++;
+ } while (tries < 20 && Math.hypot(x - ship.x, y - ship.y) < 100);
+ entities.push({
+ id: nextId++,
+ type: "insight",
+ x,
+ y,
+ vx: 0,
+ vy: 0,
+ radius: 14,
+ pulsePhase: rng() * Math.PI * 2,
+ swirlAngle: rng() * Math.PI * 2,
+ collected: false,
+ });
+ }
+
+ // 信标:1 + floor(level/4)
+ beaconCount = 1 + Math.floor(level / 4);
+ for (let i = 0; i < beaconCount; i++) {
+ let x = 0,
+ y = 0,
+ tries = 0;
+ do {
+ x = 80 + rng() * (W - 160);
+ y = 160 + rng() * (H - 280);
+ tries++;
+ } while (tries < 20 && Math.hypot(x - ship.x, y - ship.y) < 110);
+ entities.push({
+ id: nextId++,
+ type: "beacon",
+ x,
+ y,
+ vx: 0,
+ vy: 0,
+ radius: 16,
+ rotation: rng() * Math.PI * 2,
+ rotationSpeed: (rng() - 0.5) * 1.5,
+ pulsePhase: rng() * Math.PI * 2,
+ collected: false,
+ });
+ }
+
+ durationSec = Math.min(
+ CRUISE_CONFIG.baseDuration + (level - 1) * CRUISE_CONFIG.durationPerLevel,
+ CRUISE_CONFIG.maxDuration
);
- const speed = 12 + rng() * 18;
- const angle = rng() * Math.PI * 2;
- entities.push({
- id: nextId++,
- type: "storm",
- x,
- y,
- vx: Math.cos(angle) * speed,
- vy: Math.sin(angle) * speed,
- radius,
- rotation: rng() * Math.PI * 2,
- rotationSpeed: (rng() - 0.5) * 0.4,
- lightningTimer: rng() * 0.5,
- cloudSeed: Math.floor(rng() * 10000),
- });
}
- // 晶体碎片:5 + level
- const crystalCount = 5 + level;
- for (let i = 0; i < crystalCount; i++) {
- let x = 0,
- y = 0,
- tries = 0;
- do {
- x = 50 + rng() * (W - 100);
- y = 120 + rng() * (H - 220);
- tries++;
- } while (tries < 20 && Math.hypot(x - ship.x, y - ship.y) < 90);
- entities.push({
- id: nextId++,
- type: "crystal",
- x,
- y,
- vx: 0,
- vy: 0,
- radius: 12,
- rotation: rng() * Math.PI * 2,
- rotationSpeed: (rng() - 0.5) * 2,
- pulsePhase: rng() * Math.PI * 2,
- collected: false,
- });
- }
-
- // 洞见光球:2 + floor(level/3)
- const insightCount = 2 + Math.floor(level / 3);
- for (let i = 0; i < insightCount; i++) {
- let x = 0,
- y = 0,
- tries = 0;
- do {
- x = 60 + rng() * (W - 120);
- y = 140 + rng() * (H - 240);
- tries++;
- } while (tries < 20 && Math.hypot(x - ship.x, y - ship.y) < 100);
- entities.push({
- id: nextId++,
- type: "insight",
- x,
- y,
- vx: 0,
- vy: 0,
- radius: 14,
- pulsePhase: rng() * Math.PI * 2,
- swirlAngle: rng() * Math.PI * 2,
- collected: false,
- });
- }
-
- // 信标:1 + floor(level/4)
- const beaconCount = 1 + Math.floor(level / 4);
- for (let i = 0; i < beaconCount; i++) {
- let x = 0,
- y = 0,
- tries = 0;
- do {
- x = 80 + rng() * (W - 160);
- y = 160 + rng() * (H - 280);
- tries++;
- } while (tries < 20 && Math.hypot(x - ship.x, y - ship.y) < 110);
- entities.push({
- id: nextId++,
- type: "beacon",
- x,
- y,
- vx: 0,
- vy: 0,
- radius: 16,
- rotation: rng() * Math.PI * 2,
- rotationSpeed: (rng() - 0.5) * 1.5,
- pulsePhase: rng() * Math.PI * 2,
- collected: false,
- });
- }
-
- const durationSec = Math.min(
- CRUISE_CONFIG.baseDuration + (level - 1) * CRUISE_CONFIG.durationPerLevel,
- CRUISE_CONFIG.maxDuration
- );
-
return {
phase: "ready",
level,
@@ -486,6 +961,10 @@ export function generateLevel(
ship,
entities,
particles: [],
+ bullets: [],
+ powerups: [],
+ boss,
+ isBossLevel,
score: 0,
timeSec: 0,
durationSec,
@@ -498,14 +977,29 @@ export function generateLevel(
shakeAmount: 0,
shakeTimer: 0,
flashLowShield: false,
+ pickupFlash: null,
worldWidth: W,
worldHeight: H,
nextEntityId: nextId,
invulnTimer: 0,
+ shootCooldown: 0,
endedAt: null,
+ upgrades,
+ appliedUpgrades,
+ pendingUpgradeChoice: [],
+ activeBuffs: [],
+ bossDefeated: false,
};
}
+/** 计算 upgrades 在无 state 时的 shield 上限(用于 generateLevel 初始化) */
+function effectiveShieldMaxRaw(upgrades: CruiseUpgrades): number {
+ return CRUISE_CONFIG.shieldMax + upgrades.shieldMaxBonus + upgrades.startShieldBonus;
+}
+function effectiveEnergyMaxRaw(upgrades: CruiseUpgrades): number {
+ return CRUISE_CONFIG.energyMax + upgrades.energyMaxBonus + upgrades.startEnergyBonus;
+}
+
// ============================================================
// 物理与碰撞更新
// ============================================================
@@ -582,6 +1076,470 @@ function emitThrustParticle(state: CruiseState, ship: ShipEntity) {
});
}
+/** 添加子弹(自动限制数量) */
+function addBullet(state: CruiseState, b: Omit
) {
+ if (state.bullets.length >= CRUISE_CONFIG.maxBullets) {
+ state.bullets.shift();
+ }
+ state.bullets.push({
+ id: state.nextEntityId++,
+ type: "bullet",
+ ...b,
+ });
+}
+
+/** 添加道具 */
+function addPowerup(state: CruiseState, p: Omit) {
+ if (state.powerups.length >= CRUISE_CONFIG.maxPowerups) {
+ state.powerups.shift();
+ }
+ state.powerups.push({
+ id: state.nextEntityId++,
+ type: "powerup",
+ ...p,
+ });
+}
+
+// ============================================================
+// 射击
+// ============================================================
+
+/**
+ * 飞船射击(如果冷却完成且有足够能量)。
+ * 子弹朝飞船 heading 方向发射(emerald 色)。
+ */
+export function shootShip(state: CruiseState): void {
+ if (state.phase !== "playing") return;
+ if (state.shootCooldown > 0) return;
+ const ship = state.ship;
+ const energyCost = CRUISE_CONFIG.bulletEnergyCost;
+ if (ship.energy < energyCost) return;
+ ship.energy -= energyCost;
+ state.shootCooldown = effectiveShootCooldownMs(state);
+ const dmg = effectiveBulletDamage(state);
+ const ang = ship.heading;
+ const muzzleX = ship.x + Math.cos(ang) * (ship.radius + 4);
+ const muzzleY = ship.y + Math.sin(ang) * (ship.radius + 4);
+ const v = CRUISE_CONFIG.bulletSpeed;
+ // 主弹
+ addBullet(state, {
+ x: muzzleX,
+ y: muzzleY,
+ vx: Math.cos(ang) * v,
+ vy: Math.sin(ang) * v,
+ owner: "ship",
+ radius: CRUISE_CONFIG.bulletRadius,
+ damage: dmg,
+ life: CRUISE_CONFIG.bulletLife,
+ homing: false,
+ homingTime: 0,
+ color: "#34d399",
+ });
+ // 双发:上下偏移 8°
+ if (state.upgrades.doubleShot) {
+ for (const offset of [0.14, -0.14]) {
+ const a2 = ang + offset;
+ addBullet(state, {
+ x: ship.x + Math.cos(a2) * (ship.radius + 4),
+ y: ship.y + Math.sin(a2) * (ship.radius + 4),
+ vx: Math.cos(a2) * v,
+ vy: Math.sin(a2) * v,
+ owner: "ship",
+ radius: CRUISE_CONFIG.bulletRadius,
+ damage: dmg,
+ life: CRUISE_CONFIG.bulletLife,
+ homing: false,
+ homingTime: 0,
+ color: "#34d399",
+ });
+ }
+ }
+ // 枪口闪光粒子
+ burstParticles(state, muzzleX, muzzleY, "#a7f3d0", 4, 120, 0.25, 2);
+}
+
+// ============================================================
+// BOSS AI
+// ============================================================
+
+/** BOSS 攻击模式更新 */
+function updateBoss(state: CruiseState, dt: number): void {
+ const boss = state.boss;
+ if (!boss) return;
+ const ship = state.ship;
+
+ // 阶段切换(HP 比例)
+ const hpPct = boss.hp / boss.maxHp;
+ if (hpPct > 0.66) boss.attackPhase = 1;
+ else if (hpPct > 0.33) boss.attackPhase = 2;
+ else boss.attackPhase = 3;
+
+ // 视觉相位
+ boss.rotation += boss.rotationSpeed * dt;
+ boss.pulsePhase += dt * 2;
+ boss.breathPhase += dt * 1.4;
+ boss.tentaclePhase += dt * 0.8;
+
+ // 漂浮:以画布中心区域为目标,缓慢游走
+ boss.driftAngle += dt * 0.4;
+ const cx = state.worldWidth / 2 + Math.cos(boss.driftAngle) * (state.worldWidth * 0.25);
+ const cy = state.worldHeight * 0.32 + Math.sin(boss.driftAngle * 1.3) * 40;
+ const dx = cx - boss.x;
+ const dy = cy - boss.y;
+ const dist = Math.hypot(dx, dy) || 1;
+ const driftSpeed = CRUISE_CONFIG.bossDriftSpeed;
+ boss.vx = (dx / dist) * driftSpeed;
+ boss.vy = (dy / dist) * driftSpeed;
+ boss.x += boss.vx * dt;
+ boss.y += boss.vy * dt;
+ // 边界
+ boss.x = Math.max(boss.radius + 20, Math.min(state.worldWidth - boss.radius - 20, boss.x));
+ boss.y = Math.max(boss.radius + 20, Math.min(state.worldHeight * 0.55, boss.y));
+
+ // ---- 攻击 ----
+ // 阶段1:直射
+ if (boss.attackPhase >= 1) {
+ boss.directTimer -= dt;
+ if (boss.directTimer <= 0) {
+ boss.directTimer = boss.attackPhase === 1 ? CRUISE_CONFIG.bossDirectInterval : CRUISE_CONFIG.bossScatterInterval;
+ if (boss.attackPhase === 1) {
+ // 直射一发,朝飞船方向
+ const a = Math.atan2(ship.y - boss.y, ship.x - boss.x);
+ const v = 260;
+ addBullet(state, {
+ x: boss.x + Math.cos(a) * boss.radius,
+ y: boss.y + Math.sin(a) * boss.radius,
+ vx: Math.cos(a) * v,
+ vy: Math.sin(a) * v,
+ owner: "boss",
+ radius: 6,
+ damage: CRUISE_CONFIG.bossBulletDamage,
+ life: 4,
+ homing: false,
+ homingTime: 0,
+ color: "#fb7185",
+ });
+ } else {
+ // 阶段2+:散射 5 发扇形
+ const baseA = Math.atan2(ship.y - boss.y, ship.x - boss.x);
+ const v = 240;
+ for (let i = -2; i <= 2; i++) {
+ const a = baseA + i * 0.18;
+ addBullet(state, {
+ x: boss.x + Math.cos(a) * boss.radius,
+ y: boss.y + Math.sin(a) * boss.radius,
+ vx: Math.cos(a) * v,
+ vy: Math.sin(a) * v,
+ owner: "boss",
+ radius: 6,
+ damage: CRUISE_CONFIG.bossBulletDamage,
+ life: 4,
+ homing: false,
+ homingTime: 0,
+ color: "#fb7185",
+ });
+ }
+ }
+ }
+ }
+
+ // 阶段2+:召唤小陨石
+ if (boss.attackPhase >= 2) {
+ boss.summonTimer -= dt;
+ if (boss.summonTimer <= 0) {
+ boss.summonTimer = CRUISE_CONFIG.bossSummonInterval;
+ for (let i = 0; i < 2; i++) {
+ const a = Math.random() * Math.PI * 2;
+ const r = boss.radius + 10;
+ const sx = boss.x + Math.cos(a) * r;
+ const sy = boss.y + Math.sin(a) * r;
+ const v = 100 + Math.random() * 60;
+ const ang = Math.atan2(ship.y - sy, ship.x - sx) + (Math.random() - 0.5) * 0.8;
+ const vertCount = 7;
+ const vertices: number[] = [];
+ for (let k = 0; k < vertCount; k++) vertices.push(0.75 + Math.random() * 0.4);
+ state.entities.push({
+ id: state.nextEntityId++,
+ type: "asteroid",
+ x: sx,
+ y: sy,
+ vx: Math.cos(ang) * v,
+ vy: Math.sin(ang) * v,
+ radius: 14 + Math.random() * 8,
+ rotation: Math.random() * Math.PI * 2,
+ rotationSpeed: (Math.random() - 0.5) * 2,
+ vertices,
+ damage: CRUISE_CONFIG.asteroidBaseDamage,
+ summoned: true,
+ });
+ }
+ // 召唤特效
+ burstParticles(state, boss.x, boss.y, "#e879f9", 12, 200, 0.5, 3);
+ }
+ }
+
+ // 阶段3:追踪弹
+ if (boss.attackPhase >= 3) {
+ boss.homingTimer -= dt;
+ if (boss.homingTimer <= 0) {
+ boss.homingTimer = CRUISE_CONFIG.bossHomingInterval;
+ const a = Math.atan2(ship.y - boss.y, ship.x - boss.x);
+ const v = 200;
+ addBullet(state, {
+ x: boss.x + Math.cos(a) * boss.radius,
+ y: boss.y + Math.sin(a) * boss.radius,
+ vx: Math.cos(a) * v,
+ vy: Math.sin(a) * v,
+ owner: "boss",
+ radius: 7,
+ damage: CRUISE_CONFIG.bossBulletDamage,
+ life: 5,
+ homing: true,
+ homingTime: 3,
+ color: "#e879f9",
+ });
+ }
+ }
+
+ // ---- 道具掉落:每损失 25% HP 掉 1 个 ----
+ const newDropStage = Math.min(3, Math.floor((1 - hpPct) / 0.25));
+ while (boss.dropStage < newDropStage) {
+ boss.dropStage++;
+ dropRandomPowerup(state, boss.x + (Math.random() - 0.5) * 80, boss.y + 40, "boss");
+ burstParticles(state, boss.x, boss.y, "#fbbf24", 16, 200, 0.6, 3);
+ }
+}
+
+/** 掉落随机道具 */
+function dropRandomPowerup(state: CruiseState, x: number, y: number, source: "boss" | "beacon"): void {
+ const types: PowerupType[] = ["shield", "energy", "rapid", "overdrive", "magnet"];
+ const t = types[Math.floor(Math.random() * types.length)];
+ const life = source === "boss" ? CRUISE_CONFIG.powerupLifeBoss : CRUISE_CONFIG.powerupLifeNormal;
+ addPowerup(state, {
+ x,
+ y,
+ vx: (Math.random() - 0.5) * 30,
+ vy: 20 + Math.random() * 20,
+ powerupType: t,
+ radius: 14,
+ rotation: 0,
+ rotationSpeed: (Math.random() - 0.5) * 2,
+ pulsePhase: Math.random() * Math.PI * 2,
+ life,
+ maxLife: life,
+ collected: false,
+ });
+}
+
+// ============================================================
+// 子弹更新
+// ============================================================
+
+function updateBullets(state: CruiseState, dt: number): void {
+ const ship = state.ship;
+ for (let i = state.bullets.length - 1; i >= 0; i--) {
+ const b = state.bullets[i];
+ b.life -= dt;
+ if (b.life <= 0) {
+ state.bullets.splice(i, 1);
+ continue;
+ }
+ // 追踪弹
+ if (b.homing && b.homingTime > 0) {
+ b.homingTime -= dt;
+ const a = Math.atan2(ship.y - b.y, ship.x - b.x);
+ const v = Math.hypot(b.vx, b.vy);
+ // 平滑转向
+ const curA = Math.atan2(b.vy, b.vx);
+ let diff = a - curA;
+ while (diff > Math.PI) diff -= Math.PI * 2;
+ while (diff < -Math.PI) diff += Math.PI * 2;
+ const turn = Math.max(-2 * dt, Math.min(2 * dt, diff));
+ const newA = curA + turn;
+ b.vx = Math.cos(newA) * v;
+ b.vy = Math.sin(newA) * v;
+ }
+ b.x += b.vx * dt;
+ b.y += b.vy * dt;
+ // 离屏剔除
+ const m = 50;
+ if (b.x < -m || b.x > state.worldWidth + m || b.y < -m || b.y > state.worldHeight + m) {
+ state.bullets.splice(i, 1);
+ continue;
+ }
+ // 尾焰粒子(少量)
+ if (Math.random() < 0.4) {
+ addParticle(state, {
+ x: b.x,
+ y: b.y,
+ vx: -b.vx * 0.05,
+ vy: -b.vy * 0.05,
+ life: 0.2,
+ maxLife: 0.3,
+ size: 2,
+ color: b.color,
+ shrink: true,
+ });
+ }
+
+ // ---- 碰撞 ----
+ if (b.owner === "ship") {
+ // 飞船子弹 vs 陨石(小陨石/召唤陨石一击碎;大陨石伤害衰减)
+ let consumed = false;
+ for (const e of state.entities) {
+ if (e.type !== "asteroid") continue;
+ const d = Math.hypot(b.x - e.x, b.y - e.y);
+ if (d < e.radius + b.radius) {
+ if (e.summoned || e.radius < 22) {
+ // 一击碎
+ burstParticles(state, e.x, e.y, "#fb7185", 14, 220, 0.5, 3);
+ // 标记移除(后面统一过滤)
+ (e as AsteroidEntity).damage = -999; // 标记删除
+ state.score += 30;
+ } else {
+ // 大陨石:只是擦伤
+ burstParticles(state, b.x, b.y, "#fbbf24", 6, 160, 0.3, 2);
+ }
+ consumed = true;
+ break;
+ }
+ }
+ // 飞船子弹 vs BOSS
+ if (!consumed && state.boss) {
+ const d = Math.hypot(b.x - state.boss.x, b.y - state.boss.y);
+ if (d < state.boss.radius + b.radius) {
+ state.boss.hp = Math.max(0, state.boss.hp - b.damage);
+ burstParticles(state, b.x, b.y, "#fbbf24", 8, 180, 0.4, 2.5);
+ // BOSS 受击闪光
+ burstParticles(state, b.x, b.y, "#fb7185", 4, 100, 0.2, 2);
+ consumed = true;
+ }
+ }
+ if (consumed) {
+ state.bullets.splice(i, 1);
+ continue;
+ }
+ } else {
+ // BOSS 子弹 vs 飞船
+ if (state.invulnTimer <= 0) {
+ const d = Math.hypot(b.x - ship.x, b.y - ship.y);
+ if (d < ship.radius + b.radius) {
+ // 受击
+ const overdrive = state.activeBuffs.some((bb) => bb.type === "overdrive");
+ if (!overdrive) {
+ const dmg = b.damage * state.upgrades.damageReduceMult;
+ ship.shield = Math.max(0, ship.shield - dmg);
+ burstParticles(state, ship.x, ship.y, "#fb7185", 10, 200, 0.4, 3);
+ triggerShake(state, 10);
+ state.invulnTimer = effectiveInvulnMs(state);
+ }
+ state.bullets.splice(i, 1);
+ continue;
+ }
+ }
+ }
+ }
+ // 移除被子弹击碎的陨石
+ state.entities = state.entities.filter((e) => {
+ if (e.type === "asteroid" && (e as AsteroidEntity).damage <= -100) return false;
+ return true;
+ });
+}
+
+// ============================================================
+// 道具更新 + 拾取
+// ============================================================
+
+function updatePowerups(state: CruiseState, dt: number): void {
+ const ship = state.ship;
+ for (let i = state.powerups.length - 1; i >= 0; i--) {
+ const p = state.powerups[i];
+ if (p.collected) {
+ state.powerups.splice(i, 1);
+ continue;
+ }
+ p.life -= dt;
+ if (p.life <= 0) {
+ state.powerups.splice(i, 1);
+ continue;
+ }
+ p.rotation += p.rotationSpeed * dt;
+ p.pulsePhase += dt * 3;
+ p.x += p.vx * dt;
+ p.y += p.vy * dt;
+ // 摩擦衰减
+ p.vx *= 0.96;
+ p.vy *= 0.96;
+ // 边界
+ p.x = Math.max(p.radius, Math.min(state.worldWidth - p.radius, p.x));
+ p.y = Math.max(p.radius, Math.min(state.worldHeight - p.radius, p.y));
+
+ const d = Math.hypot(ship.x - p.x, ship.y - p.y);
+ // 吸引半径(受 magnet buff 影响)
+ const attractR = CRUISE_CONFIG.powerupAttractRadius * (state.activeBuffs.some((b) => b.type === "magnet") ? 3 : 1);
+ if (d < attractR) {
+ const a = Math.atan2(ship.y - p.y, ship.x - p.x);
+ const pull = (1 - d / attractR) * 380;
+ p.x += Math.cos(a) * pull * dt;
+ p.y += Math.sin(a) * pull * dt;
+ }
+ // 拾取
+ if (d < CRUISE_CONFIG.powerupPickupRadius + ship.radius) {
+ p.collected = true;
+ applyPowerup(state, p.powerupType);
+ state.powerups.splice(i, 1);
+ }
+ }
+}
+
+/** 应用道具效果 */
+function applyPowerup(state: CruiseState, type: PowerupType): void {
+ const ship = state.ship;
+ let color = "#34d399";
+ switch (type) {
+ case "shield":
+ ship.shield = Math.min(effectiveShieldMax(state), ship.shield + 30);
+ color = "#34d399";
+ burstParticles(state, ship.x, ship.y, color, 18, 220, 0.6, 3);
+ break;
+ case "energy":
+ ship.energy = Math.min(effectiveEnergyMax(state), ship.energy + 50);
+ color = "#fbbf24";
+ burstParticles(state, ship.x, ship.y, color, 18, 220, 0.6, 3);
+ break;
+ case "rapid":
+ state.activeBuffs.push({ type: "rapid", remaining: CRUISE_CONFIG.buffRapidDuration, total: CRUISE_CONFIG.buffRapidDuration });
+ color = "#e879f9";
+ break;
+ case "overdrive":
+ state.activeBuffs.push({ type: "overdrive", remaining: CRUISE_CONFIG.buffOverdriveDuration, total: CRUISE_CONFIG.buffOverdriveDuration });
+ color = "#fb7185";
+ break;
+ case "magnet":
+ state.activeBuffs.push({ type: "magnet", remaining: CRUISE_CONFIG.buffMagnetDuration, total: CRUISE_CONFIG.buffMagnetDuration });
+ color = "#a7f3d0";
+ break;
+ }
+ // 屏幕边缘闪光
+ state.pickupFlash = { color, timer: 400 };
+ state.score += 50;
+}
+
+// ============================================================
+// Buff 计时
+// ============================================================
+
+function updateBuffs(state: CruiseState, dt: number): void {
+ for (let i = state.activeBuffs.length - 1; i >= 0; i--) {
+ state.activeBuffs[i].remaining -= dt;
+ if (state.activeBuffs[i].remaining <= 0) state.activeBuffs.splice(i, 1);
+ }
+}
+
+// ============================================================
+// 主更新
+// ============================================================
+
/**
* 推进游戏状态一帧。
* @param state 当前状态(会被原地修改)
@@ -608,10 +1566,23 @@ export function updateCruise(
}
}
+ // 拾取闪光衰减
+ if (state.pickupFlash) {
+ state.pickupFlash.timer -= dtMs;
+ if (state.pickupFlash.timer <= 0) state.pickupFlash = null;
+ }
+
// 无敌时间衰减
if (state.invulnTimer > 0) {
state.invulnTimer = Math.max(0, state.invulnTimer - dtMs);
}
+ // 射击冷却衰减
+ if (state.shootCooldown > 0) {
+ state.shootCooldown = Math.max(0, state.shootCooldown - dtMs);
+ }
+
+ // Buff 计时
+ updateBuffs(state, dt);
const ship = state.ship;
@@ -620,8 +1591,8 @@ export function updateCruise(
const mag = Math.hypot(input.dx, input.dy);
const nx = input.dx / mag;
const ny = input.dy / mag;
- ship.vx += nx * CRUISE_CONFIG.shipAccel * dt;
- ship.vy += ny * CRUISE_CONFIG.shipAccel * dt;
+ ship.vx += nx * effectiveShipAccel(state) * dt;
+ ship.vy += ny * effectiveShipAccel(state) * dt;
ship.heading = Math.atan2(ny, nx);
ship.thrust = Math.min(1, ship.thrust + dt * 4);
// 尾焰粒子
@@ -632,15 +1603,16 @@ export function updateCruise(
}
// 摩擦(指数衰减)
- const fric = Math.pow(CRUISE_CONFIG.shipFriction, dt * 60);
+ const fric = Math.pow(effectiveFriction(state), dt * 60);
ship.vx *= fric;
ship.vy *= fric;
// 限速
const sp = Math.hypot(ship.vx, ship.vy);
- if (sp > CRUISE_CONFIG.shipMaxSpeed) {
- ship.vx = (ship.vx / sp) * CRUISE_CONFIG.shipMaxSpeed;
- ship.vy = (ship.vy / sp) * CRUISE_CONFIG.shipMaxSpeed;
+ const maxSp = effectiveShipMaxSpeed(state);
+ if (sp > maxSp) {
+ ship.vx = (ship.vx / sp) * maxSp;
+ ship.vy = (ship.vy / sp) * maxSp;
}
// 位置更新 + 边界反弹
@@ -663,23 +1635,26 @@ export function updateCruise(
ship.vy = -Math.abs(ship.vy) * 0.4;
}
+ // ---- 射击输入 ----
+ if (input.shoot) shootShip(state);
+
// 护盾恢复(未受击时)
- if (state.invulnTimer <= 0 && ship.shield < CRUISE_CONFIG.shieldMax) {
+ if (state.invulnTimer <= 0 && ship.shield < effectiveShieldMax(state)) {
ship.shield = Math.min(
- CRUISE_CONFIG.shieldMax,
- ship.shield + CRUISE_CONFIG.shieldRegen * dt
+ effectiveShieldMax(state),
+ ship.shield + CRUISE_CONFIG.shieldRegen * state.upgrades.shieldRegenMult * dt
);
}
// 能量恢复
- if (ship.energy < CRUISE_CONFIG.energyMax) {
+ if (ship.energy < effectiveEnergyMax(state)) {
ship.energy = Math.min(
- CRUISE_CONFIG.energyMax,
- ship.energy + CRUISE_CONFIG.energyRegen * dt
+ effectiveEnergyMax(state),
+ ship.energy + CRUISE_CONFIG.energyRegen * state.upgrades.energyRegenMult * dt
);
}
// 低护盾闪烁标记
- state.flashLowShield = ship.shield < CRUISE_CONFIG.shieldMax * 0.3;
+ state.flashLowShield = ship.shield < effectiveShieldMax(state) * 0.3;
// ---- 实体更新 ----
for (const e of state.entities) {
@@ -696,6 +1671,11 @@ export function updateCruise(
e.y < -m ||
e.y > state.worldHeight + m
) {
+ // 召唤陨石离屏就消失(不重生)
+ if (e.summoned) {
+ (e as AsteroidEntity).damage = -999;
+ break;
+ }
// 从随机边缘重生,朝场内运动
const edge = Math.floor(Math.random() * 4);
if (edge === 0) {
@@ -753,6 +1733,20 @@ export function updateCruise(
}
}
}
+ // 清理被击碎的陨石
+ state.entities = state.entities.filter((e) => {
+ if (e.type === "asteroid" && (e as AsteroidEntity).damage <= -100) return false;
+ return true;
+ });
+
+ // ---- BOSS 更新 ----
+ if (state.boss) updateBoss(state, dt);
+
+ // ---- 子弹更新 ----
+ updateBullets(state, dt);
+
+ // ---- 道具更新 ----
+ updatePowerups(state, dt);
// ---- 粒子更新 ----
for (let i = state.particles.length - 1; i >= 0; i--) {
@@ -771,12 +1765,16 @@ export function updateCruise(
// ---- 碰撞检测 ----
// 飞船 vs 陨石
if (state.invulnTimer <= 0) {
+ const overdrive = state.activeBuffs.some((b) => b.type === "overdrive");
for (const e of state.entities) {
if (e.type !== "asteroid") continue;
const dist = Math.hypot(ship.x - e.x, ship.y - e.y);
if (dist < ship.radius + e.radius) {
- // 受击
- ship.shield = Math.max(0, ship.shield - e.damage);
+ if (!overdrive) {
+ // 受击
+ const dmg = e.damage * state.upgrades.damageReduceMult;
+ ship.shield = Math.max(0, ship.shield - dmg);
+ }
// 击退
const ang = Math.atan2(ship.y - e.y, ship.x - e.x);
ship.vx += Math.cos(ang) * 200;
@@ -788,7 +1786,7 @@ export function updateCruise(
burstParticles(state, ship.x, ship.y, "#fb7185", 18, 260, 0.7, 3.5);
burstParticles(state, ship.x, ship.y, "#fbbf24", 8, 180, 0.5, 2.5);
triggerShake(state, CRUISE_CONFIG.shakeIntensity);
- state.invulnTimer = CRUISE_CONFIG.invulnMs;
+ state.invulnTimer = effectiveInvulnMs(state);
break;
}
}
@@ -796,11 +1794,15 @@ export function updateCruise(
// 飞船 vs 风暴(持续伤害)
if (state.invulnTimer <= 0) {
+ const overdrive = state.activeBuffs.some((b) => b.type === "overdrive");
for (const e of state.entities) {
if (e.type !== "storm") continue;
const dist = Math.hypot(ship.x - e.x, ship.y - e.y);
if (dist < ship.radius + e.radius * 0.7) {
- ship.shield = Math.max(0, ship.shield - CRUISE_CONFIG.stormDps * dt);
+ if (!overdrive) {
+ const dmg = CRUISE_CONFIG.stormDps * dt * state.upgrades.damageReduceMult;
+ ship.shield = Math.max(0, ship.shield - dmg);
+ }
// 风暴边缘小粒子
if (Math.random() < 0.3) {
burstParticles(state, ship.x, ship.y, "#e879f9", 2, 100, 0.4, 2);
@@ -809,56 +1811,103 @@ export function updateCruise(
}
}
+ // 飞船 vs BOSS(接触伤害)
+ if (state.invulnTimer <= 0 && state.boss) {
+ const overdrive = state.activeBuffs.some((b) => b.type === "overdrive");
+ const dist = Math.hypot(ship.x - state.boss.x, ship.y - state.boss.y);
+ if (dist < ship.radius + state.boss.radius * 0.85) {
+ if (!overdrive) {
+ const dmg = CRUISE_CONFIG.bossContactDamage * state.upgrades.damageReduceMult;
+ ship.shield = Math.max(0, ship.shield - dmg);
+ }
+ // 击退
+ const ang = Math.atan2(ship.y - state.boss.y, ship.x - state.boss.x);
+ ship.vx += Math.cos(ang) * 280;
+ ship.vy += Math.sin(ang) * 280;
+ burstParticles(state, ship.x, ship.y, "#fb7185", 14, 240, 0.5, 3);
+ triggerShake(state, 16);
+ state.invulnTimer = effectiveInvulnMs(state);
+ }
+ }
+
// 收集物:吸引 + 收集
+ const attractR = effectiveAttractRadius(state);
+ const collectR = effectiveCollectRadius(state);
for (const e of state.entities) {
if (e.type === "crystal" || e.type === "insight" || e.type === "beacon") {
if (e.collected) continue;
const dist = Math.hypot(ship.x - e.x, ship.y - e.y);
// 吸引
- if (dist < CRUISE_CONFIG.attractRadius) {
+ if (dist < attractR) {
const ang = Math.atan2(ship.y - e.y, ship.x - e.x);
- const pull = (1 - dist / CRUISE_CONFIG.attractRadius) * 320;
+ const pull = (1 - dist / attractR) * 320;
e.x += Math.cos(ang) * pull * dt;
e.y += Math.sin(ang) * pull * dt;
}
// 收集
- if (dist < CRUISE_CONFIG.collectRadius) {
+ if (dist < collectR) {
e.collected = true;
if (e.type === "crystal") {
state.collectedCrystals++;
- state.score += 100;
+ state.score += Math.round(100 * state.upgrades.normalScoreMult);
burstParticles(state, e.x, e.y, "#34d399", 10, 200, 0.6, 3);
} else if (e.type === "insight") {
state.collectedInsights++;
- state.score += 250;
+ state.score += Math.round(250 * state.upgrades.normalScoreMult);
burstParticles(state, e.x, e.y, "#e879f9", 10, 200, 0.6, 3);
} else if (e.type === "beacon") {
state.collectedBeacons++;
- state.score += 500;
+ state.score += Math.round(500 * state.upgrades.normalScoreMult);
burstParticles(state, e.x, e.y, "#fbbf24", 12, 220, 0.7, 3.5);
+ // 信标 5% 概率掉落道具
+ if (!e.powerupDropped && Math.random() < 0.05) {
+ e.powerupDropped = true;
+ dropRandomPowerup(state, e.x, e.y, "beacon");
+ }
}
}
}
}
- // 星门到达判定
- for (const e of state.entities) {
- if (e.type !== "stargate") continue;
- const dist = Math.hypot(ship.x - e.x, ship.y - e.y);
- if (dist < ship.radius + e.radius * 0.6) {
- // 通关!
- state.phase = "won";
- state.endedAt = Date.now();
- // 烟花
- for (let k = 0; k < 5; k++) {
- const cx = state.worldWidth * (0.2 + Math.random() * 0.6);
- const cy = state.worldHeight * (0.2 + Math.random() * 0.6);
- const colors = ["#34d399", "#fb7185", "#fbbf24", "#e879f9"];
- const col = colors[k % colors.length];
- burstParticles(state, cx, cy, col, 24, 320, 1.0, 4);
+ // ---- 关卡结束判定 ----
+ // BOSS 关:BOSS HP 归零 → 胜利
+ if (state.boss && state.boss.hp <= 0 && !state.bossDefeated) {
+ state.bossDefeated = true;
+ state.phase = "won";
+ state.endedAt = Date.now();
+ // 大爆炸(200+ 粒子)
+ const bx = state.boss.x;
+ const by = state.boss.y;
+ const colors = ["#fb7185", "#fbbf24", "#e879f9", "#34d399"];
+ for (let k = 0; k < 8; k++) {
+ const col = colors[k % colors.length];
+ burstParticles(state, bx + (Math.random() - 0.5) * 80, by + (Math.random() - 0.5) * 80, col, 28, 360, 1.0, 4);
+ }
+ triggerShake(state, 28);
+ const result = computeRewards(state);
+ return result;
+ }
+
+ // 普通关卡:星门到达判定
+ if (!state.isBossLevel) {
+ for (const e of state.entities) {
+ if (e.type !== "stargate") continue;
+ const dist = Math.hypot(ship.x - e.x, ship.y - e.y);
+ if (dist < ship.radius + e.radius * 0.6) {
+ // 通关!
+ state.phase = "won";
+ state.endedAt = Date.now();
+ // 烟花
+ for (let k = 0; k < 5; k++) {
+ const cx = state.worldWidth * (0.2 + Math.random() * 0.6);
+ const cy = state.worldHeight * (0.2 + Math.random() * 0.6);
+ const colors = ["#34d399", "#fb7185", "#fbbf24", "#e879f9"];
+ const col = colors[k % colors.length];
+ burstParticles(state, cx, cy, col, 24, 320, 1.0, 4);
+ }
+ const result = computeRewards(state);
+ return result;
}
- const result = computeRewards(state);
- return result;
}
}
@@ -894,7 +1943,8 @@ export function updateCruise(
* - crystals = 收集碎片数 × (8 + level×2)
* - insights = 收集洞见数 × (2 + level×0.5)
* - contact = 收集信标数 × (1.5 + level×0.3)
- * - 通关额外 +50% 奖励;失败只得已收集的 50%
+ * - 普通通关额外 +50% 奖励;失败只得已收集的 50%
+ * - BOSS 战胜利 ×3 奖励(再叠 bossBonusMult)
*/
export function computeRewards(state: CruiseState): CruiseRunResult {
const level = state.level;
@@ -903,8 +1953,12 @@ export function computeRewards(state: CruiseState): CruiseRunResult {
const baseContact = state.collectedBeacons * (1.5 + level * 0.3);
let mult = 1;
- if (state.phase === "won") mult = 1.5;
- else if (state.phase === "lost") mult = 0.5;
+ if (state.phase === "won") {
+ mult = state.isBossLevel ? 3 : 1.5;
+ if (state.isBossLevel) mult *= state.upgrades.bossBonusMult;
+ } else if (state.phase === "lost") {
+ mult = 0.5;
+ }
const rewards = {
crystals: Math.round(baseCrystals * mult),
@@ -915,13 +1969,14 @@ export function computeRewards(state: CruiseState): CruiseRunResult {
// 综合得分(用于排行榜/统计)
const timeBonus = Math.max(0, 1000 - state.timeSec * 5);
const shieldBonus = Math.round(state.ship.shield * 10);
- const winBonus = state.phase === "won" ? 2000 : 0;
+ const winBonus = state.phase === "won" ? (state.isBossLevel ? 5000 : 2000) : 0;
const score =
state.score + timeBonus + shieldBonus + winBonus + level * 500;
return {
level,
phase: state.phase as "won" | "lost",
+ isBossLevel: state.isBossLevel,
durationSec: Math.round(state.timeSec * 10) / 10,
collectedCrystals: state.collectedCrystals,
collectedInsights: state.collectedInsights,
@@ -948,6 +2003,7 @@ function emptyStats(): CruiseStats {
totalCrystals: 0,
totalInsights: 0,
totalContact: 0,
+ bossKills: 0,
lastResult: null,
recentResults: [],
};
@@ -989,6 +2045,7 @@ export function recordRun(result: CruiseRunResult): CruiseStats {
if (result.phase === "won") {
stats.totalWins++;
stats.bestLevel = Math.max(stats.bestLevel, result.level);
+ if (result.isBossLevel) stats.bossKills++;
}
stats.highScore = Math.max(stats.highScore, result.score);
stats.totalCrystals += result.rewards.crystals;
diff --git a/tool-results/read_1782242271953_abf0610082e8.txt b/tool-results/read_1782242271953_abf0610082e8.txt
deleted file mode 100644
index 7b2a76441..000000000
--- a/tool-results/read_1782242271953_abf0610082e8.txt
+++ /dev/null
@@ -1,843 +0,0 @@
- 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) {
- 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()(
- 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→
\ No newline at end of file
diff --git a/tool-results/read_1782247259300_abf0610082e8.txt b/tool-results/read_1782247259300_abf0610082e8.txt
deleted file mode 100644
index 7b2a76441..000000000
--- a/tool-results/read_1782247259300_abf0610082e8.txt
+++ /dev/null
@@ -1,843 +0,0 @@
- 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) {
- 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()(
- 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→
\ No newline at end of file
diff --git a/tool-results/read_1782247263000_ec7aa6aa7dd7.txt b/tool-results/read_1782247263000_ec7aa6aa7dd7.txt
deleted file mode 100644
index 278298362..000000000
--- a/tool-results/read_1782247263000_ec7aa6aa7dd7.txt
+++ /dev/null
@@ -1,843 +0,0 @@
- 1→ 1→"use client";
- 2→ 2→// 回响星核 / Echo Nexus — Zustand 游戏状态管理
- 3→ 3→import { create } from "zustand";
- 4→ 4→import { persist, createJSONStorage } from "zustand/middleware";
- 5→ 5→import type {
- 6→ 6→ GameState,
- 7→ 7→ Crystal,
- 8→ 8→ CrystalTier,
- 9→ 9→ DecodePuzzle,
- 10→ 10→ ExpeditionResult,
- 11→ 11→} from "@/lib/game/types";
- 12→ 12→import {
- 13→ 13→ INITIAL_STATE,
- 14→ 14→ TECH_TREE,
- 15→ 15→ CRYSTAL_VALUE,
- 16→ 16→ CONTACT,
- 17→ 17→ CRYSTAL_SPAWN,
- 18→ 18→ FRAGMENTS,
- 19→ 19→ PRESTIGE,
- 20→ 20→} from "@/lib/game/config";
- 21→ 21→import {
- 22→ 22→ createInitialState,
- 23→ 23→ recomputeStats,
- 24→ 24→ decodeRewards,
- 25→ 25→ rollCrystalTierWithBonus,
- 26→ 26→ computeNewBlueprints,
- 27→ 27→ performPrestige,
- 28→ 28→} from "@/lib/game/engine";
- 29→ 29→import {
- 30→ 30→ generatePuzzle,
- 31→ 31→ tryClickNode,
- 32→ 32→ isSolvable,
- 33→ 33→ resetPuzzle as resetPuz,
- 34→ 34→} from "@/lib/game/decode";
- 35→ 35→import {
- 36→ 36→ generateExpedition,
- 37→ 37→ resolveNode,
- 38→ 38→ advanceExpedition,
- 39→ 39→ computeExpeditionPower,
- 40→ 40→ computeExpeditionHp,
- 41→ 41→ computeEnergyRegen,
- 42→ 42→ EXPEDITION_CONFIG,
- 43→ 43→} from "@/lib/game/expedition";
- 44→ 44→import { ACHIEVEMENTS, type Achievement } from "@/lib/game/achievements";
- 45→ 45→import {
- 46→ 46→ TIDE_CONFIG,
- 47→ 47→ rollTide,
- 48→ 48→ getTideModifiers,
- 49→ 49→ computeSilenceCompensation,
- 50→ 50→ type StarTide,
- 51→ 51→ type TideType,
- 52→ 52→} from "@/lib/game/starTide";
- 53→ 53→import {
- 54→ 54→ getPerk,
- 55→ 55→ constellationBonuses,
- 56→ 56→ rollPerkChoices,
- 57→ 57→} from "@/lib/game/constellation";
- 58→ 58→import {
- 59→ 59→ migrateChronicleFields,
- 60→ 60→ withPerks,
- 61→ 61→} from "@/lib/game/chronicle";
- 62→ 62→import {
- 63→ 63→ generateDailyChallenge,
- 64→ 64→ loadDailyProgress,
- 65→ 65→ addBeaconProgress,
- 66→ 66→ type BeaconDailyChallenge,
- 67→ 67→ type BeaconDailyProgress,
- 68→ 68→} from "@/lib/game/beacon";
- 69→ 69→import { setPendingOfflineReport } from "@/lib/game/offlineReport";
- 70→ 70→
- 71→ 71→interface GameActions {
- 72→ 72→ // 生命周期
- 73→ 73→ init: () => void;
- 74→ 74→ loadOnline: () => void;
- 75→ 75→ hardReset: () => void;
- 76→ 76→
- 77→ 77→ // 主循环
- 78→ 78→ tick: (now: number) => void;
- 79→ 79→ pulse: () => { gain: number; combo: number } | null;
- 80→ 80→
- 81→ 81→ // 星潮
- 82→ 82→ tickTide: (now: number) => { started?: TideType; ended?: TideType; silenceCompensation?: number } | null;
- 83→ 83→ consumeTideEvents: () => { started?: TideType; ended?: TideType; silenceCompensation?: number }[];
- 84→ 84→
- 85→ 85→ // 解码
- 86→ 86→ startDecode: (crystalId: string) => void;
- 87→ 87→ clickNode: (nodeId: number) => { ok: boolean; finished: boolean; solvable?: boolean; failReason?: string };
- 88→ 88→ undoStep: () => void;
- 89→ 89→ retryPuzzle: () => void;
- 90→ 90→ abandonPuzzle: () => void;
- 91→ 91→ /** 自动解码 T1(技术解锁后由 tick 调用) */
- 92→ 92→ autoDecodeTick: () => void;
- 93→ 93→
- 94→ 94→ // 探险
- 95→ 95→ startExpedition: () => { ok: boolean; reason?: string };
- 96→ 96→ resolveCurrentNode: () => ExpeditionResult | null;
- 97→ 97→ advanceNode: () => void;
- 98→ 98→ abortExpedition: () => void;
- 99→ 99→
- 100→ 100→ // 技术
- 101→ 101→ buyTech: (techId: string) => boolean;
- 102→ 102→
- 103→ 103→ // 飞升
- 104→ 104→ doPrestige: () => { newBp: number } | null;
- 105→ 105→
- 106→ 106→ // 星图天文台
- 107→ 107→ chooseConstellationPerk: (perkId: string) => boolean;
- 108→ 108→ rerollPerkChoices: () => void;
- 109→ 109→
- 110→ 110→ // 成就
- 111→ 111→ checkAchievements: () => Achievement[];
- 112→ 112→ consumeAchievementQueue: () => Achievement[];
- 113→ 113→
- 114→ 114→ // 设置
- 115→ 115→ toggleTheme: () => void;
- 116→ 116→ toggleSound: () => void;
- 117→ 117→
- 118→ 118→ // 深空信标奖励发放(v0.5)
- 119→ 119→ grantBeaconReward: (insights: number, contact: number) => void;
- 120→ 120→
- 121→ 121→ // 深空巡航奖励发放(v0.6 — 实时 Canvas 玩法)
- 122→ 122→ grantCruiseReward: (rewards: { crystals?: number; insights?: number; contact?: number }) => void;
- 123→ 123→
- 124→ 124→ // 派生
- 125→ 125→ canPrestige: () => boolean;
- 126→ 126→}
- 127→ 127→
- 128→ 128→type Store = GameState & GameActions & {
- 129→ 129→ _lastAutoDecode: number;
- 130→ 130→ _lastSpawn: number;
- 131→ 131→ _combo: number;
- 132→ 132→ _lastPulse: number;
- 133→ 133→ _achievementQueue: Achievement[];
- 134→ 134→ _tideEvents: { started?: TideType; ended?: TideType; silenceCompensation?: number }[];
- 135→ 135→};
- 136→ 136→
- 137→ 137→/** 计算并写回产能字段 */
- 138→ 138→function syncStats(state: Partial) {
- 139→ 139→ const s = recomputeStats(state);
- 140→ 140→ return {
- 141→ 141→ crystalsPerSec: s.crystalsPerSec,
- 142→ 142→ crystalCap: s.crystalCap,
- 143→ 143→ pulsePower: s.pulsePower,
- 144→ 144→ offlineEff: s.offlineEff,
- 145→ 145→ insightMult: s.insightMult,
- 146→ 146→ contactRateMult: s.contactRateMult,
- 147→ 147→ autoDecode: s.autoDecode,
- 148→ 148→ decodeStepsBonus: s.decodeStepsBonus,
- 149→ 149→ };
- 150→ 150→}
- 151→ 151→
- 152→ 152→/**
- 153→ 153→ * 深空信标进度追踪(v0.5)。
- 154→ 154→ * 在游戏关键动作(脉冲/解码/探险/BOSS/洞见)后调用,按今日挑战类型增量更新进度。
- 155→ 155→ * 进度独立存储于 localStorage(echo-nexus-beacon-prog-v1),不污染 GameState。
- 156→ 156→ * @returns 若刚完成则返回 true(供 UI 触发通知)
- 157→ 157→ */
- 158→ 158→function trackBeacon(
- 159→ 159→ type: "pulse" | "decode" | "expedition" | "boss" | "insight",
- 160→ 160→ delta: number
- 161→ 161→): boolean {
- 162→ 162→ if (typeof window === "undefined") return false;
- 163→ 163→ try {
- 164→ 164→ const challenge: BeaconDailyChallenge = generateDailyChallenge();
- 165→ 165→ if (challenge.type !== type) return false;
- 166→ 166→ const current: BeaconDailyProgress = loadDailyProgress();
- 167→ 167→ if (current.completedAt !== null) return false; // 已完成不再累加
- 168→ 168→ const { justCompleted } = addBeaconProgress(current, challenge, delta);
- 169→ 169→ return justCompleted;
- 170→ 170→ } catch {
- 171→ 171→ return false;
- 172→ 172→ }
- 173→ 173→}
- 174→ 174→
- 175→ 175→/** 检查并解锁叙事碎片 */
- 176→ 176→function checkFragments(state: GameState): string[] {
- 177→ 177→ const unlocked: string[] = [];
- 178→ 178→ for (const f of FRAGMENTS) {
- 179→ 179→ if (!state.fragments[f.id] && state.totalDecoded >= f.threshold) {
- 180→ 180→ state.fragments[f.id] = true;
- 181→ 181→ unlocked.push(f.id);
- 182→ 182→ }
- 183→ 183→ }
- 184→ 184→ return unlocked;
- 185→ 185→}
- 186→ 186→
- 187→ 187→export const useGameStore = create()(
- 188→ 188→ persist(
- 189→ 189→ (set, get) => ({
- 190→ 190→ ...createInitialState(),
- 191→ 191→ _lastAutoDecode: Date.now(),
- 192→ 192→ _lastSpawn: Date.now(),
- 193→ 193→ _combo: 0,
- 194→ 194→ _lastPulse: 0,
- 195→ 195→ _achievementQueue: [],
- 196→ 196→ _tideEvents: [],
- 197→ 197→
- 198→ 198→ init: () => {
- 199→ 199→ const s = get();
- 200→ 200→ const now = Date.now();
- 201→ 201→ // 兼容旧存档:若存在不可解的活跃谜题(旧算法生成),直接清掉
- 202→ 202→ let activePuzzle = s.activePuzzle;
- 203→ 203→ if (activePuzzle && !isSolvable(activePuzzle)) {
- 204→ 204→ // 把晶体放回队列,避免玩家卡死
- 205→ 205→ const crystal: Crystal = {
- 206→ 206→ id: `c_${now}_rec`,
- 207→ 207→ tier: activePuzzle.tier,
- 208→ 208→ value: CRYSTAL_VALUE[activePuzzle.tier].crystals,
- 209→ 209→ createdAt: now,
- 210→ 210→ };
- 211→ 211→ activePuzzle = null;
- 212→ 212→ set({
- 213→ 213→ pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending),
- 214→ 214→ });
- 215→ 215→ }
- 216→ 216→ // 兼容旧存档:补全 achievements / 星潮 / 星图 字段
- 217→ 217→ const achievements = s.achievements ?? {};
- 218→ 218→ const activeTide = s.activeTide ?? null;
- 219→ 219→ const constellation = s.constellation ?? [];
- 220→ 220→ const pendingPerkChoices = s.pendingPerkChoices ?? null;
- 221→ 221→ // v0.4 编年史兼容:补全 chronicle / runStart / bossKills / starTidesEncountered
- 222→ 222→ const migrated = migrateChronicleFields(s);
- 223→ 223→ // 星图「能量共振」天赋 +1 能量上限
- 224→ 224→ const cm = constellationBonuses(constellation);
- 225→ 225→ const energyMax = (INITIAL_STATE.energyMax) + cm.energyMaxBonus;
- 226→ 226→ // lastTideEnd:0 表示从未触发过星潮(首次游玩),保留 0 让 firstDelay 生效
- 227→ 227→ const lastTideEndRaw = s.lastTideEnd ?? 0;
- 228→ 228→ // 若旧存档有已过期的星潮,清掉
- 229→ 229→ const tide = activeTide && activeTide.endsAt > now ? activeTide : null;
- 230→ 230→ // 首次进入:补发离线收益
- 231→ 231→ const elapsed = Math.max(0, (now - s.lastTick) / 1000);
- 232→ 232→ if (elapsed > 5) {
- 233→ 233→ const cap = 8 * 3600;
- 234→ 234→ const secs = Math.min(elapsed, cap);
- 235→ 235→ const gain = s.crystalsPerSec * secs * s.offlineEff;
- 236→ 236→ const crystalsBefore = s.crystals;
- 237→ 237→ const crystalsAfter = Math.min(s.crystalCap, s.crystals + gain);
- 238→ 238→ // 设置离线报告(v0.5.2):通知 UI 弹出"欢迎回来"对话框
- 239→ 239→ setPendingOfflineReport({
- 240→ 240→ elapsedSec: secs,
- 241→ 241→ rawElapsedSec: elapsed,
- 242→ 242→ gain: crystalsAfter - crystalsBefore,
- 243→ 243→ rate: s.crystalsPerSec,
- 244→ 244→ eff: s.offlineEff,
- 245→ 245→ capped: elapsed > cap,
- 246→ 246→ crystalsBefore,
- 247→ 247→ crystalsAfter,
- 248→ 248→ crystalCap: s.crystalCap,
- 249→ 249→ });
- 250→ 250→ set({
- 251→ 251→ crystals: crystalsAfter,
- 252→ 252→ lastTick: now,
- 253→ 253→ activePuzzle,
- 254→ 254→ achievements,
- 255→ 255→ activeTide: tide,
- 256→ 256→ lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw,
- 257→ 257→ constellation,
- 258→ 258→ pendingPerkChoices,
- 259→ 259→ energyMax,
- 260→ 260→ chronicle: migrated.chronicle,
- 261→ 261→ runStart: migrated.runStart,
- 262→ 262→ bossKills: migrated.bossKills,
- 263→ 263→ starTidesEncountered: migrated.starTidesEncountered,
- 264→ 264→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation }),
- 265→ 265→ });
- 266→ 266→ } else {
- 267→ 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→ 268→ }
- 269→ 269→ },
- 270→ 270→
- 271→ 271→ loadOnline: () => {
- 272→ 272→ const s = get();
- 273→ 273→ set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation }) });
- 274→ 274→ },
- 275→ 275→
- 276→ 276→ hardReset: () => {
- 277→ 277→ set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0, _achievementQueue: [], _tideEvents: [] });
- 278→ 278→ },
- 279→ 279→
- 280→ 280→ tickTide: (now) => {
- 281→ 281→ const s = get();
- 282→ 282→ const tide = s.activeTide;
- 283→ 283→ // 星图「星潮引导」减少间隙
- 284→ 284→ const cm = constellationBonuses(s.constellation ?? []);
- 285→ 285→ const gap = Math.max(15000, TIDE_CONFIG.gap + cm.tideGapDeltaSec * 1000);
- 286→ 286→ // 1) 检查当前星潮是否结束
- 287→ 287→ if (tide && now >= tide.endsAt) {
- 288→ 288→ const endedType = tide.type;
- 289→ 289→ // 寂静期补偿洞见
- 290→ 290→ let silenceCompensation = 0;
- 291→ 291→ if (tide.type === "silence") {
- 292→ 292→ silenceCompensation = computeSilenceCompensation(tide);
- 293→ 293→ }
- 294→ 294→ const newInsights = s.insights + silenceCompensation;
- 295→ 295→ const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { ended: endedType };
- 296→ 296→ if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation;
- 297→ 297→ set({
- 298→ 298→ activeTide: null,
- 299→ 299→ lastTideEnd: now,
- 300→ 300→ insights: newInsights,
- 301→ 301→ // 星潮结束后重算 stats(移除 contactRate/insight 修饰)
- 302→ 302→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation }),
- 303→ 303→ _tideEvents: [...s._tideEvents, event],
- 304→ 304→ });
- 305→ 305→ return event;
- 306→ 306→ }
- 307→ 307→ // 2) 检查是否该触发新星潮(间隙已过)
- 308→ 308→ if (!tide) {
- 309→ 309→ const since = now - s.lastTideEnd;
- 310→ 310→ // 首次:以 createdAt 起算 firstDelay;之后以 lastTideEnd 起算 gap
- 311→ 311→ const firstStart = s.lastTideEnd === 0 || s.lastTideEnd <= s.createdAt + 1000;
- 312→ 312→ const need = firstStart ? TIDE_CONFIG.firstDelay : gap;
- 313→ 313→ if (since >= need) {
- 314→ 314→ const type = rollTide();
- 315→ 315→ const newTide: StarTide = {
- 316→ 316→ id: `tide_${now}_${Math.random().toString(36).slice(2, 7)}`,
- 317→ 317→ type,
- 318→ 318→ startedAt: now,
- 319→ 319→ endsAt: now + TIDE_CONFIG.duration,
- 320→ 320→ };
- 321→ 321→ const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { started: type };
- 322→ 322→ // v0.4 编年史:累计遇到的星潮 ID(去重)
- 323→ 323→ const tidesAll = s.starTidesEncountered ?? [];
- 324→ 324→ const tideId = `tide_${type}`;
- 325→ 325→ const newTidesAll = tidesAll.includes(tideId) ? tidesAll : [...tidesAll, tideId];
- 326→ 326→ set({
- 327→ 327→ activeTide: newTide,
- 328→ 328→ starTidesEncountered: newTidesAll,
- 329→ 329→ // 星潮开始后重算 stats(应用 contactRate/insight 修饰)
- 330→ 330→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation }),
- 331→ 331→ _tideEvents: [...s._tideEvents, event],
- 332→ 332→ });
- 333→ 333→ return event;
- 334→ 334→ }
- 335→ 335→ }
- 336→ 336→ return null;
- 337→ 337→ },
- 338→ 338→
- 339→ 339→ consumeTideEvents: () => {
- 340→ 340→ const s = get();
- 341→ 341→ if (s._tideEvents.length === 0) return [];
- 342→ 342→ const items = s._tideEvents;
- 343→ 343→ set({ _tideEvents: [] });
- 344→ 344→ return items;
- 345→ 345→ },
- 346→ 346→
- 347→ 347→ tick: (now) => {
- 348→ 348→ const s = get();
- 349→ 349→ const dt = Math.max(0, (now - s.lastTick) / 1000);
- 350→ 350→ if (dt <= 0) return;
- 351→ 351→
- 352→ 352→ // 星潮产能修饰(即时乘)
- 353→ 353→ const tideMod = getTideModifiers(s.activeTide);
- 354→ 354→ const effCps = s.crystalsPerSec * tideMod.crystalsPerSecMult;
- 355→ 355→ // 产能累加(仅闲置产能受仓库上限;探险奖励可超出)
- 356→ 356→ const newCrystals =
- 357→ 357→ s.crystals >= s.crystalCap
- 358→ 358→ ? s.crystals // 已达/超上限,不再自动产出
- 359→ 359→ : Math.min(s.crystalCap, s.crystals + effCps * dt);
- 360→ 360→
- 361→ 361→ // 自动产出待解码晶体(每 baseInterval 秒一颗,受技术/飞升略提速)
- 362→ 362→ const bpBoost = 1 + s.blueprints.length * 0.03;
- 363→ 363→ const spawnInterval = (CRYSTAL_SPAWN.baseIntervalSec / bpBoost) * 1000;
- 364→ 364→ let pending = s.pendingCrystals;
- 365→ 365→ let lastSpawn = s._lastSpawn;
- 366→ 366→ if (
- 367→ 367→ now - lastSpawn > spawnInterval &&
- 368→ 368→ pending.length < CRYSTAL_SPAWN.maxPending
- 369→ 369→ ) {
- 370→ 370→ // 星图「晶体富集」提升 T2/T3 概率
- 371→ 371→ const cm = constellationBonuses(s.constellation ?? []);
- 372→ 372→ const tier = rollCrystalTierWithBonus(cm.t2t3BonusRate);
- 373→ 373→ const crystal: Crystal = {
- 374→ 374→ id: `c_${now}_${Math.random().toString(36).slice(2, 7)}`,
- 375→ 375→ tier,
- 376→ 376→ value: CRYSTAL_VALUE[tier].crystals,
- 377→ 377→ createdAt: now,
- 378→ 378→ };
- 379→ 379→ pending = [...pending, crystal];
- 380→ 380→ lastSpawn = now;
- 381→ 381→ }
- 382→ 382→
- 383→ 383→ // 能量恢复(探险系统)
- 384→ 384→ let energy = s.energy;
- 385→ 385→ let lastEnergyTick = s.lastEnergyTick;
- 386→ 386→ if (energy < s.energyMax) {
- 387→ 387→ const regen = computeEnergyRegen(lastEnergyTick, now, energy, s.energyMax);
- 388→ 388→ energy = regen.energy;
- 389→ 389→ lastEnergyTick = regen.lastTick;
- 390→ 390→ } else {
- 391→ 391→ lastEnergyTick = now;
- 392→ 392→ }
- 393→ 393→
- 394→ 394→ set({
- 395→ 395→ crystals: newCrystals,
- 396→ 396→ lastTick: now,
- 397→ 397→ pendingCrystals: pending,
- 398→ 398→ _lastSpawn: lastSpawn,
- 399→ 399→ energy,
- 400→ 400→ lastEnergyTick,
- 401→ 401→ });
- 402→ 402→ },
- 403→ 403→
- 404→ 404→ pulse: () => {
- 405→ 405→ const s = get();
- 406→ 406→ const now = Date.now();
- 407→ 407→ // 连击
- 408→ 408→ let combo = 1;
- 409→ 409→ if (now - s._lastPulse < 1500) {
- 410→ 410→ combo = Math.min(10, s._combo + 1);
- 411→ 411→ }
- 412→ 412→ const mult = 1 + (combo - 1) * 0.15;
- 413→ 413→ // 星潮脉冲威力修饰
- 414→ 414→ const tideMod = getTideModifiers(s.activeTide);
- 415→ 415→ const gain = s.pulsePower * mult * tideMod.pulsePowerMult;
- 416→ 416→ set({
- 417→ 417→ crystals: Math.min(s.crystalCap, s.crystals + gain),
- 418→ 418→ _combo: combo,
- 419→ 419→ _lastPulse: now,
- 420→ 420→ });
- 421→ 421→ // 深空信标:脉冲任务进度 +1
- 422→ 422→ trackBeacon("pulse", 1);
- 423→ 423→ return { gain, combo };
- 424→ 424→ },
- 425→ 425→
- 426→ 426→ startDecode: (crystalId) => {
- 427→ 427→ const s = get();
- 428→ 428→ const crystal = s.pendingCrystals.find((c) => c.id === crystalId);
- 429→ 429→ if (!crystal) return;
- 430→ 430→ const puzzle = generatePuzzle(crystal.tier);
- 431→ 431→ set({
- 432→ 432→ activePuzzle: puzzle,
- 433→ 433→ pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystalId),
- 434→ 434→ });
- 435→ 435→ },
- 436→ 436→
- 437→ 437→ clickNode: (nodeId) => {
- 438→ 438→ const s = get();
- 439→ 439→ if (!s.activePuzzle) return { ok: false, finished: false, failReason: "无活跃谜题" };
- 440→ 440→ // 深拷贝谜题
- 441→ 441→ const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle));
- 442→ 442→ const res = tryClickNode(puzzle, nodeId);
- 443→ 443→ if (res.ok) {
- 444→ 444→ if (res.finished) {
- 445→ 445→ // 结算奖励(星潮解码奖励修饰 + 星图「二周目经验」)
- 446→ 446→ const tideMod = getTideModifiers(s.activeTide);
- 447→ 447→ const cm = constellationBonuses(s.constellation ?? []);
- 448→ 448→ const base = decodeRewards(puzzle.tier, s.insightMult, s.contactRateMult);
- 449→ 449→ const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult;
- 450→ 450→ const rewards = {
- 451→ 451→ crystals: Math.round(base.crystals * finalMult),
- 452→ 452→ insights: Math.round(base.insights * finalMult),
- 453→ 453→ contact: +(base.contact * finalMult).toFixed(2),
- 454→ 454→ };
- 455→ 455→ const newTotal = s.totalDecoded + 1;
- 456→ 456→ const newContact = Math.min(100, s.contact + rewards.contact);
- 457→ 457→ const newInsights = s.insights + rewards.insights;
- 458→ 458→ const newCrystals = s.crystals + rewards.crystals;
- 459→ 459→ // 解锁碎片
- 460→ 460→ const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } };
- 461→ 461→ const unlocked = checkFragments(tentative);
- 462→ 462→ set({
- 463→ 463→ activePuzzle: null,
- 464→ 464→ crystals: newCrystals,
- 465→ 465→ insights: newInsights,
- 466→ 466→ contact: newContact,
- 467→ 467→ totalDecoded: newTotal,
- 468→ 468→ fragments: tentative.fragments,
- 469→ 469→ });
- 470→ 470→ // 深空信标:解码 +1,洞见累计
- 471→ 471→ trackBeacon("decode", 1);
- 472→ 472→ trackBeacon("insight", rewards.insights);
- 473→ 473→ return { ok: true, finished: true, failReason: unlocked.join(",") || undefined };
- 474→ 474→ }
- 475→ 475→ // 点击成功但未完成:检测当前局面是否仍可解
- 476→ 476→ const solvable = isSolvable(puzzle);
- 477→ 477→ set({ activePuzzle: puzzle });
- 478→ 478→ return { ok: true, finished: false, solvable };
- 479→ 479→ }
- 480→ 480→ return res;
- 481→ 481→ },
- 482→ 482→
- 483→ 483→ undoStep: () => {
- 484→ 484→ const s = get();
- 485→ 485→ if (!s.activePuzzle || s.activePuzzle.path.length === 0) return;
- 486→ 486→ const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle));
- 487→ 487→ const lastId = puzzle.path.pop();
- 488→ 488→ if (lastId !== undefined) {
- 489→ 489→ const node = puzzle.grid.find((n) => n.id === lastId);
- 490→ 490→ if (node) node.used = false;
- 491→ 491→ }
- 492→ 492→ set({ activePuzzle: puzzle });
- 493→ 493→ },
- 494→ 494→
- 495→ 495→ retryPuzzle: () => {
- 496→ 496→ const s = get();
- 497→ 497→ if (!s.activePuzzle) return;
- 498→ 498→ set({ activePuzzle: resetPuz(s.activePuzzle) });
- 499→ 499→ },
- 500→ 500→
- 501→ 501→ abandonPuzzle: () => {
- 502→ 502→ const s = get();
- 503→ 503→ if (!s.activePuzzle) return;
- 504→ 504→ // 晶体放回队列末尾
- 505→ 505→ const crystal: Crystal = {
- 506→ 506→ id: `c_${Date.now()}_ret`,
- 507→ 507→ tier: s.activePuzzle.tier,
- 508→ 508→ value: CRYSTAL_VALUE[s.activePuzzle.tier].crystals,
- 509→ 509→ createdAt: Date.now(),
- 510→ 510→ };
- 511→ 511→ set({
- 512→ 512→ activePuzzle: null,
- 513→ 513→ pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending),
- 514→ 514→ });
- 515→ 515→ },
- 516→ 516→
- 517→ 517→ autoDecodeTick: () => {
- 518→ 518→ const s = get();
- 519→ 519→ if (!s.autoDecode) return;
- 520→ 520→ const now = Date.now();
- 521→ 521→ // 星图「自动校准」减少自动解码周期
- 522→ 522→ const cm = constellationBonuses(s.constellation ?? []);
- 523→ 523→ const interval = Math.max(5000, 12000 + cm.autoDecodeIntervalDeltaSec * 1000);
- 524→ 524→ if (now - s._lastAutoDecode < interval) return;
- 525→ 525→ // 找一颗 T1 晶体自动解码
- 526→ 526→ const idx = s.pendingCrystals.findIndex((c) => c.tier === 1);
- 527→ 527→ if (idx < 0) return;
- 528→ 528→ const crystal = s.pendingCrystals[idx];
- 529→ 529→ const tideMod = getTideModifiers(s.activeTide);
- 530→ 530→ const base = decodeRewards(1, s.insightMult, s.contactRateMult);
- 531→ 531→ const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult;
- 532→ 532→ const rewards = {
- 533→ 533→ crystals: Math.round(base.crystals * finalMult),
- 534→ 534→ insights: Math.round(base.insights * finalMult),
- 535→ 535→ contact: +(base.contact * finalMult).toFixed(2),
- 536→ 536→ };
- 537→ 537→ const newTotal = s.totalDecoded + 1;
- 538→ 538→ const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } };
- 539→ 539→ checkFragments(tentative);
- 540→ 540→ set({
- 541→ 541→ pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystal.id),
- 542→ 542→ crystals: s.crystals + rewards.crystals,
- 543→ 543→ insights: s.insights + rewards.insights,
- 544→ 544→ contact: Math.min(100, s.contact + rewards.contact),
- 545→ 545→ totalDecoded: newTotal,
- 546→ 546→ fragments: tentative.fragments,
- 547→ 547→ _lastAutoDecode: now,
- 548→ 548→ });
- 549→ 549→ // 深空信标:自动解码也算进度
- 550→ 550→ trackBeacon("decode", 1);
- 551→ 551→ trackBeacon("insight", rewards.insights);
- 552→ 552→ },
- 553→ 553→
- 554→ 554→ buyTech: (techId) => {
- 555→ 555→ const s = get();
- 556→ 556→ const node = TECH_TREE.find((t) => t.id === techId);
- 557→ 557→ if (!node) return false;
- 558→ 558→ const cur = s.tech[techId] ?? 0;
- 559→ 559→ if (cur >= 1) return false; // v0.1 每节点 1 级
- 560→ 560→ if (s.insights < node.cost) return false;
- 561→ 561→ const newTech = { ...s.tech, [techId]: 1 };
- 562→ 562→ set({
- 563→ 563→ insights: s.insights - node.cost,
- 564→ 564→ tech: newTech,
- 565→ 565→ ...syncStats({ tech: newTech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation }),
- 566→ 566→ });
- 567→ 567→ return true;
- 568→ 568→ },
- 569→ 569→
- 570→ 570→ // ============ 探险系统 ============
- 571→ 571→ startExpedition: () => {
- 572→ 572→ const s = get();
- 573→ 573→ if (s.activeExpedition && !s.activeExpedition.finished) {
- 574→ 574→ return { ok: false, reason: "已有进行中的探险" };
- 575→ 575→ }
- 576→ 576→ if (s.energy < EXPEDITION_CONFIG.energyCost) {
- 577→ 577→ return { ok: false, reason: "能量不足" };
- 578→ 578→ }
- 579→ 579→ const tideMod = getTideModifiers(s.activeTide);
- 580→ 580→ const power = computeExpeditionPower(s) + tideMod.expeditionPowerBonus;
- 581→ 581→ const hp = computeExpeditionHp(s) + tideMod.expeditionHpBonus;
- 582→ 582→ const seed = Math.floor(Math.random() * 1e9);
- 583→ 583→ const expedition = generateExpedition(seed, power, hp);
- 584→ 584→ set({
- 585→ 585→ activeExpedition: expedition,
- 586→ 586→ energy: s.energy - EXPEDITION_CONFIG.energyCost,
- 587→ 587→ totalExpeditions: s.totalExpeditions + 1,
- 588→ 588→ });
- 589→ 589→ return { ok: true };
- 590→ 590→ },
- 591→ 591→
- 592→ 592→ resolveCurrentNode: () => {
- 593→ 593→ const s = get();
- 594→ 594→ if (!s.activeExpedition || s.activeExpedition.finished) return null;
- 595→ 595→ // 深拷贝
- 596→ 596→ const exp = JSON.parse(JSON.stringify(s.activeExpedition));
- 597→ 597→ const result = resolveNode(exp);
- 598→ 598→ // 累计奖励
- 599→ 599→ if (result.crystals) exp.rewards.crystals += result.crystals;
- 600→ 600→ if (result.insights) exp.rewards.insights += result.insights;
- 601→ 601→ if (result.contact) exp.rewards.contact += result.contact;
- 602→ 602→ if (result.fragments) exp.rewards.fragments.push(...result.fragments);
- 603→ 603→ if (result.hpDelta) exp.hp = Math.max(0, Math.min(exp.maxHp, exp.hp + result.hpDelta));
- 604→ 604→ // 实时入账(玩家立即获得)
- 605→ 605→ const newCrystals = s.crystals + (result.crystals || 0);
- 606→ 606→ const newInsights = s.insights + (result.insights || 0);
- 607→ 607→ const newContact = Math.min(100, s.contact + (result.contact || 0));
- 608→ 608→ // 碎片解锁
- 609→ 609→ const newFragments = { ...s.fragments };
- 610→ 610→ if (result.fragments) {
- 611→ 611→ for (const fid of result.fragments) newFragments[fid] = true;
- 612→ 612→ }
- 613→ 613→ // 日志
- 614→ 614→ const logEntry = {
- 615→ 615→ expeditionId: exp.id,
- 616→ 616→ nodeType: exp.nodes[exp.currentNode]?.type || "combat",
- 617→ 617→ result: result.log,
- 618→ 618→ rewards: [
- 619→ 619→ result.crystals ? `+${result.crystals}晶体` : "",
- 620→ 620→ result.insights ? `+${result.insights}洞见` : "",
- 621→ 621→ result.contact ? `+${result.contact.toFixed(1)}接触` : "",
- 622→ 622→ result.hpDelta ? `${result.hpDelta > 0 ? "+" : ""}${result.hpDelta}生命` : "",
- 623→ 623→ ].filter(Boolean).join(" "),
- 624→ 624→ timestamp: Date.now(),
- 625→ 625→ };
- 626→ 626→ const newLog = [logEntry, ...s.expeditionLog].slice(0, 30);
- 627→ 627→
- 628→ 628→ if (result.ended) {
- 629→ 629→ // 探险结束(胜利或失败)
- 630→ 630→ exp.finished = true;
- 631→ 631→ }
- 632→ 632→
- 633→ 633→ // v0.4 编年史:击破 BOSS 时累计计数
- 634→ 634→ let bossKills = s.bossKills ?? 0;
- 635→ 635→ if (
- 636→ 636→ result.ended &&
- 637→ 637→ result.endReason === "victory" &&
- 638→ 638→ exp.nodes[exp.currentNode]?.type === "boss"
- 639→ 639→ ) {
- 640→ 640→ bossKills = bossKills + 1;
- 641→ 641→ }
- 642→ 642→
- 643→ 643→ set({
- 644→ 644→ activeExpedition: exp,
- 645→ 645→ crystals: newCrystals,
- 646→ 646→ insights: newInsights,
- 647→ 647→ contact: newContact,
- 648→ 648→ fragments: newFragments,
- 649→ 649→ expeditionLog: newLog,
- 650→ 650→ bossKills,
- 651→ 651→ });
- 652→ 652→ // 深空信标:探险完成(胜负都计)+ 洞见累计 + BOSS 击破
- 653→ 653→ if (result.ended) {
- 654→ 654→ trackBeacon("expedition", 1);
- 655→ 655→ if (result.endReason === "victory" && exp.nodes[exp.currentNode]?.type === "boss") {
- 656→ 656→ trackBeacon("boss", 1);
- 657→ 657→ }
- 658→ 658→ }
- 659→ 659→ if (result.insights) trackBeacon("insight", result.insights);
- 660→ 660→ return result;
- 661→ 661→ },
- 662→ 662→
- 663→ 663→ advanceNode: () => {
- 664→ 664→ const s = get();
- 665→ 665→ if (!s.activeExpedition || s.activeExpedition.finished) return;
- 666→ 666→ const exp = JSON.parse(JSON.stringify(s.activeExpedition));
- 667→ 667→ const node = exp.nodes[exp.currentNode];
- 668→ 668→ if (!node || !node.cleared) return; // 当前节点未结算不能前进
- 669→ 669→ if (exp.currentNode >= exp.nodes.length - 1) return;
- 670→ 670→ exp.currentNode++;
- 671→ 671→ set({ activeExpedition: exp });
- 672→ 672→ },
- 673→ 673→
- 674→ 674→ abortExpedition: () => {
- 675→ 675→ const s = get();
- 676→ 676→ if (!s.activeExpedition) return;
- 677→ 677→ const exp = JSON.parse(JSON.stringify(s.activeExpedition));
- 678→ 678→ exp.finished = true;
- 679→ 679→ const logEntry = {
- 680→ 680→ expeditionId: exp.id,
- 681→ 681→ nodeType: "rest" as const,
- 682→ 682→ result: "探险队主动撤退,保留已获奖励。",
- 683→ 683→ rewards: "",
- 684→ 684→ timestamp: Date.now(),
- 685→ 685→ };
- 686→ 686→ set({
- 687→ 687→ activeExpedition: exp,
- 688→ 688→ expeditionLog: [logEntry, ...s.expeditionLog].slice(0, 30),
- 689→ 689→ });
- 690→ 690→ },
- 691→ 691→
- 692→ 692→ doPrestige: () => {
- 693→ 693→ const s = get();
- 694→ 694→ if (s.contact < CONTACT.prestigeMin) return null;
- 695→ 695→ const newBp = computeNewBlueprints(s);
- 696→ 696→ const next = performPrestige(s);
- 697→ 697→ // 星图「能量共振」提升上限
- 698→ 698→ const cm = constellationBonuses(next.constellation ?? []);
- 699→ 699→ const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus;
- 700→ 700→ set({
- 701→ 701→ ...next,
- 702→ 702→ energyMax,
- 703→ 703→ ...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements, activeTide: next.activeTide, constellation: next.constellation }),
- 704→ 704→ _lastAutoDecode: Date.now(),
- 705→ 705→ _lastSpawn: Date.now(),
- 706→ 706→ _combo: 0,
- 707→ 707→ _lastPulse: 0,
- 708→ 708→ _tideEvents: [],
- 709→ 709→ });
- 710→ 710→ return { newBp };
- 711→ 711→ },
- 712→ 712→
- 713→ 713→ chooseConstellationPerk: (perkId) => {
- 714→ 714→ const s = get();
- 715→ 715→ if (!s.pendingPerkChoices || !s.pendingPerkChoices.includes(perkId)) return false;
- 716→ 716→ const perk = getPerk(perkId);
- 717→ 717→ if (!perk) return false;
- 718→ 718→ if (s.constellation?.includes(perkId)) return false;
- 719→ 719→ const newConstellation = [...(s.constellation ?? []), perkId];
- 720→ 720→ const cm = constellationBonuses(newConstellation);
- 721→ 721→ const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus;
- 722→ 722→ // v0.4 编年史:回填最近一条 entry 的 perksThisAscension
- 723→ 723→ const chronicle = s.chronicle ?? [];
- 724→ 724→ let newChronicle = chronicle;
- 725→ 725→ if (chronicle.length > 0) {
- 726→ 726→ const lastEntry = chronicle[chronicle.length - 1];
- 727→ 727→ const updatedLast = withPerks(lastEntry, [perkId]);
- 728→ 728→ newChronicle = [...chronicle.slice(0, -1), updatedLast];
- 729→ 729→ }
- 730→ 730→ set({
- 731→ 731→ constellation: newConstellation,
- 732→ 732→ pendingPerkChoices: null,
- 733→ 733→ energyMax,
- 734→ 734→ chronicle: newChronicle,
- 735→ 735→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: newConstellation }),
- 736→ 736→ });
- 737→ 737→ return true;
- 738→ 738→ },
- 739→ 739→
- 740→ 740→ rerollPerkChoices: () => {
- 741→ 741→ const s = get();
- 742→ 742→ if (!s.pendingPerkChoices) return;
- 743→ 743→ const choices = rollPerkChoices(s.constellation ?? []);
- 744→ 744→ if (choices.length > 0) set({ pendingPerkChoices: choices });
- 745→ 745→ },
- 746→ 746→
- 747→ 747→ checkAchievements: () => {
- 748→ 748→ const s = get();
- 749→ 749→ const newlyUnlocked: Achievement[] = [];
- 750→ 750→ const updated = { ...s.achievements };
- 751→ 751→ let crystals = s.crystals;
- 752→ 752→ let insights = s.insights;
- 753→ 753→ let contact = s.contact;
- 754→ 754→ let statsDirty = false;
- 755→ 755→ for (const a of ACHIEVEMENTS) {
- 756→ 756→ if (updated[a.id]) continue;
- 757→ 757→ if (a.check(s)) {
- 758→ 758→ updated[a.id] = true;
- 759→ 759→ newlyUnlocked.push(a);
- 760→ 760→ // 发放即时奖励
- 761→ 761→ if (a.reward.crystals) crystals += a.reward.crystals;
- 762→ 762→ if (a.reward.insights) insights += a.reward.insights;
- 763→ 763→ if (a.reward.contact) contact = Math.min(100, contact + a.reward.contact);
- 764→ 764→ if (a.reward.crystalsPerSecPct || a.reward.insightPct) statsDirty = true;
- 765→ 765→ }
- 766→ 766→ }
- 767→ 767→ if (newlyUnlocked.length === 0) return [];
- 768→ 768→ set({
- 769→ 769→ achievements: updated,
- 770→ 770→ crystals,
- 771→ 771→ insights,
- 772→ 772→ contact,
- 773→ 773→ ...(statsDirty
- 774→ 774→ ? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated, activeTide: s.activeTide, constellation: s.constellation })
- 775→ 775→ : {}),
- 776→ 776→ _achievementQueue: [...s._achievementQueue, ...newlyUnlocked],
- 777→ 777→ });
- 778→ 778→ return newlyUnlocked;
- 779→ 779→ },
- 780→ 780→
- 781→ 781→ consumeAchievementQueue: () => {
- 782→ 782→ const s = get();
- 783→ 783→ if (s._achievementQueue.length === 0) return [];
- 784→ 784→ const items = s._achievementQueue;
- 785→ 785→ set({ _achievementQueue: [] });
- 786→ 786→ return items;
- 787→ 787→ },
- 788→ 788→
- 789→ 789→ canPrestige: () => get().contact >= CONTACT.prestigeMin,
- 790→ 790→
- 791→ 791→ toggleTheme: () => set({ theme: get().theme === "dark" ? "light" : "dark" }),
- 792→ 792→ toggleSound: () => set({ soundOn: !get().soundOn }),
- 793→ 793→
- 794→ 794→ // 深空信标:发放每日挑战奖励(v0.5)
- 795→ 795→ grantBeaconReward: (insights, contact) => {
- 796→ 796→ const s = get();
- 797→ 797→ set({
- 798→ 798→ insights: s.insights + Math.round(insights),
- 799→ 799→ contact: Math.min(100, s.contact + contact),
- 800→ 800→ });
- 801→ 801→ },
- 802→ 802→
- 803→ 803→ // 深空巡航:发放实时玩法奖励(v0.6)
- 804→ 804→ grantCruiseReward: (rewards) => {
- 805→ 805→ const s = get();
- 806→ 806→ const addCrystals = rewards.crystals ?? 0;
- 807→ 807→ const addInsights = rewards.insights ?? 0;
- 808→ 808→ const addContact = rewards.contact ?? 0;
- 809→ 809→ set({
- 810→ 810→ crystals: Math.min(s.crystalCap, s.crystals + addCrystals),
- 811→ 811→ insights: s.insights + Math.round(addInsights),
- 812→ 812→ contact: Math.min(100, s.contact + addContact),
- 813→ 813→ });
- 814→ 814→ },
- 815→ 815→ }),
- 816→ 816→ {
- 817→ 817→ name: "echo-nexus-save-v1",
- 818→ 818→ storage: createJSONStorage(() => localStorage),
- 819→ 819→ // 不持久化临时字段
- 820→ 820→ partialize: (s) => {
- 821→ 821→ const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, ...rest } = s;
- 822→ 822→ void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue; void _tideEvents;
- 823→ 823→ return rest as GameState;
- 824→ 824→ },
- 825→ 825→ }
- 826→ 826→ )
- 827→ 827→);
- 828→ 828→
- 829→ 829→/** 选择器:未解锁碎片中下一个门槛 */
- 830→ 830→export function nextFragmentThreshold(totalDecoded: number): number | null {
- 831→ 831→ for (const f of FRAGMENTS) {
- 832→ 832→ if (totalDecoded < f.threshold) return f.threshold;
- 833→ 833→ }
- 834→ 834→ return null;
- 835→ 835→}
- 836→ 836→
- 837→ 837→export { FRAGMENTS, PRESTIGE, TECH_TREE };
- 838→ 838→
- 839→ 839→// 开发期调试:暴露 store 到 window,便于 QA 测试
- 840→ 840→if (typeof window !== "undefined" && process.env.NODE_ENV !== "production") {
- 841→ 841→ (window as unknown as { __gameStore?: typeof useGameStore }).__gameStore = useGameStore;
- 842→ 842→}
- 843→ 843→
\ No newline at end of file
diff --git a/worklog.md b/worklog.md
index 293790065..2cd3a019e 100644
--- a/worklog.md
+++ b/worklog.md
@@ -158,23 +158,26 @@
7. **探险能量恢复较慢**:45s/点,可加技术提升恢复速度。
8. **编年史上限 50 条**:超过自动丢弃最早的;v0.4 之前飞升无回填(空状态有提示)。
9. **5 纪元叙事为模板化生成**:v0.5+ 将加入手写剧情节点。
-10. **CrystalOrb 仍为 CSS 动画**:v0.6 未完成 Canvas 粒子系统升级(上下文截断),是下一阶段第一优先级。
+10. ~~**CrystalOrb 仍为 CSS 动画**~~ → ✅ **v0.7 已完成 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 样式细节打磨**:标签页图标/文字比例、视觉层次强化、移动端响应式进一步优化。
+### 下一阶段优先级(v0.7 后修正版)
+> 工单修正:原 P0 CrystalOrb Canvas + P1 角色属性两项均已在 v0.7 完成,现重新排序。
+
+1. **🔴 P0 — 巡航玩法大增强(v0.8 主线)**:BOSS 战关卡(每 5 关一个 BOSS,三阶段攻击模式 + 飞船射击系统)/ 道具掉落(5 种 powerup:护盾/能量/急速/超载/磁吸)/ 事件选择节点(每关通关后 3 选 1 强化牌,10 种强化)。这是当前最高优先级,能显著提升巡航玩法的深度与可玩性。
+2. **🟠 P1 — 探险 BOSS 战胜率平衡 + 探险能量恢复**:修复已知问题 6/7(探险 BOSS 胜率 25% 偏低,能量 45s/点 偏慢),提升探险体验。
+3. **🟠 P1 — 信标系统扩展**:周挑战 / 信标链(连续完成 N 天奖励),修复已知问题 5。
+4. **🟡 P2 — UI 样式细节打磨**:标签页图标/文字比例、视觉层次强化、移动端响应式进一步优化、CrystalOrb 周边留白优化(VLM 指出主界面左侧有空白)。
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`
+- **当前 job_id**: **228357**(2026-06-24 13:25 重建)
+- **配置**: `fixed_rate` + `"900"`(15分钟间隔),`priority=10`(高优先级),`payload.kind=webDevReview`,`tz=Asia/Shanghai`
- **任务名**: Echo Nexus - 任务审查与持续迭代
+- **任务描述强制要求**:每次触发 MUST 先完整阅读 worklog.md 工单,以工单为准,不凭记忆工作
- **正常完成不会被删除**,只有未捕获异常会标记失败。若任务列表为空,需用 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版)
+- **job_id 历史**: 227909(失效) → 228130(失效) → 228172(失效) → 228186(失效) → 228218(删除重建) → 228236(cron版,删除重建) → 228266(fixed_rate,失效) → 228329(失效) → **228357(当前,重建)**
---
@@ -269,3 +272,33 @@ Stage Summary:
- ✅ 严格四色全息(emerald/fuchsia/amber/rose),零蓝色/靛色
- ✅ 8 标签页 + 巡航按钮全部保留,data-tut 锚点未破坏
- ✅ 待分配点数时标签页 rose 红点提示 + 顶部 badge 闪烁
+
+---
+Task ID: 8-deploy
+Agent: main
+Task: 重建定时任务 + 更新 README + 部署 gh-pages(v0.7)
+
+Work Log:
+- 检查项目状态:CrystalOrb.tsx 已是 v0.7 Canvas 粒子系统(659 行),无活动 cron job
+- 重建定时任务:cron create fixed_rate="900" priority=10 webDevReview → job_id 228329(tz=Asia/Shanghai)
+- 重写 README.md:
+ - 顶部显著位置添加「在线游玩」链接(https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/)
+ - 版本号 v0.1 → v0.7
+ - 新增 10 大系统表格(含 v0.6 巡航 + v0.7 角色属性)
+ - 完整开发路线表(v0.1-v1.0,标记 ✅/🔜 状态)
+ - 新增本地开发命令 + Gitea Pages 部署指南
+ - 文档索引扩展至 10 个版本文档
+- 静态导出:BUILD_EXPORT=true bun run build:static → out/ 1.6M(HTTP 200 编译 4.3s)
+- 推送 gh-pages 分支:force push origin gh-pages(commit 8727fed "deploy: v0.7")
+- 提交 main:README 更新(commit 43dfc2d "docs: README 添加在线游玩链接 + v0.7 状态更新")
+- 验证 Gitea Pages:https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/ → HTTP 200 ✅
+- QA 验证:agent-browser 打开 http://localhost:3000/ → VLM 评分 8/10(主界面 polished,minor 空白)
+
+Stage Summary:
+- ✅ 定时任务重建成功(job_id 228329,每 15 分钟自动审查迭代)
+- ✅ README 重写完成,网站链接显著展示,v0.7 状态完整
+- ✅ gh-pages 部署成功,Gitea Pages HTTP 200 可访问
+- ✅ main 分支已同步推送(README + worklog 更新)
+- ✅ dev 服务器健康(HTTP 200,编译 < 250ms)
+- ✅ CrystalOrb v0.7 Canvas 粒子系统已确认完整(659 行)
+- 下一步:v0.8 巡航玩法增强(BOSS 战 / 道具掉落 / 事件选择节点)