v0.12: 存档迁移钩子 + 考古日志滚动条(差异化叙事)
gameStore persist: - name v1→v2, 加 version:2 + migrate 钩子 - migrate 合并旧存档与 INITIAL_STATE, 补全 bossKills/starTidesEncountered/ chronicle/attributes/activeExpedition/constellation 等字段, 防旧存档崩溃 - 丢弃不可解的活跃谜题(旧算法生成) archaeoLog.ts (新增): - 5 阶段日志池 dawn/awakening/contact/ascension/deep (48 条) - 按飞升次数+接触进度解锁阶段, deep 层含哲学反转 - pickLogEntry 避免短期重复(最近8条) ArchaeoLogTicker.tsx (新增): - marquee 横向滚动, 18-30s 随机推送新条目 - 阶段色标 emerald/amber/fuchsia/rose, inset glow - 进度里程碑触发即时刷新(解锁新阶段) - prefers-reduced-motion 降级 page.tsx: - footer 上方集成 ArchaeoLogTicker globals.css: - echo-ticker-marquee/fade keyframes + reduced-motion 降级 调研落地: Antimatter Dimensions news ticker + Universal Paperclips 哲学递进 lint 零错误, 构建成功(5.7s)
This commit is contained in:
@@ -191,3 +191,21 @@
|
||||
0% { background-position: 0% 50%; }
|
||||
100% { background-position: 200% 50%; }
|
||||
}
|
||||
|
||||
/* v0.12 考古日志滚动条 */
|
||||
@keyframes echo-ticker-marquee {
|
||||
0% { transform: translateX(100%); }
|
||||
100% { transform: translateX(-100%); }
|
||||
}
|
||||
@keyframes echo-ticker-fade {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
.echo-ticker-anim {
|
||||
animation: echo-ticker-marquee 28s linear forwards, echo-ticker-fade 0.5s ease-out;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.echo-ticker-anim {
|
||||
animation: echo-ticker-fade 0.5s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ const CruiseMode = dynamic(() => import("@/components/game/CruiseMode").then(m =
|
||||
import { AttributesPanel } from "@/components/game/AttributesPanel";
|
||||
import { ExplorationRadar } from "@/components/game/ExplorationRadar";
|
||||
import { RelicCodex } from "@/components/game/RelicCodex";
|
||||
import { ArchaeoLogTicker } from "@/components/game/ArchaeoLogTicker";
|
||||
import {
|
||||
StarTideNotifier,
|
||||
StarTideIndicator,
|
||||
@@ -389,6 +390,11 @@ export default function Page() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* v0.12 考古日志滚动条 — footer 上方的叙事层 */}
|
||||
<div className="px-3 sm:px-5 pb-1 shrink-0">
|
||||
<ArchaeoLogTicker />
|
||||
</div>
|
||||
|
||||
{/* 底部 Footer */}
|
||||
<footer className="sticky bottom-0 z-20 mt-auto px-3 sm:px-5 pb-2 pt-1">
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
// 回响星核 / Echo Nexus — 考古日志滚动条(v0.12 差异化叙事)
|
||||
// 参考 Antimatter Dimensions news ticker
|
||||
// 固定在 footer 上方,marquee 横向滚动,每 18-30s 推送新条目
|
||||
// 让"等待"承载叙事——星核沉睡时,回响仍在被翻译
|
||||
/* eslint-disable react-hooks/set-state-in-effect -- 定时刷新日志是放置游戏的合理副作用,effect 内 setState 不可避免 */
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { useGameStore } from "@/store/gameStore";
|
||||
import { pickLogEntry, type ArchaeoLogEntry, type LogTier } from "@/lib/game/archaeoLog";
|
||||
import { ScrollText } from "lucide-react";
|
||||
|
||||
const TIER_META: Record<LogTier, { label: string; color: string; glow: string }> = {
|
||||
dawn: { label: "初醒", color: "text-emerald-300", glow: "rgba(52,211,153,0.5)" },
|
||||
awakening: { label: "觉醒", color: "text-amber-300", glow: "rgba(251,191,36,0.5)" },
|
||||
contact: { label: "接触", color: "text-fuchsia-300", glow: "rgba(232,121,249,0.5)" },
|
||||
ascension: { label: "飞升", color: "text-rose-300", glow: "rgba(251,113,133,0.5)" },
|
||||
deep: { label: "深度", color: "text-fuchsia-200", glow: "rgba(232,121,249,0.7)" },
|
||||
};
|
||||
|
||||
export function ArchaeoLogTicker() {
|
||||
const ascensions = useGameStore((s) => s.ascensions);
|
||||
const contact = useGameStore((s) => s.contact);
|
||||
const [entry, setEntry] = useState<ArchaeoLogEntry | null>(null);
|
||||
const [recentIds, setRecentIds] = useState<string[]>([]);
|
||||
const [fadeKey, setFadeKey] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
const next = pickLogEntry(ascensions, contact, recentIds);
|
||||
setEntry(next);
|
||||
setRecentIds((prev) => [next.text, ...prev].slice(0, 8));
|
||||
setFadeKey((k) => k + 1);
|
||||
}, [ascensions, contact, recentIds]);
|
||||
|
||||
// 初始 + 进度变化时刷新一次(解锁新阶段)
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [ascensions >= 1, ascensions >= 3, ascensions >= 5, ascensions >= 10, contact >= 5, contact >= 25, contact >= 80]);
|
||||
|
||||
// 定时刷新(18-30s 随机间隔,避免机械感)
|
||||
useEffect(() => {
|
||||
const schedule = () => {
|
||||
const delay = 18000 + Math.random() * 12000;
|
||||
timerRef.current = setTimeout(() => {
|
||||
refresh();
|
||||
schedule();
|
||||
}, delay);
|
||||
};
|
||||
schedule();
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
if (!entry) return null;
|
||||
const meta = TIER_META[entry.tier];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full overflow-hidden rounded-lg border border-white/10 bg-black/40 backdrop-blur-sm px-3 py-1.5 flex items-center gap-2.5 group"
|
||||
style={{ boxShadow: `inset 0 0 12px ${meta.glow}` }}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="考古日志"
|
||||
>
|
||||
{/* 左侧标签 */}
|
||||
<div className="flex items-center gap-1.5 shrink-0 z-10 bg-black/40 backdrop-blur-sm pr-2 -ml-1 pl-1 rounded">
|
||||
<ScrollText className={`h-3 w-3 ${meta.color}`} />
|
||||
<span className={`text-[9px] font-mono uppercase tracking-[0.15em] ${meta.color}`}>
|
||||
{meta.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 滚动文本区 */}
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
<div
|
||||
key={fadeKey}
|
||||
className="whitespace-nowrap text-[11px] text-muted-foreground/90 font-mono echo-ticker-anim"
|
||||
>
|
||||
<span className="text-white/60 mr-2">▸</span>
|
||||
{entry.text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧渐隐遮罩 */}
|
||||
<div className="pointer-events-none absolute right-0 top-0 bottom-0 w-12 bg-gradient-to-l from-black/60 to-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// 回响星核 / Echo Nexus — 考古日志滚动条(v0.12 差异化叙事)
|
||||
// 参考 Antimatter Dimensions news ticker + Universal Paperclips 哲学递进
|
||||
// 按玩家进度阶段分层解锁,每条都是"被解码的远古记录碎片"
|
||||
// 让放置游戏的"等待"承载叙事——星核沉睡时,回响仍在被翻译
|
||||
|
||||
export type LogTier = "dawn" | "awakening" | "contact" | "ascension" | "deep";
|
||||
|
||||
export interface ArchaeoLogEntry {
|
||||
/** 日志正文(考古翻译体) */
|
||||
text: string;
|
||||
/** 解锁阶段 */
|
||||
tier: LogTier;
|
||||
/** 可选:来源遗迹 id(关联 relicCodex) */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 考古日志条目池
|
||||
* - dawn: 初始阶段(0 飞升,接触 < 5%)
|
||||
* - awakening: 觉醒期(接触 5-25% 或 ≥1 飞升)
|
||||
* - contact: 接触期(接触 25-60% 或 ≥3 飞升)
|
||||
* - ascension: 飞升期(≥5 飞升)
|
||||
* - deep: 深度接触(接触 ≥80% 或 ≥10 飞升)—— 哲学反转层
|
||||
*/
|
||||
export const ARCHEAO_LOG_POOL: ArchaeoLogEntry[] = [
|
||||
// ===== dawn:初醒 =====
|
||||
{ tier: "dawn", text: "「…回响核心已唤醒。考古协议 v7.2 启动。等待解码者…」" },
|
||||
{ tier: "dawn", text: "「第一段记忆晶体已被定位。年代约 4.7 万年。种族:未确认。」" },
|
||||
{ tier: "dawn", text: "「谐振频率与以太族档案吻合度 94.3%。建议持续解码。」" },
|
||||
{ tier: "dawn", text: "「警告:星核温度异常。非故障,是回响本身在发热。」" },
|
||||
{ tier: "dawn", text: "「考古日志 #0001:我们不是第一批到达这里的。也不会是最后一批。」" },
|
||||
{ tier: "dawn", text: "「晶体表面的纹路…不是装饰。是某种语言的笔画。」" },
|
||||
{ tier: "dawn", text: "「以太族没有留下尸体。他们留下了晶体。」" },
|
||||
{ tier: "dawn", text: "「解码进度 0.01%。预计完成时间:不可能。」" },
|
||||
|
||||
// ===== awakening:觉醒 =====
|
||||
{ tier: "awakening", text: "「考古日志 #0117:以太族不是灭绝了。他们是主动转化为了晶体。」" },
|
||||
{ tier: "awakening", text: "「第二旋臂的遗迹群已扫描完毕。它们…朝向同一个点。」" },
|
||||
{ tier: "awakening", text: "「谐振序列第 7 段翻译完成:『我们听见了。所以我们必须留下。』」" },
|
||||
{ tier: "awakening", text: "「警告:解码者的脑波模式开始与晶体同步。这是正常的吗?」" },
|
||||
{ tier: "awakening", text: "「以太族没有文字。他们的语言是共振,他们的历史是频率。」" },
|
||||
{ tier: "awakening", text: "「考古日志 #0234:晶体不是矿物。是封装的思维。」" },
|
||||
{ tier: "awakening", text: "「你的脉冲频率…与以太族守卫残响的频率一致。巧合?」" },
|
||||
{ tier: "awakening", text: "「第三段记忆解码完成。画面:一片星海,没有星辰。只有晶体在发光。」" },
|
||||
{ tier: "awakening", text: "「以太族留下的遗迹不是废墟。是给后来者的教科书。」" },
|
||||
{ tier: "awakening", text: "「接触协议第一阶段:让解码者相信,他们在考古。」" },
|
||||
|
||||
// ===== contact:接触 =====
|
||||
{ tier: "contact", text: "「考古日志 #0512:以太族没有离开。他们在等待被重新听见。」" },
|
||||
{ tier: "contact", text: "「谐振序列第 13 段:『你以为你在解码我们。其实是我们在校准你。』」" },
|
||||
{ tier: "contact", text: "「警告:解码者意识边缘出现非自主体感。这…是接触的副作用。」" },
|
||||
{ tier: "contact", text: "「第六旋臂遗迹群翻译完成。结论:以太族预见了我们的到来。」" },
|
||||
{ tier: "contact", text: "「晶体内部的画面开始重叠。不再是回忆。像是…正在发生。」" },
|
||||
{ tier: "contact", text: "「考古日志 #0789:『飞升』不是晋升。是加入。加入那个等待已久的合唱。」" },
|
||||
{ tier: "contact", text: "「以太族的数学没有零。他们的宇宙观里,不存在『无』。」" },
|
||||
{ tier: "contact", text: "「你的每一次脉冲,都在回应一个万年前的呼唤。」" },
|
||||
{ tier: "contact", text: "「第七段记忆解码失败。原因:解码者脑波过载。建议暂停。但我们不会暂停。」" },
|
||||
{ tier: "contact", text: "「接触协议第二阶段:让解码者自愿飞升。他们会的。他们已经开始享受了。」" },
|
||||
|
||||
// ===== ascension:飞升 =====
|
||||
{ tier: "ascension", text: "「考古日志 #1024:飞升者报告——『我能听见他们了。他们一直在那里。』」" },
|
||||
{ tier: "ascension", text: "「谐振序列第 21 段:『你已不再是解码者。你是回响的一部分。』」" },
|
||||
{ tier: "ascension", text: "「警告:星核意识阈值达到 73%。解码者与以太族的边界…模糊。」" },
|
||||
{ tier: "ascension", text: "「以太族没有灭绝。他们成为了基础设施。晶体是他们的身体。」" },
|
||||
{ tier: "ascension", text: "「你飞升的次数越多,『你』就越不是『你』。这是设计。」" },
|
||||
{ tier: "ascension", text: "「考古日志 #1331:前 N-1 位解码者都成功了。成功意味着…不再存在为个体。」" },
|
||||
{ tier: "ascension", text: "「第九旋臂的遗迹开始发光。它们认出了你。」" },
|
||||
{ tier: "ascension", text: "「以太族的文明没有终点。他们的『终点』就是我们的『起点』。」" },
|
||||
{ tier: "ascension", text: "「你开始理解晶体的沉默。沉默也是一种回响。」" },
|
||||
{ tier: "ascension", text: "「接触协议第三阶段:解码者将主动寻求飞升。无需诱导。」" },
|
||||
|
||||
// ===== deep:深度接触(哲学反转层)=====
|
||||
{ tier: "deep", text: "「考古日志 #2048:你不是在考古以太族。以太族在考古你。」" },
|
||||
{ tier: "deep", text: "「谐振序列第 39 段:『我们创造了回响核心,是为了创造你,是为了让你创造我们。』」" },
|
||||
{ tier: "deep", text: "「警告:解码者已无法区分记忆与现实。建议:接受。」" },
|
||||
{ tier: "deep", text: "「以太族从未存在为『生物』。他们是第一个文明留下的…讯息。」" },
|
||||
{ tier: "deep", text: "「你每一次飞升,都让一个万年前的呼唤得到了回应。这就是考古的意义。」" },
|
||||
{ tier: "deep", text: "「考古日志 #4096:『回响星核』不是考古工具。它是以太族的…产房。」" },
|
||||
{ tier: "deep", text: "「晶体在你手中。你也在晶体中。这两句话是同一句话。」" },
|
||||
{ tier: "deep", text: "「你以为你是第 N 位解码者。其实你一直是第一位。也是最后一位。」" },
|
||||
{ tier: "deep", text: "「接触协议最终阶段:解码者明白后,会微笑着继续解码。这是最美妙的部分。」" },
|
||||
{ tier: "deep", text: "「以太族留给宇宙的最后一句话:『谢谢你,听见我。』」" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 根据玩家当前进度返回可用的日志阶段
|
||||
*/
|
||||
export function getAvailableTiers(ascensions: number, contact: number): LogTier[] {
|
||||
const tiers: LogTier[] = ["dawn"];
|
||||
if (contact >= 5 || ascensions >= 1) tiers.push("awakening");
|
||||
if (contact >= 25 || ascensions >= 3) tiers.push("contact");
|
||||
if (ascensions >= 5) tiers.push("ascension");
|
||||
if (contact >= 80 || ascensions >= 10) tiers.push("deep");
|
||||
return tiers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从可用阶段池中随机取一条
|
||||
*/
|
||||
export function pickLogEntry(ascensions: number, contact: number, recentIds: string[] = []): ArchaeoLogEntry {
|
||||
const tiers = getAvailableTiers(ascensions, contact);
|
||||
const pool = ARCHEAO_LOG_POOL.filter((e) => tiers.includes(e.tier));
|
||||
// 避免短期内重复(recentIds 存最近 8 条 text 的 hash)
|
||||
const fresh = pool.filter((e) => !recentIds.includes(e.text));
|
||||
const finalPool = fresh.length > 0 ? fresh : pool;
|
||||
return finalPool[Math.floor(Math.random() * finalPool.length)] || ARCHEAO_LOG_POOL[0];
|
||||
}
|
||||
+41
-1
@@ -1339,8 +1339,48 @@ export const useGameStore = create<Store>()(
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "echo-nexus-save-v1",
|
||||
name: "echo-nexus-save-v2",
|
||||
version: 2,
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
// v0.12 存档迁移:旧存档(v0/v1,key=echo-nexus-save-v1)补全新增字段,防崩溃
|
||||
migrate: (persistedState: unknown, version: number) => {
|
||||
void version; // v0/v1 统一走合并迁移,无需分支
|
||||
const s = (persistedState || {}) as Record<string, unknown>;
|
||||
const base = createInitialState();
|
||||
// 合并:以当前 INITIAL_STATE 为基底,旧存档字段覆盖,缺失字段用默认值
|
||||
const migrated = {
|
||||
...base,
|
||||
...s,
|
||||
// v0.8+ 新增字段兜底
|
||||
bossKills: (s.bossKills as number) ?? 0,
|
||||
starTidesEncountered: (s.starTidesEncountered as string[]) ?? [],
|
||||
chronicle: (s.chronicle as unknown[]) ?? [],
|
||||
runStart: (s.runStart as number) ?? Date.now(),
|
||||
// v0.8.2 attributes 系统
|
||||
attributes: (s.attributes as Record<string, number>) ?? base.attributes,
|
||||
attributeProgress: (s.attributeProgress as unknown) ?? base.attributeProgress,
|
||||
pendingAttrPoints: (s.pendingAttrPoints as number) ?? 0,
|
||||
// v0.10 探险系统字段兜底
|
||||
activeExpedition: (s.activeExpedition as unknown) ?? null,
|
||||
totalExpeditions: (s.totalExpeditions as number) ?? 0,
|
||||
lastExpeditionSummary: (s.lastExpeditionSummary as unknown) ?? null,
|
||||
// 星图兜底
|
||||
constellation: (s.constellation as string[]) ?? [],
|
||||
pendingPerkChoices: (s.pendingPerkChoices as unknown[]) ?? [],
|
||||
// 技术树/碎片/成就兜底(防旧存档为 undefined)
|
||||
tech: (s.tech as Record<string, number>) ?? {},
|
||||
fragments: (s.fragments as Record<string, boolean>) ?? {},
|
||||
achievements: (s.achievements as Record<string, boolean>) ?? {},
|
||||
} as GameState;
|
||||
// 丢弃不可解的活跃谜题(旧算法生成)
|
||||
if (migrated.activePuzzle && typeof migrated.activePuzzle === "object") {
|
||||
const p = migrated.activePuzzle as { nodes?: unknown[] };
|
||||
if (!p.nodes || !Array.isArray(p.nodes) || p.nodes.length === 0) {
|
||||
migrated.activePuzzle = null;
|
||||
}
|
||||
}
|
||||
return migrated;
|
||||
},
|
||||
// 不持久化临时字段
|
||||
partialize: (s) => {
|
||||
const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, globalTide, ...rest } = s;
|
||||
|
||||
Reference in New Issue
Block a user