Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions STATUS.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ body {
}

/* 無障礙:尊重「減少動態」偏好 → 停用非必要動畫/轉場(前庭敏感/暈動友善)。
自動播放的場景影片另在 SceneVideo 以 poster 靜態首幀取代。 */
自動播放的場景影片另在 SceneVideo 以 poster 靜態首幀取代。
body.wfg-reduced = 遊戲內手動開啟(個人檔案 → 設定),與系統偏好等效。 */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
Expand All @@ -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;
}
4 changes: 4 additions & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ErrorBoundary>
Expand Down
2 changes: 1 addition & 1 deletion src/state/caseStudies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
26 changes: 21 additions & 5 deletions src/state/dailyTasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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);
Expand Down Expand Up @@ -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);
});
}

// 全部完成?
Expand Down
32 changes: 28 additions & 4 deletions src/state/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/state/incidents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Loading
Loading