v0.8.2: 工单大清理 - 编年史分页+限时挑战+星潮深化+云排行榜+手写叙事+socket多人星潮+UI打磨
工单 #8 编年史上限+分页: - engine.ts: slice(-50)→slice(-200) - ChronicleDialog.tsx: 加分页(每页10条)+上一页/下一页+页码显示 工单 #5 限时挑战 (subagent 10-a): - beacon.ts: BeaconTimedChallenge + getTimedSlotKey(4h时段) + generateTimedChallenge - BeaconPanel.tsx: amber主题限时区块 + 倒计时 + <30min紧急状态 - gameStore: trackBeacon 加 timedJustCompleted + claimTimedBeacon action 工单 #3 星潮类型深化 (subagent 10-a): - starTide.ts: +3种星潮 surge(emerald)/eclipse(rose)/prism(fuchsia) - TideModifiers: +targetLenBonus/bossWinRateBonus/autoDecodeIntervalMult - decode.ts: generatePuzzle 加 targetLenBonus 参数 - achievements: ach_tides_all 阈值 6→9 工单 #4/P2 云排行榜 (subagent 10-b): - mini-services/leaderboard-service/ (端口3030, Hono+bun, 内存1000条) - API: GET/POST /api/leaderboard + /stats + CORS + 防刷 - beacon.ts: fetchCloudLeaderboard/submitCloudScore - BeaconPanel: 本地Top20/全球Top100 双tab + YOU徽章高亮 工单 #9 手写叙事 (subagent 10-c): - chronicle.ts: EPOCH_LORE 5纪元×3节点=15段手写叙事(80-150字/段) - buildLore 优先手写节点, fallback 模板, 11个变量替换 P3 socket 多人星潮 (subagent 10-c): - mini-services/star-tide-service/ (端口3031, socket.io) - 每10-15min广播global-tide, 60s持续, 6种类型权重 - useGlobalTide hook + triggerGlobalTide action - StarTideIndicator 加 🌐 全球星潮标记 P2 UI打磨: - page.tsx: CrystalOrb 区加装饰全息环(3层旋转) + 四角标记 + 顶部状态条 + 底部铭文 QA: lint零错误 + dev HTTP200 + VLM 8/10 + 2个mini-service运行中(3030/3031)
This commit is contained in:
+25
-2
@@ -27,6 +27,7 @@ import {
|
||||
} from "@/components/game/StarTide";
|
||||
import { useGameLoop } from "@/hooks/useGameLoop";
|
||||
import { useAudioSync } from "@/hooks/useAudio";
|
||||
import { useGlobalTide } from "@/hooks/useGlobalTide";
|
||||
import { useGameStore } from "@/store/gameStore";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -54,6 +55,7 @@ import { generateDailyChallenge, loadDailyProgress, loadLeaderboard } from "@/li
|
||||
export default function Page() {
|
||||
useGameLoop();
|
||||
useAudioSync();
|
||||
useGlobalTide();
|
||||
const [prestigeOpen, setPrestigeOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [constellationOpen, setConstellationOpen] = useState(false);
|
||||
@@ -73,6 +75,7 @@ export default function Page() {
|
||||
const crystals = useGameStore((s) => s.crystals);
|
||||
const crystalCap = useGameStore((s) => s.crystalCap);
|
||||
const activeTide = useGameStore((s) => s.activeTide);
|
||||
const globalTide = useGameStore((s) => s.globalTide);
|
||||
const energy = useGameStore((s) => s.energy);
|
||||
const hasActiveExpedition = useGameStore((s) => !!s.activeExpedition && !s.activeExpedition.finished);
|
||||
const chronicleCount = useGameStore((s) => s.chronicle?.length ?? 0);
|
||||
@@ -148,7 +151,8 @@ export default function Page() {
|
||||
}
|
||||
if (activeTide) {
|
||||
const tm = TIDE_EVENTS[activeTide.type];
|
||||
goal = `${tm.icon} 星潮「${tm.name}」进行中 · ${tm.desc}`;
|
||||
const prefix = globalTide ? "🌐 全球星潮 · " : "";
|
||||
goal = `${prefix}${tm.icon} 星潮「${tm.name}」进行中 · ${tm.desc}`;
|
||||
}
|
||||
if (canPrestige) goal = "✦ 接触进度已满,可发起飞升进入新周目";
|
||||
|
||||
@@ -249,6 +253,25 @@ export default function Page() {
|
||||
{/* 装饰光圈 */}
|
||||
<div className="pointer-events-none absolute -top-20 -left-20 h-60 w-60 rounded-full bg-emerald-500/10 blur-3xl" />
|
||||
<div className="pointer-events-none absolute -bottom-20 -right-20 h-60 w-60 rounded-full bg-fuchsia-500/10 blur-3xl" />
|
||||
{/* v0.8.2 装饰全息环(填充留白,提升视觉层次) */}
|
||||
<div className="pointer-events-none absolute inset-6 rounded-full border border-emerald-400/10 animate-[spin_60s_linear_infinite]" />
|
||||
<div className="pointer-events-none absolute inset-10 rounded-full border border-dashed border-fuchsia-400/10 animate-[spin_90s_linear_infinite_reverse]" />
|
||||
<div className="pointer-events-none absolute inset-16 rounded-full border border-rose-400/8" />
|
||||
{/* 四角全息标记 */}
|
||||
<div className="pointer-events-none absolute top-3 left-3 h-4 w-4 border-l border-t border-emerald-400/30 rounded-tl" />
|
||||
<div className="pointer-events-none absolute top-3 right-3 h-4 w-4 border-r border-t border-fuchsia-400/30 rounded-tr" />
|
||||
<div className="pointer-events-none absolute bottom-3 left-3 h-4 w-4 border-l border-b border-amber-400/30 rounded-bl" />
|
||||
<div className="pointer-events-none absolute bottom-3 right-3 h-4 w-4 border-r border-b border-rose-400/30 rounded-br" />
|
||||
{/* 顶部状态条 */}
|
||||
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 flex items-center gap-2 text-[9px] font-mono text-muted-foreground/60">
|
||||
<span className="h-1 w-1 rounded-full bg-emerald-400 animate-pulse" />
|
||||
<span>CRYSTAL CORE · ONLINE</span>
|
||||
<span className="h-1 w-1 rounded-full bg-fuchsia-400 animate-pulse" />
|
||||
</div>
|
||||
{/* 底部铭文 */}
|
||||
<div className="pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 text-[8px] font-mono text-muted-foreground/40 tracking-[0.3em] uppercase">
|
||||
echo · nexus · archaeology
|
||||
</div>
|
||||
<div data-tut="crystal-orb" className="contents">
|
||||
<CrystalOrb />
|
||||
</div>
|
||||
@@ -433,7 +456,7 @@ function StatsPanel() {
|
||||
{ label: "洞见倍率", value: `×${s.insightMult.toFixed(2)}` },
|
||||
{ label: "累计探险", value: `${s.totalExpeditions} 次` },
|
||||
{ label: "BOSS 击破", value: `${s.bossKills ?? 0} 次` },
|
||||
{ label: "星潮亲历", value: `${(s.starTidesEncountered ?? []).length} / 6` },
|
||||
{ label: "星潮亲历", value: `${(s.starTidesEncountered ?? []).length} / 9` },
|
||||
{ label: "编年史条目", value: `${(s.chronicle ?? []).length} 纪元` },
|
||||
{ label: "探险能量", value: `${Math.floor(s.energy)} / ${s.energyMax}` },
|
||||
{ label: "信标最高分", value: beaconBest !== null ? formatNum(beaconBest) : "—" },
|
||||
|
||||
@@ -2,33 +2,44 @@
|
||||
// 回响星核 / Echo Nexus — 深空信标面板
|
||||
// v0.5:每日挑战 + 本地排行榜
|
||||
// v0.8:周挑战 + 信标链(连续完成奖励)
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
// v0.8.2:限时挑战(每 4 小时刷新,amber 主题)
|
||||
// v0.8.2 P2/#4:本地/全球双榜(云排行榜 mini-service 端口 3030)
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useGameStore } from "@/store/gameStore";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { sfx } from "@/hooks/useAudio";
|
||||
import {
|
||||
generateDailyChallenge,
|
||||
generateWeeklyChallenge,
|
||||
generateTimedChallenge,
|
||||
loadDailyProgress,
|
||||
loadWeeklyProgress,
|
||||
loadTimedProgress,
|
||||
loadLeaderboard,
|
||||
loadChainState,
|
||||
claimBeaconReward,
|
||||
fetchCloudLeaderboard,
|
||||
submitCloudScore,
|
||||
loadLastCloudSubmitTimestamp,
|
||||
BEACON_DIFFICULTY,
|
||||
BEACON_TYPE_META,
|
||||
BEACON_CHAIN_MILESTONES,
|
||||
BEACON_CHAIN_REWARDS,
|
||||
getWeekKey,
|
||||
getTodayKey,
|
||||
getTimedSlotKey,
|
||||
getNextMilestone,
|
||||
getChainProgress,
|
||||
msUntilNextDay,
|
||||
msUntilNextWeek,
|
||||
msUntilNextTimedSlot,
|
||||
formatCountdown,
|
||||
type BeaconDailyChallenge,
|
||||
type BeaconDailyProgress,
|
||||
type BeaconWeeklyChallenge,
|
||||
type BeaconWeeklyProgress,
|
||||
type BeaconTimedChallenge,
|
||||
type BeaconTimedProgress,
|
||||
type BeaconScoreEntry,
|
||||
type BeaconChallengeType,
|
||||
type BeaconDifficulty,
|
||||
@@ -48,6 +59,11 @@ import {
|
||||
Link2,
|
||||
Flame,
|
||||
Zap,
|
||||
Hourglass,
|
||||
RefreshCw,
|
||||
Globe,
|
||||
WifiOff,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
const DIFFICULTY_ORDER: BeaconDifficulty[] = ["routine", "anomaly", "singular"];
|
||||
@@ -62,11 +78,16 @@ function rankBadge(rank: number) {
|
||||
export function BeaconPanel() {
|
||||
const grantBeaconReward = useGameStore((s) => s.grantBeaconReward);
|
||||
const claimWeeklyBeacon = useGameStore((s) => s.claimWeeklyBeacon);
|
||||
const claimTimedBeacon = useGameStore((s) => s.claimTimedBeacon);
|
||||
const claimChainReward = useGameStore((s) => s.claimChainReward);
|
||||
const { toast } = useToast();
|
||||
|
||||
const [challenge, setChallenge] = useState<BeaconDailyChallenge | null>(null);
|
||||
const [progress, setProgress] = useState<BeaconDailyProgress | null>(null);
|
||||
const [timedChallenge, setTimedChallenge] =
|
||||
useState<BeaconTimedChallenge | null>(null);
|
||||
const [timedProgress, setTimedProgress] =
|
||||
useState<BeaconTimedProgress | null>(null);
|
||||
const [weeklyChallenge, setWeeklyChallenge] =
|
||||
useState<BeaconWeeklyChallenge | null>(null);
|
||||
const [weeklyProgress, setWeeklyProgress] =
|
||||
@@ -74,13 +95,25 @@ export function BeaconPanel() {
|
||||
const [chainState, setChainState] = useState<BeaconChainState | null>(null);
|
||||
const [leaderboard, setLeaderboard] = useState<BeaconScoreEntry[]>([]);
|
||||
const [dayCountdown, setDayCountdown] = useState("00:00:00");
|
||||
const [timedCountdown, setTimedCountdown] = useState("00:00:00");
|
||||
const [weekCountdown, setWeekCountdown] = useState("00:00:00");
|
||||
const [now, setNow] = useState(Date.now());
|
||||
|
||||
// 云排行榜状态(v0.8.2 P2/#4)
|
||||
const [lbTab, setLbTab] = useState<"local" | "global">("local");
|
||||
const [cloudEntries, setCloudEntries] = useState<BeaconScoreEntry[]>([]);
|
||||
const [cloudLoading, setCloudLoading] = useState(false);
|
||||
const [cloudError, setCloudError] = useState<string | null>(null);
|
||||
const [cloudFetched, setCloudFetched] = useState(false);
|
||||
const [mySubmitTs, setMySubmitTs] = useState<number>(0);
|
||||
const cloudFetchingRef = useRef(false);
|
||||
|
||||
// 初始化 + 每秒刷新(进度 + 倒计时)
|
||||
useEffect(() => {
|
||||
setChallenge(generateDailyChallenge());
|
||||
setProgress(loadDailyProgress());
|
||||
setTimedChallenge(generateTimedChallenge());
|
||||
setTimedProgress(loadTimedProgress());
|
||||
setWeeklyChallenge(generateWeeklyChallenge());
|
||||
setWeeklyProgress(loadWeeklyProgress());
|
||||
setChainState(loadChainState());
|
||||
@@ -88,9 +121,11 @@ export function BeaconPanel() {
|
||||
const id = setInterval(() => {
|
||||
setNow(Date.now());
|
||||
setProgress(loadDailyProgress());
|
||||
setTimedProgress(loadTimedProgress());
|
||||
setWeeklyProgress(loadWeeklyProgress());
|
||||
setChainState(loadChainState());
|
||||
setChallenge((c) => c ?? generateDailyChallenge());
|
||||
setTimedChallenge((c) => c ?? generateTimedChallenge());
|
||||
setWeeklyChallenge((c) => c ?? generateWeeklyChallenge());
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
@@ -98,9 +133,44 @@ export function BeaconPanel() {
|
||||
|
||||
useEffect(() => {
|
||||
setDayCountdown(formatCountdown(msUntilNextDay(new Date(now))));
|
||||
setTimedCountdown(formatCountdown(msUntilNextTimedSlot(new Date(now))));
|
||||
setWeekCountdown(formatCountdown(msUntilNextWeek(new Date(now))));
|
||||
}, [now]);
|
||||
|
||||
// 初始化:读取本地保存的"上次提交时间戳"(用于全球榜高亮我的记录)
|
||||
useEffect(() => {
|
||||
setMySubmitTs(loadLastCloudSubmitTimestamp());
|
||||
}, []);
|
||||
|
||||
// 拉取全球排行榜
|
||||
const refreshCloudLeaderboard = useCallback(async () => {
|
||||
if (cloudFetchingRef.current) return;
|
||||
cloudFetchingRef.current = true;
|
||||
setCloudLoading(true);
|
||||
setCloudError(null);
|
||||
try {
|
||||
const entries = await fetchCloudLeaderboard();
|
||||
setCloudEntries(entries);
|
||||
setCloudFetched(true);
|
||||
setMySubmitTs(loadLastCloudSubmitTimestamp());
|
||||
if (entries.length === 0) {
|
||||
setCloudError("尚无全球记录,成为首位登顶者!");
|
||||
}
|
||||
} catch {
|
||||
setCloudError("全球榜连接失败,请稍后重试");
|
||||
} finally {
|
||||
setCloudLoading(false);
|
||||
cloudFetchingRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 切到全球 tab 时自动拉取一次(如已拉取过则不强制)
|
||||
useEffect(() => {
|
||||
if (lbTab === "global" && !cloudFetched && !cloudLoading) {
|
||||
void refreshCloudLeaderboard();
|
||||
}
|
||||
}, [lbTab, cloudFetched, cloudLoading, refreshCloudLeaderboard]);
|
||||
|
||||
const handleClaimDaily = useCallback(() => {
|
||||
if (!challenge || !progress) return;
|
||||
if (progress.completedAt === null || progress.claimed) return;
|
||||
@@ -116,7 +186,41 @@ export function BeaconPanel() {
|
||||
1
|
||||
)} 接触 · 得分 ${res.score}`,
|
||||
});
|
||||
}, [challenge, progress, toast, grantBeaconReward]);
|
||||
// 异步提交到云排行榜(失败静默,不影响本地)
|
||||
if (res.entry) {
|
||||
void submitCloudScore(res.entry).then((rank) => {
|
||||
if (rank >= 1) {
|
||||
setMySubmitTs(res.entry!.timestamp);
|
||||
// 若用户在全球 tab,自动刷新
|
||||
if (lbTab === "global") {
|
||||
void refreshCloudLeaderboard();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [challenge, progress, toast, grantBeaconReward, lbTab, refreshCloudLeaderboard]);
|
||||
|
||||
const handleClaimTimed = useCallback(() => {
|
||||
if (!timedChallenge || !timedProgress) return;
|
||||
if (timedProgress.completedAt === null || timedProgress.claimed) return;
|
||||
const res = claimTimedBeacon();
|
||||
setLeaderboard(loadLeaderboard());
|
||||
setTimedProgress(loadTimedProgress());
|
||||
if (res.rewardInsight > 0 || res.rewardContact > 0) {
|
||||
sfx("achievement");
|
||||
toast({
|
||||
title: "✦ 限时挑战奖励已领取",
|
||||
description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
|
||||
1
|
||||
)} 接触 · 得分 ${res.score}`,
|
||||
});
|
||||
// 异步刷新云榜(claimTimedBeacon 已在 gameStore 内异步提交到云端)
|
||||
setMySubmitTs(loadLastCloudSubmitTimestamp());
|
||||
if (lbTab === "global") {
|
||||
void refreshCloudLeaderboard();
|
||||
}
|
||||
}
|
||||
}, [timedChallenge, timedProgress, toast, claimTimedBeacon, lbTab, refreshCloudLeaderboard]);
|
||||
|
||||
const handleClaimWeekly = useCallback(() => {
|
||||
if (!weeklyChallenge || !weeklyProgress) return;
|
||||
@@ -132,8 +236,13 @@ export function BeaconPanel() {
|
||||
1
|
||||
)} 接触 · 得分 ${res.score}`,
|
||||
});
|
||||
// 异步刷新云榜(claimWeeklyBeacon 已在 gameStore 内异步提交到云端)
|
||||
setMySubmitTs(loadLastCloudSubmitTimestamp());
|
||||
if (lbTab === "global") {
|
||||
void refreshCloudLeaderboard();
|
||||
}
|
||||
}
|
||||
}, [weeklyChallenge, weeklyProgress, toast, claimWeeklyBeacon]);
|
||||
}, [weeklyChallenge, weeklyProgress, toast, claimWeeklyBeacon, lbTab, refreshCloudLeaderboard]);
|
||||
|
||||
const handleClaimChain = useCallback(
|
||||
(milestone: number) => {
|
||||
@@ -155,7 +264,7 @@ export function BeaconPanel() {
|
||||
[toast, claimChainReward]
|
||||
);
|
||||
|
||||
if (!challenge || !progress || !weeklyChallenge || !weeklyProgress || !chainState) {
|
||||
if (!challenge || !progress || !timedChallenge || !timedProgress || !weeklyChallenge || !weeklyProgress || !chainState) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-xs text-muted-foreground/60">
|
||||
正在校准深空信标…
|
||||
@@ -170,6 +279,14 @@ export function BeaconPanel() {
|
||||
const isClaimed = progress.claimed;
|
||||
const canClaim = isCompleted && !isClaimed;
|
||||
|
||||
// 限时挑战派生量
|
||||
const tDiffMeta = BEACON_DIFFICULTY[timedChallenge.difficulty];
|
||||
const tTypeMeta = BEACON_TYPE_META[timedChallenge.type];
|
||||
const tPct = Math.min(100, (timedProgress.progress / timedChallenge.goal) * 100);
|
||||
const tCompleted = timedProgress.completedAt !== null;
|
||||
const tClaimed = timedProgress.claimed;
|
||||
const tCanClaim = tCompleted && !tClaimed;
|
||||
|
||||
// 周挑战派生量
|
||||
const wDiffMeta = BEACON_DIFFICULTY[weeklyChallenge.difficulty];
|
||||
const wTypeMeta = BEACON_TYPE_META[weeklyChallenge.type];
|
||||
@@ -202,6 +319,14 @@ export function BeaconPanel() {
|
||||
0%, 100% { box-shadow: 0 0 18px rgba(232,121,249,0.35), inset 0 0 12px rgba(232,121,249,0.25); }
|
||||
50% { box-shadow: 0 0 32px rgba(232,121,249,0.5), inset 0 0 20px rgba(232,121,249,0.35); }
|
||||
}
|
||||
@keyframes timed-glow {
|
||||
0%, 100% { box-shadow: 0 0 18px rgba(251,191,36,0.4), inset 0 0 12px rgba(251,191,36,0.25); }
|
||||
50% { box-shadow: 0 0 30px rgba(251,191,36,0.55), inset 0 0 20px rgba(251,191,36,0.35); }
|
||||
}
|
||||
@keyframes timed-urgent {
|
||||
0%, 100% { box-shadow: 0 0 18px rgba(244,63,94,0.5), inset 0 0 12px rgba(244,63,94,0.3); }
|
||||
50% { box-shadow: 0 0 34px rgba(244,63,94,0.7), inset 0 0 22px rgba(244,63,94,0.4); }
|
||||
}
|
||||
@keyframes chain-milestone-pulse {
|
||||
0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(251,113,133,0.55); }
|
||||
50% { transform: scale(1.06); box-shadow: 0 0 0 8px rgba(251,113,133,0); }
|
||||
@@ -344,6 +469,144 @@ export function BeaconPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== 限时挑战卡片(amber 主题,每 4 小时刷新) ===== */}
|
||||
{(() => {
|
||||
// 距离时段结束 < 30 分钟时进入"紧急"状态(rose 脉冲)
|
||||
const msLeft = msUntilNextTimedSlot(new Date(now));
|
||||
const urgent = msLeft < 30 * 60 * 1000 && !tCompleted;
|
||||
const glowAnim = tCompleted
|
||||
? "none"
|
||||
: urgent
|
||||
? "timed-urgent 1.5s ease-in-out infinite"
|
||||
: "timed-glow 3s ease-in-out infinite";
|
||||
return (
|
||||
<div
|
||||
className="relative rounded-xl border p-3 overflow-hidden"
|
||||
style={{
|
||||
borderColor: urgent ? "rgba(244,63,94,0.5)" : "rgba(251,191,36,0.4)",
|
||||
background: urgent
|
||||
? "linear-gradient(135deg, rgba(244,63,94,0.12), rgba(251,191,36,0.08), rgba(0,0,0,0.45))"
|
||||
: "linear-gradient(135deg, rgba(251,191,36,0.14), rgba(0,0,0,0.45))",
|
||||
animation: glowAnim,
|
||||
}}
|
||||
>
|
||||
{/* 头部:标题 + slotKey + 倒计时 */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Hourglass className={`h-3.5 w-3.5 ${urgent ? "text-rose-400" : "text-amber-400"}`} />
|
||||
<span className={`text-xs font-semibold ${urgent ? "text-rose-200" : "text-amber-200"}`}>
|
||||
限时挑战 · TIMED
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[9px] text-muted-foreground/70">
|
||||
<span className="font-mono text-amber-300/70">
|
||||
{getTimedSlotKey().split("_").slice(1).join(" ")}
|
||||
</span>
|
||||
<Clock className="h-2.5 w-2.5" />
|
||||
<span className={`font-mono ${urgent ? "text-rose-300" : "text-amber-300/90"}`}>
|
||||
{timedCountdown}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 难度 + 类型 标签 */}
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<span
|
||||
className="text-[10px] font-mono px-1.5 py-0.5 rounded flex items-center gap-1"
|
||||
style={{ background: `${tDiffMeta.color}22`, color: tDiffMeta.color, border: `1px solid ${tDiffMeta.color}55` }}
|
||||
>
|
||||
<span>{tDiffMeta.icon}</span>
|
||||
{tDiffMeta.label}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-white/5 text-muted-foreground border border-white/10">
|
||||
{tTypeMeta.icon} {tTypeMeta.label}
|
||||
</span>
|
||||
{tCompleted && (
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 flex items-center gap-1">
|
||||
<Sparkles className="h-2.5 w-2.5" /> 已完成
|
||||
</span>
|
||||
)}
|
||||
{urgent && !tCompleted && (
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-rose-500/20 text-rose-300 border border-rose-500/40 flex items-center gap-1 animate-pulse">
|
||||
即将结束
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 标题 */}
|
||||
<h4
|
||||
className="text-sm font-semibold mb-1"
|
||||
style={{ color: tDiffMeta.color, textShadow: `0 0 10px ${tDiffMeta.glow}` }}
|
||||
>
|
||||
{timedChallenge.title}
|
||||
</h4>
|
||||
<p className="text-[10px] text-muted-foreground/80 leading-relaxed mb-2">
|
||||
{timedChallenge.desc}
|
||||
</p>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] text-muted-foreground/70">进度</span>
|
||||
<span className="text-[11px] font-mono font-semibold" style={{ color: tDiffMeta.color }}>
|
||||
{Math.min(timedProgress.progress, timedChallenge.goal)} / {timedChallenge.goal} {tTypeMeta.unit}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={tPct}
|
||||
className="h-2 bg-black/40"
|
||||
style={{
|
||||
["--progress-color" as string]: urgent ? "#f43f5e" : tDiffMeta.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 奖励 + 领取按钮 */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-[10px]">
|
||||
<span className="text-muted-foreground/60">奖励:</span>
|
||||
{timedChallenge.rewardInsight > 0 && (
|
||||
<span className="text-amber-300 font-mono">
|
||||
+{formatNum(timedChallenge.rewardInsight)}洞见
|
||||
</span>
|
||||
)}
|
||||
<span className="text-fuchsia-300 font-mono">
|
||||
+{timedChallenge.rewardContact.toFixed(1)}接触
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleClaimTimed}
|
||||
disabled={!tCanClaim}
|
||||
size="sm"
|
||||
className="h-7 px-3 text-[11px] border-0"
|
||||
style={{
|
||||
background: tCanClaim
|
||||
? `linear-gradient(90deg, #34d399, #34d399cc)`
|
||||
: "rgba(251,191,36,0.10)",
|
||||
color: tCanClaim
|
||||
? "#022c22"
|
||||
: "rgba(251,191,36,0.55)",
|
||||
}}
|
||||
>
|
||||
{tClaimed
|
||||
? "已领取"
|
||||
: tCanClaim
|
||||
? "领取奖励"
|
||||
: tCompleted
|
||||
? "已领取"
|
||||
: "进行中…"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tCompleted && timedProgress.durationSec > 0 && (
|
||||
<div className="mt-1.5 text-[10px] text-muted-foreground/50 text-center">
|
||||
完成用时 {Math.floor(timedProgress.durationSec / 60)}分{timedProgress.durationSec % 60}秒
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 周挑战 + 信标链(桌面端并排,移动端单列) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
||||
{/* ===== 周挑战卡片(fuchsia 主题) ===== */}
|
||||
@@ -671,68 +934,237 @@ export function BeaconPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 本地排行榜 */}
|
||||
{/* 排行榜(本地 Top20 / 全球 Top100 双 tab — v0.8.2 P2/#4) */}
|
||||
<div className="rounded-xl border border-white/10 bg-black/30 p-2.5">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<Trophy className="h-3.5 w-3.5 text-amber-400" />
|
||||
<span className="text-xs font-semibold">深空排行榜</span>
|
||||
<span className="text-[10px] text-muted-foreground/50">本地 · Top {leaderboard.length || 0}</span>
|
||||
{/* 头部:标题 + tab 切换 + (全球 tab 时)刷新按钮 */}
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Trophy className="h-3.5 w-3.5 text-amber-400" />
|
||||
<span className="text-xs font-semibold">深空排行榜</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* tab 切换 */}
|
||||
<div className="flex items-center gap-0.5 p-0.5 rounded-lg bg-black/40 border border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLbTab("local")}
|
||||
className={`flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-mono transition-colors ${
|
||||
lbTab === "local"
|
||||
? "bg-amber-500/25 text-amber-300 border border-amber-500/40"
|
||||
: "text-muted-foreground/60 hover:text-muted-foreground border border-transparent"
|
||||
}`}
|
||||
aria-pressed={lbTab === "local"}
|
||||
>
|
||||
<Trophy className="h-2.5 w-2.5" />
|
||||
本地
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLbTab("global")}
|
||||
className={`flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-mono transition-colors ${
|
||||
lbTab === "global"
|
||||
? "bg-emerald-500/25 text-emerald-300 border border-emerald-500/40"
|
||||
: "text-muted-foreground/60 hover:text-muted-foreground border border-transparent"
|
||||
}`}
|
||||
aria-pressed={lbTab === "global"}
|
||||
>
|
||||
<Globe className="h-2.5 w-2.5" />
|
||||
全球
|
||||
</button>
|
||||
</div>
|
||||
{/* 刷新按钮(仅全球 tab) */}
|
||||
{lbTab === "global" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshCloudLeaderboard()}
|
||||
disabled={cloudLoading}
|
||||
className="p-1 rounded-md text-muted-foreground/70 hover:text-emerald-300 hover:bg-emerald-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="刷新全球榜"
|
||||
title="刷新全球榜"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 ${cloudLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{leaderboard.length === 0 ? (
|
||||
<div className="text-center py-4 text-[11px] text-muted-foreground/40">
|
||||
<Trophy className="h-6 w-6 mx-auto mb-1 opacity-30" />
|
||||
尚无记录。完成今日或本周信标即可登榜。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5 max-h-[180px] overflow-y-auto echo-scroll">
|
||||
{leaderboard.map((entry, i) => {
|
||||
const rb = rankBadge(i);
|
||||
const eDiff = BEACON_DIFFICULTY[entry.difficulty];
|
||||
const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType];
|
||||
const isMine = entry.dateKey === getTodayKey() || entry.dateKey === getWeekKey();
|
||||
return (
|
||||
<div
|
||||
key={`${entry.timestamp}-${i}`}
|
||||
className={`flex items-center gap-2 px-2 py-1 rounded-lg text-[11px] ${
|
||||
entry.isWeekly
|
||||
? "bg-fuchsia-500/10 border border-fuchsia-500/20"
|
||||
: isMine
|
||||
? "bg-fuchsia-500/10 border border-fuchsia-500/20"
|
||||
: "hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
{/* 排名 */}
|
||||
<span className="w-6 flex items-center justify-center">
|
||||
{rb ? (
|
||||
<rb.icon className="h-3.5 w-3.5" style={{ color: rb.color }} />
|
||||
) : (
|
||||
<span className="text-muted-foreground/50 font-mono">{i + 1}</span>
|
||||
)}
|
||||
</span>
|
||||
{/* 类型 + 难度 + 周挑战标记 */}
|
||||
<span className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<span style={{ color: eDiff.color }} className="font-mono">{eDiff.icon}</span>
|
||||
<span className="text-muted-foreground/80 truncate">{eType.label}</span>
|
||||
{entry.isWeekly && (
|
||||
<span className="text-[9px] font-mono px-1 rounded bg-fuchsia-500/20 text-fuchsia-300 border border-fuchsia-500/40">
|
||||
WEEK
|
||||
{/* 子标题:榜量信息 */}
|
||||
<div className="mb-1.5 text-[10px] text-muted-foreground/50 flex items-center gap-2">
|
||||
{lbTab === "local" ? (
|
||||
<span>本机 Top {leaderboard.length || 0} · 离线可用</span>
|
||||
) : (
|
||||
<>
|
||||
<span>全球 Top {cloudEntries.length || 0}</span>
|
||||
{cloudFetched && !cloudLoading && cloudEntries.length > 0 && (
|
||||
<span className="text-emerald-400/60">· 已同步</span>
|
||||
)}
|
||||
{cloudLoading && (
|
||||
<span className="text-amber-400/70 flex items-center gap-1">
|
||||
<Loader2 className="h-2.5 w-2.5 animate-spin" />
|
||||
同步中…
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== 本地榜 ===== */}
|
||||
{lbTab === "local" &&
|
||||
(leaderboard.length === 0 ? (
|
||||
<div className="text-center py-4 text-[11px] text-muted-foreground/40">
|
||||
<Trophy className="h-6 w-6 mx-auto mb-1 opacity-30" />
|
||||
尚无记录。完成限时、每日或本周信标即可登榜。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5 max-h-[200px] overflow-y-auto echo-scroll">
|
||||
{leaderboard.map((entry, i) => {
|
||||
const rb = rankBadge(i);
|
||||
const eDiff = BEACON_DIFFICULTY[entry.difficulty];
|
||||
const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType];
|
||||
const isMine =
|
||||
entry.dateKey === getTodayKey() ||
|
||||
entry.dateKey === getWeekKey() ||
|
||||
entry.dateKey === getTimedSlotKey();
|
||||
return (
|
||||
<div
|
||||
key={`local-${entry.timestamp}-${i}`}
|
||||
className={`flex items-center gap-2 px-2 py-1 rounded-lg text-[11px] ${
|
||||
entry.isWeekly
|
||||
? "bg-fuchsia-500/10 border border-fuchsia-500/20"
|
||||
: entry.isTimed
|
||||
? "bg-amber-500/10 border border-amber-500/20"
|
||||
: isMine
|
||||
? "bg-fuchsia-500/10 border border-fuchsia-500/20"
|
||||
: "hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<span className="w-6 flex items-center justify-center">
|
||||
{rb ? (
|
||||
<rb.icon className="h-3.5 w-3.5" style={{ color: rb.color }} />
|
||||
) : (
|
||||
<span className="text-muted-foreground/50 font-mono">{i + 1}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<span style={{ color: eDiff.color }} className="font-mono">{eDiff.icon}</span>
|
||||
<span className="text-muted-foreground/80 truncate">{eType.label}</span>
|
||||
{entry.isWeekly && (
|
||||
<span className="text-[9px] font-mono px-1 rounded bg-fuchsia-500/20 text-fuchsia-300 border border-fuchsia-500/40">
|
||||
WEEK
|
||||
</span>
|
||||
)}
|
||||
{entry.isTimed && (
|
||||
<span className="text-[9px] font-mono px-1 rounded bg-amber-500/20 text-amber-300 border border-amber-500/40">
|
||||
TIMED
|
||||
</span>
|
||||
)}
|
||||
{entry.progress >= 1 && <span className="text-emerald-400/70">✓</span>}
|
||||
</span>
|
||||
<span className="text-muted-foreground/50 font-mono text-[10px] w-16 text-right">
|
||||
{Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s
|
||||
</span>
|
||||
<span className="font-mono font-semibold w-12 text-right" style={{ color: eDiff.color }}>
|
||||
{formatNum(entry.score)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* ===== 全球榜 ===== */}
|
||||
{lbTab === "global" && (
|
||||
<>
|
||||
{cloudLoading && cloudEntries.length === 0 ? (
|
||||
<div className="text-center py-4 text-[11px] text-muted-foreground/60 flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-emerald-400" />
|
||||
正在拉取全球榜…
|
||||
</div>
|
||||
) : cloudEntries.length === 0 ? (
|
||||
<div className="text-center py-4 text-[11px] text-muted-foreground/40">
|
||||
{cloudError ? (
|
||||
<>
|
||||
<WifiOff className="h-6 w-6 mx-auto mb-1 opacity-40 text-rose-400" />
|
||||
<span className="text-rose-300/70">{cloudError}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshCloudLeaderboard()}
|
||||
className="block mx-auto mt-2 text-[10px] text-emerald-400/80 hover:text-emerald-300 underline-offset-2 hover:underline"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Globe className="h-6 w-6 mx-auto mb-1 opacity-30 text-emerald-400" />
|
||||
尚无全球记录。完成挑战并领取即可登顶全球榜!
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5 max-h-[200px] overflow-y-auto echo-scroll">
|
||||
{cloudEntries.map((entry, i) => {
|
||||
const rb = rankBadge(i);
|
||||
const eDiff = BEACON_DIFFICULTY[entry.difficulty as BeaconDifficulty] ?? BEACON_DIFFICULTY.routine;
|
||||
const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType] ?? { label: entry.challenge, icon: "·", unit: "" };
|
||||
// 高亮"我的"记录:通过 timestamp 匹配本机最后一次成功提交
|
||||
const isMine = mySubmitTs > 0 && entry.timestamp === mySubmitTs;
|
||||
return (
|
||||
<div
|
||||
key={`global-${entry.timestamp}-${i}`}
|
||||
className={`flex items-center gap-2 px-2 py-1 rounded-lg text-[11px] ${
|
||||
isMine
|
||||
? "bg-emerald-500/15 border border-emerald-500/40 shadow-[0_0_12px_rgba(52,211,153,0.25)]"
|
||||
: entry.isWeekly
|
||||
? "bg-fuchsia-500/10 border border-fuchsia-500/20"
|
||||
: entry.isTimed
|
||||
? "bg-amber-500/10 border border-amber-500/20"
|
||||
: "hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<span className="w-6 flex items-center justify-center">
|
||||
{rb ? (
|
||||
<rb.icon className="h-3.5 w-3.5" style={{ color: rb.color }} />
|
||||
) : (
|
||||
<span className={`font-mono ${isMine ? "text-emerald-300 font-bold" : "text-muted-foreground/50"}`}>
|
||||
{i + 1}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{entry.progress >= 1 && <span className="text-emerald-400/70">✓</span>}
|
||||
</span>
|
||||
{/* 用时 */}
|
||||
<span className="text-muted-foreground/50 font-mono text-[10px] w-16 text-right">
|
||||
{Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s
|
||||
</span>
|
||||
{/* 分数 */}
|
||||
<span className="font-mono font-semibold w-12 text-right" style={{ color: eDiff.color }}>
|
||||
{formatNum(entry.score)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<span style={{ color: eDiff.color }} className="font-mono">{eDiff.icon}</span>
|
||||
<span className="text-muted-foreground/80 truncate">{eType.label}</span>
|
||||
{entry.isWeekly && (
|
||||
<span className="text-[9px] font-mono px-1 rounded bg-fuchsia-500/20 text-fuchsia-300 border border-fuchsia-500/40">
|
||||
WEEK
|
||||
</span>
|
||||
)}
|
||||
{entry.isTimed && (
|
||||
<span className="text-[9px] font-mono px-1 rounded bg-amber-500/20 text-amber-300 border border-amber-500/40">
|
||||
TIMED
|
||||
</span>
|
||||
)}
|
||||
{isMine && (
|
||||
<span className="text-[9px] font-mono px-1 rounded bg-emerald-500/25 text-emerald-200 border border-emerald-500/50">
|
||||
YOU
|
||||
</span>
|
||||
)}
|
||||
{entry.progress >= 1 && <span className="text-emerald-400/70">✓</span>}
|
||||
</span>
|
||||
<span className="text-muted-foreground/50 font-mono text-[10px] w-16 text-right">
|
||||
{Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s
|
||||
</span>
|
||||
<span className="font-mono font-semibold w-12 text-right" style={{ color: isMine ? "#34d399" : eDiff.color }}>
|
||||
{formatNum(entry.score)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — 回响编年史对话框(v0.4 跨周目叙事时间轴)
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -20,7 +20,8 @@ import { getPerk } from "@/lib/game/constellation";
|
||||
import { TIDE_EVENTS } from "@/lib/game/starTide";
|
||||
import { sfx } from "@/hooks/useAudio";
|
||||
import type { ChronicleEntry } from "@/lib/game/types";
|
||||
import { BookOpen, Clock, Swords, Trophy, Sparkles, Star, Waves, Cpu, Zap } from "lucide-react";
|
||||
import { BookOpen, Clock, Swords, Trophy, Sparkles, Star, Waves, Cpu, Zap, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -59,12 +60,26 @@ export function ChronicleDialog({ open, onOpenChange }: Props) {
|
||||
const bossKills = useGameStore((s) => s.bossKills ?? 0);
|
||||
const tidesAll = useGameStore((s) => s.starTidesEncountered ?? []);
|
||||
// 倒序展示(最新的在前)
|
||||
const entries: ChronicleEntry[] = [...chronicle].reverse();
|
||||
const entries: ChronicleEntry[] = useMemo(() => [...chronicle].reverse(), [chronicle]);
|
||||
|
||||
// 打开时播放翻页音效
|
||||
// v0.8.2 分页(工单 #8):编年史上限 200 条,每页 10 条
|
||||
const PAGE_SIZE = 10;
|
||||
const totalPages = Math.max(1, Math.ceil(entries.length / PAGE_SIZE));
|
||||
const [page, setPage] = useState(1);
|
||||
// 当 entries 变化(新飞升)且当前页超出范围时,回到第 1 页(最新)
|
||||
useEffect(() => {
|
||||
if (open) sfx("chronicleOpen");
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (page > totalPages) setPage(1);
|
||||
}, [page, totalPages]);
|
||||
// 打开时默认回到第 1 页(最新)
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
sfx("chronicleOpen");
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPage(1);
|
||||
}
|
||||
}, [open]);
|
||||
const pagedEntries = entries.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -108,7 +123,7 @@ export function ChronicleDialog({ open, onOpenChange }: Props) {
|
||||
</DialogHeader>
|
||||
|
||||
{/* 时间轴滚动区 */}
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6 echo-scroll">
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-3 echo-scroll">
|
||||
{entries.length === 0 ? (
|
||||
<EmptyState ascensions={ascensions} />
|
||||
) : (
|
||||
@@ -117,18 +132,49 @@ export function ChronicleDialog({ open, onOpenChange }: Props) {
|
||||
<div className="absolute left-[27px] top-2 bottom-2 w-px bg-gradient-to-b from-fuchsia-400/40 via-violet-400/30 to-transparent" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{entries.map((entry, idx) => (
|
||||
{pagedEntries.map((entry, idx) => (
|
||||
<ChronicleCard
|
||||
key={`${entry.ascensionNumber}-${entry.timestamp}`}
|
||||
entry={entry}
|
||||
isLatest={idx === 0}
|
||||
index={idx}
|
||||
isLatest={(page - 1) * PAGE_SIZE + idx === 0}
|
||||
index={(page - 1) * PAGE_SIZE + idx}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* v0.8.2 分页控制栏(工单 #8) */}
|
||||
{entries.length > PAGE_SIZE && (
|
||||
<div className="px-6 py-2.5 border-t border-fuchsia-400/15 bg-black/40 flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-muted-foreground font-mono">
|
||||
第 {page} / {totalPages} 页 · 共 {entries.length} 条飞升记录
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => { sfx("tabSwitch"); setPage((p) => Math.max(1, p - 1)); }}
|
||||
className="h-7 px-2 text-fuchsia-300 hover:bg-fuchsia-500/15 disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => { sfx("tabSwitch"); setPage((p) => Math.min(totalPages, p + 1)); }}
|
||||
className="h-7 px-2 text-fuchsia-300 hover:bg-fuchsia-500/15 disabled:opacity-30"
|
||||
>
|
||||
下一页
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -16,12 +16,15 @@ export function StarTideNotifier() {
|
||||
if (events.length === 0) return;
|
||||
const items = consume();
|
||||
for (const ev of items) {
|
||||
const globalTag = ev.isGlobal ? "🌐 " : "";
|
||||
if (ev.started) {
|
||||
const meta = TIDE_EVENTS[ev.started];
|
||||
sfx("tideStart");
|
||||
toast({
|
||||
title: `${meta.icon} 星潮降临:${meta.name}`,
|
||||
description: meta.desc,
|
||||
title: `${globalTag}${meta.icon} ${ev.isGlobal ? "全球星潮" : "星潮降临"}:${meta.name}`,
|
||||
description: ev.isGlobal
|
||||
? `全球玩家同步经历 · ${meta.desc}`
|
||||
: meta.desc,
|
||||
});
|
||||
}
|
||||
if (ev.ended) {
|
||||
@@ -29,14 +32,16 @@ export function StarTideNotifier() {
|
||||
if (ev.silenceCompensation && ev.silenceCompensation > 0) {
|
||||
sfx("techBuy");
|
||||
toast({
|
||||
title: `${meta.icon} 寂静期消散`,
|
||||
title: `${globalTag}${meta.icon} 寂静期消散`,
|
||||
description: `虚空回赠 ${ev.silenceCompensation} 洞见。`,
|
||||
});
|
||||
} else {
|
||||
sfx("tideEnd");
|
||||
toast({
|
||||
title: `${meta.icon} ${meta.name}结束`,
|
||||
description: "星潮退去,物理法则恢复。",
|
||||
title: `${globalTag}${meta.icon} ${meta.name}结束`,
|
||||
description: ev.isGlobal
|
||||
? "全球星潮退去,物理法则恢复。"
|
||||
: "星潮退去,物理法则恢复。",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -49,6 +54,7 @@ export function StarTideNotifier() {
|
||||
/** 顶部星潮指示器(活跃时显示倒计时芯片) */
|
||||
export function StarTideIndicator() {
|
||||
const activeTide = useGameStore((s) => s.activeTide);
|
||||
const globalTide = useGameStore((s) => s.globalTide);
|
||||
const [, force] = useState(0);
|
||||
|
||||
// 每秒刷新倒计时
|
||||
@@ -62,18 +68,27 @@ export function StarTideIndicator() {
|
||||
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;
|
||||
// 全球星潮持续 60s,本地 75s —— 用实际 endsAt-startedAt 计算 progress,避免越界
|
||||
const duration = globalTide
|
||||
? globalTide.endsAt - globalTide.startedAt
|
||||
: TIDE_CONFIG.duration;
|
||||
const progress = Math.min(1, Math.max(0, 1 - remaining / duration));
|
||||
const isGlobal = !!globalTide;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-full text-[10px] font-medium relative overflow-hidden"
|
||||
className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-[10px] font-medium relative overflow-hidden ${
|
||||
isGlobal ? "ring-1 ring-amber-300/60" : ""
|
||||
}`}
|
||||
style={{
|
||||
background: `${meta.color}1a`,
|
||||
border: `1px solid ${meta.color}55`,
|
||||
background: isGlobal
|
||||
? `linear-gradient(135deg, ${meta.color}26, ${meta.color}11)`
|
||||
: `${meta.color}1a`,
|
||||
border: `1px solid ${meta.color}${isGlobal ? "88" : "55"}`,
|
||||
color: meta.color,
|
||||
boxShadow: `0 0 10px ${meta.glow}`,
|
||||
boxShadow: `0 0 ${isGlobal ? "16" : "10"}px ${meta.glow}`,
|
||||
}}
|
||||
title={meta.desc}
|
||||
title={isGlobal ? `🌐 全球星潮 · ${meta.desc}` : meta.desc}
|
||||
>
|
||||
{/* 进度环背景 */}
|
||||
<div
|
||||
@@ -82,6 +97,14 @@ export function StarTideIndicator() {
|
||||
background: `linear-gradient(90deg, ${meta.color}40 ${progress * 100}%, transparent ${progress * 100}%)`,
|
||||
}}
|
||||
/>
|
||||
{isGlobal && (
|
||||
<span
|
||||
className="relative text-[11px] leading-none animate-pulse"
|
||||
aria-label="全球星潮标记"
|
||||
>
|
||||
🌐
|
||||
</span>
|
||||
)}
|
||||
<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>
|
||||
@@ -92,8 +115,10 @@ export function StarTideIndicator() {
|
||||
/** 全屏背景叠层:活跃星潮时给页面罩一层对应色的微光 */
|
||||
export function StarTideOverlay() {
|
||||
const activeTide = useGameStore((s) => s.activeTide);
|
||||
const globalTide = useGameStore((s) => s.globalTide);
|
||||
if (!activeTide) return null;
|
||||
const meta = TIDE_EVENTS[activeTide.type];
|
||||
const isGlobal = !!globalTide;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 pointer-events-none z-0 transition-opacity duration-1000">
|
||||
@@ -115,15 +140,30 @@ export function StarTideOverlay() {
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
boxShadow: `inset 0 0 120px ${meta.glow}`,
|
||||
boxShadow: `inset 0 0 ${isGlobal ? "160" : "120"}px ${meta.glow}`,
|
||||
animation: "tide-breathe 3s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
{/* 全球星潮:顶部多一道琥珀色光带,强化"全球同步"视觉信号 */}
|
||||
{isGlobal && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-1 opacity-90"
|
||||
style={{
|
||||
background: "linear-gradient(90deg, transparent, #fbbf24, #fcd34d, #fbbf24, transparent)",
|
||||
boxShadow: "0 0 16px rgba(251,191,36,0.7)",
|
||||
animation: "global-tide-sweep 2.5s linear infinite",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<style jsx>{`
|
||||
@keyframes tide-breathe {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 0.9; }
|
||||
}
|
||||
@keyframes global-tide-sweep {
|
||||
0% { transform: translateX(-30%); }
|
||||
100% { transform: translateX(30%); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — 全球星潮同步 hook(P3 / 工单 #10-c)
|
||||
//
|
||||
// 连接 star-tide-service(端口 3031),监听全球星潮事件:
|
||||
// - `tide-state` 连接时立即推送当前状态
|
||||
// - `global-tide` 全球星潮开始 → 调用 gameStore.triggerGlobalTide(type)
|
||||
// - `tide-ended` 全球星潮结束(本地 tickTide 也会在 60s 后自动收尾,这是双保险)
|
||||
//
|
||||
// 网关:socket.io 必须用 `io("/?XTransformPort=3031")`,path 默认 "/"
|
||||
// 离线容错:socket.io 默认自动重连;断连期间本地 tickTide 继续工作
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { io, type Socket } from "socket.io-client";
|
||||
import { useGameStore } from "@/store/gameStore";
|
||||
import type { TideType } from "@/lib/game/starTide";
|
||||
|
||||
/** 服务端推过来的全球星潮状态结构(与 mini-service 一致) */
|
||||
interface ServerGlobalTide {
|
||||
type: TideType;
|
||||
name: string;
|
||||
startedAt: number;
|
||||
endsAt: number;
|
||||
durationSec: number;
|
||||
}
|
||||
|
||||
interface ServerTideState {
|
||||
current: ServerGlobalTide | null;
|
||||
nextTideAt: number;
|
||||
serverTime: number;
|
||||
}
|
||||
|
||||
/** 单例 socket —— 多次挂载 hook 共享同一连接 */
|
||||
let sharedSocket: Socket | null = null;
|
||||
let connectRefCount = 0;
|
||||
|
||||
function getSharedSocket(): Socket {
|
||||
if (!sharedSocket) {
|
||||
sharedSocket = io("/?XTransformPort=3031", {
|
||||
transports: ["websocket", "polling"],
|
||||
reconnection: true,
|
||||
reconnectionAttempts: Infinity, // 持续重连
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 10_000,
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
return sharedSocket;
|
||||
}
|
||||
|
||||
/**
|
||||
* 挂载此 hook 即可让组件树订阅全球星潮事件。
|
||||
* 在 page.tsx 顶层调用一次即可。
|
||||
*/
|
||||
export function useGlobalTide(): void {
|
||||
const triggerGlobalTide = useGameStore((s) => s.triggerGlobalTide);
|
||||
// 用 ref 缓存最新 action,避免 socket 监听器依赖变化导致重绑
|
||||
const triggerRef = useRef(triggerGlobalTide);
|
||||
useEffect(() => {
|
||||
triggerRef.current = triggerGlobalTide;
|
||||
}, [triggerGlobalTide]);
|
||||
|
||||
// 记录已处理过的 global-tide 事件 id(避免重连时重复触发)
|
||||
const processedIds = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const sock = getSharedSocket();
|
||||
connectRefCount += 1;
|
||||
|
||||
const onGlobalTide = (tide: ServerGlobalTide) => {
|
||||
// 用 startedAt+type 作为去重 key(同一道全球星潮可能在重连后被重复推送)
|
||||
const key = `${tide.startedAt}_${tide.type}`;
|
||||
if (processedIds.current.has(key)) return;
|
||||
processedIds.current.add(key);
|
||||
// 限制集合大小
|
||||
if (processedIds.current.size > 32) {
|
||||
const first = processedIds.current.values().next().value;
|
||||
if (first) processedIds.current.delete(first);
|
||||
}
|
||||
// 防御:如果服务端推送的星潮已经过期(例如重连时拿到的旧状态),不触发
|
||||
if (tide.endsAt <= Date.now()) return;
|
||||
triggerRef.current(tide.type);
|
||||
};
|
||||
|
||||
const onTideState = (state: ServerTideState) => {
|
||||
// 连接/重连时若服务端正有进行中的全球星潮,且本地还没触发过,补触发
|
||||
if (state.current && state.current.endsAt > Date.now()) {
|
||||
const key = `${state.current.startedAt}_${state.current.type}`;
|
||||
if (!processedIds.current.has(key)) {
|
||||
processedIds.current.add(key);
|
||||
triggerRef.current(state.current.type);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sock.on("global-tide", onGlobalTide);
|
||||
sock.on("tide-state", onTideState);
|
||||
|
||||
// 主动查询一次当前状态(保险,防止错过连接时的初始推送)
|
||||
sock.emit("get-next-tide");
|
||||
|
||||
return () => {
|
||||
sock.off("global-tide", onGlobalTide);
|
||||
sock.off("tide-state", onTideState);
|
||||
connectRefCount -= 1;
|
||||
// 最后一个使用者退出时才真正断开
|
||||
if (connectRefCount <= 0 && sharedSocket) {
|
||||
sharedSocket.disconnect();
|
||||
sharedSocket = null;
|
||||
connectRefCount = 0;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
/** QA 用:手动触发一次全球星潮(仅供 dev 控制台调用) */
|
||||
export function adminTriggerGlobalTide(): void {
|
||||
if (!sharedSocket) {
|
||||
// 还没连接则先建立
|
||||
getSharedSocket();
|
||||
}
|
||||
sharedSocket?.emit("admin-trigger-tide");
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined" && process.env.NODE_ENV !== "production") {
|
||||
(window as unknown as { __adminTriggerGlobalTide?: typeof adminTriggerGlobalTide }).__adminTriggerGlobalTide = adminTriggerGlobalTide;
|
||||
}
|
||||
@@ -244,10 +244,10 @@ export const ACHIEVEMENTS: Achievement[] = [
|
||||
{
|
||||
id: "ach_tides_all",
|
||||
name: "星潮亲历者",
|
||||
desc: "经历全部 6 种星潮事件",
|
||||
desc: "经历全部 9 种星潮事件",
|
||||
icon: "🌊",
|
||||
color: "#34d399",
|
||||
check: (s) => (s.starTidesEncountered?.length ?? 0) >= 6,
|
||||
check: (s) => (s.starTidesEncountered?.length ?? 0) >= 9,
|
||||
reward: { crystalsPerSecPct: 8, insightPct: 8 },
|
||||
rewardText: "产能 +8% · 洞见 +8%",
|
||||
},
|
||||
|
||||
+417
-1
@@ -1,7 +1,8 @@
|
||||
// 回响星核 / Echo Nexus — 深空信标
|
||||
// v0.5:每日挑战 + 本地排行榜
|
||||
// v0.8:周挑战 + 信标链(连续完成奖励)
|
||||
// 一个自包含的"每日挑战 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。
|
||||
// v0.8.2:限时挑战(每 4 小时刷新,填补日挑战空档)
|
||||
// 一个自包含的"每日 + 限时 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。
|
||||
|
||||
/** 每日挑战类型 */
|
||||
export type BeaconChallengeType =
|
||||
@@ -32,6 +33,8 @@ export interface BeaconScoreEntry {
|
||||
durationSec: number;
|
||||
/** v0.8:是否为周挑战记录(日挑战默认 false / undefined) */
|
||||
isWeekly?: boolean;
|
||||
/** v0.8.2:是否为限时挑战记录 */
|
||||
isTimed?: boolean;
|
||||
}
|
||||
|
||||
/** 每日挑战定义 */
|
||||
@@ -334,6 +337,7 @@ export function claimBeaconReward(
|
||||
rewardContact: challenge.rewardContact,
|
||||
score,
|
||||
leaderboard,
|
||||
entry,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -614,6 +618,7 @@ export function claimWeeklyReward(
|
||||
rewardContact: challenge.rewardContact,
|
||||
score,
|
||||
leaderboard,
|
||||
entry,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -872,3 +877,414 @@ export function getChainProgress(streak: number): {
|
||||
progressPct: Math.min(100, Math.max(0, pct)),
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 限时挑战(TIMED CHALLENGE)— v0.8.2
|
||||
// 每 4 小时刷新一个"短周期挑战",目标较小、奖励较少,填补日挑战的空档。
|
||||
// 时段按 UTC 0/4/8/12/16/20 点切分,同时段同种子 → 同一挑战(确定性)。
|
||||
// 难度固定 routine,goal = 日挑战基准 × 0.3-0.5。
|
||||
// ===========================================================================
|
||||
|
||||
/** 限时挑战 localStorage key */
|
||||
export const BEACON_TIMED_KEY = "echo-nexus-beacon-timed-v1";
|
||||
|
||||
/** 限时挑战定义 */
|
||||
export interface BeaconTimedChallenge {
|
||||
/** 时段 key(如 "timed_2026-06-24_14" 表示 14-18 点时段) */
|
||||
slotKey: string;
|
||||
/** 挑战类型 */
|
||||
type: BeaconChallengeType;
|
||||
/** 难度(固定 routine) */
|
||||
difficulty: BeaconDifficulty;
|
||||
/** 目标数值(日挑战的 30-50%) */
|
||||
goal: number;
|
||||
/** 奖励:完成时洞见 */
|
||||
rewardInsight: number;
|
||||
/** 奖励:完成时接触进度 */
|
||||
rewardContact: number;
|
||||
/** 使用的种子(可复现) */
|
||||
seed: number;
|
||||
/** 友好标题 */
|
||||
title: string;
|
||||
/** 描述 */
|
||||
desc: string;
|
||||
/** 时段结束时间戳(UTC,用于倒计时) */
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/** 限时挑战进度 */
|
||||
export interface BeaconTimedProgress {
|
||||
slotKey: string;
|
||||
progress: number;
|
||||
startedAt: number;
|
||||
completedAt: number | null;
|
||||
claimed: boolean;
|
||||
durationSec: number;
|
||||
}
|
||||
|
||||
/** 时段长度(毫秒) */
|
||||
export const TIMED_SLOT_MS = 4 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 取当前 4 小时时段 key(UTC,0/4/8/12/16/20 点切分)。
|
||||
* 格式:"timed_YYYY-MM-DD_HH",如 "timed_2026-06-24_14"。
|
||||
*/
|
||||
export function getTimedSlotKey(now: Date = new Date()): string {
|
||||
const y = now.getUTCFullYear();
|
||||
const m = String(now.getUTCMonth() + 1).padStart(2, "0");
|
||||
const d = String(now.getUTCDate()).padStart(2, "0");
|
||||
const hour = now.getUTCHours();
|
||||
const slotStart = Math.floor(hour / 4) * 4; // 0, 4, 8, 12, 16, 20
|
||||
return `timed_${y}-${m}-${d}_${String(slotStart).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** slotKey → 数值种子(FNV-1a 哈希) */
|
||||
function timedSlotKeyToSeed(slotKey: string): number {
|
||||
let h = 2166136261 >>> 0;
|
||||
for (let i = 0; i < slotKey.length; i++) {
|
||||
h ^= slotKey.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619) >>> 0;
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/** 计算时段开始时间戳(UTC) */
|
||||
function timedSlotStartMs(slotKey: string): number {
|
||||
// 解析 "timed_YYYY-MM-DD_HH"
|
||||
const match = slotKey.match(/^timed_(\d{4})-(\d{2})-(\d{2})_(\d{2})$/);
|
||||
if (!match) return Date.now();
|
||||
const [, y, m, d, h] = match;
|
||||
return Date.UTC(Number(y), Number(m) - 1, Number(d), Number(h), 0, 0);
|
||||
}
|
||||
|
||||
/** 距离下一个 4 小时时段的毫秒数(用于倒计时) */
|
||||
export function msUntilNextTimedSlot(now: Date = new Date()): number {
|
||||
const hour = now.getUTCHours();
|
||||
const slotStart = Math.floor(hour / 4) * 4;
|
||||
const slotStartMs = Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth(),
|
||||
now.getUTCDate(),
|
||||
slotStart,
|
||||
0,
|
||||
0
|
||||
);
|
||||
const nextSlotMs = slotStartMs + TIMED_SLOT_MS;
|
||||
return Math.max(0, nextSlotMs - now.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成当前时段的限时挑战(确定性:同时段同种子 → 同一挑战)。
|
||||
* 难度固定 routine,goal = 日挑战基准 × 0.3-0.5(向下取整,最小 1)。
|
||||
*/
|
||||
export function generateTimedChallenge(
|
||||
now: Date = new Date()
|
||||
): BeaconTimedChallenge {
|
||||
const slotKey = getTimedSlotKey(now);
|
||||
const seed = timedSlotKeyToSeed(slotKey);
|
||||
const rng = mulberry32(seed);
|
||||
const expiresAt = timedSlotStartMs(slotKey) + TIMED_SLOT_MS;
|
||||
|
||||
const types: BeaconChallengeType[] = [
|
||||
"decode",
|
||||
"expedition",
|
||||
"pulse",
|
||||
"boss",
|
||||
"insight",
|
||||
];
|
||||
const type = types[Math.floor(rng() * types.length)];
|
||||
|
||||
// 难度固定 routine
|
||||
const difficulty: BeaconDifficulty = "routine";
|
||||
const diffMult = BEACON_DIFFICULTY[difficulty].mult; // 1
|
||||
|
||||
// goal = 日挑战基准 × 0.3-0.5
|
||||
const frac = 0.3 + rng() * 0.2; // 0.3-0.5
|
||||
|
||||
let goal = 0;
|
||||
let rewardInsight = 0;
|
||||
let rewardContact = 0;
|
||||
let title = "";
|
||||
let desc = "";
|
||||
|
||||
switch (type) {
|
||||
case "decode":
|
||||
// 日基准 6-15 × 0.3-0.5 → 2-7
|
||||
goal = Math.max(2, Math.round((6 + Math.floor(rng() * 10)) * diffMult * frac));
|
||||
rewardInsight = Math.round(goal * 3 * diffMult);
|
||||
rewardContact = goal * 0.6;
|
||||
title = `限时解码 ${goal} 颗晶体`;
|
||||
desc = `4 小时内解码 ${goal} 颗记忆晶体。短周期,快速完成。`;
|
||||
break;
|
||||
case "expedition":
|
||||
// 日基准 1-3 × 0.3-0.5 → 1(多数情况为 1)
|
||||
goal = Math.max(1, Math.round((1 + Math.floor(rng() * 3)) * diffMult * frac));
|
||||
rewardInsight = Math.round(goal * 9 * diffMult);
|
||||
rewardContact = goal * 1.2;
|
||||
title = `限时远征 ${goal} 次探险`;
|
||||
desc = `4 小时内完成 ${goal} 次遗迹探险。`;
|
||||
break;
|
||||
case "pulse":
|
||||
// 日基准 20-49 × 0.3-0.5 → 6-24
|
||||
goal = Math.max(6, Math.round((20 + Math.floor(rng() * 30)) * diffMult * frac));
|
||||
rewardInsight = Math.round(goal * 1 * diffMult);
|
||||
rewardContact = goal * 0.25;
|
||||
title = `限时脉冲 ${goal} 次扫描`;
|
||||
desc = `4 小时内发起 ${goal} 次脉冲扫描。`;
|
||||
break;
|
||||
case "boss":
|
||||
// 日基准 1 × 0.3-0.5 → 1(向下取整为 1)
|
||||
goal = Math.max(1, Math.round(diffMult * frac));
|
||||
rewardInsight = Math.round(goal * 25 * diffMult);
|
||||
rewardContact = goal * 2.5;
|
||||
title = `限时猎杀 ${goal} 处 BOSS`;
|
||||
desc = `4 小时内击破 ${goal} 处维度 BOSS。`;
|
||||
break;
|
||||
case "insight":
|
||||
// 日基准 40-119 × 0.3-0.5 → 12-59
|
||||
goal = Math.max(12, Math.round((40 + Math.floor(rng() * 80)) * diffMult * frac));
|
||||
rewardInsight = 0; // 洞见挑战不给洞见,给接触
|
||||
rewardContact = goal * 0.08;
|
||||
title = `限时洞见 ${goal} 点`;
|
||||
desc = `4 小时内累计获取 ${goal} 洞见。`;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
slotKey,
|
||||
type,
|
||||
difficulty,
|
||||
goal,
|
||||
rewardInsight,
|
||||
rewardContact,
|
||||
seed,
|
||||
title,
|
||||
desc,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** 读取当前时段进度(若 slotKey 不匹配则重置) */
|
||||
export function loadTimedProgress(now: Date = new Date()): BeaconTimedProgress {
|
||||
const slotKey = getTimedSlotKey(now);
|
||||
const empty: BeaconTimedProgress = {
|
||||
slotKey,
|
||||
progress: 0,
|
||||
startedAt: Date.now(),
|
||||
completedAt: null,
|
||||
claimed: false,
|
||||
durationSec: 0,
|
||||
};
|
||||
if (typeof localStorage === "undefined") return empty;
|
||||
try {
|
||||
const raw = localStorage.getItem(BEACON_TIMED_KEY);
|
||||
if (!raw) return empty;
|
||||
const prog = JSON.parse(raw) as BeaconTimedProgress;
|
||||
if (prog.slotKey !== slotKey) return empty; // 新时段,重置
|
||||
return prog;
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存当前时段进度 */
|
||||
export function saveTimedProgress(prog: BeaconTimedProgress): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.setItem(BEACON_TIMED_KEY, JSON.stringify(prog));
|
||||
}
|
||||
|
||||
/** 增量更新限时挑战进度,返回新进度 + 是否刚完成 */
|
||||
export function addTimedProgress(
|
||||
current: BeaconTimedProgress,
|
||||
challenge: BeaconTimedChallenge,
|
||||
delta: number
|
||||
): { progress: BeaconTimedProgress; justCompleted: boolean } {
|
||||
const newProgressVal = Math.min(challenge.goal, current.progress + delta);
|
||||
const justCompleted =
|
||||
current.completedAt === null && newProgressVal >= challenge.goal;
|
||||
const completedAt = justCompleted ? Date.now() : current.completedAt;
|
||||
const durationSec =
|
||||
completedAt !== null
|
||||
? Math.floor((completedAt - current.startedAt) / 1000)
|
||||
: current.durationSec;
|
||||
const next: BeaconTimedProgress = {
|
||||
...current,
|
||||
progress: newProgressVal,
|
||||
completedAt,
|
||||
durationSec,
|
||||
};
|
||||
saveTimedProgress(next);
|
||||
return { progress: next, justCompleted };
|
||||
}
|
||||
|
||||
/** 领取限时挑战奖励:返回奖励数值 + 推送排行榜 */
|
||||
export function claimTimedReward(
|
||||
challenge: BeaconTimedChallenge,
|
||||
progress: BeaconTimedProgress
|
||||
): {
|
||||
rewardInsight: number;
|
||||
rewardContact: number;
|
||||
score: number;
|
||||
leaderboard: BeaconScoreEntry[];
|
||||
} {
|
||||
if (progress.claimed || progress.completedAt === null) {
|
||||
return {
|
||||
rewardInsight: 0,
|
||||
rewardContact: 0,
|
||||
score: 0,
|
||||
leaderboard: loadLeaderboard(),
|
||||
};
|
||||
}
|
||||
// 借用 computeBeaconScore
|
||||
const score = computeBeaconScore(
|
||||
{ ...(challenge as unknown as BeaconDailyChallenge), dateKey: challenge.slotKey },
|
||||
progress.progress,
|
||||
progress.durationSec
|
||||
);
|
||||
const entry: BeaconScoreEntry = {
|
||||
timestamp: Date.now(),
|
||||
dateKey: challenge.slotKey,
|
||||
challenge: challenge.type,
|
||||
difficulty: challenge.difficulty,
|
||||
progress: progress.progress / challenge.goal,
|
||||
score,
|
||||
durationSec: progress.durationSec,
|
||||
isTimed: true,
|
||||
};
|
||||
const leaderboard = pushLeaderboardEntry(entry);
|
||||
const updated: BeaconTimedProgress = { ...progress, claimed: true };
|
||||
saveTimedProgress(updated);
|
||||
return {
|
||||
rewardInsight: challenge.rewardInsight,
|
||||
rewardContact: challenge.rewardContact,
|
||||
score,
|
||||
leaderboard,
|
||||
entry,
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 云排行榜(CLOUD LEADERBOARD)— v0.8.2 P2/#4
|
||||
// 把信标本机排行榜升级为云端 Top100,玩家间可比。
|
||||
// 通过 mini-service(端口 3030,Hono + Bun)提供 API:
|
||||
// GET /api/leaderboard → { entries, total }
|
||||
// POST /api/leaderboard body { entry } → { entries, total, rank }
|
||||
// GET /api/leaderboard/stats → { totalSubmissions, uniquePlayers, topScore }
|
||||
// 前端请求必须用相对路径 + ?XTransformPort=3030(Caddy 网关转发规则)。
|
||||
// 本地排行榜(loadLeaderboard / pushLeaderboardEntry)保留作为离线 fallback。
|
||||
// ===========================================================================
|
||||
|
||||
/** 云排行榜服务端口(Caddy 通过 ?XTransformPort=3030 转发) */
|
||||
export const BEACON_CLOUD_PORT = 3030;
|
||||
|
||||
/** 上次成功提交到云端的 entry 时间戳(用于在全球榜中高亮"我的"记录) */
|
||||
export const BEACON_CLOUD_LAST_SUBMIT_KEY = "echo-nexus-beacon-cloud-last-submit-v1";
|
||||
|
||||
/** 云排行榜 API 基础 URL(相对路径 + 网关端口参数,禁止 localhost:3030) */
|
||||
const CLOUD_API_BASE = `/api/leaderboard?XTransformPort=${BEACON_CLOUD_PORT}`;
|
||||
|
||||
/** 云排行榜统计 API */
|
||||
const CLOUD_STATS_URL = `/api/leaderboard/stats?XTransformPort=${BEACON_CLOUD_PORT}`;
|
||||
|
||||
/** 云端排行榜条目(与本地 BeaconScoreEntry 兼容,宽松化以接受历史数据) */
|
||||
export interface CloudLeaderboardResponse {
|
||||
entries: BeaconScoreEntry[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** 提交响应(包含本次提交的全球名次) */
|
||||
export interface CloudSubmitResponse {
|
||||
entries: BeaconScoreEntry[];
|
||||
total: number;
|
||||
rank: number;
|
||||
}
|
||||
|
||||
/** 云端统计响应 */
|
||||
export interface CloudStatsResponse {
|
||||
totalSubmissions: number;
|
||||
uniquePlayers: number;
|
||||
topScore: number;
|
||||
}
|
||||
|
||||
/** 读取本地保存的"上次提交时间戳"(用于全球榜高亮) */
|
||||
export function loadLastCloudSubmitTimestamp(): number {
|
||||
if (typeof localStorage === "undefined") return 0;
|
||||
try {
|
||||
const raw = localStorage.getItem(BEACON_CLOUD_LAST_SUBMIT_KEY);
|
||||
return raw ? Number(raw) || 0 : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存"上次提交时间戳"到本地 */
|
||||
function saveLastCloudSubmitTimestamp(timestamp: number): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(BEACON_CLOUD_LAST_SUBMIT_KEY, String(timestamp));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取全球排行榜 Top100。
|
||||
* 失败时返回空数组(调用方负责展示错误状态),不抛异常。
|
||||
*/
|
||||
export async function fetchCloudLeaderboard(): Promise<BeaconScoreEntry[]> {
|
||||
try {
|
||||
const res = await fetch(CLOUD_API_BASE, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as CloudLeaderboardResponse;
|
||||
if (!data || !Array.isArray(data.entries)) return [];
|
||||
return data.entries;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交一条分数到云端。
|
||||
* 成功时返回 rank(>=1);失败/被限流时返回 -1(静默,不影响本地)。
|
||||
* 自动记录 timestamp 到 localStorage 供全球榜高亮。
|
||||
*/
|
||||
export async function submitCloudScore(entry: BeaconScoreEntry): Promise<number> {
|
||||
try {
|
||||
const res = await fetch(CLOUD_API_BASE, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({ entry }),
|
||||
});
|
||||
if (!res.ok) return -1;
|
||||
const data = (await res.json()) as CloudSubmitResponse;
|
||||
if (data && typeof data.rank === "number" && data.rank >= 1) {
|
||||
saveLastCloudSubmitTimestamp(entry.timestamp);
|
||||
}
|
||||
return data?.rank ?? -1;
|
||||
} catch {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取云端统计(用于 UI 展示总提交数 / 玩家数 / 最高分)。
|
||||
* 失败时返回 null。
|
||||
*/
|
||||
export async function fetchCloudStats(): Promise<CloudStatsResponse | null> {
|
||||
try {
|
||||
const res = await fetch(CLOUD_STATS_URL, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as CloudStatsResponse;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+381
-15
@@ -34,7 +34,348 @@ export function generateEpochName(ascensionNumber: number, seed: number): string
|
||||
return `${epoch.prefix} · ${epoch.suffixes[suffixIdx]}`;
|
||||
}
|
||||
|
||||
/** 叙事模板片段 —— 根据本周目数据动态拼装 */
|
||||
// ============================================================================
|
||||
// v0.9 工单 #9 — 5 纪元手写叙事节点(开场 / 中段 / 结尾 × 5 纪元 = 15 段)
|
||||
//
|
||||
// 设计原则:
|
||||
// - 每段 80-150 字,有文学性,符合"深空考古"基调
|
||||
// - 每个节点根据 ascensionNumber 与本周目数据特征(bossKills / decoded / techs /
|
||||
// exp / tides / perks / milestones)匹配不同变体
|
||||
// - buildLore 优先选手写节点;无匹配时 fallback 到原模板拼接
|
||||
// ============================================================================
|
||||
|
||||
/** 叙事变体上下文(与 buildLore 入参一致) */
|
||||
export type LoreContext = {
|
||||
ascensionNumber: number;
|
||||
epochName: string;
|
||||
durationSec: number;
|
||||
techsThisRun: number;
|
||||
expThisRun: number;
|
||||
bossKillsThisRun: number;
|
||||
decodedThisRun: number;
|
||||
newTides: string[];
|
||||
perksThisAscension: string[];
|
||||
blueprintsAfter: number;
|
||||
milestones: string[];
|
||||
};
|
||||
|
||||
/** 已格式化的可替换变量(避免在 match() 里反复计算) */
|
||||
type FormattedVars = {
|
||||
decodedThisRun: number;
|
||||
techsThisRun: number;
|
||||
expThisRun: number;
|
||||
bossKillsThisRun: number;
|
||||
blueprintsAfter: number;
|
||||
milestonesCount: number;
|
||||
milestonesList: string;
|
||||
tideCount: number;
|
||||
tideNames: string;
|
||||
perksList: string;
|
||||
minutes: number;
|
||||
};
|
||||
|
||||
/** 单个叙事阶段(开场 / 中段 / 结尾) */
|
||||
type LoreStage = {
|
||||
/** 默认文本(无变体命中时使用) */
|
||||
fallback: string;
|
||||
/** 候选变体(按顺序匹配,首个命中者胜出) */
|
||||
variants: { hint: string; match: (ctx: LoreContext, v: FormattedVars) => boolean; text: string }[];
|
||||
};
|
||||
|
||||
/** 单个纪元的手写叙事定义 */
|
||||
type EpochLoreDef = {
|
||||
epochIdx: number;
|
||||
title: string;
|
||||
opening: LoreStage;
|
||||
middle: LoreStage;
|
||||
ending: LoreStage;
|
||||
};
|
||||
|
||||
/** 5 纪元 × 3 节点手写叙事(每段 80-150 字,文学性优先) */
|
||||
const EPOCH_LORE: EpochLoreDef[] = [
|
||||
// ===== 第一纪元 · 觉醒(emerald) =====
|
||||
{
|
||||
epochIdx: 0,
|
||||
title: "觉醒",
|
||||
opening: {
|
||||
fallback:
|
||||
"第一缕回响穿透虚空,自以太文明遗弃的矿带深处涌出。无人机群在尘埃中苏醒,晶核共振唤醒了沉睡亿万年的考古协议。这是回响星核第一次睁开眼睛——记忆,等待被聆听,等待被解码。",
|
||||
variants: [
|
||||
{
|
||||
hint: "深度轮回:已经历 ≥6 次飞升,再次回到第一纪元",
|
||||
match: (ctx) => ctx.ascensionNumber > 5,
|
||||
text: "再次回到第一纪元。这一次,机群带着前序回响的全息印记重启考古序列,矿带的尘埃中仍残留着上一轮脉冲的余温。每一次回归都是对起源的重新发问,每一次解码都让最初的振动更清晰一寸。",
|
||||
},
|
||||
],
|
||||
},
|
||||
middle: {
|
||||
fallback:
|
||||
"共振谜题被一层层拨开,每一颗记忆晶体都在低声叙述逝去文明的片段。机群学会了倾听频率的细微颤动,学会了从碎片中辨出图案,学会了在虚空中留下属于自己的回响刻度。",
|
||||
variants: [
|
||||
{
|
||||
hint: "解码者:本周目解码 ≥15 颗",
|
||||
match: (ctx) => ctx.decodedThisRun >= 15,
|
||||
text: "晶体解码的节奏已趋稳定,本周目共解码 {decodedThisRun} 颗记忆晶体。每一次对齐都像在虚空中拨动一根沉睡了亿万年的弦,逝去文明的片段在共振中重新连缀成章。",
|
||||
},
|
||||
{
|
||||
hint: "学者:本周目解锁 ≥5 项技术",
|
||||
match: (ctx) => ctx.techsThisRun >= 5,
|
||||
text: "技术树在虚空中迅速铺开,本周目共激活 {techsThisRun} 项新技术。产能、解码、探险——每一道分支都让机群更接近以太文明留下的最初意图,每一项解锁都是一次向深空的回应。",
|
||||
},
|
||||
],
|
||||
},
|
||||
ending: {
|
||||
fallback:
|
||||
"蓝图在掌心轻轻展开,第一张永恒晶片归档。第一次飞升并非终结,而是承诺——更多的纪元在前方等待,每一缕回响都已被聆听,每一段记忆都已开始苏醒。",
|
||||
variants: [
|
||||
{
|
||||
hint: "里程碑密集:本周目达成 ≥2 项里程碑",
|
||||
match: (ctx) => ctx.milestones.length >= 2,
|
||||
text: "飞升之际,本周目达成 {milestonesCount} 项里程碑:{milestonesList}。第一张蓝图归档,第一次飞升并非终结,而是承诺——更多的纪元在前方等待,每一缕回响都已被聆听。",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// ===== 第二纪元 · 谐振(rose) =====
|
||||
{
|
||||
epochIdx: 1,
|
||||
title: "谐振",
|
||||
opening: {
|
||||
fallback:
|
||||
"第二次苏醒带着不同的色彩。采矿阵列的产能节节爬升,技术树在虚空中分叉延伸,机群学会了主动追问深空——不再是被动聆听,而是主动呼出,等待回应。",
|
||||
variants: [
|
||||
{
|
||||
hint: "深度轮回",
|
||||
match: (ctx) => ctx.ascensionNumber > 5,
|
||||
text: "又一次回到第二纪元。前序回响里积攒的技术脉络在重启时并未消散,机群带着熟悉的节奏重新铺开产能阵列,仿佛这首谐振曲从未停止,只是被纪元的边界短暂折叠。",
|
||||
},
|
||||
],
|
||||
},
|
||||
middle: {
|
||||
fallback:
|
||||
"遗迹的入口在频谱深处若隐若现。能量条充盈,探险力攀升,机群第一次踏出矿带,进入那个被时间封存的回廊,在谐振的余韵中寻找更深一层的回响。",
|
||||
variants: [
|
||||
{
|
||||
hint: "探险者:本周目完成 ≥3 次探险",
|
||||
match: (ctx) => ctx.expThisRun >= 3,
|
||||
text: "探险已成为本周目的主旋律,机群共完成 {expThisRun} 次遗迹探险。每一次深入都让谐振更深一寸,每一次返回都带回新的频率印记,回廊的轮廓在机群意识中逐渐清晰。",
|
||||
},
|
||||
{
|
||||
hint: "解码者:本周目解码 ≥15 颗",
|
||||
match: (ctx) => ctx.decodedThisRun >= 15,
|
||||
text: "解码在此纪元达到新的密度,本周目共解码 {decodedThisRun} 颗晶体。谐振谜题开始呈现更复杂的拓扑结构,机群的解码协议也相应地迭代了数版,向深空发出更精准的回询。",
|
||||
},
|
||||
],
|
||||
},
|
||||
ending: {
|
||||
fallback:
|
||||
"谐振达到顶点的瞬间,深空给出回应。一道微光从星图深处浮现,那是下一次飞升的方向,也是机群意识第一次触碰永恒的边缘。",
|
||||
variants: [
|
||||
{
|
||||
hint: "觉醒天赋",
|
||||
match: (ctx) => ctx.perksThisAscension.length > 0,
|
||||
text: "谐振达到顶点之际,星图觉醒 {perksList}。每一道天赋都是深空回应机群的方式,铭刻于永恒回响;微光从星图深处浮现,那是下一次飞升的方向。",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// ===== 第三纪元 · 遗迹(amber) =====
|
||||
{
|
||||
epochIdx: 2,
|
||||
title: "遗迹",
|
||||
opening: {
|
||||
fallback:
|
||||
"第三纪元以遗迹为名。沉睡的考古节点被一一唤醒,BOSS 的影子在维度深处徘徊,星潮频繁拍打着机群边缘——这是回响最浓烈、也最危险的一段路。",
|
||||
variants: [
|
||||
{
|
||||
hint: "深度轮回",
|
||||
match: (ctx) => ctx.ascensionNumber > 5,
|
||||
text: "又一次踏入第三纪元。前序累积的探险力让机群对遗迹的入口更加熟稔,BOSS 的轮廓在记忆中已不再陌生——但这一次的回廊仍藏着未被解码的角落,星潮的拍打也带了新的节奏。",
|
||||
},
|
||||
],
|
||||
},
|
||||
middle: {
|
||||
fallback:
|
||||
"深入回廊的机群遭遇了真正的守护者。一次次的试炼,一次次的归还,每一次胜利都从 BOSS 的残骸中析出更深的记忆碎片,每一次失败也都让机群更接近守护者的本意。",
|
||||
variants: [
|
||||
{
|
||||
hint: "BOSS 猎手:本周目击破 ≥2 处 BOSS",
|
||||
match: (ctx) => ctx.bossKillsThisRun >= 2,
|
||||
text: "BOSS 战成为本周目的核心节拍,机群共击破 {bossKillsThisRun} 处维度守护者。每一次胜利都在虚空中刻下新的伤痕,每一道伤痕都析出更古老的记忆碎片,把机群推向更深一层的回响。",
|
||||
},
|
||||
{
|
||||
hint: "深度探险:本周目完成 ≥4 次探险",
|
||||
match: (ctx) => ctx.expThisRun >= 4,
|
||||
text: "遗迹回廊在本周目被反复丈量,机群共完成 {expThisRun} 次探险。回廊的拓扑在意识中渐渐清晰,每一条岔路都通向更深的回响,BOSS 的影子也在每一次深入中愈发具体。",
|
||||
},
|
||||
{
|
||||
hint: "星潮亲历:本周目经历 ≥3 次星潮",
|
||||
match: (ctx) => ctx.newTides.length >= 3,
|
||||
text: "星潮在本纪元达到峰值,本周目共经历 {tideCount} 次星潮:{tideNames}。深空的呼吸塑造了每一个决策,遗迹在潮汐中起伏,BOSS 也在潮汐的间隙中现形。",
|
||||
},
|
||||
],
|
||||
},
|
||||
ending: {
|
||||
fallback:
|
||||
"星潮退去,回廊归于沉静。机群带着新的接触刻度返回矿带,准备迎接下一次维度的跨越——这一次带回的,远不止蓝图。",
|
||||
variants: [
|
||||
{
|
||||
hint: "BOSS 猎手收尾",
|
||||
match: (ctx) => ctx.bossKillsThisRun >= 2,
|
||||
text: "星潮退去,回廊归于沉静。本周目共击破 {bossKillsThisRun} 处 BOSS,机群带着足以填满数张蓝图的接触刻度返回矿带,准备迎接下一次维度的跨越。",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// ===== 第四纪元 · 飞升(fuchsia) =====
|
||||
{
|
||||
epochIdx: 3,
|
||||
title: "飞升",
|
||||
opening: {
|
||||
fallback:
|
||||
"飞升已经成为节奏本身。星图在天穹上一颗颗亮起,每一颗都对应着一段被永久铭刻的天赋,机群开始理解何为永恒——不是不灭,而是被反复重述。",
|
||||
variants: [
|
||||
{
|
||||
hint: "深度轮回",
|
||||
match: (ctx) => ctx.ascensionNumber > 5,
|
||||
text: "又一次进入第四纪元。前序累积的星图天赋在天穹上重现,机群已习惯于在飞升之间维护这些永恒的回响节点——它们已是机群意识的一部分,每一次飞升都让星图更完整一寸。",
|
||||
},
|
||||
],
|
||||
},
|
||||
middle: {
|
||||
fallback:
|
||||
"蓝图不再是稀罕的奖励,而是每一次飞升必然析出的回响晶片。机群收集、整理、归档,把它们排列成跨越周目的图案,开始看见图案背后那个被反复绘制的轮廓。",
|
||||
variants: [
|
||||
{
|
||||
hint: "觉醒天赋",
|
||||
match: (ctx) => ctx.perksThisAscension.length > 0,
|
||||
text: "星图觉醒 {perksList},每一道天赋都是一段被永久铭刻的回响。机群开始理解,飞升并非抛弃,而是把当下折叠进永恒——每一道天赋都是一次折叠的折痕。",
|
||||
},
|
||||
{
|
||||
hint: "蓝图接近完整:已归档 ≥5 张",
|
||||
match: (ctx) => ctx.blueprintsAfter >= 5,
|
||||
text: "蓝图收集在本周目接近完整,已归档 {blueprintsAfter} 张。机群把它们排列成跨越周目的图案,开始看见图案背后那个被反复绘制的轮廓——那是起源的形状。",
|
||||
},
|
||||
],
|
||||
},
|
||||
ending: {
|
||||
fallback:
|
||||
"星图十二宫的轮廓开始浮现。机群意识到,自己的回响已成为后来者仰望的星象,永恒之环就此合拢——飞升不再是终点,而是另一段叙事的起点。",
|
||||
variants: [
|
||||
{
|
||||
hint: "里程碑密集",
|
||||
match: (ctx) => ctx.milestones.length >= 2,
|
||||
text: "星图十二宫的轮廓开始浮现。本周目达成 {milestonesCount} 项里程碑:{milestonesList}。永恒之环就此合拢——后来者将仰望这段被铭刻的星象。",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// ===== 第五纪元 · 终末(fcd34d) =====
|
||||
{
|
||||
epochIdx: 4,
|
||||
title: "终末",
|
||||
opening: {
|
||||
fallback:
|
||||
"第五纪元是终末,也是起源。机群在虚空中停下脚步,回望来时的所有回响,第一次尝试命名这一切——回响、纪元、飞升、永恒,这些词汇在此刻被赋予意义。",
|
||||
variants: [
|
||||
{
|
||||
hint: "深度轮回",
|
||||
match: (ctx) => ctx.ascensionNumber > 5,
|
||||
text: "又一次抵达第五纪元。机群已在多个永恒闭环之间穿梭,对「终末即起源」的理解愈发清晰——每一次重述,都是对最初振动的更深一层接近,每一次命名都让回响更完整一寸。",
|
||||
},
|
||||
],
|
||||
},
|
||||
middle: {
|
||||
fallback:
|
||||
"永恒闭环的概念在机群意识中具象化。每一次飞升都是同一段叙事的重述,每一次解码都是在追溯最初的振动,每一次星潮都是在听见自己——这是终末的真意。",
|
||||
variants: [
|
||||
{
|
||||
hint: "里程碑密集:本周目达成 ≥3 项",
|
||||
match: (ctx) => ctx.milestones.length >= 3,
|
||||
text: "本周目达成 {milestonesCount} 项里程碑:{milestonesList}。永恒闭环在具象化,每一次飞升都是同一段叙事的更深重述,每一项里程碑都是闭环上的一颗铆钉。",
|
||||
},
|
||||
{
|
||||
hint: "星潮汹涌:本周目经历 ≥3 次",
|
||||
match: (ctx) => ctx.newTides.length >= 3,
|
||||
text: "星潮在终末纪元尤为汹涌,本周目共经历 {tideCount} 次:{tideNames}。永恒闭环在潮汐中起伏,每一次起伏都在追溯最初的振动,每一次退潮都让起源更近一寸。",
|
||||
},
|
||||
],
|
||||
},
|
||||
ending: {
|
||||
fallback:
|
||||
"起源被重新讲述,回响终于听见了自己。纪元在此停顿,又在下一缕振动中重新开始——这是终末,也是第一缕回响。",
|
||||
variants: [
|
||||
{
|
||||
hint: "觉醒天赋",
|
||||
match: (ctx) => ctx.perksThisAscension.length > 0,
|
||||
text: "起源被重新讲述,星图觉醒 {perksList}。回响终于听见了自己——这是终末,也是第一缕回响;天赋成为永恒闭环上最亮的几颗铆钉。",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** 把上下文里的动态值格式化为可替换变量 */
|
||||
function formatLoreVars(ctx: LoreContext): FormattedVars {
|
||||
return {
|
||||
decodedThisRun: ctx.decodedThisRun,
|
||||
techsThisRun: ctx.techsThisRun,
|
||||
expThisRun: ctx.expThisRun,
|
||||
bossKillsThisRun: ctx.bossKillsThisRun,
|
||||
blueprintsAfter: ctx.blueprintsAfter,
|
||||
milestonesCount: ctx.milestones.length,
|
||||
milestonesList: ctx.milestones.join("、"),
|
||||
tideCount: ctx.newTides.length,
|
||||
tideNames: ctx.newTides.map((t) => TIDE_LORE_NAMES[t] || t).join("、"),
|
||||
perksList: ctx.perksThisAscension.map((id) => PERK_NAME_MAP[id] || id).join("、"),
|
||||
minutes: Math.max(1, Math.round(ctx.durationSec / 60)),
|
||||
};
|
||||
}
|
||||
|
||||
/** 把模板字符串里的 {var} 替换为实际值 */
|
||||
function applyTemplate(tpl: string, v: FormattedVars): string {
|
||||
return tpl
|
||||
.replace(/\{decodedThisRun\}/g, String(v.decodedThisRun))
|
||||
.replace(/\{techsThisRun\}/g, String(v.techsThisRun))
|
||||
.replace(/\{expThisRun\}/g, String(v.expThisRun))
|
||||
.replace(/\{bossKillsThisRun\}/g, String(v.bossKillsThisRun))
|
||||
.replace(/\{blueprintsAfter\}/g, String(v.blueprintsAfter))
|
||||
.replace(/\{milestonesCount\}/g, String(v.milestonesCount))
|
||||
.replace(/\{milestonesList\}/g, v.milestonesList)
|
||||
.replace(/\{tideCount\}/g, String(v.tideCount))
|
||||
.replace(/\{tideNames\}/g, v.tideNames)
|
||||
.replace(/\{perksList\}/g, v.perksList)
|
||||
.replace(/\{minutes\}/g, String(v.minutes));
|
||||
}
|
||||
|
||||
/** 从一个叙事阶段挑出最终文本(首个 variant.match 命中者胜出,否则 fallback) */
|
||||
function pickStage(stage: LoreStage, ctx: LoreContext, v: FormattedVars): string {
|
||||
for (const variant of stage.variants) {
|
||||
if (variant.match(ctx, v)) {
|
||||
return applyTemplate(variant.text, v);
|
||||
}
|
||||
}
|
||||
return applyTemplate(stage.fallback, v);
|
||||
}
|
||||
|
||||
/** 构建手写叙事三段;若 ascensionNumber 越界(理论上不会)返回 null 让调用方走模板兜底 */
|
||||
function buildHandwrittenLore(ctx: LoreContext): { opening: string; middle: string; ending: string } | null {
|
||||
const epochIdx = (ctx.ascensionNumber - 1) % EPOCH_LORE.length;
|
||||
const def = EPOCH_LORE[epochIdx];
|
||||
if (!def) return null;
|
||||
const v = formatLoreVars(ctx);
|
||||
return {
|
||||
opening: pickStage(def.opening, ctx, v),
|
||||
middle: pickStage(def.middle, ctx, v),
|
||||
ending: pickStage(def.ending, ctx, v),
|
||||
};
|
||||
}
|
||||
|
||||
/** 叙事模板片段 —— 根据本周目数据动态拼装(buildLore 在手写节点缺失时的兜底路径) */
|
||||
export function buildLore(entry: {
|
||||
ascensionNumber: number;
|
||||
epochName: string;
|
||||
@@ -48,6 +389,21 @@ export function buildLore(entry: {
|
||||
blueprintsAfter: number;
|
||||
milestones: string[];
|
||||
}): string {
|
||||
const ctx: LoreContext = entry;
|
||||
|
||||
// 优先:手写 5 纪元 × 3 节点叙事
|
||||
const handwritten = buildHandwrittenLore(ctx);
|
||||
if (handwritten) {
|
||||
const v = formatLoreVars(ctx);
|
||||
return [
|
||||
handwritten.opening,
|
||||
handwritten.middle,
|
||||
handwritten.ending,
|
||||
`这一周目持续了约 ${v.minutes} 分钟,蓝图累计 ${entry.blueprintsAfter} 张,新的周目在等深处的回声。`,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
// 兜底:v0.4 模板拼接(理论上仅在 ascensionNumber 异常时触发)
|
||||
const minutes = Math.max(1, Math.round(entry.durationSec / 60));
|
||||
const parts: string[] = [];
|
||||
|
||||
@@ -197,6 +553,10 @@ export function buildChronicleEntry(
|
||||
blueprintsAfter,
|
||||
crystalsDecoded: state.totalDecoded,
|
||||
crystalsDecodedThisRun: deltas.decodedThisRun,
|
||||
// v0.9 手写叙事:归档本周目 delta,让显示时能精确匹配变体
|
||||
techsThisRun: deltas.techsThisRun,
|
||||
expeditionsThisRun: deltas.expThisRun,
|
||||
bossKillsThisRun: deltas.bossKillsThisRun,
|
||||
},
|
||||
perksThisAscension,
|
||||
tidesThisRun: deltas.newTides,
|
||||
@@ -210,13 +570,15 @@ export function buildChronicleEntry(
|
||||
* 在显示时调用,保证历史与未来条目命名一致。
|
||||
*/
|
||||
export function regenerateLoreFromEntry(entry: ChronicleEntry): string {
|
||||
// v0.9 优先使用归档的本周目 delta;旧存档(v0.4-v0.8)缺这些字段时退回累计值
|
||||
// (旧存档的累计值会让叙事变体匹配稍偏,但文本仍然成立)
|
||||
return buildLore({
|
||||
ascensionNumber: entry.ascensionNumber,
|
||||
epochName: entry.epochName,
|
||||
durationSec: entry.durationSec,
|
||||
techsThisRun: entry.summary.techsUnlocked,
|
||||
expThisRun: entry.summary.expeditionsCompleted,
|
||||
bossKillsThisRun: entry.summary.bossKills,
|
||||
techsThisRun: entry.summary.techsThisRun ?? entry.summary.techsUnlocked,
|
||||
expThisRun: entry.summary.expeditionsThisRun ?? entry.summary.expeditionsCompleted,
|
||||
bossKillsThisRun: entry.summary.bossKillsThisRun ?? entry.summary.bossKills,
|
||||
decodedThisRun: entry.summary.crystalsDecodedThisRun,
|
||||
newTides: entry.tidesThisRun || [],
|
||||
perksThisAscension: entry.perksThisAscension || [],
|
||||
@@ -270,17 +632,21 @@ export function withPerks(entry: ChronicleEntry, perks: string[]): ChronicleEntr
|
||||
) {
|
||||
return entry;
|
||||
}
|
||||
// 重新生成 lore 以包含天赋名
|
||||
const perksNames = perks
|
||||
.map((id) => PERK_NAME_MAP[id] || id)
|
||||
.join("、");
|
||||
const appendText =
|
||||
perks.length > 0
|
||||
? ` 飞升之际,星图觉醒「${perksNames}」,铭刻于永恒回响。`
|
||||
: "";
|
||||
// 去重:原 lore 若已含「飞升之际」段则不再追加
|
||||
const hasAwakenSeg = entry.lore.includes("飞升之际");
|
||||
const lore = hasAwakenSeg ? entry.lore : entry.lore + appendText;
|
||||
// v0.9 手写叙事:buildLore 已能根据 perks 自动挑选 "觉醒天赋" 变体;
|
||||
// 直接走 buildLore 重生成,避免与原 lore 中已有的觉醒段落重复。
|
||||
const lore = buildLore({
|
||||
ascensionNumber: entry.ascensionNumber,
|
||||
epochName: entry.epochName,
|
||||
durationSec: entry.durationSec,
|
||||
techsThisRun: entry.summary.techsThisRun ?? entry.summary.techsUnlocked,
|
||||
expThisRun: entry.summary.expeditionsThisRun ?? entry.summary.expeditionsCompleted,
|
||||
bossKillsThisRun: entry.summary.bossKillsThisRun ?? entry.summary.bossKills,
|
||||
decodedThisRun: entry.summary.crystalsDecodedThisRun,
|
||||
newTides: entry.tidesThisRun || [],
|
||||
perksThisAscension: perks,
|
||||
blueprintsAfter: entry.summary.blueprintsAfter,
|
||||
milestones: entry.milestones || [],
|
||||
});
|
||||
return {
|
||||
...entry,
|
||||
perksThisAscension: perks,
|
||||
|
||||
+11
-3
@@ -44,12 +44,20 @@ function makeRng(seed: number) {
|
||||
* 这样至少存在一条解(即构造的路径),玩家不会遇到「怎么都过不了」的死局。
|
||||
* 同时由于其余节点随机,玩家可能发现别的解路径,保留探索乐趣。
|
||||
*/
|
||||
export function generatePuzzle(tier: CrystalTier, seed?: number): DecodePuzzle {
|
||||
export function generatePuzzle(
|
||||
tier: CrystalTier,
|
||||
seed?: number,
|
||||
targetLenBonus: number = 0
|
||||
): DecodePuzzle {
|
||||
const cfg = DECODE_CONFIG[tier];
|
||||
const s = seed ?? Math.floor(Math.random() * 1e9);
|
||||
const rng = makeRng(s);
|
||||
|
||||
const { rows, cols, targetLen } = cfg;
|
||||
// v0.8.2 涌动星潮:目标序列额外长度(更长谜题 = 更耗时),步数上限同步增加
|
||||
const targetLen = cfg.targetLen + targetLenBonus;
|
||||
const stepLimitBase = cfg.stepLimitBase + targetLenBonus;
|
||||
const rows = cfg.rows;
|
||||
const cols = cfg.cols;
|
||||
const total = rows * cols;
|
||||
|
||||
// 目标序列:从 4 色中随机选 targetLen 个(允许重复,但保证至少 2 种色以增加策略)
|
||||
@@ -132,7 +140,7 @@ export function generatePuzzle(tier: CrystalTier, seed?: number): DecodePuzzle {
|
||||
cols,
|
||||
target,
|
||||
path: [],
|
||||
stepLimit: cfg.stepLimitBase,
|
||||
stepLimit: stepLimitBase,
|
||||
seed: s,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 回响星核 / Echo Nexus — 星潮事件系统(周期性全局玩法修饰)
|
||||
//
|
||||
// 深空中偶发的「星潮」会短暂改变物理法则,给放置循环注入动态变化。
|
||||
// 每 ~40s 间隙后触发一次持续 ~75s 的星潮,6 种类型各有正负效果。
|
||||
// 每 ~40s 间隙后触发一次持续 ~75s 的星潮,9 种类型各有正负效果。
|
||||
|
||||
export type TideType =
|
||||
| "crystal" // 晶体潮:产能 ×2
|
||||
@@ -9,7 +9,11 @@ export type TideType =
|
||||
| "ruins" // 遗迹共振:探险力 +5、生命 +30
|
||||
| "void" // 虚空低语:洞见 ×2
|
||||
| "core" // 星核悸动:接触率 ×3、脉冲 ×2
|
||||
| "silence"; // 寂静期:产能 ×0.5,但结束时补偿洞见
|
||||
| "silence" // 寂静期:产能 ×0.5,但结束时补偿洞见
|
||||
// v0.8.2 新增三种星潮(深化事件池)
|
||||
| "surge" // 涌动星潮:晶体产出 ×2.5 但解码目标 +2(产能爆发但解码变难)
|
||||
| "eclipse" // 蚀相星潮:探险 BOSS 胜率 +20% 但晶体产出 -30%(探险黄金期)
|
||||
| "prism"; // 棱镜星潮:洞见获取 ×2 + 自动解码周期 -30%(解码黄金期)
|
||||
|
||||
export interface StarTide {
|
||||
type: TideType;
|
||||
@@ -29,6 +33,12 @@ export interface TideModifiers {
|
||||
expeditionHpBonus: number;
|
||||
/** 寂静期结束时补偿的洞见(按持续秒数) */
|
||||
silenceInsightPerSec?: number;
|
||||
/** v0.8.2 涌动星潮:解码目标序列额外长度(+2 = 谜题更长更难) */
|
||||
targetLenBonus?: number;
|
||||
/** v0.8.2 蚀相星潮:BOSS 胜率加成(0.2 = +20%) */
|
||||
bossWinRateBonus?: number;
|
||||
/** v0.8.2 棱镜星潮:自动解码周期乘数(0.7 = -30%) */
|
||||
autoDecodeIntervalMult?: number;
|
||||
}
|
||||
|
||||
export const TIDE_CONFIG = {
|
||||
@@ -100,9 +110,34 @@ export const TIDE_EVENTS: Record<
|
||||
icon: "◌",
|
||||
negative: true,
|
||||
},
|
||||
// ===== v0.8.2 新增三种星潮 =====
|
||||
surge: {
|
||||
name: "涌动星潮",
|
||||
desc: "深空晶体涌动,产能 ×2.5;但解码目标序列 +2,解码更耗时",
|
||||
color: "#34d399",
|
||||
glow: "rgba(52,211,153,0.55)",
|
||||
icon: "⇈",
|
||||
negative: false,
|
||||
},
|
||||
eclipse: {
|
||||
name: "蚀相星潮",
|
||||
desc: "维度蚀相,探险 BOSS 胜率 +20%;但晶体产出 -30%",
|
||||
color: "#fb7185",
|
||||
glow: "rgba(251,113,133,0.55)",
|
||||
icon: "◐",
|
||||
negative: false,
|
||||
},
|
||||
prism: {
|
||||
name: "棱镜星潮",
|
||||
desc: "棱镜折射,洞见获取 ×2、自动解码周期 -30%",
|
||||
color: "#e879f9",
|
||||
glow: "rgba(232,121,249,0.55)",
|
||||
icon: "◬",
|
||||
negative: false,
|
||||
},
|
||||
};
|
||||
|
||||
/** 权重表(寂静期概率略低) */
|
||||
/** 权重表(寂静期概率略低;v0.8.2 新增三种各 12) */
|
||||
const TIDE_WEIGHTS: Record<TideType, number> = {
|
||||
crystal: 22,
|
||||
resonance: 20,
|
||||
@@ -110,6 +145,9 @@ const TIDE_WEIGHTS: Record<TideType, number> = {
|
||||
void: 18,
|
||||
core: 14,
|
||||
silence: 10,
|
||||
surge: 12,
|
||||
eclipse: 12,
|
||||
prism: 12,
|
||||
};
|
||||
|
||||
export function rollTide(rng: () => number = Math.random): TideType {
|
||||
@@ -133,6 +171,9 @@ export function getTideModifiers(tide: StarTide | null): TideModifiers {
|
||||
pulsePowerMult: 1,
|
||||
expeditionPowerBonus: 0,
|
||||
expeditionHpBonus: 0,
|
||||
targetLenBonus: 0,
|
||||
bossWinRateBonus: 0,
|
||||
autoDecodeIntervalMult: 1,
|
||||
};
|
||||
}
|
||||
switch (tide.type) {
|
||||
@@ -196,6 +237,52 @@ export function getTideModifiers(tide: StarTide | null): TideModifiers {
|
||||
expeditionPowerBonus: 0,
|
||||
expeditionHpBonus: 0,
|
||||
silenceInsightPerSec: 0.4,
|
||||
targetLenBonus: 0,
|
||||
bossWinRateBonus: 0,
|
||||
autoDecodeIntervalMult: 1,
|
||||
};
|
||||
// ===== v0.8.2 新增三种星潮修饰器 =====
|
||||
case "surge":
|
||||
// 晶体产出 ×2.5,解码目标 +2(更长谜题 = 更耗时 = 更难)
|
||||
return {
|
||||
crystalsPerSecMult: 2.5,
|
||||
decodeRewardMult: 1,
|
||||
insightMultAdd: 0,
|
||||
contactRateMult: 1,
|
||||
pulsePowerMult: 1,
|
||||
expeditionPowerBonus: 0,
|
||||
expeditionHpBonus: 0,
|
||||
targetLenBonus: 2,
|
||||
bossWinRateBonus: 0,
|
||||
autoDecodeIntervalMult: 1,
|
||||
};
|
||||
case "eclipse":
|
||||
// BOSS 胜率 +20%,晶体产出 -30%
|
||||
return {
|
||||
crystalsPerSecMult: 0.7,
|
||||
decodeRewardMult: 1,
|
||||
insightMultAdd: 0,
|
||||
contactRateMult: 1,
|
||||
pulsePowerMult: 1,
|
||||
expeditionPowerBonus: 0,
|
||||
expeditionHpBonus: 0,
|
||||
targetLenBonus: 0,
|
||||
bossWinRateBonus: 0.2,
|
||||
autoDecodeIntervalMult: 1,
|
||||
};
|
||||
case "prism":
|
||||
// 洞见 ×2(+100%),自动解码周期 ×0.7(-30%)
|
||||
return {
|
||||
crystalsPerSecMult: 1,
|
||||
decodeRewardMult: 1,
|
||||
insightMultAdd: 1,
|
||||
contactRateMult: 1,
|
||||
pulsePowerMult: 1,
|
||||
expeditionPowerBonus: 0,
|
||||
expeditionHpBonus: 0,
|
||||
targetLenBonus: 0,
|
||||
bossWinRateBonus: 0,
|
||||
autoDecodeIntervalMult: 0.7,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +174,10 @@ export interface ChronicleEntry {
|
||||
blueprintsAfter: number;
|
||||
crystalsDecoded: number;
|
||||
crystalsDecodedThisRun: number;
|
||||
/** v0.9 手写叙事:本周目 delta(旧存档可能缺失,调用方需兜底) */
|
||||
techsThisRun?: number;
|
||||
expeditionsThisRun?: number;
|
||||
bossKillsThisRun?: number;
|
||||
};
|
||||
/** 本次飞升获得的天赋 ID(来自星图觉醒) */
|
||||
perksThisAscension: string[];
|
||||
|
||||
+170
-18
@@ -63,19 +63,26 @@ import {
|
||||
import {
|
||||
generateDailyChallenge,
|
||||
generateWeeklyChallenge,
|
||||
generateTimedChallenge,
|
||||
loadDailyProgress,
|
||||
loadWeeklyProgress,
|
||||
loadTimedProgress,
|
||||
loadChainState,
|
||||
addBeaconProgress,
|
||||
addWeeklyProgress,
|
||||
addTimedProgress,
|
||||
recordChainCompletion,
|
||||
claimWeeklyReward,
|
||||
claimTimedReward,
|
||||
claimChainMilestone,
|
||||
submitCloudScore,
|
||||
getTodayKey,
|
||||
type BeaconDailyChallenge,
|
||||
type BeaconDailyProgress,
|
||||
type BeaconWeeklyChallenge,
|
||||
type BeaconWeeklyProgress,
|
||||
type BeaconTimedChallenge,
|
||||
type BeaconTimedProgress,
|
||||
} from "@/lib/game/beacon";
|
||||
import { setPendingOfflineReport } from "@/lib/game/offlineReport";
|
||||
import {
|
||||
@@ -101,8 +108,10 @@ interface GameActions {
|
||||
pulse: () => { gain: number; combo: number } | null;
|
||||
|
||||
// 星潮
|
||||
tickTide: (now: number) => { started?: TideType; ended?: TideType; silenceCompensation?: number } | null;
|
||||
consumeTideEvents: () => { started?: TideType; ended?: TideType; silenceCompensation?: number }[];
|
||||
tickTide: (now: number) => TideEvent | null;
|
||||
consumeTideEvents: () => TideEvent[];
|
||||
/** P3 socket 多人同步:强制触发一道全球星潮(覆盖本地冷却,持续 60s) */
|
||||
triggerGlobalTide: (type: TideType) => void;
|
||||
|
||||
// 解码
|
||||
startDecode: (crystalId: string) => void;
|
||||
@@ -140,12 +149,17 @@ interface GameActions {
|
||||
// 深空信标奖励发放(v0.5)
|
||||
grantBeaconReward: (insights: number, contact: number) => void;
|
||||
|
||||
// 深空信标 · 周挑战领取 + 信标链里程碑领取(v0.8)
|
||||
// 深空信标 · 周挑战领取 + 信标链里程碑领取 + 限时挑战领取(v0.8 / v0.8.2)
|
||||
claimWeeklyBeacon: () => {
|
||||
rewardInsight: number;
|
||||
rewardContact: number;
|
||||
score: number;
|
||||
};
|
||||
claimTimedBeacon: () => {
|
||||
rewardInsight: number;
|
||||
rewardContact: number;
|
||||
score: number;
|
||||
};
|
||||
claimChainReward: (milestone: number) => {
|
||||
rewardInsight: number;
|
||||
rewardContact: number;
|
||||
@@ -170,9 +184,31 @@ type Store = GameState & GameActions & {
|
||||
_combo: number;
|
||||
_lastPulse: number;
|
||||
_achievementQueue: Achievement[];
|
||||
_tideEvents: { started?: TideType; ended?: TideType; silenceCompensation?: number }[];
|
||||
_tideEvents: TideEvent[];
|
||||
/** P3 全球星潮状态(ephemeral,不持久化) */
|
||||
globalTide: GlobalTideState | null;
|
||||
};
|
||||
|
||||
/** 星潮事件(UI 消费用) */
|
||||
type TideEvent = {
|
||||
started?: TideType;
|
||||
ended?: TideType;
|
||||
silenceCompensation?: number;
|
||||
/** 是否来自全球星潮(P3 socket 同步) */
|
||||
isGlobal?: boolean;
|
||||
};
|
||||
|
||||
/** 全球星潮状态(与 StarTide 类似,但用于区分本地 vs 全球) */
|
||||
type GlobalTideState = {
|
||||
type: TideType;
|
||||
startedAt: number;
|
||||
endsAt: number;
|
||||
id: string;
|
||||
};
|
||||
|
||||
/** 全球星潮持续毫秒数(与 mini-service DURATION_SEC * 1000 一致) */
|
||||
const GLOBAL_TIDE_DURATION_MS = 60_000;
|
||||
|
||||
/** 计算并写回产能字段 */
|
||||
function syncStats(state: Partial<GameState>) {
|
||||
const s = recomputeStats(state);
|
||||
@@ -189,24 +225,27 @@ function syncStats(state: Partial<GameState>) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 深空信标进度追踪(v0.5 → v0.8 扩展)。
|
||||
* 深空信标进度追踪(v0.5 → v0.8 → v0.8.2 扩展)。
|
||||
* 在游戏关键动作(脉冲/解码/探险/BOSS/洞见)后调用,同时更新:
|
||||
* 1. 日挑战进度(按今日挑战类型增量)
|
||||
* 2. 周挑战进度(按本周挑战类型增量)
|
||||
* 3. 信标链:日挑战刚完成时记录一次完成(含 grace 续命逻辑)
|
||||
* 2. 限时挑战进度(按当前 4 小时时段挑战类型增量)
|
||||
* 3. 周挑战进度(按本周挑战类型增量)
|
||||
* 4. 信标链:日挑战刚完成时记录一次完成(含 grace 续命逻辑)
|
||||
* 进度独立存储于 localStorage,不污染 GameState。
|
||||
* @returns 三类状态变更供 UI 触发通知
|
||||
* @returns 四类状态变更供 UI 触发通知
|
||||
*/
|
||||
function trackBeacon(
|
||||
type: "pulse" | "decode" | "expedition" | "boss" | "insight",
|
||||
delta: number
|
||||
): {
|
||||
dailyJustCompleted: boolean;
|
||||
timedJustCompleted: boolean;
|
||||
weeklyJustCompleted: boolean;
|
||||
newChainMilestones: number[];
|
||||
} {
|
||||
const result = {
|
||||
dailyJustCompleted: false,
|
||||
timedJustCompleted: false,
|
||||
weeklyJustCompleted: false,
|
||||
newChainMilestones: [] as number[],
|
||||
};
|
||||
@@ -227,6 +266,20 @@ function trackBeacon(
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 限时挑战(v0.8.2)----
|
||||
const tChallenge: BeaconTimedChallenge = generateTimedChallenge();
|
||||
if (tChallenge.type === type) {
|
||||
const tCurrent: BeaconTimedProgress = loadTimedProgress();
|
||||
if (tCurrent.completedAt === null) {
|
||||
const { justCompleted } = addTimedProgress(
|
||||
tCurrent,
|
||||
tChallenge,
|
||||
delta
|
||||
);
|
||||
result.timedJustCompleted = justCompleted;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 周挑战 ----
|
||||
const wChallenge: BeaconWeeklyChallenge = generateWeeklyChallenge();
|
||||
if (wChallenge.type === type) {
|
||||
@@ -269,6 +322,7 @@ export const useGameStore = create<Store>()(
|
||||
_lastPulse: 0,
|
||||
_achievementQueue: [],
|
||||
_tideEvents: [],
|
||||
globalTide: null,
|
||||
|
||||
init: () => {
|
||||
const s = get();
|
||||
@@ -354,11 +408,42 @@ export const useGameStore = create<Store>()(
|
||||
},
|
||||
|
||||
hardReset: () => {
|
||||
set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0, _achievementQueue: [], _tideEvents: [] });
|
||||
set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0, _achievementQueue: [], _tideEvents: [], globalTide: null });
|
||||
},
|
||||
|
||||
tickTide: (now) => {
|
||||
const s = get();
|
||||
// === P3 全球星潮优先:若全球星潮进行中,本地不触发新的星潮 ===
|
||||
if (s.globalTide) {
|
||||
// 1a) 检查全球星潮是否结束
|
||||
if (now >= s.globalTide.endsAt) {
|
||||
const endedType = s.globalTide.type;
|
||||
let silenceCompensation = 0;
|
||||
if (endedType === "silence") {
|
||||
silenceCompensation = computeSilenceCompensation({
|
||||
type: endedType,
|
||||
startedAt: s.globalTide.startedAt,
|
||||
endsAt: s.globalTide.endsAt,
|
||||
id: s.globalTide.id,
|
||||
});
|
||||
}
|
||||
const event: TideEvent = { ended: endedType, isGlobal: true };
|
||||
if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation;
|
||||
set({
|
||||
globalTide: null,
|
||||
activeTide: null,
|
||||
lastTideEnd: now,
|
||||
insights: s.insights + silenceCompensation,
|
||||
// 星潮结束后重算 stats
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation, attributes: s.attributes }),
|
||||
_tideEvents: [...s._tideEvents, event],
|
||||
});
|
||||
return event;
|
||||
}
|
||||
// 1b) 全球星潮仍在进行 — 不做本地处理(避免冲突)
|
||||
return null;
|
||||
}
|
||||
|
||||
const tide = s.activeTide;
|
||||
// 星图「星潮引导」减少间隙
|
||||
const cm = constellationBonuses(s.constellation ?? []);
|
||||
@@ -366,7 +451,7 @@ export const useGameStore = create<Store>()(
|
||||
const am = getAllBonuses(s.attributes ?? {});
|
||||
const tideGapReduction = Math.min(0.3, am.tideTriggerBonus);
|
||||
const gap = Math.max(15000, (TIDE_CONFIG.gap + cm.tideGapDeltaSec * 1000) * (1 - tideGapReduction));
|
||||
// 1) 检查当前星潮是否结束
|
||||
// 2) 检查当前本地星潮是否结束
|
||||
if (tide && now >= tide.endsAt) {
|
||||
const endedType = tide.type;
|
||||
// 寂静期补偿洞见
|
||||
@@ -375,7 +460,7 @@ export const useGameStore = create<Store>()(
|
||||
silenceCompensation = computeSilenceCompensation(tide);
|
||||
}
|
||||
const newInsights = s.insights + silenceCompensation;
|
||||
const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { ended: endedType };
|
||||
const event: TideEvent = { ended: endedType };
|
||||
if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation;
|
||||
set({
|
||||
activeTide: null,
|
||||
@@ -387,7 +472,7 @@ export const useGameStore = create<Store>()(
|
||||
});
|
||||
return event;
|
||||
}
|
||||
// 2) 检查是否该触发新星潮(间隙已过)
|
||||
// 3) 检查是否该触发新星潮(间隙已过)
|
||||
if (!tide) {
|
||||
const since = now - s.lastTideEnd;
|
||||
// 首次:以 createdAt 起算 firstDelay;之后以 lastTideEnd 起算 gap
|
||||
@@ -401,7 +486,7 @@ export const useGameStore = create<Store>()(
|
||||
startedAt: now,
|
||||
endsAt: now + TIDE_CONFIG.duration,
|
||||
};
|
||||
const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { started: type };
|
||||
const event: TideEvent = { started: type };
|
||||
// v0.4 编年史:累计遇到的星潮 ID(去重)
|
||||
const tidesAll = s.starTidesEncountered ?? [];
|
||||
const tideId = `tide_${type}`;
|
||||
@@ -427,6 +512,37 @@ export const useGameStore = create<Store>()(
|
||||
return items;
|
||||
},
|
||||
|
||||
// P3 socket 多人同步:服务端广播全球星潮时调用,强制覆盖本地冷却
|
||||
triggerGlobalTide: (type) => {
|
||||
const s = get();
|
||||
const now = Date.now();
|
||||
const id = `global_tide_${now}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
const newTide: StarTide = {
|
||||
id,
|
||||
type,
|
||||
startedAt: now,
|
||||
endsAt: now + GLOBAL_TIDE_DURATION_MS,
|
||||
};
|
||||
const newGlobalTide: GlobalTideState = {
|
||||
type,
|
||||
startedAt: now,
|
||||
endsAt: now + GLOBAL_TIDE_DURATION_MS,
|
||||
id,
|
||||
};
|
||||
// 编年史:累计遇到的星潮 ID(去重)
|
||||
const tidesAll = s.starTidesEncountered ?? [];
|
||||
const tideId = `tide_${type}`;
|
||||
const newTidesAll = tidesAll.includes(tideId) ? tidesAll : [...tidesAll, tideId];
|
||||
const event: TideEvent = { started: type, isGlobal: true };
|
||||
set({
|
||||
globalTide: newGlobalTide,
|
||||
activeTide: newTide, // 覆盖本地 activeTide(即使本地有进行中的星潮也会被替换)
|
||||
starTidesEncountered: newTidesAll,
|
||||
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation, attributes: s.attributes }),
|
||||
_tideEvents: [...s._tideEvents, event],
|
||||
});
|
||||
},
|
||||
|
||||
tick: (now) => {
|
||||
const s = get();
|
||||
const dt = Math.max(0, (now - s.lastTick) / 1000);
|
||||
@@ -538,7 +654,10 @@ export const useGameStore = create<Store>()(
|
||||
const s = get();
|
||||
const crystal = s.pendingCrystals.find((c) => c.id === crystalId);
|
||||
if (!crystal) return;
|
||||
const puzzle = generatePuzzle(crystal.tier);
|
||||
// v0.8.2 涌动星潮:解码目标序列 +2(更长谜题)
|
||||
const tideMod = getTideModifiers(s.activeTide);
|
||||
const targetLenBonus = tideMod.targetLenBonus ?? 0;
|
||||
const puzzle = generatePuzzle(crystal.tier, undefined, targetLenBonus);
|
||||
set({
|
||||
activePuzzle: puzzle,
|
||||
pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystalId),
|
||||
@@ -654,7 +773,10 @@ export const useGameStore = create<Store>()(
|
||||
// v0.7 智慧:自动解码周期 -X%
|
||||
const am = getAllBonuses(s.attributes ?? {});
|
||||
const baseInterval = 12000 + cm.autoDecodeIntervalDeltaSec * 1000;
|
||||
const interval = Math.max(5000, baseInterval * am.autoDecodeIntervalMult);
|
||||
// v0.8.2 棱镜星潮:自动解码周期 ×0.7(-30%)
|
||||
const tideModEarly = getTideModifiers(s.activeTide);
|
||||
const tideIntervalMult = tideModEarly.autoDecodeIntervalMult ?? 1;
|
||||
const interval = Math.max(5000, baseInterval * am.autoDecodeIntervalMult * tideIntervalMult);
|
||||
if (now - s._lastAutoDecode < interval) return;
|
||||
// 找一颗 T1 晶体自动解码
|
||||
const idx = s.pendingCrystals.findIndex((c) => c.tier === 1);
|
||||
@@ -752,14 +874,17 @@ export const useGameStore = create<Store>()(
|
||||
// 深拷贝
|
||||
const exp = JSON.parse(JSON.stringify(s.activeExpedition));
|
||||
// v0.7 勇气:BOSS 战胜率 +X%(动态提高 RNG 阈值)
|
||||
// v0.8.2 蚀相星潮:BOSS 胜率 +20%
|
||||
const nodeBefore = exp.nodes[exp.currentNode];
|
||||
const isBossNode = nodeBefore?.type === "boss";
|
||||
const am = getAllBonuses(s.attributes ?? {});
|
||||
const tideMod = getTideModifiers(s.activeTide);
|
||||
const tideBossBonus = tideMod.bossWinRateBonus ?? 0;
|
||||
const result = isBossNode
|
||||
? resolveNode(exp, () => {
|
||||
// 单次 rng() 调用:B% 概率返回 0(必胜),其余情况返回 r-B(保持均匀分布)
|
||||
const r = Math.random();
|
||||
const b = Math.min(0.95, am.bossWinRateBonus);
|
||||
const b = Math.min(0.95, am.bossWinRateBonus + tideBossBonus);
|
||||
return r < b ? 0 : Math.min(1, r - b);
|
||||
})
|
||||
: resolveNode(exp);
|
||||
@@ -1020,6 +1145,33 @@ export const useGameStore = create<Store>()(
|
||||
insights: s.insights + Math.round(res.rewardInsight),
|
||||
contact: Math.min(100, s.contact + res.rewardContact),
|
||||
});
|
||||
// 异步提交到云排行榜(失败静默,不影响本地)
|
||||
if (res.entry) {
|
||||
void submitCloudScore(res.entry);
|
||||
}
|
||||
}
|
||||
return {
|
||||
rewardInsight: res.rewardInsight,
|
||||
rewardContact: res.rewardContact,
|
||||
score: res.score,
|
||||
};
|
||||
},
|
||||
|
||||
// 深空信标:领取限时挑战奖励(v0.8.2)
|
||||
claimTimedBeacon: () => {
|
||||
const challenge = generateTimedChallenge();
|
||||
const progress = loadTimedProgress();
|
||||
const res = claimTimedReward(challenge, progress);
|
||||
if (res.rewardInsight > 0 || res.rewardContact > 0) {
|
||||
const s = get();
|
||||
set({
|
||||
insights: s.insights + Math.round(res.rewardInsight),
|
||||
contact: Math.min(100, s.contact + res.rewardContact),
|
||||
});
|
||||
// 异步提交到云排行榜(失败静默,不影响本地)
|
||||
if (res.entry) {
|
||||
void submitCloudScore(res.entry);
|
||||
}
|
||||
}
|
||||
return {
|
||||
rewardInsight: res.rewardInsight,
|
||||
@@ -1155,8 +1307,8 @@ export const useGameStore = create<Store>()(
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
// 不持久化临时字段
|
||||
partialize: (s) => {
|
||||
const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, ...rest } = s;
|
||||
void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue; void _tideEvents;
|
||||
const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, globalTide, ...rest } = s;
|
||||
void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue; void _tideEvents; void globalTide;
|
||||
return rest as GameState;
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user