1→ 1→"use client"; 2→ 2→// 回响星核 / Echo Nexus — 深空信标面板 3→ 3→// v0.5:每日挑战 + 本地排行榜 4→ 4→// v0.8:周挑战 + 信标链(连续完成奖励) 5→ 5→import { useState, useEffect, useCallback } from "react"; 6→ 6→import { useGameStore } from "@/store/gameStore"; 7→ 7→import { useToast } from "@/hooks/use-toast"; 8→ 8→import { sfx } from "@/hooks/useAudio"; 9→ 9→import { 10→ 10→ generateDailyChallenge, 11→ 11→ generateWeeklyChallenge, 12→ 12→ loadDailyProgress, 13→ 13→ loadWeeklyProgress, 14→ 14→ loadLeaderboard, 15→ 15→ loadChainState, 16→ 16→ claimBeaconReward, 17→ 17→ BEACON_DIFFICULTY, 18→ 18→ BEACON_TYPE_META, 19→ 19→ BEACON_CHAIN_MILESTONES, 20→ 20→ BEACON_CHAIN_REWARDS, 21→ 21→ getWeekKey, 22→ 22→ getTodayKey, 23→ 23→ getNextMilestone, 24→ 24→ getChainProgress, 25→ 25→ msUntilNextDay, 26→ 26→ msUntilNextWeek, 27→ 27→ formatCountdown, 28→ 28→ type BeaconDailyChallenge, 29→ 29→ type BeaconDailyProgress, 30→ 30→ type BeaconWeeklyChallenge, 31→ 31→ type BeaconWeeklyProgress, 32→ 32→ type BeaconScoreEntry, 33→ 33→ type BeaconChallengeType, 34→ 34→ type BeaconDifficulty, 35→ 35→ type BeaconChainState, 36→ 36→} from "@/lib/game/beacon"; 37→ 37→import { formatNum } from "@/lib/game/config"; 38→ 38→import { Button } from "@/components/ui/button"; 39→ 39→import { Progress } from "@/components/ui/progress"; 40→ 40→import { 41→ 41→ Radio, 42→ 42→ Clock, 43→ 43→ Trophy, 44→ 44→ Sparkles, 45→ 45→ Award, 46→ 46→ Crown, 47→ 47→ Medal, 48→ 48→ Link2, 49→ 49→ Flame, 50→ 50→ Zap, 51→ 51→} from "lucide-react"; 52→ 52→ 53→ 53→const DIFFICULTY_ORDER: BeaconDifficulty[] = ["routine", "anomaly", "singular"]; 54→ 54→ 55→ 55→function rankBadge(rank: number) { 56→ 56→ if (rank === 0) return { icon: Crown, color: "#fbbf24", label: "1st" }; 57→ 57→ if (rank === 1) return { icon: Medal, color: "#cbd5e1", label: "2nd" }; 58→ 58→ if (rank === 2) return { icon: Award, color: "#f97316", label: "3rd" }; 59→ 59→ return null; 60→ 60→} 61→ 61→ 62→ 62→export function BeaconPanel() { 63→ 63→ const grantBeaconReward = useGameStore((s) => s.grantBeaconReward); 64→ 64→ const claimWeeklyBeacon = useGameStore((s) => s.claimWeeklyBeacon); 65→ 65→ const claimChainReward = useGameStore((s) => s.claimChainReward); 66→ 66→ const { toast } = useToast(); 67→ 67→ 68→ 68→ const [challenge, setChallenge] = useState(null); 69→ 69→ const [progress, setProgress] = useState(null); 70→ 70→ const [weeklyChallenge, setWeeklyChallenge] = 71→ 71→ useState(null); 72→ 72→ const [weeklyProgress, setWeeklyProgress] = 73→ 73→ useState(null); 74→ 74→ const [chainState, setChainState] = useState(null); 75→ 75→ const [leaderboard, setLeaderboard] = useState([]); 76→ 76→ const [dayCountdown, setDayCountdown] = useState("00:00:00"); 77→ 77→ const [weekCountdown, setWeekCountdown] = useState("00:00:00"); 78→ 78→ const [now, setNow] = useState(Date.now()); 79→ 79→ 80→ 80→ // 初始化 + 每秒刷新(进度 + 倒计时) 81→ 81→ useEffect(() => { 82→ 82→ setChallenge(generateDailyChallenge()); 83→ 83→ setProgress(loadDailyProgress()); 84→ 84→ setWeeklyChallenge(generateWeeklyChallenge()); 85→ 85→ setWeeklyProgress(loadWeeklyProgress()); 86→ 86→ setChainState(loadChainState()); 87→ 87→ setLeaderboard(loadLeaderboard()); 88→ 88→ const id = setInterval(() => { 89→ 89→ setNow(Date.now()); 90→ 90→ setProgress(loadDailyProgress()); 91→ 91→ setWeeklyProgress(loadWeeklyProgress()); 92→ 92→ setChainState(loadChainState()); 93→ 93→ setChallenge((c) => c ?? generateDailyChallenge()); 94→ 94→ setWeeklyChallenge((c) => c ?? generateWeeklyChallenge()); 95→ 95→ }, 1000); 96→ 96→ return () => clearInterval(id); 97→ 97→ }, []); 98→ 98→ 99→ 99→ useEffect(() => { 100→ 100→ setDayCountdown(formatCountdown(msUntilNextDay(new Date(now)))); 101→ 101→ setWeekCountdown(formatCountdown(msUntilNextWeek(new Date(now)))); 102→ 102→ }, [now]); 103→ 103→ 104→ 104→ const handleClaimDaily = useCallback(() => { 105→ 105→ if (!challenge || !progress) return; 106→ 106→ if (progress.completedAt === null || progress.claimed) return; 107→ 107→ const res = claimBeaconReward(challenge, progress); 108→ 108→ setLeaderboard(res.leaderboard); 109→ 109→ setProgress(loadDailyProgress()); 110→ 110→ // 发放奖励到游戏状态 111→ 111→ grantBeaconReward(res.rewardInsight, res.rewardContact); 112→ 112→ sfx("achievement"); 113→ 113→ toast({ 114→ 114→ title: "✦ 每日信标奖励已领取", 115→ 115→ description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed( 116→ 116→ 1 117→ 117→ )} 接触 · 得分 ${res.score}`, 118→ 118→ }); 119→ 119→ }, [challenge, progress, toast, grantBeaconReward]); 120→ 120→ 121→ 121→ const handleClaimWeekly = useCallback(() => { 122→ 122→ if (!weeklyChallenge || !weeklyProgress) return; 123→ 123→ if (weeklyProgress.completedAt === null || weeklyProgress.claimed) return; 124→ 124→ const res = claimWeeklyBeacon(); 125→ 125→ setLeaderboard(loadLeaderboard()); 126→ 126→ setWeeklyProgress(loadWeeklyProgress()); 127→ 127→ if (res.rewardInsight > 0 || res.rewardContact > 0) { 128→ 128→ sfx("achievement"); 129→ 129→ toast({ 130→ 130→ title: "✦ 周挑战奖励已领取", 131→ 131→ description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed( 132→ 132→ 1 133→ 133→ )} 接触 · 得分 ${res.score}`, 134→ 134→ }); 135→ 135→ } 136→ 136→ }, [weeklyChallenge, weeklyProgress, toast, claimWeeklyBeacon]); 137→ 137→ 138→ 138→ const handleClaimChain = useCallback( 139→ 139→ (milestone: number) => { 140→ 140→ const reward = BEACON_CHAIN_REWARDS.find( 141→ 141→ (r) => r.milestone === milestone 142→ 142→ ); 143→ 143→ const res = claimChainReward(milestone); 144→ 144→ setChainState(loadChainState()); 145→ 145→ if (res.ok) { 146→ 146→ sfx("achievement"); 147→ 147→ toast({ 148→ 148→ title: `✦ ${res.label || reward?.label || "里程碑"} 已领取`, 149→ 149→ description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed( 150→ 150→ 1 151→ 151→ )} 接触`, 152→ 152→ }); 153→ 153→ } 154→ 154→ }, 155→ 155→ [toast, claimChainReward] 156→ 156→ ); 157→ 157→ 158→ 158→ if (!challenge || !progress || !weeklyChallenge || !weeklyProgress || !chainState) { 159→ 159→ return ( 160→ 160→
161→ 161→ 正在校准深空信标… 162→ 162→
163→ 163→ ); 164→ 164→ } 165→ 165→ 166→ 166→ const diffMeta = BEACON_DIFFICULTY[challenge.difficulty]; 167→ 167→ const typeMeta = BEACON_TYPE_META[challenge.type]; 168→ 168→ const pct = Math.min(100, (progress.progress / challenge.goal) * 100); 169→ 169→ const isCompleted = progress.completedAt !== null; 170→ 170→ const isClaimed = progress.claimed; 171→ 171→ const canClaim = isCompleted && !isClaimed; 172→ 172→ 173→ 173→ // 周挑战派生量 174→ 174→ const wDiffMeta = BEACON_DIFFICULTY[weeklyChallenge.difficulty]; 175→ 175→ const wTypeMeta = BEACON_TYPE_META[weeklyChallenge.type]; 176→ 176→ const wPct = Math.min(100, (weeklyProgress.progress / weeklyChallenge.goal) * 100); 177→ 177→ const wCompleted = weeklyProgress.completedAt !== null; 178→ 178→ const wClaimed = weeklyProgress.claimed; 179→ 179→ const wCanClaim = wCompleted && !wClaimed; 180→ 180→ 181→ 181→ // 信标链派生量 182→ 182→ const chainProgress = getChainProgress(chainState.currentStreak); 183→ 183→ const nextMilestone = getNextMilestone(chainState.currentStreak); 184→ 184→ const todayKey = getTodayKey(); 185→ 185→ const completedToday = chainState.lastCompletedDateKey === todayKey; 186→ 186→ 187→ 187→ return ( 188→ 188→
189→ 189→ 225→ 225→ 226→ 226→ {/* 头部:信标 + 倒计时 */} 227→ 227→
228→ 228→

