v0.9 工单修复: 4 bug + 4 玩法改进

Gitea Issue #2 修复:
Bug:
1. 探险只能一次 → 能量恢复 45→20s, 初始能量 3→5, 上限 5→8
   新增 lastExpeditionSummary 显示上次结果
   新增 dismissExpedition action
2. 元素偏移/遮挡 → 标签页 grid-cols-8 → 响应式 grid-cols-4/sm:8
3. 成就UI遮挡 → max-h 440→520, min-h 提高
4. 提示框遮挡 → toast top-0→top-14 避开 header, TOAST_LIMIT 1→3

玩法改进:
1. 中央晶体球 → 下方新增 4 指标收益面板(产能/脉冲/连击/仓库)
2. 技术树 12→20 节点 (每分支 3→5 级)
3. 成就解锁 → 全屏居中特效通知 (framer-motion 弹窗)
4. 叙事融入 → 新建 narrative.ts, 脉冲/技术树注入世界观文本
This commit is contained in:
2026-06-24 00:41:16 +00:00
parent eb9836b8af
commit 44b94f9325
17 changed files with 2236 additions and 43 deletions
+128 -2
View File
@@ -45,6 +45,10 @@ import {
Radio,
Navigation,
User,
Gem,
Zap,
Flame,
Database,
} from "lucide-react";
import { TECH_TREE, FRAGMENTS, formatNum } from "@/lib/game/config";
import { ACHIEVEMENTS } from "@/lib/game/achievements";
@@ -275,6 +279,8 @@ export default function Page() {
<div data-tut="crystal-orb" className="contents">
<CrystalOrb />
</div>
{/* v0.9 工单 #2:全息收益信息面板 — 让晶体球价值可视化 */}
<CrystalYieldPanel />
</section>
{/* 右侧:解码 + 标签面板 */}
@@ -284,9 +290,11 @@ export default function Page() {
<DecodePanel />
</div>
{/* 标签面板:探险 / 技术 / 星图 / 图谱 / 成就 / 信标 / 统计 / 角色 */}
<div className="glass rounded-2xl p-3 min-h-[280px] sm:min-h-[320px] max-h-[440px] flex flex-col">
{/* v0.9 工单修复:移除 max-h 硬限制,改用 min-h + flex 自适应,避免成就/技术树内容被挤压遮挡 */}
<div className="glass rounded-2xl p-3 min-h-[300px] sm:min-h-[360px] max-h-[520px] flex flex-col">
<Tabs defaultValue={hasActiveExpedition ? "expedition" : hasPendingPerk ? "constellation" : "tech"} className="h-full flex flex-col">
<TabsList className="grid grid-cols-8 h-9 bg-black/30 gap-0.5 p-1">
{/* v0.9 工单修复:标签页响应式布局,小屏 4 列 2 行避免拥挤遮挡 */}
<TabsList className="grid grid-cols-4 sm:grid-cols-8 h-auto sm:h-9 bg-black/30 gap-0.5 p-1">
<TabsTrigger value="expedition" data-tut="tab-expedition" className="text-[11px] gap-1 relative px-0.5 flex-col h-7 data-[state=active]:bg-amber-500/15 data-[state=active]:shadow-[0_0_12px_rgba(251,191,36,0.3)] transition-all">
<Rocket className="h-3.5 w-3.5" />
<span className="leading-none"></span>
@@ -428,6 +436,124 @@ export default function Page() {
);
}
/**
* v0.9 工单 #2:全息收益信息面板
* 在中央晶体球下方展示 4 项核心收益指标(产能 / 脉冲 / 连击 / 仓库),
* 让玩家明确感知晶体球的价值,缓解"占地方又没用"的反馈。
* 严格四色全息配色:emerald / rose / amber / fuchsia(禁止蓝色/靛色)。
*/
function CrystalYieldPanel() {
const crystalsPerSec = useGameStore((s) => s.crystalsPerSec);
const pulsePower = useGameStore((s) => s.pulsePower);
const combo = useGameStore((s) => s._combo);
const crystals = useGameStore((s) => s.crystals);
const crystalCap = useGameStore((s) => s.crystalCap);
// 连击倍率:1 + (combo - 1) * 0.15(仅 combo≥1 时生效)
const comboMult = combo >= 1 ? 1 + (combo - 1) * 0.15 : 1;
const comboHot = combo >= 3; // 连击≥3 高亮闪烁
// 仓库容量百分比
const fillPct = crystalCap > 0 ? (crystals / crystalCap) * 100 : 0;
const warehouseWarn = fillPct >= 90; // ≥90% 警告色 + pulse
return (
<div className="mt-3 grid grid-cols-2 sm:grid-cols-4 gap-2 w-full max-w-md pointer-events-none">
{/* 晶体产能 — emerald */}
<div className="rounded-lg border border-emerald-400/30 bg-black/30 backdrop-blur p-2 flex items-center gap-1.5">
<Gem className="h-3.5 w-3.5 text-emerald-400 shrink-0" />
<div className="min-w-0 leading-tight">
<div className="text-[9px] uppercase tracking-wider text-emerald-300/70 truncate">
</div>
<div className="text-sm font-mono tabular-nums text-emerald-200 truncate">
{formatNum(crystalsPerSec)}
<span className="text-[9px] text-emerald-300/60">/s</span>
</div>
</div>
</div>
{/* 主动脉冲 — rose */}
<div className="rounded-lg border border-rose-400/30 bg-black/30 backdrop-blur p-2 flex items-center gap-1.5">
<Zap className="h-3.5 w-3.5 text-rose-400 shrink-0" />
<div className="min-w-0 leading-tight">
<div className="text-[9px] uppercase tracking-wider text-rose-300/70 truncate">
</div>
<div className="text-sm font-mono tabular-nums text-rose-200 truncate">
{formatNum(pulsePower)}
{combo >= 1 && (
<span className="text-[9px] text-rose-300/80"> ×{comboMult.toFixed(2)}</span>
)}
</div>
</div>
</div>
{/* 当前连击 — amber,≥3 时高亮闪烁 */}
<div
className={`rounded-lg border bg-black/30 backdrop-blur p-2 flex items-center gap-1.5 ${
comboHot ? "border-amber-400/70 animate-pulse" : "border-amber-400/30"
}`}
>
<Flame
className={`h-3.5 w-3.5 text-amber-400 shrink-0 ${
comboHot ? "drop-shadow-[0_0_4px_rgba(251,191,36,0.7)]" : ""
}`}
/>
<div className="min-w-0 leading-tight">
<div className="text-[9px] uppercase tracking-wider text-amber-300/70 truncate">
</div>
<div
className={`text-sm font-mono tabular-nums truncate ${
comboHot ? "text-amber-200" : "text-amber-300/80"
}`}
>
{combo}
<span className="text-[9px] text-amber-300/60">/10</span>
</div>
</div>
</div>
{/* 仓库容量 — fuchsia,≥90% 切 rose 警告色 + pulse */}
<div
className={`rounded-lg border bg-black/30 backdrop-blur p-2 flex items-center gap-1.5 ${
warehouseWarn ? "border-rose-400/70 animate-pulse" : "border-fuchsia-400/30"
}`}
>
<Database
className={`h-3.5 w-3.5 shrink-0 ${
warehouseWarn ? "text-rose-400" : "text-fuchsia-400"
}`}
/>
<div className="min-w-0 leading-tight">
<div
className={`text-[9px] uppercase tracking-wider truncate ${
warehouseWarn ? "text-rose-300/80" : "text-fuchsia-300/70"
}`}
>
</div>
<div
className={`text-sm font-mono tabular-nums truncate ${
warehouseWarn ? "text-rose-200" : "text-fuchsia-200"
}`}
>
{formatNum(crystals)}
<span
className={`text-[9px] ${
warehouseWarn ? "text-rose-300/70" : "text-fuchsia-300/60"
}`}
>
/{formatNum(crystalCap)}
</span>
</div>
</div>
</div>
</div>
);
}
function StatsPanel() {
const s = useGameStore();
const achCount = Object.values(s.achievements).filter(Boolean).length;
+146 -4
View File
@@ -1,26 +1,168 @@
"use client";
// 回响星核 / Echo Nexus — 成就解锁通知消费者
import { useEffect } from "react";
// 回响星核 / Echo Nexus — 成就解锁通知v0.9 全屏特效增强)
// 工单 Issue #2 反馈"成就系统没看出来有什么用",改为居中全息弹窗 + 奖励数字弹出
import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useGameStore } from "@/store/gameStore";
import { useToast } from "@/hooks/use-toast";
import { sfx } from "@/hooks/useAudio";
import type { Achievement } from "@/lib/game/achievements";
interface NotificationItem {
achievement: Achievement;
id: number;
}
export function AchievementNotifier() {
const queue = useGameStore((s) => s._achievementQueue);
const consume = useGameStore((s) => s.consumeAchievementQueue);
const { toast } = useToast();
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
useEffect(() => {
if (queue.length === 0) return;
const items = consume();
for (const a of items) {
sfx("achievement");
// v0.9:全屏居中特效通知
const id = Date.now() + Math.random();
// eslint-disable-next-line react-hooks/set-state-in-effect
setNotifications((prev) => [...prev, { achievement: a, id }]);
// 4 秒后自动移除
setTimeout(() => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
}, 4000);
// 同时保留底部 toast 作为日志
toast({
title: `🏆 成就解锁:${a.name}`,
title: `成就解锁:${a.name}`,
description: `${a.desc} 奖励:${a.rewardText}`,
});
}
}, [queue, consume, toast]);
return null;
if (notifications.length === 0) return null;
return (
<div className="fixed inset-0 z-[120] pointer-events-none flex items-center justify-center">
<AnimatePresence>
{notifications.map((n, idx) => {
const a = n.achievement;
// 多个同时出现时垂直堆叠偏移
const offset = (idx - (notifications.length - 1) / 2) * 140;
return (
<motion.div
key={n.id}
initial={{ opacity: 0, scale: 0.4, y: 40, rotateX: -30 }}
animate={{ opacity: 1, scale: 1, y: offset, rotateX: 0 }}
exit={{ opacity: 0, scale: 0.6, y: -60 }}
transition={{ type: "spring", stiffness: 280, damping: 22 }}
className="absolute"
style={{ transformOrigin: "center center" }}
>
<div
className="relative rounded-2xl border-2 px-8 py-5 backdrop-blur-xl overflow-hidden"
style={{
borderColor: a.color,
background: `linear-gradient(135deg, ${a.color}22, rgba(5,4,16,0.92))`,
boxShadow: `0 0 60px ${a.color}66, 0 0 120px ${a.color}33, inset 0 0 30px ${a.color}11`,
minWidth: 320,
maxWidth: 420,
}}
>
{/* 背景径向辉光 */}
<div
className="absolute -top-20 -right-20 h-40 w-40 rounded-full blur-3xl opacity-50"
style={{ background: a.color }}
/>
{/* 旋转光线 */}
<motion.div
className="absolute inset-0 rounded-2xl opacity-20"
style={{
background: `conic-gradient(from 0deg, transparent, ${a.color}, transparent, ${a.color}, transparent)`,
}}
animate={{ rotate: 360 }}
transition={{ duration: 8, repeat: Infinity, ease: "linear" }}
/>
<div className="relative flex items-center gap-4">
{/* 图标徽章 */}
<motion.div
className="shrink-0 h-16 w-16 rounded-2xl flex items-center justify-center text-3xl"
style={{
background: `${a.color}33`,
border: `2px solid ${a.color}`,
boxShadow: `0 0 20px ${a.color}88, inset 0 0 12px ${a.color}44`,
}}
animate={{
boxShadow: [
`0 0 20px ${a.color}88, inset 0 0 12px ${a.color}44`,
`0 0 40px ${a.color}cc, inset 0 0 20px ${a.color}66`,
`0 0 20px ${a.color}88, inset 0 0 12px ${a.color}44`,
],
}}
transition={{ duration: 2, repeat: Infinity }}
>
{a.icon}
</motion.div>
{/* 文本 */}
<div className="min-w-0 flex-1">
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.15 }}
className="text-[10px] font-mono uppercase tracking-[0.2em] mb-0.5"
style={{ color: a.color }}
>
Achievement Unlocked
</motion.div>
<motion.h3
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
className="text-lg font-bold truncate"
style={{ color: a.color, textShadow: `0 0 12px ${a.color}66` }}
>
{a.name}
</motion.h3>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="text-[11px] text-muted-foreground/90 truncate"
>
{a.desc}
</motion.p>
{/* 奖励条 */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="mt-1.5 inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-mono font-semibold"
style={{
background: `${a.color}22`,
border: `1px solid ${a.color}55`,
color: a.color,
}}
>
{a.rewardText}
</motion.div>
</div>
</div>
{/* 进度条(倒计时) */}
<motion.div
className="absolute bottom-0 left-0 h-0.5"
style={{ background: a.color }}
initial={{ width: "100%" }}
animate={{ width: "0%" }}
transition={{ duration: 4, ease: "linear" }}
/>
</div>
</motion.div>
);
})}
</AnimatePresence>
</div>
);
}
+10 -1
View File
@@ -5,6 +5,7 @@ import { useGameStore } from "@/store/gameStore";
import { formatNum } from "@/lib/game/config";
import { useToast } from "@/hooks/use-toast";
import { sfx } from "@/hooks/useAudio";
import { PULSE_WHISPERS, maybeWhisper } from "@/lib/game/narrative";
interface FloatNum {
id: number;
@@ -242,10 +243,18 @@ export function CrystalOrb() {
setFloats((f) => f.filter((it) => it.id !== id));
}, 900);
if (res.combo >= 5 && res.combo % 5 === 0) {
// v0.9 叙事融入:高连击时注入世界观低语
const whisper = maybeWhisper(PULSE_WHISPERS, 0.6);
toast({
title: `×${res.combo} 连击!`,
description: "谐振叠加,产能飙升。",
description: whisper ? `${whisper}` : "谐振叠加,产能飙升。",
});
} else if (res.combo >= 3 && res.combo % 3 === 0) {
// 3 连击时也有概率出现叙事
const whisper = maybeWhisper(PULSE_WHISPERS, 0.3);
if (whisper) {
toast({ title: "低语", description: `${whisper}` });
}
}
},
[pulse, toast, triggerPulseEffect]
+75 -17
View File
@@ -1,6 +1,6 @@
"use client";
// 回响星核 / Echo Nexus — 遗迹探险面板(v0.2 肉鸽系统)
import { useState } from "react";
// 回响星核 / Echo Nexus — 遗迹探险面板(v0.2 肉鸽系统v0.9 工单修复
import { useState, useEffect } from "react";
import { useGameStore } from "@/store/gameStore";
import { useToast } from "@/hooks/use-toast";
import { sfx } from "@/hooks/useAudio";
@@ -42,20 +42,30 @@ export function ExpeditionPanel() {
const lastEnergyTick = useGameStore((s) => s.lastEnergyTick);
const expeditionLog = useGameStore((s) => s.expeditionLog);
const totalExpeditions = useGameStore((s) => s.totalExpeditions);
const lastExpeditionSummary = useGameStore((s) => s.lastExpeditionSummary);
// v0.8.1 动态能量恢复间隔(技术 + 探索力属性缩短)
const regenIntervalSec = useGameStore((s) => computeEnergyRegenInterval(s));
const startExpedition = useGameStore((s) => s.startExpedition);
const resolveCurrentNode = useGameStore((s) => s.resolveCurrentNode);
const advanceNode = useGameStore((s) => s.advanceNode);
const abortExpedition = useGameStore((s) => s.abortExpedition);
const dismissExpedition = useGameStore((s) => s.dismissExpedition);
const { toast } = useToast();
const [lastLog, setLastLog] = useState<string | null>(null);
const [now, setNow] = useState(() => Date.now());
// v0.9 工单修复:能量恢复实时倒计时(每秒刷新)
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
// 能量恢复进度(v0.8.1 动态间隔)
const now = Date.now();
// 能量恢复进度(v0.8.1 动态间隔v0.9 实时刷新
const regenMs = regenIntervalSec * 1000;
const regenProgress = energy >= energyMax ? 100 : Math.min(100, ((now - lastEnergyTick) / regenMs) * 100);
const elapsed = now - lastEnergyTick;
const regenProgress = energy >= energyMax ? 100 : Math.min(100, (elapsed / regenMs) * 100);
const regenBoosted = regenIntervalSec < EXPEDITION_CONFIG.energyRegenSec;
const nextEnergyInSec = energy >= energyMax ? 0 : Math.max(0, Math.ceil((regenMs - elapsed) / 1000));
const canStart = energy >= EXPEDITION_CONFIG.energyCost;
const handleStart = () => {
const res = startExpedition();
@@ -100,6 +110,9 @@ export function ExpeditionPanel() {
// ===== 无活跃探险:展示入口 =====
if (!activeExpedition || activeExpedition.finished) {
// v0.9 工单修复:如果探险刚结束(finished 且有摘要),显示结算面板
const justFinished = activeExpedition?.finished && lastExpeditionSummary &&
Date.now() - lastExpeditionSummary.finishedAt < 60000;
return (
<div className="flex flex-col gap-3 h-full">
<div className="flex items-center justify-between">
@@ -110,6 +123,40 @@ export function ExpeditionPanel() {
<span className="text-[10px] text-muted-foreground"> {totalExpeditions} </span>
</div>
{/* v0.9 工单修复:上次探险结果摘要 */}
{lastExpeditionSummary && (
<div
className={`rounded-xl border p-2.5 relative overflow-hidden ${
lastExpeditionSummary.result === "victory"
? "border-emerald-400/40 bg-emerald-950/20"
: lastExpeditionSummary.result === "defeat"
? "border-rose-400/40 bg-rose-950/20"
: "border-amber-400/30 bg-amber-950/10"
}`}
>
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] font-semibold flex items-center gap-1">
{lastExpeditionSummary.result === "victory" ? "✦ 上次探险:胜利" : lastExpeditionSummary.result === "defeat" ? "✕ 上次探险:失败" : "→ 上次探险:撤退"}
</span>
<span className="text-[9px] text-muted-foreground font-mono">{lastExpeditionSummary.nodeCount} </span>
</div>
<div className="flex items-center gap-2.5 text-[10px]">
{lastExpeditionSummary.rewards.crystals > 0 && (
<span className="text-emerald-300">+{formatNum(lastExpeditionSummary.rewards.crystals)} </span>
)}
{lastExpeditionSummary.rewards.insights > 0 && (
<span className="text-amber-300">+{lastExpeditionSummary.rewards.insights} </span>
)}
{lastExpeditionSummary.rewards.contact > 0 && (
<span className="text-fuchsia-300">+{lastExpeditionSummary.rewards.contact.toFixed(1)} </span>
)}
{lastExpeditionSummary.rewards.crystals === 0 && lastExpeditionSummary.rewards.insights === 0 && (
<span className="text-muted-foreground/60"></span>
)}
</div>
</div>
)}
{/* 能量条 */}
<div className="rounded-xl border border-amber-400/20 bg-amber-950/20 p-3">
<div className="flex items-center justify-between mb-1.5">
@@ -127,8 +174,10 @@ export function ExpeditionPanel() {
value={regenProgress}
className="h-1.5 bg-black/40 [&>div]:bg-gradient-to-r [&>div]:from-amber-500 [&>div]:to-yellow-300"
/>
<p className="text-[10px] text-muted-foreground/70 mt-1">
{Math.ceil((regenMs - (now - lastEnergyTick)) / 1000)}s
<p className="text-[10px] text-muted-foreground/70 mt-1 flex items-center gap-1">
<span></span>
<span className="text-amber-300 font-mono font-semibold">{nextEnergyInSec}s</span>
<span></span>
{regenBoosted && (
<span className="text-emerald-400/80 ml-1"> {regenIntervalSec.toFixed(0)}s/ ()</span>
)}
@@ -140,35 +189,32 @@ export function ExpeditionPanel() {
</div>
{/* 出发按钮 */}
<div className="flex-1 flex flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-amber-400/20 bg-black/30 p-6 relative overflow-hidden">
<div className="flex-1 flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-amber-400/20 bg-black/30 p-4 relative overflow-hidden">
<div className="absolute -top-10 -right-10 h-32 w-32 rounded-full bg-amber-500/10 blur-2xl" />
<div className="relative">
<div className="h-20 w-20 rounded-full bg-gradient-to-br from-amber-500/30 to-rose-500/20 flex items-center justify-center mx-auto" style={{ boxShadow: "0 0 30px rgba(251,191,36,0.3)" }}>
<Rocket className="h-9 w-9 text-amber-300" />
<div className={`h-16 w-16 rounded-full bg-gradient-to-br from-amber-500/30 to-rose-500/20 flex items-center justify-center mx-auto ${canStart ? "animate-pulse" : ""}`} style={{ boxShadow: "0 0 30px rgba(251,191,36,0.3)" }}>
<Rocket className="h-7 w-7 text-amber-300" />
</div>
</div>
<div className="text-center space-y-1">
<p className="text-sm font-medium text-amber-100"></p>
<p className="text-[11px] text-muted-foreground/80 max-w-[220px]">
沿 BOSS
沿 BOSS
</p>
</div>
<Button
onClick={handleStart}
disabled={energy < EXPEDITION_CONFIG.energyCost}
disabled={!canStart}
className="bg-gradient-to-r from-amber-600 to-rose-500 hover:from-amber-500 hover:to-rose-400 text-white border-0"
>
<Rocket className="h-4 w-4 mr-1.5" />
{EXPEDITION_CONFIG.energyCost}
{canStart ? `出发(消耗 ${EXPEDITION_CONFIG.energyCost} 能量)` : `能量不足(${nextEnergyInSec}s 后恢复)`}
</Button>
{energy < EXPEDITION_CONFIG.energyCost && (
<p className="text-[10px] text-rose-300/80"></p>
)}
</div>
{/* 探险日志 */}
{expeditionLog.length > 0 && (
<div className="rounded-xl border border-white/5 bg-black/20 p-2 max-h-32 overflow-y-auto echo-scroll">
<div className="rounded-xl border border-white/5 bg-black/20 p-2 max-h-28 overflow-y-auto echo-scroll">
<div className="text-[10px] text-muted-foreground/60 mb-1 px-1"></div>
{expeditionLog.slice(0, 4).map((entry, i) => (
<div key={i} className="text-[10px] text-muted-foreground/70 px-1 py-0.5 border-b border-white/5 last:border-0">
@@ -178,6 +224,18 @@ export function ExpeditionPanel() {
))}
</div>
)}
{/* v0.9 工单修复:刚结束的探险,显示"返回"按钮 */}
{justFinished && (
<Button
onClick={() => dismissExpedition()}
variant="outline"
size="sm"
className="h-7 text-[11px] border-amber-400/30 text-amber-200"
>
</Button>
)}
</div>
);
}
+6 -1
View File
@@ -3,6 +3,7 @@
import { useGameStore } from "@/store/gameStore";
import { TECH_TREE, TECH_BRANCH_META, formatNum } from "@/lib/game/config";
import { sfx } from "@/hooks/useAudio";
import { TECH_LORE } from "@/lib/game/narrative";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
@@ -58,10 +59,14 @@ export function TechTree() {
<span className="text-xs font-medium" style={{ color }}>
{meta.name}
</span>
<span className="text-[10px] text-muted-foreground/70">
<span className="text-[10px] text-muted-foreground/70 truncate flex-1">
{meta.desc}
</span>
</div>
{/* v0.9 叙事融入:每分支的以太族遗产描述 */}
<p className="text-[9px] italic text-muted-foreground/50 px-1 mb-1.5 truncate">
{TECH_LORE[b] ?? ""}
</p>
<div className="grid gap-1.5">
{nodes.map((node) => {
const owned = (tech[node.id] ?? 0) >= 1;
+1 -1
View File
@@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
"fixed top-14 z-[90] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
+1 -1
View File
@@ -8,7 +8,7 @@ import type {
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_LIMIT = 3
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
+2 -2
View File
@@ -90,10 +90,10 @@ export const ACHIEVEMENTS: Achievement[] = [
{
id: "ach_tech_all",
name: "全谱精通",
desc: "解锁全部 12 项技术",
desc: "解锁全部 20 项技术",
icon: "⬡",
color: "#e879f9",
check: (s) => Object.values(s.tech).filter((v) => v > 0).length >= 12,
check: (s) => Object.values(s.tech).filter((v) => v > 0).length >= 20,
reward: { crystalsPerSecPct: 15, insightPct: 15 },
rewardText: "产能 +15% · 洞见 +15%",
},
+77 -4
View File
@@ -10,7 +10,7 @@ import type {
export const INITIAL_STATE = {
crystals: 0,
insights: 0,
energy: 3,
energy: 5,
contact: 0,
crystalsPerSec: 0.4,
crystalCap: 50,
@@ -26,10 +26,11 @@ export const INITIAL_STATE = {
activePuzzle: null,
activeExpedition: null,
expeditionLog: [],
energyMax: 5,
energyMax: 8,
lastEnergyTick: Date.now(),
totalExpeditions: 0,
bossKills: 0,
lastExpeditionSummary: null as import("./types").GameState["lastExpeditionSummary"],
achievements: {},
activeTide: null,
lastTideEnd: 0,
@@ -124,7 +125,7 @@ export const COLOR_VISUAL: Record<
fuchsia: { hex: "#e879f9", glow: "rgba(232,121,249,0.55)", label: "紫" },
};
/** 技术树:4 分支 × 3 级 = 12 节点 */
/** 技术树:4 分支 × 5 级 = 20 节点v0.9 工单 #2 扩充:12→20 */
export const TECH_TREE: TechNode[] = [
// 采矿分支
{
@@ -154,6 +155,24 @@ export const TECH_TREE: TechNode[] = [
cost: 120,
effect: { kind: "crystalsPerSec", value: 5 },
},
{
id: "min_4",
branch: "mining",
level: 4,
name: "量子采掘矩阵",
desc: "晶体/秒 +12,仓库上限 +800",
cost: 400,
effect: { kind: "crystalsPerSec", value: 12 },
},
{
id: "min_5",
branch: "mining",
level: 5,
name: "戴森星核",
desc: "晶体/秒 +30,仓库上限 +2000",
cost: 1200,
effect: { kind: "crystalsPerSec", value: 30 },
},
// 解码分支
{
id: "dec_1",
@@ -182,7 +201,25 @@ export const TECH_TREE: TechNode[] = [
cost: 200,
effect: { kind: "autoDecode", value: 1 },
},
// 探险分支(v0.2:探险力/生命/能量上限/产能)
{
id: "dec_4",
branch: "decoding",
level: 4,
name: "谐振放大器",
desc: "主动脉冲产出 +8",
cost: 500,
effect: { kind: "pulsePower", value: 8 },
},
{
id: "dec_5",
branch: "decoding",
level: 5,
name: "全息解码核心",
desc: "自动解码扩展至 T2 晶体,解码步数 +3",
cost: 1500,
effect: { kind: "decodeSteps", value: 3 },
},
// 探险分支(v0.2:探险力/生命/能量上限/产能;v0.9:level 4/5 高级探险力+能量恢复)
{
id: "exp_1",
branch: "expedition",
@@ -210,6 +247,24 @@ export const TECH_TREE: TechNode[] = [
cost: 180,
effect: { kind: "contactRate", value: 0.5 },
},
{
id: "exp_4",
branch: "expedition",
level: 4,
name: "虚空航标",
desc: "探险力 +12,探险生命 +30,能量恢复 +25%",
cost: 450,
effect: { kind: "crystalCap", value: 400 },
},
{
id: "exp_5",
branch: "expedition",
level: 5,
name: "维度跃迁引擎",
desc: "探险力 +25,探险生命 +50,能量恢复 +30%",
cost: 1400,
effect: { kind: "crystalCap", value: 600 },
},
// 叙事分支
{
id: "nar_1",
@@ -238,6 +293,24 @@ export const TECH_TREE: TechNode[] = [
cost: 160,
effect: { kind: "insightMult", value: 0.6 },
},
{
id: "nar_4",
branch: "narrative",
level: 4,
name: "以太低语",
desc: "洞见获取 +100%",
cost: 500,
effect: { kind: "insightMult", value: 1.0 },
},
{
id: "nar_5",
branch: "narrative",
level: 5,
name: "文明回响",
desc: "接触进度转化率 +80%,洞见获取 +150%",
cost: 1600,
effect: { kind: "insightMult", value: 1.5 },
},
];
/** 技术分支元信息 */
+9
View File
@@ -79,6 +79,15 @@ export function recomputeStats(state: Partial<GameState>): {
}
}
// v0.9 工单 #2:多效果节点的次效果(desc 中描述但 effect.kind 只能承载一个)
// 采矿分支 desc 承诺的仓库上限加成(同时补齐 min_2/min_3 旧 desc 文案)
if (tech.min_2) crystalCap += 100;
if (tech.min_3) crystalCap += 300;
if (tech.min_4) crystalCap += 800;
if (tech.min_5) crystalCap += 2000;
// 叙事分支 nar_5 文明回响:接触进度转化率 +80%(与 insightMult +150% 并存)
if (tech.nar_5) contactRateMult += 0.8;
// 飞升蓝图加成(+ 星图「蓝图回响」额外加成)
const bpAllPct = cm.perBlueprintAllStatsPct / 100;
crystalsPerSec *= 1 + (bp * (PRESTIGE.perBlueprint.crystalsPerSecMult + bpAllPct));
+16 -7
View File
@@ -39,10 +39,11 @@ export const EXPEDITION_CONFIG = {
* v0.8.1 scale 84floor 0.250.35 */
combatDifficultyScale: 4,
combatWinRateFloor: 0.35,
/** 能量恢复基础间隔(秒),可被技术/属性缩短 */
energyRegenSec: 45,
/** /
* v0.9 4520 */
energyRegenSec: 20,
/** 能量恢复最短间隔(秒,技术+属性全满时) */
energyRegenMinSec: 12,
energyRegenMinSec: 8,
};
/** 节点类型权重(boss 固定末位,其余按权重随机) */
@@ -181,11 +182,13 @@ export function generateExpedition(
/** 计算探险力(由技术树 + 飞升蓝图 + 星图天赋) */
export function computeExpeditionPower(state: GameState): number {
let power = EXPEDITION_CONFIG.basePower;
// 探险分支技术加成
// 探险分支技术加成v0.9 扩充:exp_4 +12exp_5 +25
const exp1 = state.tech?.exp_1 ?? 0;
const exp2 = state.tech?.exp_2 ?? 0;
const exp3 = state.tech?.exp_3 ?? 0;
power += exp1 * 3 + exp2 * 5 + exp3 * 8;
const exp4 = state.tech?.exp_4 ?? 0;
const exp5 = state.tech?.exp_5 ?? 0;
power += exp1 * 3 + exp2 * 5 + exp3 * 8 + exp4 * 12 + exp5 * 25;
// 飞升蓝图加成
power *= 1 + (state.blueprints?.length ?? 0) * 0.08;
// 飞升周目加成
@@ -200,6 +203,9 @@ export function computeExpeditionPower(state: GameState): number {
export function computeExpeditionHp(state: GameState): number {
let hp = EXPEDITION_CONFIG.baseHp;
hp += (state.tech?.exp_2 ?? 0) * 20;
// v0.9 扩充:exp_4 +30exp_5 +50
hp += (state.tech?.exp_4 ?? 0) * 30;
hp += (state.tech?.exp_5 ?? 0) * 50;
hp += (state.ascensions ?? 0) * 10;
// 星图「维生护盾」加成
const cm = constellationBonuses(state.constellation ?? []);
@@ -366,13 +372,16 @@ export function advanceExpedition(expedition: Expedition): ExpeditionResult {
}
/** +
* v0.8.1exp_2 -30%exp_3 -20% -30% 12s */
* v0.8.1exp_2 -30%exp_3 -20% -30% 12s
* v0.9 exp_4 25%×0.75exp_5 30%×0.70 8s */
export function computeEnergyRegenInterval(state: GameState): number {
const base = EXPEDITION_CONFIG.energyRegenSec;
let mult = 1;
// 技术:遗迹图谱(exp_2)+ 维度信标(exp_3
// 技术:遗迹图谱(exp_2)+ 维度信标(exp_3+ 虚空航标(exp_4)+ 维度跃迁引擎(exp_5
if (state.tech?.exp_2) mult *= 0.7;
if (state.tech?.exp_3) mult *= 0.8;
if (state.tech?.exp_4) mult *= 0.75;
if (state.tech?.exp_5) mult *= 0.7;
// 角色属性:探索力(exploration)每点缩短少量,高探索力最高 -30%
const exploration = state.attributes?.exploration ?? 0;
const explorationBonus = exploration >= 50
+82
View File
@@ -0,0 +1,82 @@
// 回响星核 / Echo Nexus — 叙事氛围文本池(v0.9 工单修复)
// 工单 Issue #2 反馈"很多东西都没靠着世界观去写,像是为了做游戏而做的一样"
// 本模块为各系统交互注入叙事碎片,让世界观渗透到每个角落
/** 脉冲扫描时随机触发的氛围低语(连击越高出现概率越大) */
export const PULSE_WHISPERS: string[] = [
"晶核深处传来一阵微弱的震颤…",
"以太族的余响在谐振中回荡。",
"你听见了一句无法辨认的低语。",
"星海的寂静被你的脉冲短暂打破。",
"晶体表面的纹路闪烁,像是某种回应。",
"回响核心记录着每一次触碰。",
"虚空深处,有什么在倾听。",
"谐振频率与你的脉冲产生了共鸣。",
"这是以太族留下的第一道印记。",
"晶体内部的光,比星辰更古老。",
"你的脉冲唤醒了一段沉睡的频率。",
"维度薄膜因你的触碰而微微颤动。",
"采矿不是掠夺,是聆听。",
"每一颗晶体,都是一封跨越时间的信。",
"以太族信任你,所以留下了这些。",
];
/** 解码成功时的叙事碎片(按解码次数递进解锁更深层的叙事) */
export const DECODE_ECHOES: string[] = [
"一段记忆在你的解码中苏醒…",
"谐振序列被还原,低语化为可读的符文。",
"你触碰到了以太族思维的一角。",
"解码完成的瞬间,你看见了他们的星海。",
"记忆晶体释放出封存了万年的画面。",
"这不仅是数据,是一段被编码的经历。",
"以太族的语言不是文字,是共振本身。",
"你读懂了这句话时,接触便已开始。",
"晶体褪去外壳,露出内核中的回响。",
"解码,是以太族留给后来者的资格证明。",
"你的思维与晶体中的残响短暂重合。",
"一段文明的碎片在你手中重组。",
];
/** 技术解锁时的叙事(每分支一段通用叙事) */
export const TECH_LORE: Record<string, string> = {
mining: "以太族从不掠夺星海,他们与矿脉共振,让晶体自愿献出光芒。",
decoding: "谐振法则是以太族的核心遗产——思维可被编码为共振序列。",
expedition: "他们留下的遗迹不是废墟,是故意设置的试炼场。",
narrative: "记忆校音,是以太族赋予后来者的第一项礼物。",
};
/** 探险节点事件额外的叙事前缀 */
export const EXPEDITION_NARRATIVE: Record<string, string> = {
combat: "以太族的守卫残影仍在执行万年前的指令。",
treasure: "这些宝藏是以太族特意留下的补给。",
choice: "分岔甬道是他们对后来者的考验。",
puzzle: "谐振锁的答案,藏在以太族的思维模式里。",
rest: "这处休憩节点,是以太族留给疲惫旅人的礼物。",
boss: "终焉之核是以太族离去前留下的最后一道试炼。",
};
/** 飞升时的叙事 */
export const PRESTIGE_ECHOES: string[] = [
"你的意识与以太族的残响交汇,维度薄膜被穿透…",
"飞升不是结束,是与更高处的他们相会。",
"你褪去了这一周目的形态,回响却永存。",
"以太族在更高处等你,已等候了万年。",
"接触完成。你成为了他们等待的回应。",
];
/** 随机取一条叙事文本 */
export function pickWhisper(pool: string[], seed?: number): string {
if (pool.length === 0) return "";
const idx = seed !== undefined
? Math.abs(Math.floor(seed)) % pool.length
: Math.floor(Math.random() * pool.length);
return pool[idx];
}
/** 按概率返回一条叙事(probability 0-1 */
export function maybeWhisper(pool: string[], probability: number): string | null {
if (Math.random() < probability) {
return pickWhisper(pool);
}
return null;
}
+7
View File
@@ -125,6 +125,13 @@ export interface GameState {
lastEnergyTick: number;
totalExpeditions: number;
bossKills: number; // 累计击败 BOSS 数(跨周目)
/** v0.9 工单修复:上次探险结果摘要,用于入口界面展示 */
lastExpeditionSummary: {
result: "victory" | "defeat" | "abort";
rewards: { crystals: number; insights: number; contact: number };
nodeCount: number;
finishedAt: number;
} | null;
// 成就
achievements: Record<string, boolean>; // achievementId -> unlocked
+37 -3
View File
@@ -127,6 +127,8 @@ interface GameActions {
resolveCurrentNode: () => ExpeditionResult | null;
advanceNode: () => void;
abortExpedition: () => void;
/** v0.9 工单修复:手动关闭已结束的探险,回到入口 */
dismissExpedition: () => void;
// 技术
buyTech: (techId: string) => boolean;
@@ -778,12 +780,17 @@ export const useGameStore = create<Store>()(
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);
// 找一颗 T1 晶体自动解码;v0.9 dec_5「全息解码核心」解锁后可自动解码 T2
let autoTier: CrystalTier = 1;
let idx = s.pendingCrystals.findIndex((c) => c.tier === 1);
if (idx < 0 && s.tech?.dec_5) {
idx = s.pendingCrystals.findIndex((c) => c.tier === 2);
autoTier = 2;
}
if (idx < 0) return;
const crystal = s.pendingCrystals[idx];
const tideMod = getTideModifiers(s.activeTide);
const base = decodeRewards(1, s.insightMult, s.contactRateMult);
const base = decodeRewards(autoTier, s.insightMult, s.contactRateMult);
const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult;
const rewards = {
crystals: Math.round(base.crystals * finalMult),
@@ -923,6 +930,17 @@ export const useGameStore = create<Store>()(
exp.finished = true;
}
// v0.9 工单修复:探险结束时记录摘要,用于入口界面展示
let lastExpeditionSummary = s.lastExpeditionSummary ?? null;
if (result.ended && (result.endReason === "victory" || result.endReason === "defeat")) {
lastExpeditionSummary = {
result: result.endReason,
rewards: { ...exp.rewards },
nodeCount: exp.nodes.length,
finishedAt: Date.now(),
};
}
// v0.4 编年史:击破 BOSS 时累计计数
let bossKills = s.bossKills ?? 0;
let bossKilledThisNode = false;
@@ -979,6 +997,7 @@ export const useGameStore = create<Store>()(
bossKills,
attributes: newAttributes,
attributeProgress: newProgress,
lastExpeditionSummary,
...(statsNeedResync
? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes })
: {}),
@@ -1017,12 +1036,27 @@ export const useGameStore = create<Store>()(
rewards: "",
timestamp: Date.now(),
};
// v0.9 工单修复:记录上次探险摘要,用于入口展示
const summary = {
result: "abort" as const,
rewards: { ...exp.rewards },
nodeCount: exp.nodes.length,
finishedAt: Date.now(),
};
set({
activeExpedition: exp,
expeditionLog: [logEntry, ...s.expeditionLog].slice(0, 30),
lastExpeditionSummary: summary,
});
},
// v0.9 工单修复:手动关闭已结束探险,回到入口
dismissExpedition: () => {
const s = get();
if (!s.activeExpedition || !s.activeExpedition.finished) return;
set({ activeExpedition: null });
},
doPrestige: () => {
const s = get();
if (s.contact < CONTACT.prestigeMin) return null;