42773b7e-7147-4ad5-828d-da7fcf0290b8
This commit is contained in:
+1
-1
Submodule docs/repo updated: 51714d2532...27bb1555e5
Binary file not shown.
|
After Width: | Height: | Size: 183 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 231 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 226 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 183 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 229 KiB |
+45
-4
@@ -12,6 +12,11 @@ import { SettingsDialog } from "@/components/game/SettingsDialog";
|
||||
import { ExpeditionPanel } from "@/components/game/ExpeditionPanel";
|
||||
import { AchievementsPanel } from "@/components/game/AchievementsPanel";
|
||||
import { AchievementNotifier } from "@/components/game/AchievementNotifier";
|
||||
import {
|
||||
StarTideNotifier,
|
||||
StarTideIndicator,
|
||||
StarTideOverlay,
|
||||
} from "@/components/game/StarTide";
|
||||
import { useGameLoop } from "@/hooks/useGameLoop";
|
||||
import { useAudioSync } from "@/hooks/useAudio";
|
||||
import { useGameStore } from "@/store/gameStore";
|
||||
@@ -30,6 +35,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config";
|
||||
import { ACHIEVEMENTS } from "@/lib/game/achievements";
|
||||
import { TIDE_EVENTS } from "@/lib/game/starTide";
|
||||
|
||||
export default function Page() {
|
||||
useGameLoop();
|
||||
@@ -47,6 +53,7 @@ export default function Page() {
|
||||
const ownedAchievements = useGameStore((s) => s.achievements);
|
||||
const crystals = useGameStore((s) => s.crystals);
|
||||
const crystalCap = useGameStore((s) => s.crystalCap);
|
||||
const activeTide = useGameStore((s) => s.activeTide);
|
||||
const energy = useGameStore((s) => s.energy);
|
||||
const hasActiveExpedition = useGameStore((s) => !!s.activeExpedition && !s.activeExpedition.finished);
|
||||
|
||||
@@ -88,12 +95,18 @@ export default function Page() {
|
||||
if (warehouseFull) {
|
||||
goal = "⚠ 仓库已满,产能浪费中!请解码晶体或升级仓库";
|
||||
}
|
||||
if (activeTide) {
|
||||
const tm = TIDE_EVENTS[activeTide.type];
|
||||
goal = `${tm.icon} 星潮「${tm.name}」进行中 · ${tm.desc}`;
|
||||
}
|
||||
if (canPrestige) goal = "✦ 接触进度已满,可发起飞升进入新周目";
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen flex flex-col bg-[#050410] text-foreground overflow-x-hidden">
|
||||
{/* 星空背景 */}
|
||||
<StarfieldCanvas className="fixed inset-0 w-full h-full -z-10" />
|
||||
{/* 星潮背景叠层 */}
|
||||
<StarTideOverlay />
|
||||
|
||||
{/* 顶部 Header */}
|
||||
<header className="sticky top-0 z-30 px-3 sm:px-5 pt-3 pb-2">
|
||||
@@ -108,11 +121,12 @@ export default function Page() {
|
||||
<div className="leading-tight">
|
||||
<h1 className="text-base sm:text-lg font-bold text-gradient">回响星核</h1>
|
||||
<p className="text-[9px] sm:text-[10px] text-muted-foreground/70 -mt-0.5 tracking-wider">
|
||||
ECHO NEXUS · v0.2.1
|
||||
ECHO NEXUS · v0.3
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<StarTideIndicator />
|
||||
{canPrestige && (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -212,9 +226,35 @@ export default function Page() {
|
||||
|
||||
{/* 底部 Footer */}
|
||||
<footer className="sticky bottom-0 z-20 mt-auto px-3 sm:px-5 pb-2 pt-1">
|
||||
<div className={`glass rounded-xl px-3 py-2 flex items-center gap-2 text-xs ${warehouseFull ? "border-amber-400/40 animate-pulse" : ""}`}>
|
||||
<span className={warehouseFull ? "text-amber-400" : "text-fuchsia-300"}>▶</span>
|
||||
<span className={`flex-1 truncate ${warehouseFull ? "text-amber-200" : "text-muted-foreground"}`}>{goal}</span>
|
||||
<div
|
||||
className={`glass rounded-xl px-3 py-2 flex items-center gap-2 text-xs ${
|
||||
warehouseFull ? "border-amber-400/40 animate-pulse" : ""
|
||||
} ${activeTide ? "border-white/20" : ""}`}
|
||||
style={
|
||||
activeTide
|
||||
? { boxShadow: `inset 0 0 16px ${TIDE_EVENTS[activeTide.type].glow}` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
warehouseFull
|
||||
? "text-amber-400"
|
||||
: activeTide
|
||||
? ""
|
||||
: "text-fuchsia-300"
|
||||
}
|
||||
style={activeTide ? { color: TIDE_EVENTS[activeTide.type].color } : undefined}
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
<span
|
||||
className={`flex-1 truncate ${
|
||||
warehouseFull ? "text-amber-200" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{goal}
|
||||
</span>
|
||||
<span className="hidden sm:inline text-muted-foreground/60">|</span>
|
||||
<span className="hidden sm:inline text-muted-foreground/70">
|
||||
产能 {formatNum(crystalsPerSec)}/s
|
||||
@@ -234,6 +274,7 @@ export default function Page() {
|
||||
<PrestigeDialog open={prestigeOpen} onOpenChange={setPrestigeOpen} />
|
||||
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||
<AchievementNotifier />
|
||||
<StarTideNotifier />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — 星潮事件 UI(横幅 + 通知 + 背景叠层)
|
||||
import { useEffect, useState } from "react";
|
||||
import { useGameStore } from "@/store/gameStore";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { sfx } from "@/hooks/useAudio";
|
||||
import { TIDE_EVENTS, TIDE_CONFIG } from "@/lib/game/starTide";
|
||||
|
||||
/** 星潮事件通知消费者:监听 _tideEvents 队列,弹 Toast + 播音效 */
|
||||
export function StarTideNotifier() {
|
||||
const events = useGameStore((s) => s._tideEvents);
|
||||
const consume = useGameStore((s) => s.consumeTideEvents);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (events.length === 0) return;
|
||||
const items = consume();
|
||||
for (const ev of items) {
|
||||
if (ev.started) {
|
||||
const meta = TIDE_EVENTS[ev.started];
|
||||
sfx("tideStart");
|
||||
toast({
|
||||
title: `${meta.icon} 星潮降临:${meta.name}`,
|
||||
description: meta.desc,
|
||||
});
|
||||
}
|
||||
if (ev.ended) {
|
||||
const meta = TIDE_EVENTS[ev.ended];
|
||||
if (ev.silenceCompensation && ev.silenceCompensation > 0) {
|
||||
sfx("techBuy");
|
||||
toast({
|
||||
title: `${meta.icon} 寂静期消散`,
|
||||
description: `虚空回赠 ${ev.silenceCompensation} 洞见。`,
|
||||
});
|
||||
} else {
|
||||
sfx("tideEnd");
|
||||
toast({
|
||||
title: `${meta.icon} ${meta.name}结束`,
|
||||
description: "星潮退去,物理法则恢复。",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [events, consume, toast]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 顶部星潮指示器(活跃时显示倒计时芯片) */
|
||||
export function StarTideIndicator() {
|
||||
const activeTide = useGameStore((s) => s.activeTide);
|
||||
const [, force] = useState(0);
|
||||
|
||||
// 每秒刷新倒计时
|
||||
useEffect(() => {
|
||||
if (!activeTide) return;
|
||||
const id = setInterval(() => force((n) => n + 1), 500);
|
||||
return () => clearInterval(id);
|
||||
}, [activeTide]);
|
||||
|
||||
if (!activeTide) return null;
|
||||
const meta = TIDE_EVENTS[activeTide.type];
|
||||
const remaining = Math.max(0, activeTide.endsAt - Date.now());
|
||||
const secs = Math.ceil(remaining / 1000);
|
||||
const progress = 1 - remaining / TIDE_CONFIG.duration;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-full text-[10px] font-medium relative overflow-hidden"
|
||||
style={{
|
||||
background: `${meta.color}1a`,
|
||||
border: `1px solid ${meta.color}55`,
|
||||
color: meta.color,
|
||||
boxShadow: `0 0 10px ${meta.glow}`,
|
||||
}}
|
||||
title={meta.desc}
|
||||
>
|
||||
{/* 进度环背景 */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-20"
|
||||
style={{
|
||||
background: `linear-gradient(90deg, ${meta.color}40 ${progress * 100}%, transparent ${progress * 100}%)`,
|
||||
}}
|
||||
/>
|
||||
<span className="relative text-xs leading-none">{meta.icon}</span>
|
||||
<span className="relative leading-none">{meta.name}</span>
|
||||
<span className="relative font-mono leading-none opacity-80">{secs}s</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 全屏背景叠层:活跃星潮时给页面罩一层对应色的微光 */
|
||||
export function StarTideOverlay() {
|
||||
const activeTide = useGameStore((s) => s.activeTide);
|
||||
if (!activeTide) return null;
|
||||
const meta = TIDE_EVENTS[activeTide.type];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 pointer-events-none z-0 transition-opacity duration-1000">
|
||||
{/* 顶部色带 */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-24 opacity-40"
|
||||
style={{
|
||||
background: `linear-gradient(180deg, ${meta.color}22, transparent)`,
|
||||
}}
|
||||
/>
|
||||
{/* 底部色带 */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-24 opacity-40"
|
||||
style={{
|
||||
background: `linear-gradient(0deg, ${meta.color}22, transparent)`,
|
||||
}}
|
||||
/>
|
||||
{/* 边缘呼吸光 */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
boxShadow: `inset 0 0 120px ${meta.glow}`,
|
||||
animation: "tide-breathe 3s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
<style jsx>{`
|
||||
@keyframes tide-breathe {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 0.9; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,11 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useGameStore } from "@/store/gameStore";
|
||||
|
||||
/** 250ms tick:产能 + 自动产晶体 + 自动解码 + 成就检测 */
|
||||
/** 250ms tick:产能 + 自动产晶体 + 自动解码 + 星潮 + 成就检测 */
|
||||
export function useGameLoop() {
|
||||
const tick = useGameStore((s) => s.tick);
|
||||
const autoDecodeTick = useGameStore((s) => s.autoDecodeTick);
|
||||
const tickTide = useGameStore((s) => s.tickTide);
|
||||
const checkAchievements = useGameStore((s) => s.checkAchievements);
|
||||
const init = useGameStore((s) => s.init);
|
||||
const inited = useRef(false);
|
||||
@@ -16,30 +17,33 @@ export function useGameLoop() {
|
||||
init();
|
||||
inited.current = true;
|
||||
}
|
||||
let achCounter = 0;
|
||||
let counter = 0;
|
||||
const id = setInterval(() => {
|
||||
const now = Date.now();
|
||||
tick(now);
|
||||
autoDecodeTick();
|
||||
// 星潮检测每 tick 都查(结束/触发判定需及时)
|
||||
tickTide(now);
|
||||
counter++;
|
||||
// 成就检测每秒执行一次(降低开销)
|
||||
achCounter++;
|
||||
if (achCounter % 4 === 0) {
|
||||
if (counter % 4 === 0) {
|
||||
checkAchievements();
|
||||
}
|
||||
}, 250);
|
||||
return () => clearInterval(id);
|
||||
}, [tick, autoDecodeTick, checkAchievements, init]);
|
||||
}, [tick, autoDecodeTick, tickTide, checkAchievements, init]);
|
||||
|
||||
// 页面可见性:切回时补 tick + 立即检测成就
|
||||
// 页面可见性:切回时补 tick + 星潮 + 成就
|
||||
useEffect(() => {
|
||||
const onVis = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
tick(Date.now());
|
||||
tickTide(Date.now());
|
||||
checkAchievements();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVis);
|
||||
return () => document.removeEventListener("visibilitychange", onVis);
|
||||
}, [tick, checkAchievements]);
|
||||
}, [tick, tickTide, checkAchievements]);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ type SfxName =
|
||||
| "expeditionDefeat" // 探险失败(下行低音)
|
||||
| "prestige" // 飞升(宏大扫频)
|
||||
| "achievement" // 成就解锁(亮丽琶音)
|
||||
| "tideStart" // 星潮降临(神秘扫频)
|
||||
| "tideEnd" // 星潮结束(柔和消退)
|
||||
| "uiHover" // 界面悬停(极轻)
|
||||
| "uiClick"; // 界面点击(轻确认)
|
||||
|
||||
@@ -249,6 +251,21 @@ class AudioEngine {
|
||||
this.tone(1318.51, 0.4, "triangle", 0.18, 0.24);
|
||||
break;
|
||||
}
|
||||
case "tideStart": {
|
||||
// 神秘扫频 + 泛音列
|
||||
this.sweep(330, 660, 0.8, "sine", 0.16);
|
||||
this.sweep(440, 880, 0.8, "triangle", 0.08, 0.04);
|
||||
this.tone(523.25, 0.5, "sine", 0.1, 0.2);
|
||||
this.tone(783.99, 0.6, "sine", 0.08, 0.3);
|
||||
break;
|
||||
}
|
||||
case "tideEnd": {
|
||||
// 柔和下行消退
|
||||
this.tone(659.25, 0.3, "sine", 0.14, 0);
|
||||
this.tone(523.25, 0.35, "sine", 0.12, 0.1);
|
||||
this.tone(392, 0.5, "triangle", 0.1, 0.22);
|
||||
break;
|
||||
}
|
||||
case "uiHover": {
|
||||
this.tone(880, 0.05, "sine", 0.05);
|
||||
break;
|
||||
|
||||
@@ -30,6 +30,8 @@ export const INITIAL_STATE = {
|
||||
lastEnergyTick: Date.now(),
|
||||
totalExpeditions: 0,
|
||||
achievements: {},
|
||||
activeTide: null,
|
||||
lastTideEnd: 0,
|
||||
theme: "dark" as const,
|
||||
soundOn: true,
|
||||
};
|
||||
|
||||
+13
-2
@@ -8,8 +8,9 @@ import {
|
||||
CONTACT,
|
||||
} from "./config";
|
||||
import { achievementBonuses } from "./achievements";
|
||||
import { getTideModifiers, type StarTide } from "./starTide";
|
||||
|
||||
/** 由技术树 + 飞升蓝图 + 成就聚合计算产能字段 */
|
||||
/** 由技术树 + 飞升蓝图 + 成就 + 星潮聚合计算产能字段 */
|
||||
export function recomputeStats(state: Partial<GameState>): {
|
||||
crystalsPerSec: number;
|
||||
crystalCap: number;
|
||||
@@ -23,6 +24,7 @@ export function recomputeStats(state: Partial<GameState>): {
|
||||
const tech = state.tech ?? {};
|
||||
const bp = state.blueprints?.length ?? 0;
|
||||
const ach = achievementBonuses(state.achievements ?? {});
|
||||
const tideMod = getTideModifiers((state.activeTide as StarTide | null) ?? null);
|
||||
|
||||
let crystalsPerSec = INITIAL_STATE.crystalsPerSec;
|
||||
let crystalCap = INITIAL_STATE.crystalCap;
|
||||
@@ -74,6 +76,10 @@ export function recomputeStats(state: Partial<GameState>): {
|
||||
crystalsPerSec *= 1 + ach.crystalsPerSecPct / 100;
|
||||
insightMult *= 1 + ach.insightPct / 100;
|
||||
|
||||
// 星潮瞬时修饰(contactRateMult 与 insightMult 进缓存;产能/脉冲/探险在用点处即时乘)
|
||||
insightMult += tideMod.insightMultAdd;
|
||||
contactRateMult *= tideMod.contactRateMult;
|
||||
|
||||
return {
|
||||
crystalsPerSec,
|
||||
crystalCap,
|
||||
@@ -111,7 +117,7 @@ export function performPrestige(state: GameState): GameState {
|
||||
].slice(0, PRESTIGE.maxBlueprints);
|
||||
|
||||
// 保留:fragments, totalDecoded(累计), ascensions+1, blueprints, achievements, theme/sound, expeditionLog
|
||||
// 重置:资源、技术、产能、pendingCrystals、activePuzzle、activeExpedition、contact、lastTick、energy
|
||||
// 重置:资源、技术、产能、pendingCrystals、activePuzzle、activeExpedition、contact、lastTick、energy、星潮
|
||||
const fresh = createInitialState();
|
||||
const stats = recomputeStats({ tech: {}, blueprints, achievements: state.achievements });
|
||||
return {
|
||||
@@ -128,6 +134,9 @@ export function performPrestige(state: GameState): GameState {
|
||||
lastEnergyTick: Date.now(),
|
||||
expeditionLog: state.expeditionLog,
|
||||
totalExpeditions: state.totalExpeditions,
|
||||
// 星潮:飞升后清空,lastTideEnd 设为现在,使首次星潮在 firstDelay 后触发
|
||||
activeTide: null,
|
||||
lastTideEnd: Date.now(),
|
||||
...stats,
|
||||
};
|
||||
}
|
||||
@@ -139,6 +148,8 @@ export function createInitialState(): GameState {
|
||||
tech: {},
|
||||
fragments: {},
|
||||
achievements: {},
|
||||
activeTide: null,
|
||||
lastTideEnd: Date.now(),
|
||||
pendingCrystals: [],
|
||||
activePuzzle: null,
|
||||
activeExpedition: null,
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// 回响星核 / Echo Nexus — 星潮事件系统(周期性全局玩法修饰)
|
||||
//
|
||||
// 深空中偶发的「星潮」会短暂改变物理法则,给放置循环注入动态变化。
|
||||
// 每 ~40s 间隙后触发一次持续 ~75s 的星潮,6 种类型各有正负效果。
|
||||
|
||||
export type TideType =
|
||||
| "crystal" // 晶体潮:产能 ×2
|
||||
| "resonance" // 谐振风暴:解码奖励 +60%
|
||||
| "ruins" // 遗迹共振:探险力 +5、生命 +30
|
||||
| "void" // 虚空低语:洞见 ×2
|
||||
| "core" // 星核悸动:接触率 ×3、脉冲 ×2
|
||||
| "silence"; // 寂静期:产能 ×0.5,但结束时补偿洞见
|
||||
|
||||
export interface StarTide {
|
||||
type: TideType;
|
||||
startedAt: number;
|
||||
endsAt: number;
|
||||
/** 唯一 id,用于 React key */
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface TideModifiers {
|
||||
crystalsPerSecMult: number;
|
||||
decodeRewardMult: number;
|
||||
insightMultAdd: number; // 加到 insightMult(百分比,0.5 = +50%)
|
||||
contactRateMult: number;
|
||||
pulsePowerMult: number;
|
||||
expeditionPowerBonus: number;
|
||||
expeditionHpBonus: number;
|
||||
/** 寂静期结束时补偿的洞见(按持续秒数) */
|
||||
silenceInsightPerSec?: number;
|
||||
}
|
||||
|
||||
export const TIDE_CONFIG = {
|
||||
/** 星潮持续时间(毫秒) */
|
||||
duration: 75000,
|
||||
/** 星潮之间的间隙(毫秒) */
|
||||
gap: 40000,
|
||||
/** 首次星潮触发延迟(毫秒,从 init 起) */
|
||||
firstDelay: 30000,
|
||||
};
|
||||
|
||||
export const TIDE_EVENTS: Record<
|
||||
TideType,
|
||||
{
|
||||
name: string;
|
||||
desc: string;
|
||||
color: string;
|
||||
glow: string;
|
||||
icon: string;
|
||||
/** 是否负面事件(影响 UI 提示语气) */
|
||||
negative: boolean;
|
||||
}
|
||||
> = {
|
||||
crystal: {
|
||||
name: "晶体潮",
|
||||
desc: "深空晶体涌动,采矿产能翻倍",
|
||||
color: "#34d399",
|
||||
glow: "rgba(52,211,153,0.5)",
|
||||
icon: "✺",
|
||||
negative: false,
|
||||
},
|
||||
resonance: {
|
||||
name: "谐振风暴",
|
||||
desc: "共振序列被放大,解码奖励 +60%",
|
||||
color: "#fb7185",
|
||||
glow: "rgba(251,113,133,0.5)",
|
||||
icon: "❖",
|
||||
negative: false,
|
||||
},
|
||||
ruins: {
|
||||
name: "遗迹共振",
|
||||
desc: "远古遗迹苏醒,探险力 +5、生命 +30",
|
||||
color: "#fbbf24",
|
||||
glow: "rgba(251,191,36,0.5)",
|
||||
icon: "⬢",
|
||||
negative: false,
|
||||
},
|
||||
void: {
|
||||
name: "虚空低语",
|
||||
desc: "虚空传来回响,洞见获取翻倍",
|
||||
color: "#e879f9",
|
||||
glow: "rgba(232,121,249,0.5)",
|
||||
icon: "✷",
|
||||
negative: false,
|
||||
},
|
||||
core: {
|
||||
name: "星核悸动",
|
||||
desc: "星核震荡,接触率 ×3、脉冲威力 ×2",
|
||||
color: "#a5f3fc",
|
||||
glow: "rgba(165,243,252,0.5)",
|
||||
icon: "✦",
|
||||
negative: false,
|
||||
},
|
||||
silence: {
|
||||
name: "寂静期",
|
||||
desc: "虚空沉寂,产能减半;结束时按时长补偿洞见",
|
||||
color: "#94a3b8",
|
||||
glow: "rgba(148,163,184,0.5)",
|
||||
icon: "◌",
|
||||
negative: true,
|
||||
},
|
||||
};
|
||||
|
||||
/** 权重表(寂静期概率略低) */
|
||||
const TIDE_WEIGHTS: Record<TideType, number> = {
|
||||
crystal: 22,
|
||||
resonance: 20,
|
||||
ruins: 16,
|
||||
void: 18,
|
||||
core: 14,
|
||||
silence: 10,
|
||||
};
|
||||
|
||||
export function rollTide(rng: () => number = Math.random): TideType {
|
||||
const total = Object.values(TIDE_WEIGHTS).reduce((a, b) => a + b, 0);
|
||||
let r = rng() * total;
|
||||
for (const k of Object.keys(TIDE_WEIGHTS) as TideType[]) {
|
||||
r -= TIDE_WEIGHTS[k];
|
||||
if (r <= 0) return k;
|
||||
}
|
||||
return "crystal";
|
||||
}
|
||||
|
||||
/** 计算当前星潮的修饰器 */
|
||||
export function getTideModifiers(tide: StarTide | null): TideModifiers {
|
||||
if (!tide) {
|
||||
return {
|
||||
crystalsPerSecMult: 1,
|
||||
decodeRewardMult: 1,
|
||||
insightMultAdd: 0,
|
||||
contactRateMult: 1,
|
||||
pulsePowerMult: 1,
|
||||
expeditionPowerBonus: 0,
|
||||
expeditionHpBonus: 0,
|
||||
};
|
||||
}
|
||||
switch (tide.type) {
|
||||
case "crystal":
|
||||
return {
|
||||
crystalsPerSecMult: 2,
|
||||
decodeRewardMult: 1,
|
||||
insightMultAdd: 0,
|
||||
contactRateMult: 1,
|
||||
pulsePowerMult: 1,
|
||||
expeditionPowerBonus: 0,
|
||||
expeditionHpBonus: 0,
|
||||
};
|
||||
case "resonance":
|
||||
return {
|
||||
crystalsPerSecMult: 1,
|
||||
decodeRewardMult: 1.6,
|
||||
insightMultAdd: 0,
|
||||
contactRateMult: 1,
|
||||
pulsePowerMult: 1,
|
||||
expeditionPowerBonus: 0,
|
||||
expeditionHpBonus: 0,
|
||||
};
|
||||
case "ruins":
|
||||
return {
|
||||
crystalsPerSecMult: 1,
|
||||
decodeRewardMult: 1,
|
||||
insightMultAdd: 0,
|
||||
contactRateMult: 1,
|
||||
pulsePowerMult: 1,
|
||||
expeditionPowerBonus: 5,
|
||||
expeditionHpBonus: 30,
|
||||
};
|
||||
case "void":
|
||||
return {
|
||||
crystalsPerSecMult: 1,
|
||||
decodeRewardMult: 1,
|
||||
insightMultAdd: 1, // insightMult 翻倍(+100%)
|
||||
contactRateMult: 1,
|
||||
pulsePowerMult: 1,
|
||||
expeditionPowerBonus: 0,
|
||||
expeditionHpBonus: 0,
|
||||
};
|
||||
case "core":
|
||||
return {
|
||||
crystalsPerSecMult: 1,
|
||||
decodeRewardMult: 1,
|
||||
insightMultAdd: 0,
|
||||
contactRateMult: 3,
|
||||
pulsePowerMult: 2,
|
||||
expeditionPowerBonus: 0,
|
||||
expeditionHpBonus: 0,
|
||||
};
|
||||
case "silence":
|
||||
return {
|
||||
crystalsPerSecMult: 0.5,
|
||||
decodeRewardMult: 1,
|
||||
insightMultAdd: 0,
|
||||
contactRateMult: 1,
|
||||
pulsePowerMult: 1,
|
||||
expeditionPowerBonus: 0,
|
||||
expeditionHpBonus: 0,
|
||||
silenceInsightPerSec: 0.4,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 寂静期结束时的洞见补偿 */
|
||||
export function computeSilenceCompensation(tide: StarTide): number {
|
||||
const mod = getTideModifiers(tide);
|
||||
if (!mod.silenceInsightPerSec) return 0;
|
||||
const secs = (tide.endsAt - tide.startedAt) / 1000;
|
||||
return Math.round(mod.silenceInsightPerSec * secs);
|
||||
}
|
||||
@@ -128,6 +128,10 @@ export interface GameState {
|
||||
// 成就
|
||||
achievements: Record<string, boolean>; // achievementId -> unlocked
|
||||
|
||||
// 星潮事件
|
||||
activeTide: import("./starTide").StarTide | null;
|
||||
lastTideEnd: number;
|
||||
|
||||
// 元
|
||||
lastTick: number;
|
||||
createdAt: number;
|
||||
|
||||
+116
-17
@@ -42,6 +42,14 @@ import {
|
||||
EXPEDITION_CONFIG,
|
||||
} from "@/lib/game/expedition";
|
||||
import { ACHIEVEMENTS, type Achievement } from "@/lib/game/achievements";
|
||||
import {
|
||||
TIDE_CONFIG,
|
||||
rollTide,
|
||||
getTideModifiers,
|
||||
computeSilenceCompensation,
|
||||
type StarTide,
|
||||
type TideType,
|
||||
} from "@/lib/game/starTide";
|
||||
|
||||
interface GameActions {
|
||||
// 生命周期
|
||||
@@ -53,6 +61,10 @@ interface GameActions {
|
||||
tick: (now: number) => void;
|
||||
pulse: () => { gain: number; combo: number } | null;
|
||||
|
||||
// 星潮
|
||||
tickTide: (now: number) => { started?: TideType; ended?: TideType; silenceCompensation?: number } | null;
|
||||
consumeTideEvents: () => { started?: TideType; ended?: TideType; silenceCompensation?: number }[];
|
||||
|
||||
// 解码
|
||||
startDecode: (crystalId: string) => void;
|
||||
clickNode: (nodeId: number) => { ok: boolean; finished: boolean; solvable?: boolean; failReason?: string };
|
||||
@@ -92,6 +104,7 @@ type Store = GameState & GameActions & {
|
||||
_combo: number;
|
||||
_lastPulse: number;
|
||||
_achievementQueue: Achievement[];
|
||||
_tideEvents: { started?: TideType; ended?: TideType; silenceCompensation?: number }[];
|
||||
};
|
||||
|
||||
/** 计算并写回产能字段 */
|
||||
@@ -129,6 +142,7 @@ export const useGameStore = create<Store>()(
|
||||
_combo: 0,
|
||||
_lastPulse: 0,
|
||||
_achievementQueue: [],
|
||||
_tideEvents: [],
|
||||
|
||||
init: () => {
|
||||
const s = get();
|
||||
@@ -148,8 +162,13 @@ export const useGameStore = create<Store>()(
|
||||
pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending),
|
||||
});
|
||||
}
|
||||
// 兼容旧存档:补全 achievements 字段
|
||||
// 兼容旧存档:补全 achievements / 星潮 字段
|
||||
const achievements = s.achievements ?? {};
|
||||
const activeTide = s.activeTide ?? null;
|
||||
// lastTideEnd:0 表示从未触发过星潮(首次游玩),保留 0 让 firstDelay 生效
|
||||
const lastTideEndRaw = s.lastTideEnd ?? 0;
|
||||
// 若旧存档有已过期的星潮,清掉
|
||||
const tide = activeTide && activeTide.endsAt > now ? activeTide : null;
|
||||
// 首次进入:补发离线收益
|
||||
const elapsed = Math.max(0, (now - s.lastTick) / 1000);
|
||||
if (elapsed > 5) {
|
||||
@@ -161,20 +180,81 @@ export const useGameStore = create<Store>()(
|
||||
lastTick: now,
|
||||
activePuzzle,
|
||||
achievements,
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements }),
|
||||
activeTide: tide,
|
||||
lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw,
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide }),
|
||||
});
|
||||
} else {
|
||||
set({ lastTick: now, activePuzzle, achievements, ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements }) });
|
||||
set({ lastTick: now, activePuzzle, achievements, activeTide: tide, lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw, ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide }) });
|
||||
}
|
||||
},
|
||||
|
||||
loadOnline: () => {
|
||||
const s = get();
|
||||
set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements }) });
|
||||
set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide }) });
|
||||
},
|
||||
|
||||
hardReset: () => {
|
||||
set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0, _achievementQueue: [] });
|
||||
set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0, _achievementQueue: [], _tideEvents: [] });
|
||||
},
|
||||
|
||||
tickTide: (now) => {
|
||||
const s = get();
|
||||
const tide = s.activeTide;
|
||||
// 1) 检查当前星潮是否结束
|
||||
if (tide && now >= tide.endsAt) {
|
||||
const endedType = tide.type;
|
||||
// 寂静期补偿洞见
|
||||
let silenceCompensation = 0;
|
||||
if (tide.type === "silence") {
|
||||
silenceCompensation = computeSilenceCompensation(tide);
|
||||
}
|
||||
const newInsights = s.insights + silenceCompensation;
|
||||
const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { ended: endedType };
|
||||
if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation;
|
||||
set({
|
||||
activeTide: null,
|
||||
lastTideEnd: now,
|
||||
insights: newInsights,
|
||||
// 星潮结束后重算 stats(移除 contactRate/insight 修饰)
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null }),
|
||||
_tideEvents: [...s._tideEvents, event],
|
||||
});
|
||||
return event;
|
||||
}
|
||||
// 2) 检查是否该触发新星潮(间隙已过)
|
||||
if (!tide) {
|
||||
const since = now - s.lastTideEnd;
|
||||
// 首次:以 createdAt 起算 firstDelay;之后以 lastTideEnd 起算 gap
|
||||
const firstStart = s.lastTideEnd === 0 || s.lastTideEnd <= s.createdAt + 1000;
|
||||
const need = firstStart ? TIDE_CONFIG.firstDelay : TIDE_CONFIG.gap;
|
||||
if (since >= need) {
|
||||
const type = rollTide();
|
||||
const newTide: StarTide = {
|
||||
id: `tide_${now}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
type,
|
||||
startedAt: now,
|
||||
endsAt: now + TIDE_CONFIG.duration,
|
||||
};
|
||||
const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { started: type };
|
||||
set({
|
||||
activeTide: newTide,
|
||||
// 星潮开始后重算 stats(应用 contactRate/insight 修饰)
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide }),
|
||||
_tideEvents: [...s._tideEvents, event],
|
||||
});
|
||||
return event;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
consumeTideEvents: () => {
|
||||
const s = get();
|
||||
if (s._tideEvents.length === 0) return [];
|
||||
const items = s._tideEvents;
|
||||
set({ _tideEvents: [] });
|
||||
return items;
|
||||
},
|
||||
|
||||
tick: (now) => {
|
||||
@@ -182,11 +262,14 @@ export const useGameStore = create<Store>()(
|
||||
const dt = Math.max(0, (now - s.lastTick) / 1000);
|
||||
if (dt <= 0) return;
|
||||
|
||||
// 星潮产能修饰(即时乘)
|
||||
const tideMod = getTideModifiers(s.activeTide);
|
||||
const effCps = s.crystalsPerSec * tideMod.crystalsPerSecMult;
|
||||
// 产能累加(仅闲置产能受仓库上限;探险奖励可超出)
|
||||
const newCrystals =
|
||||
s.crystals >= s.crystalCap
|
||||
? s.crystals // 已达/超上限,不再自动产出
|
||||
: Math.min(s.crystalCap, s.crystals + s.crystalsPerSec * dt);
|
||||
: Math.min(s.crystalCap, s.crystals + effCps * dt);
|
||||
|
||||
// 自动产出待解码晶体(每 baseInterval 秒一颗,受技术/飞升略提速)
|
||||
const bpBoost = 1 + s.blueprints.length * 0.03;
|
||||
@@ -238,7 +321,9 @@ export const useGameStore = create<Store>()(
|
||||
combo = Math.min(10, s._combo + 1);
|
||||
}
|
||||
const mult = 1 + (combo - 1) * 0.15;
|
||||
const gain = s.pulsePower * mult;
|
||||
// 星潮脉冲威力修饰
|
||||
const tideMod = getTideModifiers(s.activeTide);
|
||||
const gain = s.pulsePower * mult * tideMod.pulsePowerMult;
|
||||
set({
|
||||
crystals: Math.min(s.crystalCap, s.crystals + gain),
|
||||
_combo: combo,
|
||||
@@ -266,8 +351,14 @@ export const useGameStore = create<Store>()(
|
||||
const res = tryClickNode(puzzle, nodeId);
|
||||
if (res.ok) {
|
||||
if (res.finished) {
|
||||
// 结算奖励
|
||||
const rewards = decodeRewards(puzzle.tier, s.insightMult, s.contactRateMult);
|
||||
// 结算奖励(星潮解码奖励修饰)
|
||||
const tideMod = getTideModifiers(s.activeTide);
|
||||
const base = decodeRewards(puzzle.tier, s.insightMult, s.contactRateMult);
|
||||
const rewards = {
|
||||
crystals: Math.round(base.crystals * tideMod.decodeRewardMult),
|
||||
insights: Math.round(base.insights * tideMod.decodeRewardMult),
|
||||
contact: +(base.contact * tideMod.decodeRewardMult).toFixed(2),
|
||||
};
|
||||
const newTotal = s.totalDecoded + 1;
|
||||
const newContact = Math.min(100, s.contact + rewards.contact);
|
||||
const newInsights = s.insights + rewards.insights;
|
||||
@@ -336,7 +427,13 @@ export const useGameStore = create<Store>()(
|
||||
const idx = s.pendingCrystals.findIndex((c) => c.tier === 1);
|
||||
if (idx < 0) return;
|
||||
const crystal = s.pendingCrystals[idx];
|
||||
const rewards = decodeRewards(1, s.insightMult, s.contactRateMult);
|
||||
const tideMod = getTideModifiers(s.activeTide);
|
||||
const base = decodeRewards(1, s.insightMult, s.contactRateMult);
|
||||
const rewards = {
|
||||
crystals: Math.round(base.crystals * tideMod.decodeRewardMult),
|
||||
insights: Math.round(base.insights * tideMod.decodeRewardMult),
|
||||
contact: +(base.contact * tideMod.decodeRewardMult).toFixed(2),
|
||||
};
|
||||
const newTotal = s.totalDecoded + 1;
|
||||
const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } };
|
||||
checkFragments(tentative);
|
||||
@@ -362,7 +459,7 @@ export const useGameStore = create<Store>()(
|
||||
set({
|
||||
insights: s.insights - node.cost,
|
||||
tech: newTech,
|
||||
...syncStats({ tech: newTech, blueprints: s.blueprints }),
|
||||
...syncStats({ tech: newTech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide }),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
@@ -376,8 +473,9 @@ export const useGameStore = create<Store>()(
|
||||
if (s.energy < EXPEDITION_CONFIG.energyCost) {
|
||||
return { ok: false, reason: "能量不足" };
|
||||
}
|
||||
const power = computeExpeditionPower(s);
|
||||
const hp = computeExpeditionHp(s);
|
||||
const tideMod = getTideModifiers(s.activeTide);
|
||||
const power = computeExpeditionPower(s) + tideMod.expeditionPowerBonus;
|
||||
const hp = computeExpeditionHp(s) + tideMod.expeditionHpBonus;
|
||||
const seed = Math.floor(Math.random() * 1e9);
|
||||
const expedition = generateExpedition(seed, power, hp);
|
||||
set({
|
||||
@@ -476,11 +574,12 @@ export const useGameStore = create<Store>()(
|
||||
const next = performPrestige(s);
|
||||
set({
|
||||
...next,
|
||||
...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements }),
|
||||
...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements, activeTide: next.activeTide }),
|
||||
_lastAutoDecode: Date.now(),
|
||||
_lastSpawn: Date.now(),
|
||||
_combo: 0,
|
||||
_lastPulse: 0,
|
||||
_tideEvents: [],
|
||||
});
|
||||
return { newBp };
|
||||
},
|
||||
@@ -512,7 +611,7 @@ export const useGameStore = create<Store>()(
|
||||
insights,
|
||||
contact,
|
||||
...(statsDirty
|
||||
? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated })
|
||||
? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated, activeTide: s.activeTide })
|
||||
: {}),
|
||||
_achievementQueue: [...s._achievementQueue, ...newlyUnlocked],
|
||||
});
|
||||
@@ -537,8 +636,8 @@ export const useGameStore = create<Store>()(
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
// 不持久化临时字段
|
||||
partialize: (s) => {
|
||||
const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, ...rest } = s;
|
||||
void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue;
|
||||
const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, ...rest } = s;
|
||||
void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue; void _tideEvents;
|
||||
return rest as GameState;
|
||||
},
|
||||
}
|
||||
|
||||
+14
-3
@@ -51,16 +51,27 @@
|
||||
- **验证**:agent-browser 全流程通过;成就 3/14 解锁;lint 零错误;HTTP 200
|
||||
- 详见 docs/repo/docs/06-音频与成就系统-v0.2.1.md
|
||||
|
||||
### v0.3 星潮事件系统(本轮完成)
|
||||
- **动机**:回应 Issue #1「玩法单一」反馈,为放置循环注入动态变化
|
||||
- **6 种星潮**(`src/lib/game/starTide.ts`):晶体潮(产能×2)/谐振风暴(解码×1.6)/遗迹共振(探险+5力+30血)/虚空低语(洞见×2)/星核悸动(接触×3脉冲×2)/寂静期(产能×0.5,结束补偿洞见)
|
||||
- **触发**:首次 30s,常规间隙 40s,持续 75s,权重抽取,飞升重置
|
||||
- **修饰器应用**:产能/脉冲/解码奖励即时乘;洞见/接触率进 recomputeStats 缓存;探险力/血量即时加
|
||||
- **UI**(`src/components/game/StarTide.tsx`):顶部指示器芯片(图标+名称+倒计时) + 全屏背景叠层(色带+呼吸光) + Toast通知 + Footer联动
|
||||
- **音效**:tideStart(神秘扫频) + tideEnd(柔和消退),程序化合成
|
||||
- **QA**:agent-browser 全流程通过(触发/指示器/叠层/寂静期补偿 50→81 洞见);VLM 视觉确认;lint 零错误
|
||||
- 详见 docs/repo/docs/07-星潮事件系统-v0.3.md
|
||||
|
||||
### 进行中
|
||||
- [ ] 持续迭代:socket 全局「星潮」事件(v0.3 剩余)、云存档+排行榜(v0.4)、全5纪元叙事(v0.5)
|
||||
- [ ] 持续迭代:云存档+排行榜(v0.4)、全5纪元叙事(v0.5)、socket 多人同步星潮(后续)
|
||||
|
||||
## 未解决问题或风险 / 下一阶段优先事项
|
||||
|
||||
- Issue #1 玩家反馈「玩法单一」,后续可考虑:更多探险事件变体、限时星潮事件、成就奖励多样化
|
||||
- v0.3 星潮为单机版(原计划 socket 全局事件),后续可扩展为多人同步
|
||||
- Issue #1 玩家反馈已通过星潮系统部分回应,后续可加更多探险事件变体
|
||||
- v0.2 探险 BOSS 战胜率较低(基础探索力 10 vs BOSS 难度 5-7,胜率约 25%),需玩家投资探险技术
|
||||
- 探险能量恢复较慢(45s/点),后续可加技术提升恢复速度
|
||||
- 需持续关注 Gitea 工单(仓库 Issues)获取额外需求
|
||||
- 下一阶段优先:socket 全局「星潮」实时事件、云存档+排行榜
|
||||
- 下一阶段优先:云存档+排行榜、全5纪元叙事
|
||||
|
||||
## 定时任务
|
||||
- 已设置:每 15 分钟 webDevReview(自动 QA + 迭代开发,job_id: 227581)
|
||||
|
||||
Reference in New Issue
Block a user