diff --git a/.zscripts/dev.pid b/.zscripts/dev.pid index b10554094..b0ddb5802 100644 --- a/.zscripts/dev.pid +++ b/.zscripts/dev.pid @@ -1 +1 @@ -3832 +6708 diff --git a/src/app/page.tsx b/src/app/page.tsx index b165fa51f..665c96a02 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -172,7 +172,7 @@ export default function Page() {

回响星核

- ECHO NEXUS · v0.7 + ECHO NEXUS · v0.8

diff --git a/src/components/game/CruiseMode.tsx b/src/components/game/CruiseMode.tsx index 1fab9a006..869a8319f 100644 --- a/src/components/game/CruiseMode.tsx +++ b/src/components/game/CruiseMode.tsx @@ -1,5 +1,6 @@ "use client"; -// 回响星核 / Echo Nexus — 深空巡航 Canvas 2D 实时玩法渲染层 +// 回响星核 / Echo Nexus — 深空巡航 Canvas 2D 实时玩法渲染层 (v0.8) +// v0.8 新增:BOSS 战 / 飞船射击 / 道具掉落 / 事件选择节点 // 全屏 fixed 覆盖,rAF 驱动游戏循环,HTML HUD 叠层 + Canvas 实体绘制。 import { useState, useEffect, useRef, useCallback } from "react"; import { Button } from "@/components/ui/button"; @@ -16,19 +17,44 @@ import { Zap, Clock, Trophy, + Crosshair, + Skull, + Sparkles, + Wind, + Gauge, + Feather, + HeartPulse, + Battery, + Timer, + Flame, + Split, + Magnet, + Circle, + ShieldCheck, + ShieldHalf, + Crown, + Star, + ShieldPlus, + BatteryCharging, } from "lucide-react"; import { CRUISE_CONFIG, + UPGRADE_POOL, generateLevel, updateCruise, + shootShip, cruiseSeed, loadCruiseStats, recordRun, + rollUpgradeChoices, + applyUpgradeCard, + defaultUpgrades, type CruiseState, type CruisePhase, type CruiseRunResult, type CruiseInput, type CruiseStats, + type CruiseUpgrades, type ShipEntity, type AsteroidEntity, type StormEntity, @@ -37,6 +63,12 @@ import { type BeaconEntity, type StargateEntity, type ParticleEntity, + type BossEntity, + type BulletEntity, + type PowerupEntity, + type PowerupType, + type ActiveBuff, + type UpgradeCard, } from "@/lib/game/cruise"; import { useGameStore } from "@/store/gameStore"; import { formatNum } from "@/lib/game/config"; @@ -60,6 +92,52 @@ const C = { beaconCore: "#fef3c7", stargate: "#fb7185", stargateCore: "#ffe4e6", + boss: "#fb7185", + bossGlow: "#e879f9", + bossCore: "#ffe4e6", + shipBullet: "#34d399", + bossBullet: "#fb7185", + homingBullet: "#e879f9", +}; + +// 道具颜色 +const POWERUP_COLOR: Record = { + shield: "#34d399", + energy: "#fbbf24", + rapid: "#e879f9", + overdrive: "#fb7185", + magnet: "#a7f3d0", +}; + +// 道具图标 key → lucide 组件 +const POWERUP_ICON_KEY: Record = { + shield: "Shield", + energy: "Zap", + rapid: "Timer", + overdrive: "Flame", + magnet: "Magnet", +}; + +// 升级卡图标 key → lucide +const UPGRADE_ICON_MAP: Record> = { + Wind, + Gauge, + Feather, + Shield, + HeartPulse, + Battery, + Zap, + Flame, + Timer, + Split, + Magnet, + Circle, + ShieldCheck, + ShieldHalf, + Crown, + Star, + ShieldPlus, + BatteryCharging, }; // ============================================================ @@ -136,9 +214,35 @@ function drawStars( // 实体绘制函数 // ============================================================ -function drawShip(ctx: CanvasRenderingContext2D, ship: ShipEntity, invuln: boolean) { +function drawShip( + ctx: CanvasRenderingContext2D, + ship: ShipEntity, + invuln: boolean, + buffs: ActiveBuff[] +) { ctx.save(); ctx.translate(ship.x, ship.y); + + // buff 光环(在飞船下层) + if (buffs.length > 0) { + for (const buff of buffs) { + const color = POWERUP_COLOR[buff.type]; + const pct = buff.remaining / buff.total; + const r = ship.radius * (1.6 + 0.2 * Math.sin(Date.now() / 200)); + ctx.beginPath(); + ctx.arc(0, 0, r, 0, Math.PI * 2); + ctx.strokeStyle = `${color}${Math.round(pct * 200).toString(16).padStart(2, "0")}`; + ctx.lineWidth = 2; + ctx.stroke(); + // 进度弧 + ctx.beginPath(); + ctx.arc(0, 0, r + 3, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * pct); + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + } + ctx.rotate(ship.heading); // 受击闪烁 @@ -497,12 +601,252 @@ function drawParticle(ctx: CanvasRenderingContext2D, p: ParticleEntity) { ctx.globalAlpha = 1; } +// ============================================================ +// BOSS 绘制 +// ============================================================ +function drawBoss(ctx: CanvasRenderingContext2D, b: BossEntity) { + ctx.save(); + ctx.translate(b.x, b.y); + const breath = 1 + 0.06 * Math.sin(b.breathPhase); + const pulse = 0.5 + 0.5 * Math.sin(b.pulsePhase); + + // 阶段颜色(rose 主色 + fuchsia 辉光) + const phaseColor = b.attackPhase === 1 ? "#fb7185" : b.attackPhase === 2 ? "#f43f5e" : "#e879f9"; + ctx.shadowBlur = 40; + ctx.shadowColor = C.bossGlow; + + // 外层旋转环(多层) + for (let i = 0; i < 3; i++) { + ctx.save(); + ctx.rotate(b.rotation * (i % 2 === 0 ? 1 : -1) * (1 + i * 0.4)); + ctx.beginPath(); + const ringR = b.radius * (1.15 + i * 0.18) * breath; + const segs = 24; + for (let s = 0; s < segs; s++) { + const a = (s / segs) * Math.PI * 2; + const r = ringR + Math.sin(b.pulsePhase * 2 + s * 0.6) * 4; + const x = Math.cos(a) * r; + const y = Math.sin(a) * r; + if (s === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.closePath(); + ctx.strokeStyle = `${phaseColor}${i === 0 ? "cc" : "66"}`; + ctx.lineWidth = 3 - i * 0.6; + ctx.stroke(); + ctx.restore(); + } + + // 触手 / 光环(旋转的光带) + ctx.save(); + ctx.rotate(b.tentaclePhase); + for (let i = 0; i < 6; i++) { + ctx.save(); + ctx.rotate((i / 6) * Math.PI * 2); + ctx.beginPath(); + const tlen = b.radius * 1.4; + ctx.moveTo(b.radius * 0.6, 0); + ctx.quadraticCurveTo( + b.radius * 1.1 + Math.sin(b.tentaclePhase * 2 + i) * 10, + Math.cos(b.pulsePhase + i) * 8, + tlen, + 0 + ); + ctx.strokeStyle = `rgba(232,121,249,${0.5 + 0.3 * pulse})`; + ctx.lineWidth = 2.5; + ctx.stroke(); + // 触手尖端光点 + ctx.fillStyle = "#fda4af"; + ctx.beginPath(); + ctx.arc(tlen, 0, 3, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + ctx.restore(); + + // 内核(径向渐变) + ctx.shadowBlur = 30; + ctx.shadowColor = phaseColor; + const coreGrad = ctx.createRadialGradient(0, 0, 0, 0, 0, b.radius * 0.85); + coreGrad.addColorStop(0, "#ffffff"); + coreGrad.addColorStop(0.2, C.bossCore); + coreGrad.addColorStop(0.6, phaseColor); + coreGrad.addColorStop(1, "#9f1239"); + ctx.fillStyle = coreGrad; + ctx.beginPath(); + ctx.arc(0, 0, b.radius * 0.85 * breath, 0, Math.PI * 2); + ctx.fill(); + + // 内部六边形纹理(旋转) + ctx.shadowBlur = 0; + ctx.save(); + ctx.rotate(-b.rotation * 2); + ctx.beginPath(); + for (let i = 0; i < 6; i++) { + const a = (i / 6) * Math.PI * 2; + const r = b.radius * 0.55; + const x = Math.cos(a) * r; + const y = Math.sin(a) * r; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.closePath(); + ctx.strokeStyle = `rgba(254,205,211,${0.6 + 0.2 * pulse})`; + ctx.lineWidth = 2; + ctx.stroke(); + ctx.restore(); + + // 阶段标记(小圆点) + ctx.save(); + ctx.rotate(-b.rotation * 1.5); + for (let i = 0; i < 3; i++) { + const a = (i / 3) * Math.PI * 2 - Math.PI / 2; + const r = b.radius * 0.7; + const x = Math.cos(a) * r; + const y = Math.sin(a) * r; + ctx.fillStyle = i < b.attackPhase ? "#fbbf24" : "rgba(255,255,255,0.2)"; + ctx.beginPath(); + ctx.arc(x, y, 3, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + + ctx.restore(); +} + +// ============================================================ +// 子弹绘制 +// ============================================================ +function drawBullet(ctx: CanvasRenderingContext2D, b: BulletEntity) { + ctx.save(); + ctx.translate(b.x, b.y); + const ang = Math.atan2(b.vy, b.vx); + ctx.rotate(ang); + ctx.shadowBlur = 14; + ctx.shadowColor = b.color; + + if (b.owner === "ship") { + // 飞船子弹:emerald 椭圆 + 尾焰 + const grad = ctx.createLinearGradient(-b.radius * 3, 0, b.radius, 0); + grad.addColorStop(0, "rgba(52,211,153,0)"); + grad.addColorStop(0.5, "rgba(52,211,153,0.6)"); + grad.addColorStop(1, "#a7f3d0"); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.ellipse(0, 0, b.radius * 1.8, b.radius * 0.8, 0, 0, Math.PI * 2); + ctx.fill(); + // 核心亮点 + ctx.fillStyle = "#ffffff"; + ctx.beginPath(); + ctx.arc(b.radius * 0.6, 0, b.radius * 0.4, 0, Math.PI * 2); + ctx.fill(); + } else { + // BOSS 子弹:rose / fuchsia 圆球 + 拖尾 + const grad = ctx.createRadialGradient(0, 0, 0, 0, 0, b.radius); + if (b.homing) { + grad.addColorStop(0, "#fdf4ff"); + grad.addColorStop(0.6, "#e879f9"); + grad.addColorStop(1, "#86198f"); + } else { + grad.addColorStop(0, "#ffe4e6"); + grad.addColorStop(0.6, "#fb7185"); + grad.addColorStop(1, "#9f1239"); + } + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(0, 0, b.radius, 0, Math.PI * 2); + ctx.fill(); + // 拖尾 + ctx.shadowBlur = 0; + ctx.fillStyle = `${b.color}55`; + ctx.beginPath(); + ctx.ellipse(-b.radius * 2, 0, b.radius * 2, b.radius * 0.5, 0, 0, Math.PI * 2); + ctx.fill(); + // 追踪弹旋转标记 + if (b.homing) { + ctx.rotate(Date.now() / 100); + ctx.strokeStyle = "#fdf4ff"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(0, 0, b.radius * 1.3, 0, Math.PI * 1.5); + ctx.stroke(); + } + } + ctx.restore(); +} + +// ============================================================ +// 道具绘制 +// ============================================================ +function drawPowerup(ctx: CanvasRenderingContext2D, p: PowerupEntity) { + if (p.collected) return; + ctx.save(); + ctx.translate(p.x, p.y); + const pulse = 0.5 + 0.5 * Math.sin(p.pulsePhase); + // 即将消失闪烁 + const blink = p.life < 3 && Math.floor(Date.now() / 150) % 2 === 0; + if (blink) ctx.globalAlpha = 0.5; + + const color = POWERUP_COLOR[p.powerupType]; + ctx.shadowBlur = 22; + ctx.shadowColor = color; + + // 外发光环 + ctx.beginPath(); + ctx.arc(0, 0, p.radius * (1.5 + pulse * 0.3), 0, Math.PI * 2); + ctx.strokeStyle = `${color}40`; + ctx.lineWidth = 2; + ctx.stroke(); + + // 旋转六边形 + ctx.rotate(p.rotation); + ctx.beginPath(); + for (let i = 0; i < 6; i++) { + const a = (i / 6) * Math.PI * 2; + const r = p.radius; + const x = Math.cos(a) * r; + const y = Math.sin(a) * r; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.closePath(); + const grad = ctx.createRadialGradient(0, 0, 0, 0, 0, p.radius); + grad.addColorStop(0, "#ffffff"); + grad.addColorStop(0.5, color); + grad.addColorStop(1, `${color}80`); + ctx.fillStyle = grad; + ctx.fill(); + ctx.lineWidth = 1.5; + ctx.strokeStyle = "#ffffffaa"; + ctx.stroke(); + + // 内部图标(用文字符号代替 lucide,避免 React-Canvas 桥接) + ctx.shadowBlur = 0; + ctx.rotate(-p.rotation); + ctx.fillStyle = "#ffffff"; + ctx.font = "bold 12px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + const sym = + p.powerupType === "shield" ? "S" : + p.powerupType === "energy" ? "E" : + p.powerupType === "rapid" ? "R" : + p.powerupType === "overdrive" ? "O" : + "M"; + ctx.fillText(sym, 0, 1); + + ctx.globalAlpha = 1; + ctx.restore(); +} + // ============================================================ // HUD 值 // ============================================================ interface HudValues { shield: number; + shieldMax: number; energy: number; + energyMax: number; score: number; time: number; duration: number; @@ -513,6 +857,13 @@ interface HudValues { beacons: number; totalBeacons: number; lowShield: boolean; + isBossLevel: boolean; + bossHp: number; + bossMaxHp: number; + bossPhase: number; + buffs: ActiveBuff[]; + appliedUpgrades: UpgradeCard[]; + pickupFlashColor: string | null; } // ============================================================ @@ -521,6 +872,8 @@ interface HudValues { export function CruiseMode({ onClose }: { onClose: () => void }) { const canvasRef = useRef(null); const stateRef = useRef(null); + const upgradesRef = useRef(defaultUpgrades()); + const appliedUpgradesRef = useRef([]); const rafRef = useRef(0); const lastTimeRef = useRef(0); const starsRef = useRef([]); @@ -533,12 +886,16 @@ export function CruiseMode({ onClose }: { onClose: () => void }) { knobY: number; touchId: number | null; }>({ active: false, baseX: 0, baseY: 0, knobX: 0, knobY: 0, touchId: null }); + const shootHeldRef = useRef(false); const rewardGrantedRef = useRef(false); const pausedRef = useRef(false); const phaseRef = useRef("ready"); + const lastResultRef = useRef(null); const hudRef = useRef({ shield: 100, + shieldMax: 100, energy: 100, + energyMax: 100, score: 0, time: 0, duration: 60, @@ -549,6 +906,13 @@ export function CruiseMode({ onClose }: { onClose: () => void }) { beacons: 0, totalBeacons: 0, lowShield: false, + isBossLevel: false, + bossHp: 0, + bossMaxHp: 0, + bossPhase: 1, + buffs: [], + appliedUpgrades: [], + pickupFlashColor: null, }); const lastHudUpdateRef = useRef(0); const sizeRef = useRef<{ w: number; h: number }>({ w: 0, h: 0 }); @@ -556,9 +920,15 @@ export function CruiseMode({ onClose }: { onClose: () => void }) { const [phase, setPhase] = useState("ready"); const [level, setLevel] = useState(1); const [result, setResult] = useState(null); - const [stats, setStats] = useState(null); + const [stats, setStats] = useState(() => + typeof window !== "undefined" ? loadCruiseStats() : null + ); const [paused, setPaused] = useState(false); - const [isTouch, setIsTouch] = useState(false); + const [isTouch] = useState( + () => + typeof window !== "undefined" && + ("ontouchstart" in window || navigator.maxTouchPoints > 0) + ); const [joystickVisual, setJoystickVisual] = useState<{ baseX: number; baseY: number; @@ -566,9 +936,15 @@ export function CruiseMode({ onClose }: { onClose: () => void }) { knobY: number; active: boolean; }>({ baseX: 0, baseY: 0, knobX: 0, knobY: 0, active: false }); + const [pendingChoices, setPendingChoices] = useState([]); + const [selectedChoice, setSelectedChoice] = useState(null); + // 镜像 appliedUpgradesRef 用于渲染(ref 不能在 render 中读取) + const [appliedUpgradesList, setAppliedUpgradesList] = useState([]); const [hud, setHud] = useState({ shield: 100, + shieldMax: 100, energy: 100, + energyMax: 100, score: 0, time: 0, duration: 60, @@ -579,6 +955,13 @@ export function CruiseMode({ onClose }: { onClose: () => void }) { beacons: 0, totalBeacons: 0, lowShield: false, + isBossLevel: false, + bossHp: 0, + bossMaxHp: 0, + bossPhase: 1, + buffs: [], + appliedUpgrades: [], + pickupFlashColor: null, }); // ---- 启动一局 ---- @@ -586,20 +969,24 @@ export function CruiseMode({ onClose }: { onClose: () => void }) { const { w, h } = sizeRef.current; if (w === 0 || h === 0) return; const seed = cruiseSeed(lvl); - const state = generateLevel(lvl, seed, w, h); + const state = generateLevel(lvl, seed, w, h, upgradesRef.current, appliedUpgradesRef.current); state.phase = "playing"; stateRef.current = state; rewardGrantedRef.current = false; + lastResultRef.current = null; setResult(null); setLevel(lvl); setPhase("playing"); phaseRef.current = "playing"; setPaused(false); pausedRef.current = false; + setSelectedChoice(null); // 重置 HUD hudRef.current = { shield: state.ship.shield, + shieldMax: CRUISE_CONFIG.shieldMax + state.upgrades.shieldMaxBonus, energy: state.ship.energy, + energyMax: CRUISE_CONFIG.energyMax + state.upgrades.energyMaxBonus, score: 0, time: 0, duration: state.durationSec, @@ -610,24 +997,21 @@ export function CruiseMode({ onClose }: { onClose: () => void }) { beacons: 0, totalBeacons: state.totalBeacons, lowShield: false, + isBossLevel: state.isBossLevel, + bossHp: state.boss?.hp ?? 0, + bossMaxHp: state.boss?.maxHp ?? 0, + bossPhase: state.boss?.attackPhase ?? 1, + buffs: [], + appliedUpgrades: state.appliedUpgrades, + pickupFlashColor: null, }; setHud({ ...hudRef.current }); }, []); - // ---- 加载统计 ---- - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setStats(loadCruiseStats()); - }, []); + // ---- 加载统计(已用 useState initializer 初始化,无需 effect)---- - // ---- 检测触摸设备 ---- - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setIsTouch( - typeof window !== "undefined" && - ("ontouchstart" in window || navigator.maxTouchPoints > 0) - ); - }, []); + // ---- 检测触摸设备(用 useState initializer 避免 effect 内 setState)---- + // 已在 useState 初始化时检测,此处无需 effect // ---- Canvas 初始化 + Resize ---- useEffect(() => { @@ -664,6 +1048,10 @@ export function CruiseMode({ onClose }: { onClose: () => void }) { if (["w", "a", "s", "d", "arrowup", "arrowdown", "arrowleft", "arrowright"].includes(k)) { keysRef.current.add(k); e.preventDefault(); + } else if (k === " " || k === "spacebar") { + // 空格射击 + shootHeldRef.current = true; + e.preventDefault(); } else if (k === "p") { if (phaseRef.current === "playing") { const np = !pausedRef.current; @@ -676,7 +1064,11 @@ export function CruiseMode({ onClose }: { onClose: () => void }) { }; const up = (e: KeyboardEvent) => { const k = e.key.toLowerCase(); - keysRef.current.delete(k); + if (["w", "a", "s", "d", "arrowup", "arrowdown", "arrowleft", "arrowright"].includes(k)) { + keysRef.current.delete(k); + } else if (k === " " || k === "spacebar") { + shootHeldRef.current = false; + } }; window.addEventListener("keydown", down); window.addEventListener("keyup", up); @@ -689,6 +1081,7 @@ export function CruiseMode({ onClose }: { onClose: () => void }) { // ---- 失焦自动暂停 ---- useEffect(() => { const onBlur = () => { + shootHeldRef.current = false; if (phaseRef.current === "playing") { pausedRef.current = true; setPaused(true); @@ -702,12 +1095,18 @@ export function CruiseMode({ onClose }: { onClose: () => void }) { }; }, []); - // ---- 触摸摇杆 ---- + // ---- 触摸摇杆 + 射击按钮 ---- useEffect(() => { const onStart = (e: TouchEvent) => { if (phaseRef.current !== "playing" || pausedRef.current) return; for (let i = 0; i < e.changedTouches.length; i++) { const t = e.changedTouches[i]; + // 右下区域:射击按钮 + if (t.clientX > window.innerWidth * 0.65 && t.clientY > window.innerHeight * 0.55) { + shootHeldRef.current = true; + e.preventDefault(); + continue; + } // 左下区域激活摇杆 if (t.clientX < window.innerWidth * 0.5 && t.clientY > window.innerHeight * 0.35) { if (joystickRef.current.active) continue; @@ -757,22 +1156,25 @@ export function CruiseMode({ onClose }: { onClose: () => void }) { } }; const onEnd = (e: TouchEvent) => { - if (!joystickRef.current.active) return; for (let i = 0; i < e.changedTouches.length; i++) { const t = e.changedTouches[i]; - if (t.identifier !== joystickRef.current.touchId) continue; - joystickRef.current = { - active: false, - baseX: 0, - baseY: 0, - knobX: 0, - knobY: 0, - touchId: null, - }; - setJoystickVisual({ baseX: 0, baseY: 0, knobX: 0, knobY: 0, active: false }); - e.preventDefault(); - return; + if (t.identifier === joystickRef.current.touchId) { + joystickRef.current = { + active: false, + baseX: 0, + baseY: 0, + knobX: 0, + knobY: 0, + touchId: null, + }; + setJoystickVisual({ baseX: 0, baseY: 0, knobX: 0, knobY: 0, active: false }); + } + // 右下区域松开 → 停止射击 + if (t.clientX > window.innerWidth * 0.65 && t.clientY > window.innerHeight * 0.55) { + shootHeldRef.current = false; + } } + e.preventDefault(); }; window.addEventListener("touchstart", onStart, { passive: false }); window.addEventListener("touchmove", onMove, { passive: false }); @@ -823,10 +1225,12 @@ export function CruiseMode({ onClose }: { onClose: () => void }) { dx, dy, active: Math.abs(dx) > 0.05 || Math.abs(dy) > 0.05, + shoot: shootHeldRef.current, }; const res = updateCruise(state, dt, input); if (res) { // 结算 + lastResultRef.current = res; setResult(res); setPhase(res.phase); phaseRef.current = res.phase; @@ -840,7 +1244,9 @@ export function CruiseMode({ onClose }: { onClose: () => void }) { // 更新 HUD ref hudRef.current = { shield: state.ship.shield, + shieldMax: CRUISE_CONFIG.shieldMax + state.upgrades.shieldMaxBonus, energy: state.ship.energy, + energyMax: CRUISE_CONFIG.energyMax + state.upgrades.energyMaxBonus, score: state.score, time: state.timeSec, duration: state.durationSec, @@ -851,6 +1257,13 @@ export function CruiseMode({ onClose }: { onClose: () => void }) { beacons: state.collectedBeacons, totalBeacons: state.totalBeacons, lowShield: state.flashLowShield, + isBossLevel: state.isBossLevel, + bossHp: state.boss?.hp ?? 0, + bossMaxHp: state.boss?.maxHp ?? 0, + bossPhase: state.boss?.attackPhase ?? 1, + buffs: [...state.activeBuffs], + appliedUpgrades: state.appliedUpgrades, + pickupFlashColor: state.pickupFlash?.color ?? null, }; // 节流更新 React HUD(~12fps) if (time - lastHudUpdateRef.current > 80) { @@ -894,6 +1307,8 @@ export function CruiseMode({ onClose }: { onClose: () => void }) { for (const e of state.entities) { if (e.type === "asteroid") drawAsteroid(ctx, e); } + // 道具 + for (const p of state.powerups) drawPowerup(ctx, p); // 收集物 for (const e of state.entities) { if (e.type === "crystal") drawCrystal(ctx, e); @@ -902,9 +1317,13 @@ export function CruiseMode({ onClose }: { onClose: () => void }) { } // 粒子 for (const p of state.particles) drawParticle(ctx, p); + // 子弹 + for (const b of state.bullets) drawBullet(ctx, b); + // BOSS(在飞船之下绘制) + if (state.boss) drawBoss(ctx, state.boss); // 飞船(最上层) if (state.phase === "playing" || state.phase === "won") { - drawShip(ctx, state.ship, state.invulnTimer > 0); + drawShip(ctx, state.ship, state.invulnTimer > 0, state.activeBuffs); } } @@ -917,6 +1336,17 @@ export function CruiseMode({ onClose }: { onClose: () => void }) { ctx.lineWidth = 6; ctx.strokeRect(3, 3, w - 6, h - 6); } + + // 道具拾取屏幕闪光(边缘渐变) + if (state && state.pickupFlash && state.phase === "playing") { + const alpha = state.pickupFlash.timer / 400; + const c = state.pickupFlash.color; + const grad = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.3, w / 2, h / 2, Math.max(w, h) * 0.7); + grad.addColorStop(0, "rgba(0,0,0,0)"); + grad.addColorStop(1, `${c}${Math.round(alpha * 100).toString(16).padStart(2, "0")}`); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, w, h); + } }; rafRef.current = requestAnimationFrame(loop); return () => cancelAnimationFrame(rafRef.current); @@ -935,14 +1365,44 @@ export function CruiseMode({ onClose }: { onClose: () => void }) { onClose(); }, [onClose]); + // ---- 进入升级选择阶段(won → choosing) ---- + const goToChoosing = useCallback(() => { + if (!lastResultRef.current) return; + // 失败不进入选择 + if (lastResultRef.current.phase !== "won") return; + const choices = rollUpgradeChoices(3, appliedUpgradesRef.current.map((c) => c.id)); + setPendingChoices(choices); + setSelectedChoice(null); + setPhase("choosing"); + phaseRef.current = "choosing"; + }, []); + + // ---- 选择一张升级卡 ---- + const handlePickUpgrade = useCallback((idx: number) => { + const card = pendingChoices[idx]; + if (!card) return; + setSelectedChoice(idx); + // 应用升级 + applyUpgradeCard(upgradesRef.current, card); + appliedUpgradesRef.current = [...appliedUpgradesRef.current, card]; + setAppliedUpgradesList(appliedUpgradesRef.current); + // 延迟跳转到下一关(让动画播放) + setTimeout(() => { + if (lastResultRef.current) { + startGame(lastResultRef.current.level + 1); + } + }, 700); + }, [pendingChoices, startGame]); + // ============================================================ // 渲染 // ============================================================ - const shieldPct = Math.max(0, Math.min(100, hud.shield)); + const shieldPct = hud.shieldMax > 0 ? Math.max(0, Math.min(100, (hud.shield / hud.shieldMax) * 100)) : 0; const shieldColor = shieldPct > 60 ? "#34d399" : shieldPct > 30 ? "#fbbf24" : "#fb7185"; - const energyPct = Math.max(0, Math.min(100, hud.energy)); + const energyPct = hud.energyMax > 0 ? Math.max(0, Math.min(100, (hud.energy / hud.energyMax) * 100)) : 0; const timeLeft = Math.max(0, hud.duration - hud.time); + const bossHpPct = hud.bossMaxHp > 0 ? (hud.bossHp / hud.bossMaxHp) * 100 : 0; return (
@@ -954,64 +1414,142 @@ export function CruiseMode({ onClose }: { onClose: () => void }) { {/* ============ 顶部 HUD ============ */} {phase === "playing" && ( <> - {/* 左上:护盾 + 能量 */} -
-
- - 护盾 - - {Math.round(shieldPct)} - -
-
-
-
-
- - 能量 - - {Math.round(energyPct)} - -
-
-
+ {/* 左上:护盾 + 能量 + Buff 列表 */} +
+
+
+ + 护盾 + + {Math.round(hud.shield)} + +
+
+
+
+
+ + 能量 + + {Math.round(hud.energy)} + +
+
+
+
+ {/* Buff 列表 */} + {hud.buffs.length > 0 && ( +
+ {hud.buffs.map((buff, i) => { + const color = POWERUP_COLOR[buff.type]; + const pct = Math.max(0, (buff.remaining / buff.total) * 100); + const label = + buff.type === "shield" ? "护盾" : + buff.type === "energy" ? "能量" : + buff.type === "rapid" ? "急速" : + buff.type === "overdrive" ? "过载" : + "磁吸"; + return ( +
+
+ {label} +
+
+
+ {Math.ceil(buff.remaining)}s +
+ ); + })} +
+ )} + {/* 已选升级列表(紧凑) */} + {hud.appliedUpgrades.length > 0 && ( +
+ {hud.appliedUpgrades.map((u, i) => ( +
+ {(() => { + const Icon = UPGRADE_ICON_MAP[u.icon] ?? Sparkles; + return ; + })()} +
+ ))} +
+ )}
- {/* 中上:分数 + 关卡 + 用时 */} -
-
-
关卡
-
L{level}
-
-
-
-
分数
-
{formatNum(hud.score)}
-
-
-
-
- 用时 + {/* 中上:分数 + 关卡 + 用时 + BOSS HP */} +
+
+
+
关卡
+
+ L{level}{hud.isBossLevel && } +
-
- {Math.floor(timeLeft / 60)}:{String(Math.floor(timeLeft % 60)).padStart(2, "0")} +
+
+
分数
+
{formatNum(hud.score)}
+
+
+
+
+ 用时 +
+
+ {Math.floor(timeLeft / 60)}:{String(Math.floor(timeLeft % 60)).padStart(2, "0")} +
+ {/* BOSS HP 条 */} + {hud.isBossLevel && hud.bossMaxHp > 0 && ( +
+
+
+ + BOSS +
+
+ {[1, 2, 3].map((p) => ( +
+ ))} +
+
+
+
+
+
+ )}
{/* 右上:收集计数 + 暂停 + 退出 */} @@ -1059,12 +1597,12 @@ export function CruiseMode({ onClose }: { onClose: () => void }) { {/* 底部:操作提示 / 摇杆 */} {!isTouch && (
- WASD / 方向键 移动 · P 暂停 · Esc 退出 + WASD / 方向键 移动 · 空格 射击 · P 暂停 · Esc 退出
)} {isTouch && (
- 左下区域拖动操控 + 左下摇杆 · 右下射击
)} @@ -1085,6 +1623,17 @@ export function CruiseMode({ onClose }: { onClose: () => void }) {
)} + {/* 虚拟射击按钮(触屏,右下角) */} + {isTouch && ( + + )} + {/* 暂停遮罩 */} {paused && (
@@ -1121,9 +1670,9 @@ export function CruiseMode({ onClose }: { onClose: () => void }) {
-
DEEP SPACE CRUISE
+
DEEP SPACE CRUISE · v0.8

深空巡航

-

操控飞船穿越陨石带,抵达星门

+

操控飞船穿越陨石带,抵达星门 / 击败 BOSS

@@ -1143,9 +1692,9 @@ export function CruiseMode({ onClose }: { onClose: () => void }) {
-
最高关卡
+
BOSS 击杀
- {stats && stats.bestLevel > 0 ? `L${stats.bestLevel}` : "—"} + {stats ? stats.bossKills : 0}
@@ -1167,6 +1716,18 @@ export function CruiseMode({ onClose }: { onClose: () => void }) { 抵达星门通关 → +50% 奖励
+
+ + 每 5 关 BOSS 战:击败 ×3 奖励 +
+
+ + 空格 / 右下角射击按钮 → 发射能量弹 +
+
+ + 通关后 3 选 1 强化卡牌(每局重置) +
@@ -1186,7 +1747,7 @@ export function CruiseMode({ onClose }: { onClose: () => void }) {
- {isTouch ? "左下区域拖动操控飞船" : "WASD / 方向键操控 · P 暂停 · Esc 退出"} + {isTouch ? "左下摇杆移动 · 右下射击" : "WASD / 方向键移动 · 空格射击 · P 暂停 · Esc 退出"}
@@ -1198,29 +1759,29 @@ export function CruiseMode({ onClose }: { onClose: () => void }) {
- {phase === "won" ? "MISSION COMPLETE" : "MISSION FAILED"} + {phase === "won" ? (result.isBossLevel ? "BOSS DEFEATED" : "MISSION COMPLETE") : "MISSION FAILED"}

- {phase === "won" ? "巡航成功" : "巡航失败"} + {phase === "won" ? (result.isBossLevel ? "BOSS 击破" : "巡航成功") : "巡航失败"}

关卡 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 战 / 道具掉落 / 事件选择节点)