229→ 229→ 230→ 230→ 深空信标 231→ 231→

232→ 232→
233→ 233→ 234→ 234→ 次日重置 235→ 235→ {dayCountdown} 236→ 236→
237→ 237→
238→ 238→ 239→ 239→ {/* 每日挑战卡片 */} 240→ 240→
248→ 248→ {/* 背景装饰:脉冲环 */} 249→ 249→ {!isCompleted && ( 250→ 250→ <> 251→ 251→
258→ 258→
265→ 265→ 266→ 266→ )} 267→ 267→ 268→ 268→
269→ 269→ {/* 难度 + 类型 标签 */} 270→ 270→
271→ 271→ 275→ 275→ {diffMeta.icon} 276→ 276→ {diffMeta.label} 277→ 277→ 278→ 278→ 279→ 279→ {typeMeta.icon} {typeMeta.label} 280→ 280→ 281→ 281→ {isCompleted && ( 282→ 282→ 283→ 283→ 已完成 284→ 284→ 285→ 285→ )} 286→ 286→
287→ 287→ 288→ 288→ {/* 挑战标题 */} 289→ 289→

290→ 290→ {challenge.title} 291→ 291→

292→ 292→

293→ 293→ {challenge.desc} 294→ 294→

295→ 295→ 296→ 296→ {/* 进度条 */} 297→ 297→
298→ 298→
299→ 299→ 进度 300→ 300→ 301→ 301→ {Math.min(progress.progress, challenge.goal)} / {challenge.goal} {typeMeta.unit} 302→ 302→ 303→ 303→
304→ 304→ 311→ 311→
312→ 312→ 313→ 313→ {/* 奖励 + 领取按钮 */} 314→ 314→
315→ 315→
316→ 316→ 奖励: 317→ 317→ {challenge.rewardInsight > 0 && ( 318→ 318→ +{formatNum(challenge.rewardInsight)}洞见 319→ 319→ )} 320→ 320→ +{challenge.rewardContact.toFixed(1)}接触 321→ 321→
322→ 322→ 336→ 336→
337→ 337→ 338→ 338→ {/* 完成时长 */} 339→ 339→ {isCompleted && progress.durationSec > 0 && ( 340→ 340→
341→ 341→ 完成用时 {Math.floor(progress.durationSec / 60)}分{progress.durationSec % 60}秒 342→ 342→
343→ 343→ )} 344→ 344→
345→ 345→
346→ 346→ 347→ 347→ {/* 周挑战 + 信标链(桌面端并排,移动端单列) */} 348→ 348→
349→ 349→ {/* ===== 周挑战卡片(fuchsia 主题) ===== */} 350→ 350→
358→ 358→ {/* 头部:标题 + weekKey + 倒计时 */} 359→ 359→
360→ 360→
361→ 361→ 362→ 362→ 363→ 363→ 周挑战 · WEEKLY 364→ 364→ 365→ 365→
366→ 366→
367→ 367→ 368→ 368→ {weeklyChallenge.weekKey} 369→ 369→ 370→ 370→ 371→ 371→ {weekCountdown} 372→ 372→
373→ 373→
374→ 374→ 375→ 375→ {/* 难度 + 类型 标签 */} 376→ 376→
377→ 377→ 381→ 381→ {wDiffMeta.icon} 382→ 382→ {wDiffMeta.label} 383→ 383→ 384→ 384→ 385→ 385→ {wTypeMeta.icon} {wTypeMeta.label} 386→ 386→ 387→ 387→ {wCompleted && ( 388→ 388→ 389→ 389→ 已完成 390→ 390→ 391→ 391→ )} 392→ 392→
393→ 393→ 394→ 394→ {/* 标题 */} 395→ 395→

