diff --git a/STATUS.yaml b/STATUS.yaml index fe27a41..724abbb 100644 --- a/STATUS.yaml +++ b/STATUS.yaml @@ -3,8 +3,8 @@ name_zh: 離岸風場・運維傳說 category: edtech progress: 100 status: active -last_updated: "2026-06-29" -next_milestone: "循序漸進難度與長線動機已完成:運維層級 Tier 系統(依進度自動推進,故障/備品/經濟壓力分層)、入門引導+UI 漸進揭露、每日任務(遊戲內日曆)、每週主題挑戰(風暴/斷料週等,綁戰情室事件池)、情境包匯入(教師自訂題組)、真實度深化(jack-up 安裝船動員前置期+計畫性定期保養)、故障/備品分層擴充(25 故障/33 備品)、真實案例研究事件(經查核的真實風場事故,具名+出處/去識別兩類,Tier 偶發+母港案例檔)。併發壓力測試通過(npm run stress);雲端後端 v2.1 加併發鎖(LockService);PWA 可安裝/離線(手機網頁並存);教師面板 CSV 匯出;營運趨勢儀表板/賽後復盤;登入畫面教師入口;存檔版本化遷移;code-split(初始包 653KB→268KB);知識點掌握度(答題正確率+弱點補強建議);HSE 科別加厚(LOTO 能量隔離/高處墜落防護/SIMOPS 同時作業,含圖鑑+鑑別診斷+戰情室事件);直升機進場與電網限電真實運維權衡判斷任務(159 題);真實/擬真案例擴充至 24 則,並可在自由營運中心任務清單以『案例演練』自然出現(略過 Tier、答後揭示 O&M 教訓並收錄圖鑑);手機直向專屬精簡版面(母港+交易所+出海+維修+所有彈窗,自動偵測裝置;PWA 快取版本化+錯誤邊界+啟動安全網防更新後空白);學習成效分析(各科別能力雷達圖 + 錯題本/維修檢討反思);母港建設視覺成長(用獲利升級碼頭/倉儲/起重機/燈塔,母港畫面隨之長大,純視覺不影響計分);新手友善漸進式揭露(Tier 1 隱藏三日預報/技師疲勞細節,Tier 2 解鎖)+ 圖鑑生活化比喻 + 戲劇性非技術事件(環團/媒體/社區/稽查)。後續延伸候選:掌握度同步雲端供教師端個別鑽取、獨立測驗模式(Exam Mode)、母港視覺疊到實景背景" +last_updated: "2026-07-02" +next_milestone: "出海決策支援與學習迴路閉環已完成:出海前工期預估vs天氣窗(航線/登船/檢查/維修+保留餘裕,含磨耗/疲勞/擇日/缺料提醒)、審慎返港再規劃(不計安全事件)+半途成果保留(#carry)、⚡加班搶修風險抉擇(#rush,快vs穩三選一)、任務復盤星級+一句takeaway、診斷連對streak(封頂XP加成+🔥里程碑)、錯題本主動回想(檢索練習)、無障礙(減少動態:系統偏好+遊戲內開關;色覺友善雙重編碼)、戰情室SLA季末風險+母港預算續航提醒、Pages部署來源鎖定GitHub Actions(configure-pages)。先前:循序漸進難度與長線動機已完成:運維層級 Tier 系統(依進度自動推進,故障/備品/經濟壓力分層)、入門引導+UI 漸進揭露、每日任務(遊戲內日曆)、每週主題挑戰(風暴/斷料週等,綁戰情室事件池)、情境包匯入(教師自訂題組)、真實度深化(jack-up 安裝船動員前置期+計畫性定期保養)、故障/備品分層擴充(25 故障/33 備品)、真實案例研究事件(經查核的真實風場事故,具名+出處/去識別兩類,Tier 偶發+母港案例檔)。併發壓力測試通過(npm run stress);雲端後端 v2.1 加併發鎖(LockService);PWA 可安裝/離線(手機網頁並存);教師面板 CSV 匯出;營運趨勢儀表板/賽後復盤;登入畫面教師入口;存檔版本化遷移;code-split(初始包 653KB→268KB);知識點掌握度(答題正確率+弱點補強建議);HSE 科別加厚(LOTO 能量隔離/高處墜落防護/SIMOPS 同時作業,含圖鑑+鑑別診斷+戰情室事件);直升機進場與電網限電真實運維權衡判斷任務(159 題);真實/擬真案例擴充至 24 則,並可在自由營運中心任務清單以『案例演練』自然出現(略過 Tier、答後揭示 O&M 教訓並收錄圖鑑);手機直向專屬精簡版面(母港+交易所+出海+維修+所有彈窗,自動偵測裝置;PWA 快取版本化+錯誤邊界+啟動安全網防更新後空白);學習成效分析(各科別能力雷達圖 + 錯題本/維修檢討反思);母港建設視覺成長(用獲利升級碼頭/倉儲/起重機/燈塔,母港畫面隨之長大,純視覺不影響計分);新手友善漸進式揭露(Tier 1 隱藏三日預報/技師疲勞細節,Tier 2 解鎖)+ 圖鑑生活化比喻 + 戲劇性非技術事件(環團/媒體/社區/稽查)。後續延伸候選:掌握度同步雲端供教師端個別鑽取、獨立測驗模式(Exam Mode)、母港視覺疊到實景背景" technologies: - React - TypeScript diff --git a/src/index.css b/src/index.css index de01ad5..7a08f27 100644 --- a/src/index.css +++ b/src/index.css @@ -74,7 +74,8 @@ body { } /* 無障礙:尊重「減少動態」偏好 → 停用非必要動畫/轉場(前庭敏感/暈動友善)。 - 自動播放的場景影片另在 SceneVideo 以 poster 靜態首幀取代。 */ + 自動播放的場景影片另在 SceneVideo 以 poster 靜態首幀取代。 + body.wfg-reduced = 遊戲內手動開啟(個人檔案 → 設定),與系統偏好等效。 */ @media (prefers-reduced-motion: reduce) { *, *::before, @@ -85,3 +86,11 @@ body { scroll-behavior: auto !important; } } +body.wfg-reduced *, +body.wfg-reduced *::before, +body.wfg-reduced *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; +} diff --git a/src/main.tsx b/src/main.tsx index f15933c..837c7d3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,8 +1,12 @@ import ReactDOM from "react-dom/client"; import App from "./App"; import ErrorBoundary from "./ui/ErrorBoundary"; +import { syncBodyClass } from "./ui/useReducedMotion"; import "./index.css"; +// 無障礙:開機同步「減少動態」手動覆寫(body class),讓 CSS 停用動畫規則生效 +syncBodyClass(); + // 不使用 StrictMode:其開發期重複掛載會與 Phaser 遊戲生命週期競態(重複建立/銷毀 game)。 ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/src/state/caseStudies.ts b/src/state/caseStudies.ts index 67c6c7c..99be9bb 100644 --- a/src/state/caseStudies.ts +++ b/src/state/caseStudies.ts @@ -107,7 +107,7 @@ export const CASE_STUDIES: CaseStudy[] = [ scenario: { zh: "某北美外海風場一支約 107m 大型葉片在『高振動』警報停機約六小時後斷裂解體,玻纖與泡棉碎片入海漂上岸。事後回溯安裝葉片的製程數據,找出更多膠合不足的同廠葉片,主管機關要求移除該廠所有葉片。據調查屬製造/NDT 檢驗逃逸,非設計缺陷。", en: "At a North American offshore site, a ~107m blade fractured ~6 hours after a 'high vibration' alarm tripped the unit, scattering fiberglass and foam into the sea and onto beaches. Re-mining factory process data found more under-bonded blades from the same plant; the regulator ordered removal of all that factory's blades. Per the investigation, a manufacturing/NDT quality escape, not a design flaw." }, lesson: { zh: "製造品質逃逸會潛伏到在役才以災難性失效現形;高振動警報確實正確跳機,驗證 CMS 是最後防線,但無法阻止碎片入海。正解是用『全機隊製程數據鑑識』(依工廠/序號回溯)界定系列缺陷,並在葉片出廠前加嚴並獨立稽核 NDT/膠合線驗收;事前備妥外海碎片圍堵與環境應變程序。", en: "Quality escapes lie dormant until catastrophic in-service failure; the high-vibration trip validated CMS as a last line of defense but can't prevent debris release. The fix: fleet-wide manufacturing-data forensics (by factory/serial) to scope a serial defect, tightened & independently audited NDT/bond-line acceptance before blades ship, and pre-planned offshore debris containment." }, choices: [ - { label: { zh: "依製程數據回溯全機隊同廠葉片並停機檢查", en: "Re-mine process data; ground & inspect all same-factory blades" }, good: true, feedback: { zh: "✓ 系列缺陷要用製程鑑識界定範圍,不是逐台等壞。", en: "✓ A serial defect is scoped by process forensics, not by waiting unit-by-unit." }, eff: { a: 4, b: -1500000, s: 1 } }, + { label: { zh: "依製程數據回溯全機隊同廠葉片並停機檢查", en: "Re-mine process data; ground & inspect all same-factory blades" }, good: true, feedback: { zh: "✓ 系列缺陷要用製程鑑識界定範圍,不是逐台等壞。", en: "✓ A serial defect is scoped by process forensics, not by waiting unit-by-unit." }, eff: { a: 4, b: -1500000 } }, // 修:原 s:1 誤把「正解」記一次安全事件(全內容唯一反例,資料誤植) { label: { zh: "視為單一葉片偶發,換掉就好", en: "Treat as a one-off blade and just replace it" }, good: false, feedback: { zh: "✗ 同廠序號可能共享膠合不足,漏掉會再斷。", en: "✗ Same-factory serials may share the defect — missing them means another break." }, eff: { a: -6, s: 2 } }, ], relatesTo: { incidentId: "blade", faultId: "blade_crack" }, diff --git a/src/state/dailyTasks.ts b/src/state/dailyTasks.ts index 73eade0..acbde73 100644 --- a/src/state/dailyTasks.ts +++ b/src/state/dailyTasks.ts @@ -14,6 +14,9 @@ export interface DailyDef { cash: number; // 達成獎勵 ◎ // 是否達成:以「自今日起始的增量(base)」或「當前狀態」判定 met: (d: GameData, base: DailyState["base"]) => boolean; + // 維持型任務(#daily-fix):條件在開局當下即可能為真(零事件/健康度/妥善率), + // 若即時發獎會變成「開局白拿」。deferred=true → 僅在「隔日 roll 前」結算(撐過一整天才算達成)。 + deferred?: boolean; } export const DAILY_GEN_TARGET = 100; // 今日淨發電增量目標 (MWh) @@ -24,9 +27,9 @@ export const DAILY_DEFS: DailyDef[] = [ { id: "resolve2", desc: { zh: "今日於戰情室修復 2 台機組", en: "Resolve 2 turbines in Fleet Ops today" }, xp: 30, cash: 200_000, met: (d, b) => (d.fleetResolved ?? 0) - b.resolved >= 2 }, { id: "mission1", desc: { zh: "今日完成 1 件工單/任務", en: "Complete 1 work order/task today" }, xp: 25, cash: 150_000, met: (d, b) => d.missionsDone - b.missions >= 1 }, { id: "gen", desc: { zh: `今日淨發電 ≥ ${DAILY_GEN_TARGET} MWh`, en: `Generate ≥ ${DAILY_GEN_TARGET} MWh today` }, xp: 25, cash: 150_000, met: (d, b) => d.generationMWh - b.gen >= DAILY_GEN_TARGET }, - { id: "noincident", desc: { zh: "今日零安全事件", en: "Zero safety incidents today" }, xp: 20, cash: 120_000, met: (d, b) => (d.safetyIncidents ?? 0) - b.safety <= 0 }, - { id: "health", desc: { zh: "維持機組健康度 ≥ 60%", en: "Keep fleet health ≥ 60%" }, xp: 20, cash: 120_000, met: (d) => d.fleetHealth >= 60 }, - { id: "uptime", desc: { zh: "維持機隊妥善率 ≥ 85%", en: "Keep fleet uptime ≥ 85%" }, xp: 25, cash: 150_000, met: (d) => fleetUptime(d.fleet) >= 85 }, + { id: "noincident", desc: { zh: "今日零安全事件", en: "Zero safety incidents today" }, xp: 20, cash: 120_000, deferred: true, met: (d, b) => (d.safetyIncidents ?? 0) - b.safety <= 0 }, + { id: "health", desc: { zh: "維持機組健康度 ≥ 60%", en: "Keep fleet health ≥ 60%" }, xp: 20, cash: 120_000, deferred: true, met: (d) => d.fleetHealth >= 60 }, + { id: "uptime", desc: { zh: "維持機隊妥善率 ≥ 85%", en: "Keep fleet uptime ≥ 85%" }, xp: 25, cash: 150_000, deferred: true, met: (d) => fleetUptime(d.fleet) >= 85 }, ]; export const dailyDef = (id: string): DailyDef | undefined => DAILY_DEFS.find((x) => x.id === id); @@ -61,11 +64,24 @@ export function rollDailyState(day: number, seed: string, d: GameData, prev: Dai }; } -// 目前「已達成但尚未發獎」的任務 id(DailyTracker 據此自動 claim) +// 目前「已達成但尚未發獎」的任務 id(DailyTracker 據此自動 claim);維持型(deferred)不在此列,於隔日結算 export function dueDailyClaims(d: GameData): string[] { const dl = d.daily; if (!dl) return []; - return dl.ids.filter((id) => !dl.claimed.includes(id) && (dailyDef(id)?.met(d, dl.base) ?? false)); + return dl.ids.filter((id) => { + const def = dailyDef(id); + return !!def && !def.deferred && !dl.claimed.includes(id) && def.met(d, dl.base); + }); +} + +// 日終結算(#daily-fix):隔日 roll「前」呼叫,回傳撐過一整天且達成的維持型任務 id +export function dueDeferredClaims(d: GameData): string[] { + const dl = d.daily; + if (!dl) return []; + return dl.ids.filter((id) => { + const def = dailyDef(id); + return !!def && !!def.deferred && !dl.claimed.includes(id) && def.met(d, dl.base); + }); } // 全部完成? diff --git a/src/state/game.ts b/src/state/game.ts index 0889af9..7d09b7b 100644 --- a/src/state/game.ts +++ b/src/state/game.ts @@ -373,6 +373,12 @@ export const workWindowMax = (s: { seaState: SeaState; vesselLevel: number; vess // 每個可點擊 SOP 步驟耗費的作業窗時段(工坊等級越高越省,下限 1)。RepairScreen.completeStep 與出海預估共用。 export const sopStepCost = (toolLevel: number): number => Math.max(1, 2 - toolLevel); +// 加班搶修(#rush):作業窗吃緊時的風險取捨——剩餘 SOP 一次趕完、總耗時減半(無條件進位、下限 1), +// 但有 RUSH_RISK 機率發生安全近失事件(計入 safetyIncidents、扣績效)。「快」與「穩」的抉擇。 +export const RUSH_RISK = 0.25; +export const rushCost = (remainingSteps: number, toolLevel: number): number => + Math.max(1, Math.ceil((remainingSteps * sopStepCost(toolLevel)) / 2)); + // 多風場每日基準發電總和(owned 座風場的 genPerDay 加總) function ownedFarmGen(farmsOwned: number): number { let g = 0; @@ -705,6 +711,7 @@ export type Action = | { type: "ARRIVE" } // 抵達 → onsite(#25) | { type: "FAIL_REPAIR" } // 天氣窗關閉、撤離(#17) | { type: "REPLAN_RETURN" } // 審慎返港再規劃:維修中途、作業窗吃緊時的安全決策(進 1 天、不計安全事件、工單保留) + | { type: "RUSH_SOP"; incident: boolean } // 加班搶修(#rush):剩餘 SOP 一次趕完、耗時減半;風險由 UI 擲骰後傳入(reducer 保持可測) | { type: "REST" } // 靠港休整:進日 + 重新擲海象(#18) | { type: "REMOTE_CHECK" } // 每日遠端 SCADA 巡檢(Phase A #2):消耗 1 天、累積 XP、早期偵測微幅回復健康度 | { type: "OPS_DISPATCH"; turbine: string; engineerId: string } // 戰情室派工:指派技師維修某故障機組(Phase C) @@ -837,16 +844,33 @@ export function reducer(s: GameData, a: Action): GameData { case "ARRIVE": return { ...s, jobPhase: "onsite" }; case "FAIL_REPAIR": { - // 撤離/返航改期 = 安全近失事件(#34)+ 空耗 1 天(Phase B) + // 被迫撤離(作業窗關閉才離場) = 安全近失事件(#34)+ 空耗 1 天 + 進度全失(Phase B) + // 註:審慎的「返航改期/回港再規劃」走 REPLAN_RETURN(不計事件、保留進度) —— 安全的選擇不受懲罰。 const adv = advance(s, 1); // 可用率不再用純量 −4:撤離本身不直接使機組故障;安全近失以 safetyIncidents 計入績效(#3)。 return { ...s, ...adv, jobPhase: "office", safetyIncidents: s.safetyIncidents + 1, repair: null }; } case "REPLAN_RETURN": { // 審慎返港再規劃:維修中途、作業窗吃緊時的安全決策。空耗 1 天,但「不計」安全事件(與 FAIL_REPAIR 撤離區隔); - // questStage 維持 active、清空維修進度 → 回母港可重新規劃船機/時機、擇日再出海(獎勵「看準天氣窗、及時止損」的判斷)。 + // questStage 維持 active;已完成的診斷/SOP 步驟「保留」(工作交接紀錄,#carry)——擇日再出海只需重新登塔、 + // 重擲作業窗續修。被迫撤離(FAIL_REPAIR)則進度全失+計安全事件 → 獎勵「及時止損」勝過「硬撐到窗關」。 const adv = advance(s, 1); - return { ...s, ...adv, jobPhase: "office", repair: null }; + const kept = s.repair ? { ...s.repair, boarded: false, win: 0 } : null; + return { ...s, ...adv, jobPhase: "office", repair: kept }; + } + case "RUSH_SOP": { + // 加班搶修(#rush):已登塔、有剩餘步驟、窗未關才可趕工;一次完成剩餘 SOP、耗 rushCost 時段; + // incident=true 時計 1 次安全近失事件(趕工的代價 —— 教「快」不等於「好」)。 + const rp = s.repair; + if (!rp || !rp.boarded || rp.win <= 0) return s; + const remaining = rp.steps.filter((v) => !v).length; + if (remaining === 0) return s; + const cost = rushCost(remaining, s.toolLevel); + return { + ...s, + repair: { ...rp, steps: rp.steps.map(() => true), win: Math.max(0, rp.win - cost) }, + safetyIncidents: s.safetyIncidents + (a.incident ? 1 : 0), + }; } case "REST": { const adv = advance(s, 1); @@ -1034,7 +1058,7 @@ export function reducer(s: GameData, a: Action): GameData { availability: fleetUptime(taskFleet), budget: Math.max(0, (adv.budget ?? s.budget) + a.dBudget), generationMWh: Math.max(0, (adv.generationMWh ?? s.generationMWh) + a.dGen), - safetyIncidents: s.safetyIncidents + a.dSafety, + safetyIncidents: Math.max(0, s.safetyIncidents + a.dSafety), // 夾 0:s:-1 的選項(降事件)不可把事件數打成負值 → 否則變成刷分漏洞(負事件反加績效) fleetHealth: clampN((adv.fleetHealth ?? s.fleetHealth) + a.dHealth, 0, 100), xp: s.xp + a.xp, missionsDone: s.missionsDone + 1, diff --git a/src/state/incidents.ts b/src/state/incidents.ts index 5da14fa..1d83e16 100644 --- a/src/state/incidents.ts +++ b/src/state/incidents.ts @@ -21,8 +21,8 @@ export interface IncidentType { // Tier 4:最大組件更換(葉片/主軸承磨耗) export const INCIDENTS: IncidentType[] = [ { id: "gearbox", name: { zh: "齒輪箱過熱", en: "Gearbox overheat" }, discipline: "mechanical", repairDays: 2, part: "gearbox_oil", weight: 5, minTier: 1 }, - { id: "sensor", name: { zh: "感測器誤報", en: "Sensor false alarm" }, discipline: "control", repairDays: 1, resettable: true, part: "gen_brush", weight: 5, minTier: 1 }, - { id: "pitch", name: { zh: "變槳故障", en: "Pitch fault" }, discipline: "control", repairDays: 2, part: "hydraulic_oil", weight: 4, minTier: 2 }, + { id: "sensor", name: { zh: "感測器誤報", en: "Sensor false alarm" }, discipline: "control", repairDays: 1, resettable: true, part: "anemometer", weight: 5, minTier: 1 }, // 修:原誤用發電機碳刷,感測器工單應耗感測器備品 + { id: "pitch", name: { zh: "變槳故障", en: "Pitch fault" }, discipline: "control", repairDays: 2, part: "pitch_battery", weight: 4, minTier: 2 }, // 修:控制側變槳故障耗後備電池;液壓根因由 pitch_hyd(hydraulic_valve)負責 { id: "yaw", name: { zh: "偏航失準", en: "Yaw misalignment" }, discipline: "control", repairDays: 1, resettable: true, part: "yaw_motor", weight: 4, minTier: 1 }, { id: "converter", name: { zh: "變流器跳脫", en: "Converter trip" }, discipline: "electrical", repairDays: 1, resettable: true, part: "converter", weight: 3, minTier: 2 }, { id: "bearing", name: { zh: "主軸承振動", en: "Main-bearing vibration" }, discipline: "mechanical", repairDays: 3, part: "pitch_bearing", weight: 2, minTier: 3 }, diff --git a/src/state/records.ts b/src/state/records.ts index 966e45d..61726e8 100644 --- a/src/state/records.ts +++ b/src/state/records.ts @@ -27,7 +27,7 @@ export const ACHIEVEMENTS: Achievement[] = [ { id: "fleet_master", icon: "🎯", name: { zh: "戰情室高手", en: "Ops Master" }, desc: { zh: "戰情室累積修復 20 台機組", en: "Resolve 20 turbines in Ops Center" }, test: (d) => (d.fleetResolved ?? 0) >= 20 }, { id: "multi_farm", icon: "🌊", name: { zh: "拓展版圖", en: "Fleet Expansion" }, desc: { zh: "同時營運 2 座以上風場", en: "Operate 2+ wind farms" }, test: (d) => d.farmsOwned >= 2 }, { id: "two_vessels", icon: "🚢", name: { zh: "多元船隊", en: "Diverse Fleet" }, desc: { zh: "擁有 2 種以上作業船", en: "Own 2+ vessel types" }, test: (d) => (d.ownedVessels?.length ?? 0) >= 2 }, - { id: "safety_clean", icon: "🦺", name: { zh: "零事故 30 天", en: "30 Days Incident-Free" }, desc: { zh: "營運滿 30 天且零安全事件", en: "30+ days operated with zero safety incidents" }, test: (d) => d.day - 21 >= 30 && (d.safetyIncidents ?? 0) === 0 }, + { id: "safety_clean", icon: "🦺", name: { zh: "零事故 30 天", en: "30 Days Incident-Free" }, desc: { zh: "營運滿 30 天且零安全事件", en: "30+ days operated with zero safety incidents" }, test: (d) => d.day - 21 >= 30 && (d.safetyIncidents ?? 0) <= 0 }, // <=0 防禦:舊存檔若殘留負值不致永久卡死成就 { id: "sla_keeper", icon: "📈", name: { zh: "達標守門員", en: "SLA Keeper" }, desc: { zh: "撐過一季且無 SLA 違約", en: "Clear a quarter with no SLA breach" }, test: (d) => d.quarter >= 2 && (d.slaPenalties ?? 0) === 0 }, { id: "score_500", icon: "⭐", name: { zh: "績效新星", en: "Rising Star" }, desc: { zh: "綜合績效分達 500", en: "Reach a performance score of 500" }, test: (_d, s) => s >= 500 }, { id: "score_1500", icon: "🌟", name: { zh: "績效王者", en: "Performance Ace" }, desc: { zh: "綜合績效分達 1,500", en: "Reach a performance score of 1,500" }, test: (_d, s) => s >= 1500 }, diff --git a/src/state/tasks.ts b/src/state/tasks.ts index 2ca4671..46c04e9 100644 --- a/src/state/tasks.ts +++ b/src/state/tasks.ts @@ -665,17 +665,13 @@ export const TASKS: TaskTemplate[] = [ { id: "b_data_gap", cat: "B", xp: 60, title: { zh: "SCADA 資料缺漏", en: "SCADA data gaps" }, scenario: { zh: "某機組 SCADA 資料出現大量缺漏,趨勢無法判讀。", en: "A unit's SCADA data is full of gaps; trends can't be read." }, choices: [ { label: { zh: "修復資料採集與通訊鏈路", en: "Fix data acquisition & comms link" }, good: true, feedback: { zh: "✓ 沒有可信資料就沒有預知保養。", en: "✓ No trustworthy data, no predictive maintenance." }, eff: { a: 2, b: -80000 } }, { label: { zh: "用其他機組資料推估", en: "Estimate from other units" }, good: false, feedback: { zh: "△ 代用資料掩蓋本機真實狀態。", en: "△ Proxy data hides this unit's true state." }, eff: { a: -1 } } ] }, - { id: "b_harmonics", cat: "B", xp: 70, chart: "spectrum", title: { zh: "諧波失真上升", en: "Harmonic distortion rising" }, scenario: { zh: "併網點電流總諧波失真(THD)持續升高。", en: "Current total-harmonic-distortion at the grid point keeps rising." }, choices: [ - { label: { zh: "檢查濾波器與變流器調變", en: "Check filters & converter modulation" }, good: true, feedback: { zh: "✓ THD 過高違反併網規範且傷設備。", en: "✓ Excess THD breaches grid code and stresses equipment." }, eff: { a: 2, b: -150000 } }, - { label: { zh: "在限值內就不理會", en: "Within limits — ignore" }, good: false, feedback: { zh: "△ 上升趨勢終將越限受罰。", en: "△ The rising trend will breach limits and incur penalties." }, eff: { b: -100000 } } ] }, + // (b_harmonics 已移除:與 b_harmonic 情境重複,避免同題雙倍抽中率) // ── C 預防保養 ── { id: "c_torque_audit", cat: "C", xp: 50, title: { zh: "螺栓抽驗稽核", en: "Bolt torque audit" }, scenario: { zh: "品保要求對連接螺栓做抽樣扭力稽核。", en: "QA requires a sample torque audit on connection bolts." }, choices: [ { label: { zh: "依抽樣計畫稽核並記錄", en: "Audit per sampling plan & record" }, good: true, feedback: { zh: "✓ 抽驗能及早發現系統性鬆動。", en: "✓ Sampling catches systematic loosening early." }, eff: { a: 1, b: -50000 } }, { label: { zh: "上次沒問題就免了", en: "Skip — last time was fine" }, good: false, feedback: { zh: "✗ 預緊力會隨運轉持續衰減。", en: "✗ Preload keeps relaxing with operation." }, eff: { s: 1 } } ] }, - { id: "c_dehumidify", cat: "C", xp: 40, title: { zh: "機艙除濕排水", en: "Nacelle dehumidification" }, scenario: { zh: "機艙濕度偏高、底部積水,電氣件受潮風險升高。", en: "High nacelle humidity with pooled water raises moisture risk to electronics." }, choices: [ - { label: { zh: "檢修除濕機與排水、密封", en: "Service dehumidifier, drains & seals" }, good: true, feedback: { zh: "✓ 受潮是離岸電氣故障主因之一。", en: "✓ Moisture is a top cause of offshore electrical faults." }, eff: { a: 2, b: -60000 } }, - { label: { zh: "開門通風就好", en: "Just air it out" }, good: false, feedback: { zh: "△ 海上高鹽濕,通風治標。", en: "△ Salty sea air makes airing a stopgap." }, eff: { a: -1 } } ] }, + // (c_dehumidify 已移除:與 c_dehumidifier 情境重複,避免同題雙倍抽中率) { id: "c_escape_kit", cat: "C", xp: 40, title: { zh: "逃生與緊急照明檢查", en: "Escape & emergency-light check" }, scenario: { zh: "塔內緊急照明與逃生裝備到期須檢查。", en: "Tower emergency lighting & escape kit are due for inspection." }, choices: [ { label: { zh: "逐項檢查並更換失效件", en: "Check each item & replace failures" }, good: true, feedback: { zh: "✓ 緊急時刻這些是保命裝備。", en: "✓ In an emergency these are life-saving kit." }, eff: { b: -40000 } }, { label: { zh: "外觀完好就跳過", en: "Looks ok — skip" }, good: false, feedback: { zh: "✗ 失效要到逃生時才發現就太遲。", en: "✗ Finding failures during an escape is too late." }, eff: { s: 1 } } ] }, @@ -741,9 +737,7 @@ export const TASKS: TaskTemplate[] = [ { id: "g_lightning_hit", cat: "G", xp: 70, title: { zh: "運轉中遭雷擊", en: "Lightning strike in operation" }, scenario: { zh: "一台運轉中機組剛遭雷擊,接閃系統告警、可能起火或損傷。", en: "An operating unit just took a lightning strike; the LPS alarms and fire/damage is possible." }, choices: [ { label: { zh: "遠端停機、確認無火後派員檢查", en: "Remote-stop, confirm no fire, then inspect" }, good: true, feedback: { zh: "✓ 先確保無火再進場,避免人員涉險。", en: "✓ Confirm no fire before entry to keep crews safe." }, eff: { a: 1, b: -100000 } }, { label: { zh: "立即派員登塔查看", en: "Send crew up immediately" }, good: false, feedback: { zh: "✗ 雷擊後恐有殘餘起火與帶電風險。", en: "✗ Post-strike there may be residual fire & live hazards." }, eff: { s: 2 } } ] }, - { id: "g_medical_evac", cat: "G", xp: 80, title: { zh: "海上醫療急救", en: "Offshore medical emergency" }, scenario: { zh: "一名技師在機艙突發疑似心臟不適。", en: "A technician suffers suspected cardiac distress in the nacelle." }, choices: [ - { label: { zh: "啟動急救與醫療後送程序", en: "Start first aid & medevac procedure" }, good: true, feedback: { zh: "✓ 黃金時間搶救,立即後送。", en: "✓ Act in the golden window — evacuate at once." }, eff: { a: -1 } }, - { label: { zh: "讓他休息等下班一起回", en: "Let him rest, return at shift end" }, good: false, feedback: { zh: "✗ 延誤醫療可能致命。", en: "✗ Delaying care can be fatal." }, eff: { s: 3 } } ] }, + // (g_medical_evac 已移除:與 g_medical 情境重複,避免同題雙倍抽中率) { id: "g_unauth_vessel", cat: "G", xp: 50, title: { zh: "不明船舶闖入", en: "Unauthorised vessel intrusion" }, scenario: { zh: "不明船舶進入風場安全區、逼近作業船。", en: "An unknown vessel enters the safety zone near work boats." }, choices: [ { label: { zh: "通報海巡並廣播警示", en: "Notify the coastguard & broadcast a warning" }, good: true, feedback: { zh: "✓ 安全區管制須即時通報。", en: "✓ Safety-zone control requires prompt reporting." }, eff: {} }, { label: { zh: "自行驅離", en: "Chase it off yourself" }, good: false, feedback: { zh: "✗ 自行處置恐釀碰撞與衝突。", en: "✗ Confronting it risks collision & escalation." }, eff: { s: 1 } } ] }, diff --git a/src/ui/DailyTracker.tsx b/src/ui/DailyTracker.tsx index ee3a14f..a272d2c 100644 --- a/src/ui/DailyTracker.tsx +++ b/src/ui/DailyTracker.tsx @@ -1,29 +1,47 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useGame } from "../state/GameContext"; import { accountSeed } from "./campaign"; -import { rollDailyState, dueDailyClaims, dailyDef } from "../state/dailyTasks"; +import { rollDailyState, dueDailyClaims, dueDeferredClaims, dailyDef } from "../state/dailyTasks"; import { toWan } from "../state/game"; import { toast } from "./toast"; import { Sfx } from "../audio/sfx"; -// 每日任務追蹤(#78):監看遊戲內日 →(1)日推進時產生當日任務、(2)達成時自動發獎並通知。 -// 與排行/紀錄追蹤同為 App 內常駐的 headless 元件。 +// 每日任務追蹤(#78):監看遊戲內日 →(1)日推進時「先結算昨日維持型任務、再產生當日任務」、 +// (2)增量型任務達成時自動發獎並通知。與排行/紀錄追蹤同為 App 內常駐的 headless 元件。 export default function DailyTracker() { const { data, dispatch } = useGame(); + // 已排程發獎的去重鍵(day:id):效果會因 data 變動重跑,避免同一任務重複排 setTimeout → 重複音效/通知(#daily-fix) + const scheduled = useRef>(new Set()); - // 日推進(或首次掛載)→ 產生當日每日任務(baseline = 當前累積值) + // 日推進(或首次掛載)→ 先結算「昨日維持型任務」(撐過一整天才發獎),再產生當日任務 useEffect(() => { if (!data.daily || data.daily.day !== data.day) { - dispatch({ type: "ROLL_DAILY", daily: rollDailyState(data.day, accountSeed(), data, data.daily) }); + const settled: string[] = data.daily ? dueDeferredClaims(data) : []; + for (const id of settled) { + const def = dailyDef(id); + if (!def) continue; + dispatch({ type: "CLAIM_DAILY", id, xp: def.xp, cash: def.cash }); + Sfx.success(); + toast({ + zh: `✅ 每日任務達成:${def.desc.zh}(+◎${toWan(def.cash)} 萬 ・ +${def.xp} XP)`, + en: `✅ Daily done: ${def.desc.en} (+◎${toWan(def.cash)}M ・ +${def.xp} XP)`, + }); + } + // 連勝判定要看「含剛結算」的完成數 → 用本地補丁後的 prev 再 roll + const prev = data.daily ? { ...data.daily, claimed: [...data.daily.claimed, ...settled] } : null; + dispatch({ type: "ROLL_DAILY", daily: rollDailyState(data.day, accountSeed(), data, prev) }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [data.day]); - // 狀態變更 → 自動發放已達成的每日任務獎勵(reducer 冪等,重複派發不重複計) + // 狀態變更 → 自動發放已達成的「增量型」每日任務獎勵(reducer 冪等,重複派發不重複計) useEffect(() => { const due = dueDailyClaims(data); if (!due.length) return; due.forEach((id, i) => { + const key = `${data.daily?.day}:${id}`; + if (scheduled.current.has(key)) return; // 已排程過(效果重跑) → 不重複音效/通知 + scheduled.current.add(key); const def = dailyDef(id); if (!def) return; window.setTimeout(() => { diff --git a/src/ui/HubAdvisor.tsx b/src/ui/HubAdvisor.tsx index 28ce288..47e8a97 100644 --- a/src/ui/HubAdvisor.tsx +++ b/src/ui/HubAdvisor.tsx @@ -42,6 +42,9 @@ function hubTips(d: GameData): Tip[] { // 過勞技師 if (d.engineers.some((e) => fatigueOf(e) >= 80)) tips.push({ expr: "worried", line: { zh: "有技師快過勞了,適時『靠港休整』讓他們回復,別硬派。", en: "An engineer's near burnout — rest in port to recover before dispatching again." } }); + // 半途成果保留(#carry):有保留進度的工單 → 提醒出海續修 + if (d.questStage === "active" && d.repair && !d.repair.boarded) tips.push({ expr: "happy", line: { zh: "上次返港前的維修進度已保留!挑個好天氣窗出海,登塔就能續修。", en: "Your repair progress was saved! Pick a good weather window, sail out, and resume where you left off." } }); + // 常駐教學小提示(提供穩定可輪播的內容) tips.push( { expr: "smile", line: { zh: "小提醒:售電收入=實際運轉的機組,管理好機隊就是賺錢。", en: "Tip: revenue = turbines actually running — managing the fleet is how you earn." } }, @@ -49,6 +52,8 @@ function hubTips(d: GameData): Tip[] { { expr: "smile", line: { zh: "答對診斷題、完成 SOP 才算修好;答錯會多耗作業窗喔。", en: "A correct diagnosis + full SOP completes a repair; wrong answers cost work-window time." } }, { expr: "happy", line: { zh: `目前綜合績效分要靠:高可用率、完成任務、少安全事件。預算 ◎${toWan(d.budget)} 萬。`, en: `Score comes from high uptime, missions done, and few safety incidents. Budget ◎${toWan(d.budget)}M.` } }, { expr: "smile", line: { zh: "缺料就先到『備品交易所』下單,注意有到貨前置期。", en: "Out of parts? Order at the Parts Market — mind the delivery lead time." } }, + { expr: "thinking", line: { zh: "作業窗吃緊時有三條路:繼續作業(穩)、加班搶修(快但有風險)、回港再規劃(保留進度)。", en: "When the window's tight: keep working (steady), rush (fast but risky), or return to port (progress kept)." } }, + { expr: "happy", line: { zh: "診斷第一次就答對會累積 🔥 連對,XP 加成越疊越高——出手前先想清楚!", en: "First-try correct diagnoses build a 🔥 streak with growing XP bonus — think before you answer!" } }, ); return tips; } diff --git a/src/ui/OpsCenterModal.tsx b/src/ui/OpsCenterModal.tsx index 04e0efa..2550c34 100644 --- a/src/ui/OpsCenterModal.tsx +++ b/src/ui/OpsCenterModal.tsx @@ -8,6 +8,7 @@ import { toWan, DIAG_COST } from "../state/game"; import { Sfx } from "../audio/sfx"; import { CAT_LABEL, generateTask, type TaskChoice, type TaskInstance } from "../state/tasks"; import { randomCaseDrill, visibleSources, type CaseStudy, type CaseChoice } from "../state/caseStudies"; +import { toast } from "./toast"; import type { I18n } from "../game/systems/types"; const CAT_COLOR: Record = { A: "#dc6450", B: "#5fa8d9", C: "#7fce8e", D: "#e3ad42", E: "#b08adf", F: "#e89a5b", G: "#d98ac0" }; @@ -157,6 +158,11 @@ export default function OpsCenterModal({ open, onClose }: { open: boolean; onClo if (picked !== null) return; (c.good ? Sfx.success : Sfx.error)(); setPicked(ci); + // 連對里程碑(juice):與維修診斷一致的 🔥 回饋(RECORD_ANSWER 於下方派發,先以現值+1 判斷) + if (c.good) { + const ns = (data.answerStreak ?? 0) + 1; + if (ns === 3 || (ns >= 5 && ns % 5 === 0)) toast({ zh: `🔥 判斷連對 ${ns} 題!XP 加成中`, en: `🔥 ${ns}-answer streak! XP bonus active` }); + } if (draw.kind === "case") { const cs = draw.cs; const dHealth = c.good ? 2 : -3; @@ -192,7 +198,8 @@ export default function OpsCenterModal({ open, onClose }: { open: boolean; onClo if (c.eff.a) parts.push(`${t({ zh: "可用率", en: "Avail" })} ${c.eff.a > 0 ? "+" : ""}${c.eff.a}`); if (c.eff.b) parts.push(`◎ ${c.eff.b > 0 ? "+" : ""}${Math.round(c.eff.b / 10000)}萬`); if (c.eff.g) parts.push(`${c.eff.g > 0 ? "+" : ""}${c.eff.g} MWh`); - if (c.eff.s) parts.push(`${t({ zh: "安全", en: "Safety" })} +${c.eff.s}`); + // 小細節:dSafety 是「安全事件」次數(正=更糟,負=改善),帶正確符號並明確標示,避免「安全 +1」看起來像加分 + if (c.eff.s) parts.push(`${c.eff.s > 0 ? "⚠ " : ""}${t({ zh: "安全事件", en: "Incidents" })} ${c.eff.s > 0 ? "+" : ""}${c.eff.s}`); return parts.join(" · "); }; @@ -200,6 +207,11 @@ export default function OpsCenterModal({ open, onClose }: { open: boolean; onClo
{t({ zh: "風場日常各種狀況的判斷練習,結算計入排行榜績效分(不影響計分週任務)。本場次已處理:", en: "Judgment practice on day-to-day farm situations; results count toward leaderboard score (not the graded weekly tasks). Resolved this session: " })}{count} + {(data.answerStreak ?? 0) >= 2 && ( + + 🔥 {t({ zh: `連對 ${data.answerStreak}`, en: `Streak ${data.answerStreak}` })} + + )}
{/* 進階檢測解鎖(#scada):付費後 SCADA 圖顯示投影/門檻/讀數,判讀更清晰 */} diff --git a/src/ui/ProfileModal.tsx b/src/ui/ProfileModal.tsx index ebcf929..43d67d2 100644 --- a/src/ui/ProfileModal.tsx +++ b/src/ui/ProfileModal.tsx @@ -13,6 +13,7 @@ import { DISC } from "./disc"; import { CAT_LABEL } from "../state/tasks"; import { CLOUD_FIRST } from "../cloud/sheet"; import { cloudEnabled } from "../cloud/api"; +import { useReducedMotion, getReducedOverride, setReducedOverride } from "./useReducedMotion"; import type { I18n } from "../game/systems/types"; // 個人檔案頁(階段 3):身分、關鍵數據、最佳紀錄與成就牆。資料來源 = 即時遊戲狀態 + 學習紀錄。 @@ -20,6 +21,7 @@ export default function ProfileModal({ open, onClose }: { open: boolean; onClose useLang(); const { data } = useGame(); const profile = getProfile(); + const reduced = useReducedMotion(); // 設定:減少動態(跟隨系統 or 手動開啟) // 開啟時讀一次紀錄(含本回合已累積的最佳值) const rec = useMemo(() => loadRecord(profile), [open, profile]); if (!open) return null; @@ -68,6 +70,20 @@ export default function ProfileModal({ open, onClose }: { open: boolean; onClose
+ {/* 設定:減少動態(無障礙)——影片改靜態圖、停用動畫;跟隨系統偏好,也可在此手動開啟 */} +
+
+
🎬 {t({ zh: "減少動態(影片改靜態圖、停用動畫)", en: "Reduce motion (static scenes, no animations)" })}
+
{t({ zh: "暈動/前庭敏感/投影友善;預設跟隨系統偏好", en: "Motion-sensitivity friendly; follows OS preference by default" })}
+
+ +
+ {/* 數據格 */}
{stats.map((s, i) => ( diff --git a/src/ui/Toaster.tsx b/src/ui/Toaster.tsx index 4ae115c..a001e50 100644 --- a/src/ui/Toaster.tsx +++ b/src/ui/Toaster.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { C, FONT_SERIF } from "./tokens"; import { t } from "../game/systems/i18n"; import { useLang } from "./useLang"; @@ -7,18 +7,16 @@ import type { I18n } from "../game/systems/types"; export default function Toaster() { useLang(); - const [msg, setMsg] = useState(null); - const timer = useRef(undefined); + // 小佇列:多則通知(完工獎勵/每日任務/連對里程碑)依序各顯示 1.8 秒,不再互相蓋掉;上限 5 則防洪 + const [queue, setQueue] = useState([]); + const msg = queue[0] ?? null; - useEffect( - () => - onToast((m) => { - setMsg(m); - window.clearTimeout(timer.current); - timer.current = window.setTimeout(() => setMsg(null), 1800); - }), - [] - ); + useEffect(() => onToast((m) => setQueue((q) => (q.length >= 5 ? q : [...q, m]))), []); + useEffect(() => { + if (!msg) return; + const id = window.setTimeout(() => setQueue((q) => q.slice(1)), 1800); + return () => window.clearTimeout(id); + }, [msg]); if (!msg) return null; return ( diff --git a/src/ui/TopBar.tsx b/src/ui/TopBar.tsx index 2e9dfc3..e11d9b3 100644 --- a/src/ui/TopBar.tsx +++ b/src/ui/TopBar.tsx @@ -1,4 +1,4 @@ -import { useState, type CSSProperties, type ReactNode } from "react"; +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { C, FONT_SERIF, FONT_CINZEL, chip } from "./tokens"; import { Sfx } from "../audio/sfx"; import { Bgm } from "../audio/bgm"; @@ -56,6 +56,16 @@ export default function TopBar({ const { data } = useGame(); const sea = SEA[data.seaState as SeaState]; const profile = getProfile(); + // 預算變動即時回饋(juice):金額增減時短暫變色(綠=進帳/紅=支出);純顏色變化,減少動態偏好下也安全 + const prevBudget = useRef(data.budget); + const [flash, setFlash] = useState(null); + useEffect(() => { + if (prevBudget.current === data.budget) return; + setFlash(data.budget > prevBudget.current ? "up" : "down"); + prevBudget.current = data.budget; + const id = window.setTimeout(() => setFlash(null), 900); + return () => window.clearTimeout(id); + }, [data.budget]); return (
- + {toWan(data.budget)} {t(S.hud.wan)}
diff --git a/src/ui/data.ts b/src/ui/data.ts index 08925d1..8482b1e 100644 --- a/src/ui/data.ts +++ b/src/ui/data.ts @@ -24,7 +24,7 @@ export const PARTS: Part[] = [ { id: "main_controller", n: { zh: "主控制器 PLC", en: "Main Controller (PLC)" }, stars: 3, idx: 99, qty: 28, price: "900,000", minTier: 2 }, { id: "cable_joint", n: { zh: "海纜接頭", en: "Subsea Cable Joint" }, stars: 2, idx: 105, qty: 243, price: "900,000", minTier: 3 }, { id: "transformer_bushing", n: { zh: "變壓器套管", en: "Transformer Bushing" }, stars: 2, idx: 97, qty: 26, price: "350,000", minTier: 3 }, - { id: "yaw_motor", n: { zh: "偏航電機", en: "Yaw Motor" }, stars: 2, idx: 70, qty: 0, price: "700,000", minTier: 1 }, + { id: "yaw_motor", n: { zh: "偏航電機", en: "Yaw Motor" }, stars: 2, idx: 70, qty: 6, price: "700,000", minTier: 1 }, // 修:原 qty 0 讓 Tier1 必需品看似缺貨(qty 僅展示) { id: "yaw_gear", n: { zh: "偏航齒輪組", en: "Yaw Gear Set" }, stars: 2, idx: 92, qty: 18, price: "600,000", minTier: 2 }, { id: "pitch_controller", n: { zh: "變槳控制器", en: "Pitch Controller" }, stars: 2, idx: 101, qty: 30, price: "500,000", minTier: 2 }, { id: "slip_ring", n: { zh: "集電環", en: "Slip Ring" }, stars: 2, idx: 88, qty: 22, price: "400,000", minTier: 2 }, @@ -42,7 +42,7 @@ export const PARTS: Part[] = [ { id: "bolt_m36", n: { zh: "螺栓組 M36", en: "Bolt Set M36" }, stars: 1, idx: 103, qty: 73, price: "50,000", minTier: 2 }, { id: "corrosion_anode", n: { zh: "犧牲陽極(防蝕)", en: "Sacrificial Anode" }, stars: 1, idx: 100, qty: 90, price: "55,000", minTier: 2 }, { id: "seal_kit", n: { zh: "油封組", en: "Seal Kit" }, stars: 1, idx: 99, qty: 160, price: "45,000", minTier: 1 }, - { id: "gen_brush", n: { zh: "發電機碳刷", en: "Generator Brush" }, stars: 1, idx: 81, qty: 0, price: "40,000", minTier: 1 }, + { id: "gen_brush", n: { zh: "發電機碳刷", en: "Generator Brush" }, stars: 1, idx: 81, qty: 24, price: "40,000", minTier: 1 }, // 修:原 qty 0 讓 Tier1 必需品看似缺貨(qty 僅展示) { id: "leading_edge_tape", n: { zh: "葉片前緣保護帶", en: "Leading-edge Tape" }, stars: 1, idx: 100, qty: 210, price: "35,000", minTier: 2 }, { id: "gearbox_filter", n: { zh: "齒輪箱濾芯", en: "Gearbox Filter" }, stars: 1, idx: 98, qty: 130, price: "30,000", minTier: 1 }, { id: "coolant", n: { zh: "冷卻液", en: "Coolant" }, stars: 1, idx: 101, qty: 180, price: "25,000", minTier: 1 }, diff --git a/src/ui/faults.ts b/src/ui/faults.ts index 4e3bfae..8cffc55 100644 --- a/src/ui/faults.ts +++ b/src/ui/faults.ts @@ -136,7 +136,7 @@ export const FAULTS: Record = { { zh: "順槳測試並回報 SCADA", en: "Feather test & report to SCADA" }, ], knowledge_point: "pitch_backup", - part: "hydraulic_oil", + part: "pitch_battery", // 修:quiz/SOP 都在講後備電池,原誤耗液壓油(液壓根因另有 pitch_hydraulic_leak) discipline: "control", }, converter_fault: { diff --git a/src/ui/screens/HubScreen.tsx b/src/ui/screens/HubScreen.tsx index 1f45a03..a1e425c 100644 --- a/src/ui/screens/HubScreen.tsx +++ b/src/ui/screens/HubScreen.tsx @@ -363,6 +363,19 @@ export default function HubScreen({ setScreen, accent, onDispatch, onFacility, s
{t(quest.title)}
{quest.unit} · {fault ? t(fault.name) : "—"} · {t(stage === "available" ? S.status.available : stage === "active" ? S.status.active : S.status.done)}
{stage === "active" &&
{t({ zh: "⚠ 停機中:每天約損失 3 萬", en: "⚠ Down: ~30k/day lost" })}
} + {/* 半途成果保留(#carry):返港後在工單上顯示已保留的進度,提示出海續修 */} + {stage === "active" && !data.overhaul && (() => { + const rk = `${data.customQuest ? "c" : data.campaignIndex}:${quest.id}`; + const rp = data.repair && data.repair.key === rk && !data.repair.boarded ? data.repair : null; + const diagOk = !!rp && !!fault && rp.pick === fault.quiz.correct; + const doneSteps = rp ? rp.steps.filter(Boolean).length : 0; + if (!rp || (!diagOk && doneSteps <= 2)) return null; + return ( +
+ ♻ {t({ zh: `上次進度已保留(診斷${diagOk ? "✓" : "未完成"} · SOP ${doneSteps}/${rp.steps.length})——出海續修即可`, en: `Progress kept (diag ${diagOk ? "done" : "pending"} · SOP ${doneSteps}/${rp.steps.length}) — sail to resume` })} +
+ ); + })()} {/* 多回合大修(#4):需連續可作業天氣窗,惡劣海象停滯 + 船舶待命費 */} {stage === "active" && data.overhaul && (oh => (oh.mobilizeLeft ?? 0) > 0 ? ( // 安裝船(jack-up)動員/航行中(#81):尚未到場,僅倒數,不收待命費 diff --git a/src/ui/screens/MarketScreen.tsx b/src/ui/screens/MarketScreen.tsx index cdb3115..2f86cda 100644 --- a/src/ui/screens/MarketScreen.tsx +++ b/src/ui/screens/MarketScreen.tsx @@ -28,8 +28,16 @@ export default function MarketScreen({ accent, mobile = false }: { accent: strin const [cart, setCart] = useState>({}); const [showAll, setShowAll] = useState(false); // #77 漸進揭露:預設只顯示已解鎖備品,可切換顯示全部 const tier = tierOf(data); - const buyParts = showAll ? PARTS : partsForTier(tier); // 依運維層級過濾(非阻擋:可切換全部) - const lockedCount = PARTS.length - partsForTier(tier).length; + // 判斷提醒/小細節:進行中工單的必備備品「永遠可見」——即使 minTier 高於目前層級,也不能讓主線卡在找不到料 + const activeQuest = data.customQuest ?? missionInstance(data.campaignIndex); + const activeNeed = data.questStage === "active" ? FAULTS[activeQuest.targetFault]?.part : undefined; + const tierParts = partsForTier(tier); + const buyParts = showAll + ? PARTS + : activeNeed && !tierParts.some((p) => p.id === activeNeed) + ? [...tierParts, ...PARTS.filter((p) => p.id === activeNeed)] + : tierParts; // 依運維層級過濾(非阻擋:可切換全部) + const lockedCount = PARTS.length - tierParts.length; const subtotal = PARTS.reduce((s, p) => s + priceNum(p) * (cart[p.id] ?? 0), 0); const total = Math.round(subtotal * (1 + TAX)); @@ -39,8 +47,7 @@ export default function MarketScreen({ accent, mobile = false }: { accent: strin const owned = PARTS.filter((p) => (data.inventory[p.id] ?? 0) > 0); // 判斷提醒:進行中工單的必備備品是否備齊;缺料時提示到貨前置期趕不趕得上出海。 - const quest = data.customQuest ?? missionInstance(data.campaignIndex); - const need = data.questStage === "active" ? FAULTS[quest.targetFault]?.part : undefined; + const need = activeNeed; const needPart = need ? PARTS.find((p) => p.id === need) : undefined; const needInStock = need ? (data.inventory[need] ?? 0) > 0 : false; const needInTransit = need ? data.pendingOrders.filter((o) => o.partId === need).reduce((a, o) => a + o.qty, 0) : 0; diff --git a/src/ui/screens/RepairScreen.tsx b/src/ui/screens/RepairScreen.tsx index 025987b..3507b2f 100644 --- a/src/ui/screens/RepairScreen.tsx +++ b/src/ui/screens/RepairScreen.tsx @@ -13,7 +13,8 @@ import { FAULTS, LOCATION_LABEL, locationOf, isMajorFault } from "../faults"; import RepairScene from "../RepairScene"; import { FallbackImg } from "../SceneVideo"; import { useReducedMotion } from "../useReducedMotion"; -import { workWindowMax, sopStepCost, type RepairState } from "../../state/game"; +import { workWindowMax, sopStepCost, toWan, rushCost, RUSH_RISK, type RepairState } from "../../state/game"; +import { toast } from "../toast"; import { PARTS } from "../data"; import { missionInstance } from "../campaign"; import type { Screen } from "../../App"; @@ -37,9 +38,10 @@ export default function RepairScreen({ setScreen, mode = "sim", mobile = false } const pick = rp?.pick ?? null; const steps = rp?.steps ?? fault.sop.map((_, i) => i < 2); // 前兩步預設完成;步驟 3~5 可點擊完成 const win = rp?.win ?? windowMax; - // 寫回進度(合併補丁);未登塔前以目前 windowMax 為基準 + // 寫回進度(合併補丁);未登塔前以目前 windowMax 為基準。 + // 注意:基底必須帶上 misses,否則登塔/完成步驟等未傳 misses 的補丁會把答錯次數歸零(復盤會低估)。 const saveRepair = (patch: Partial>) => - dispatch({ type: "SET_REPAIR", repair: { key: repairKey, boarded, pick, steps, win, ...patch } }); + dispatch({ type: "SET_REPAIR", repair: { key: repairKey, boarded, pick, steps, win, misses: rp?.misses ?? 0, ...patch } }); // #33 登船事件 + 作業地點 const location = locationOf(fault.id); @@ -77,11 +79,26 @@ export default function RepairScreen({ setScreen, mode = "sim", mobile = false } setScreen("hub"); }; - // Part B — 審慎返港再規劃(維修不利時的安全選擇):進 1 天、不計安全事件、工單保留可改天再來 + // 加班搶修(#rush) — 作業窗吃緊時的風險取捨:剩餘 SOP 一次趕完、耗時減半,但 25% 機率安全近失事件。 + const stepsRemaining = steps.filter((v) => !v).length; + const rushSlots = rushCost(stepsRemaining, data.toolLevel); + const rush = () => { + const incident = Math.random() < RUSH_RISK; // UI 擲骰、reducer 保持確定性(可測) + (incident ? Sfx.error : Sfx.success)(); + dispatch({ type: "RUSH_SOP", incident }); + say( + incident + ? { speaker: "safety_officer", expr: "alert", line: { zh: "趕工出狀況!有人差點失足——列入安全近失紀錄。快歸快,程序不能省!", en: "Rushing went wrong — a near-miss on the ladder. Logged as a safety incident. Fast isn't free!" } } + : { speaker: "repair_eng", expr: "confident", line: { zh: "加班趕上了!所有步驟完成——這次運氣站在我們這邊。", en: "Overtime paid off — all steps done. Luck was on our side this time." } } + ); + }; + + // Part B — 審慎返港再規劃(維修不利時的安全選擇):進 1 天、不計安全事件、工單保留可改天再來; + // 已完成的診斷/SOP 進度「保留」(#carry),擇日只需重新登塔續修。 const replan = () => { Sfx.click(); dispatch({ type: "REPLAN_RETURN" }); - say({ speaker: "veteran_sailor", line: { zh: "作業窗吃緊、別硬拚。先回港重新規劃船機與時機,擇日再來——工單還在。", en: "Window's tight — don't force it. Head back and re-plan the vessel and timing; the order stays open." } }); + say({ speaker: "veteran_sailor", line: { zh: "作業窗吃緊、別硬拚。已完成的步驟都記錄在案,回港重新規劃、擇日登塔續修!", en: "Window's tight — don't force it. Completed steps are logged; re-plan in port and resume another day." } }); setScreen("hub"); }; @@ -92,9 +109,11 @@ export default function RepairScreen({ setScreen, mode = "sim", mobile = false } saveRepair({ boarded: true, win: roughBoarding ? Math.max(3, windowMax - 3) : windowMax }); }; const abortBoarding = () => { - Sfx.error(); - dispatch({ type: "FAIL_REPAIR" }); // 返航改期:回辦公室、可用率小扣 - say({ speaker: "veteran_sailor", line: { zh: "浪太大、登船風險高,先返航改期吧。", en: "Seas too rough to board safely — return and reschedule." } }); + Sfx.click(); + // 操作邏輯一致性:浪高「不登船、返航改期」是審慎決策 → 與回港再規劃同語意(進 1 天、不計安全事件、進度保留); + // 硬要「頂浪登船」才承擔延誤/風險。安全的選擇不該被安全事件懲罰。 + dispatch({ type: "REPLAN_RETURN" }); + say({ speaker: "veteran_sailor", line: { zh: "浪太大、登船風險高——返航改期是對的判斷,擇日再來。", en: "Seas too rough to board — returning is the right call. Try another day." } }); setScreen("sail"); }; @@ -112,11 +131,13 @@ export default function RepairScreen({ setScreen, mode = "sim", mobile = false } } Sfx.success(); dispatch({ type: "FINISH_REPAIR", quest, part: need, discipline: fault.discipline }); + // 完工即時回饋(juice):跳出本次獎勵,強化成就感 + toast({ zh: `💰 +◎${toWan(quest.rewardBudget)} 萬 ⭐ +${quest.rewardXp} XP`, en: `💰 +◎${toWan(quest.rewardBudget)}M ⭐ +${quest.rewardXp} XP` }); const m = data.customQuest ? null : missionInstance(data.campaignIndex); // 任務復盤(#debrief):量化本次出海的決策品質 → 星級 + 一句 takeaway(把每次出海變成可檢討的學習點) const misses = rp?.misses ?? 0; - const used = windowMax - win; - const pctLeft = windowMax > 0 ? win / windowMax : 0; + const used = Math.max(0, windowMax - win); // 夾 0:跨日後 windowMax 重算可能小於登塔時基準 + const pctLeft = windowMax > 0 ? Math.min(1, win / windowMax) : 0; const stars = Math.max(1, (pctLeft >= 0.4 ? 3 : pctLeft >= 0.15 ? 2 : 1) - (misses >= 2 ? 1 : 0)); const starStr = "★".repeat(stars) + "☆".repeat(3 - stars); const tipZh = misses > 0 @@ -203,12 +224,18 @@ export default function RepairScreen({ setScreen, mode = "sim", mobile = false } : t({ zh: "海象平穩,可安全登船,前往作業地點。", en: "Calm seas — board safely and proceed to the work area." })}
{t({ zh: "作業地點", en: "Work area" })}:{t(LOCATION_LABEL[location])}
+ {/* 半途成果保留(#carry):上次審慎返港所留的診斷/SOP 進度,登塔後直接續修 */} + {rp && (quizCorrect || steps.filter(Boolean).length > 2) && ( +
+ ♻ {t({ zh: `上次進度已保留:診斷${quizCorrect ? "✓" : "未完成"} · SOP ${steps.filter(Boolean).length}/${steps.length} 步——登塔後續修即可。`, en: `Progress kept: diagnosis ${quizCorrect ? "done" : "pending"} · SOP ${steps.filter(Boolean).length}/${steps.length} — resume after boarding.` })} +
+ )} {roughBoarding && ( )}
@@ -337,6 +364,11 @@ export default function RepairScreen({ setScreen, mode = "sim", mobile = false } dispatch({ type: "RECORD_ANSWER", keys: [`disc:${fault.discipline}`], correct: i === q.correct }); // 錯題本(#mistake-log):第一次就答錯 → 記錄情境/你的選擇/正解/解析,供事後複習 if (i !== q.correct) dispatch({ type: "RECORD_MISTAKE", mk: { topic: `disc:${fault.discipline}`, question: q.question, chosen: q.options[i], correct: q.options[q.correct], lesson: q.ok, day: data.day } }); + // 連對里程碑(juice):3 連對與每 5 連對跳慶祝,強化「先思考再作答」的正循環 + if (i === q.correct) { + const ns = (data.answerStreak ?? 0) + 1; + if (ns === 3 || (ns >= 5 && ns % 5 === 0)) toast({ zh: `🔥 診斷連對 ${ns} 題!XP 加成中`, en: `🔥 ${ns}-answer streak! XP bonus active` }); + } } // 答錯多耗作業窗(可重新作答,但每次扣時段);累計答錯次數供任務復盤(#debrief) saveRepair({ pick: i, win: Math.max(0, win - (i === q.correct ? 1 : 3)), misses: (rp?.misses ?? 0) + (i === q.correct ? 0 : 1) }); @@ -394,12 +426,23 @@ export default function RepairScreen({ setScreen, mode = "sim", mobile = false } ) : ( <> - {/* Part B —「維修不利」提示:作業窗吃緊時,明確給出「繼續作業 / 回港再規劃」兩條路 */} + {/* Part B —「維修不利」提示:作業窗吃緊時,給三條路「繼續作業 / ⚡加班搶修(風險) / 回港再規劃」 */} {tightWindow && ( -
- - {t({ zh: `作業窗吃緊(剩 ${win} 時段,估計還需 ${remainingCost})——可繼續作業,或回港再規劃、擇日再來。`, en: `Window's tight (${win} slots left, ~${remainingCost} needed) — keep going, or return to port and re-plan.` })} -
+ <> +
+ + {t({ zh: `作業窗吃緊(剩 ${win} 時段,估計還需 ${remainingCost})——可繼續作業、加班搶修(有風險),或回港再規劃。`, en: `Window's tight (${win} slots left, ~${remainingCost} needed) — keep going, rush (risky), or return & re-plan.` })} +
+ {stepsRemaining > 0 && win >= rushSlots && ( + + )} + )}