399→ 399→ {weeklyChallenge.title} 400→ 400→

401→ 401→

402→ 402→ {weeklyChallenge.desc} 403→ 403→

404→ 404→ 405→ 405→ {/* 进度条 */} 406→ 406→
407→ 407→
408→ 408→ 进度 409→ 409→ 410→ 410→ {Math.min(weeklyProgress.progress, weeklyChallenge.goal)} / {weeklyChallenge.goal} {wTypeMeta.unit} 411→ 411→ 412→ 412→
413→ 413→ 420→ 420→
421→ 421→ 422→ 422→ {/* 奖励 + 领取按钮 */} 423→ 423→
424→ 424→
425→ 425→ 奖励: 426→ 426→ {weeklyChallenge.rewardInsight > 0 && ( 427→ 427→ 428→ 428→ +{formatNum(weeklyChallenge.rewardInsight)}洞见 429→ 429→ 430→ 430→ )} 431→ 431→ 432→ 432→ +{weeklyChallenge.rewardContact.toFixed(1)}接触 433→ 433→ 434→ 434→
435→ 435→ 457→ 457→
458→ 458→ 459→ 459→ {wCompleted && weeklyProgress.durationSec > 0 && ( 460→ 460→
461→ 461→ 完成用时 {Math.floor(weeklyProgress.durationSec / 60)}分{weeklyProgress.durationSec % 60}秒 462→ 462→
463→ 463→ )} 464→ 464→
465→ 465→ 466→ 466→ {/* ===== 信标链卡片(amber→rose 渐变) ===== */} 467→ 467→
474→ 474→ {/* 头部:标题 + 当前连续天数 */} 475→ 475→
476→ 476→
477→ 477→ 478→ 478→ 479→ 479→ 信标链 · CHAIN 480→ 480→ 481→ 481→
482→ 482→
483→ 483→ 484→ 484→ 连续 485→ 485→ 486→ 486→ {chainState.currentStreak} 487→ 487→ 488→ 488→ 489→ 489→
490→ 490→
491→ 491→ 492→ 492→ {/* 今日完成状态 */} 493→ 493→
494→ 494→ 495→ 495→ {completedToday 496→ 496→ ? "今日已贡献" 497→ 497→ : "今日尚未完成日挑战"} 498→ 498→ 499→ 499→ 506→ 506→ {completedToday ? "✓ 已记录" : "○ 待完成"} 507→ 507→ 508→ 508→
509→ 509→ 510→ 510→ {/* 4 个里程碑节点横向排列 */} 511→ 511→
512→ 512→ {/* 节点之间的连线(背景灰) */} 513→ 513→
514→ 514→ {/* 已达成部分高亮(基于 prev→next 插值) */} 515→ 515→ {(() => { 516→ 516→ // 节点圆心水平位置(百分比) 517→ 517→ const MILESTONE_POS: Record = { 518→ 518→ 0: 12.5, 519→ 519→ 3: 12.5, 520→ 520→ 7: 37.5, 521→ 521→ 14: 62.5, 522→ 522→ 30: 87.5, 523→ 523→ }; 524→ 524→ const prevPos = 525→ 525→ MILESTONE_POS[chainProgress.prev] ?? 12.5; 526→ 526→ const nextPos = 527→ 527→ chainProgress.next !== null 528→ 528→ ? MILESTONE_POS[chainProgress.next] ?? 87.5 529→ 529→ : 87.5; 530→ 530→ const pct = chainProgress.progressPct / 100; 531→ 531→ const activeEndPos = prevPos + (nextPos - prevPos) * pct; 532→ 532→ const widthPct = Math.max(0, activeEndPos - 12.5); 533→ 533→ if (widthPct <= 0) return null; 534→ 534→ return ( 535→ 535→
545→ 545→ ); 546→ 546→ })()} 547→ 547→ 548→ 548→ {BEACON_CHAIN_MILESTONES.map((m) => { 549→ 549→ const reward = BEACON_CHAIN_REWARDS.find((r) => r.milestone === m); 550→ 550→ const claimed = chainState.milestonesClaimed.includes(m); 551→ 551→ const reachable = 552→ 552→ chainState.currentStreak >= m && !claimed; 553→ 553→ const inProgress = chainState.currentStreak > 0 && nextMilestone === m; 554→ 554→ // 节点配色 555→ 555→ let nodeBg = "rgba(255,255,255,0.05)"; 556→ 556→ let nodeBorder = "rgba(255,255,255,0.15)"; 557→ 557→ let textColor = "rgba(255,255,255,0.4)"; 558→ 558→ if (claimed) { 559→ 559→ nodeBg = "rgba(52,211,153,0.25)"; 560→ 560→ nodeBorder = "#34d399"; 561→ 561→ textColor = "#34d399"; 562→ 562→ } else if (reachable) { 563→ 563→ nodeBg = "rgba(251,113,133,0.20)"; 564→ 564→ nodeBorder = "#fb7185"; 565→ 565→ textColor = "#fb7185"; 566→ 566→ } else if (inProgress) { 567→ 567→ nodeBg = "rgba(251,191,36,0.20)"; 568→ 568→ nodeBorder = "#fbbf24"; 569→ 569→ textColor = "#fbbf24"; 570→ 570→ } 571→ 571→ 572→ 572→ return ( 573→ 573→
577→ 577→
587→ 587→ {claimed ? ( 588→ 588→ 589→ 589→ ) : ( 590→ 590→ m 591→ 591→ )} 592→ 592→
593→ 593→ 597→ 597→ {reward?.label} 598→ 598→ 599→ 599→ {claimed ? ( 600→ 600→ 601→ 601→ 已领 602→ 602→ 603→ 603→ ) : reachable ? ( 604→ 604→ 616→ 616→ ) : ( 617→ 617→ 618→ 618→ +{reward?.rewardInsight}洞 619→ 619→ 620→ 620→ )} 621→ 621→
622→ 622→ ); 623→ 623→ })} 624→ 624→
625→ 625→ 626→ 626→ {/* 进度条 */} 627→ 627→
628→ 628→
629→ 629→ 630→ 630→ {chainProgress.next === null 631→ 631→ ? "已通关全部里程碑" 632→ 632→ : `下一目标:${chainProgress.next} 天`} 633→ 633→ 634→ 634→ 635→ 635→ {chainProgress.current} 636→ 636→ {chainProgress.next !== null && ` / ${chainProgress.next}`} 天 637→ 637→ 638→ 638→
639→ 639→ 646→ 646→
647→ 647→ 648→ 648→ {/* 底部统计 */} 649→ 649→
650→ 650→
651→ 651→ 最长 652→ 652→ 653→ 653→ {chainState.longestStreak}天 654→ 654→ 655→ 655→
656→ 656→
657→ 657→ 累计 658→ 658→ 659→ 659→ {chainState.totalCompletions}次 660→ 660→ 661→ 661→
662→ 662→
663→ 663→ 续命 664→ 664→ {chainState.graceUsed >= 1 ? ( 665→ 665→ 已用 666→ 666→ ) : ( 667→ 667→ 可用 668→ 668→ )} 669→ 669→
670→ 670→
671→ 671→
672→ 672→
673→ 673→ 674→ 674→ {/* 本地排行榜 */} 675→ 675→
676→ 676→
677→ 677→ 678→ 678→ 深空排行榜 679→ 679→ 本地 · Top {leaderboard.length || 0} 680→ 680→
681→ 681→ 682→ 682→ {leaderboard.length === 0 ? ( 683→ 683→
684→ 684→ 685→ 685→ 尚无记录。完成今日或本周信标即可登榜。 686→ 686→
687→ 687→ ) : ( 688→ 688→
689→ 689→ {leaderboard.map((entry, i) => { 690→ 690→ const rb = rankBadge(i); 691→ 691→ const eDiff = BEACON_DIFFICULTY[entry.difficulty]; 692→ 692→ const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType]; 693→ 693→ const isMine = entry.dateKey === getTodayKey() || entry.dateKey === getWeekKey(); 694→ 694→ return ( 695→ 695→
705→ 705→ {/* 排名 */} 706→ 706→ 707→ 707→ {rb ? ( 708→ 708→ 709→ 709→ ) : ( 710→ 710→ {i + 1} 711→ 711→ )} 712→ 712→ 713→ 713→ {/* 类型 + 难度 + 周挑战标记 */} 714→ 714→ 715→ 715→ {eDiff.icon} 716→ 716→ {eType.label} 717→ 717→ {entry.isWeekly && ( 718→ 718→ 719→ 719→ WEEK 720→ 720→ 721→ 721→ )} 722→ 722→ {entry.progress >= 1 && } 723→ 723→ 724→ 724→ {/* 用时 */} 725→ 725→ 726→ 726→ {Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s 727→ 727→ 728→ 728→ {/* 分数 */} 729→ 729→ 730→ 730→ {formatNum(entry.score)} 731→ 731→ 732→ 732→
733→ 733→ ); 734→ 734→ })} 735→ 735→
736→ 736→ )} 737→ 737→
738→ 738→ 739→ 739→ {/* 难度图例 */} 740→ 740→
741→ 741→ {DIFFICULTY_ORDER.map((d) => { 742→ 742→ const m = BEACON_DIFFICULTY[d]; 743→ 743→ return ( 744→ 744→ 745→ 745→ {m.icon} 746→ 746→ {m.label} ×{m.mult} 747→ 747→ 748→ 748→ ); 749→ 749→ })} 750→ 750→
751→ 751→
752→ 752→ ); 753→ 753→} 754→ 754→