From a95b7271c6aee3aa9872fe9d0643f0300ebf83ed Mon Sep 17 00:00:00 2001 From: Yue Zhen Huang <70954297+paul931130@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:15:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20add=20Wafer=20Empire=20(=E6=99=B6?= =?UTF-8?q?=E5=9C=93=E5=B8=9D=E5=9C=8B)=20interactive=20fab=20tycoon=20+?= =?UTF-8?q?=20process=20puzzle=20game?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- README.md | 16 + empire/DESIGN.md | 519 ++++++ empire/README.md | 36 + empire/css/base.css | 99 ++ empire/css/puzzle.css | 169 ++ empire/css/tycoon.css | 110 ++ empire/data/balance.json | 56 + empire/data/content.json | 2177 ++++++++++++++++++++++++ empire/data/events.json | 39 + empire/index.html | 55 + empire/js/audio.js | 70 + empire/js/main.js | 278 ++++ empire/js/puzzle/engine.js | 406 +++++ empire/js/puzzle/index.js | 5 + empire/js/puzzle/levels.json | 2673 ++++++++++++++++++++++++++++++ empire/js/puzzle/levels.test.js | 218 +++ empire/js/puzzle/renderer.js | 280 ++++ empire/js/puzzle/ui.js | 506 ++++++ empire/js/shared/bus.js | 20 + empire/js/shared/content.js | 28 + empire/js/shared/rng.js | 12 + empire/js/shared/storage.js | 49 + empire/js/tutorial.js | 223 +++ empire/js/tycoon/boot.js | 29 + empire/js/tycoon/bridge.js | 56 + empire/js/tycoon/clock.js | 50 + empire/js/tycoon/economy.js | 128 ++ empire/js/tycoon/economy.test.js | 130 ++ empire/js/tycoon/events.js | 137 ++ empire/js/tycoon/factory-view.js | 270 +++ empire/js/tycoon/hud.js | 140 ++ empire/js/tycoon/sim.js | 123 ++ empire/js/tycoon/state.js | 206 +++ empire/js/tycoon/ui.js | 70 + empire/js/tycoon/unlock-view.js | 71 + empire/puzzle-dev.html | 76 + empire/tycoon-dev.html | 37 + 37 files changed, 9567 insertions(+) create mode 100644 empire/DESIGN.md create mode 100644 empire/README.md create mode 100644 empire/css/base.css create mode 100644 empire/css/puzzle.css create mode 100644 empire/css/tycoon.css create mode 100644 empire/data/balance.json create mode 100644 empire/data/content.json create mode 100644 empire/data/events.json create mode 100644 empire/index.html create mode 100644 empire/js/audio.js create mode 100644 empire/js/main.js create mode 100644 empire/js/puzzle/engine.js create mode 100644 empire/js/puzzle/index.js create mode 100644 empire/js/puzzle/levels.json create mode 100644 empire/js/puzzle/levels.test.js create mode 100644 empire/js/puzzle/renderer.js create mode 100644 empire/js/puzzle/ui.js create mode 100644 empire/js/shared/bus.js create mode 100644 empire/js/shared/content.js create mode 100644 empire/js/shared/rng.js create mode 100644 empire/js/shared/storage.js create mode 100644 empire/js/tutorial.js create mode 100644 empire/js/tycoon/boot.js create mode 100644 empire/js/tycoon/bridge.js create mode 100644 empire/js/tycoon/clock.js create mode 100644 empire/js/tycoon/economy.js create mode 100644 empire/js/tycoon/economy.test.js create mode 100644 empire/js/tycoon/events.js create mode 100644 empire/js/tycoon/factory-view.js create mode 100644 empire/js/tycoon/hud.js create mode 100644 empire/js/tycoon/sim.js create mode 100644 empire/js/tycoon/state.js create mode 100644 empire/js/tycoon/ui.js create mode 100644 empire/js/tycoon/unlock-view.js create mode 100644 empire/puzzle-dev.html create mode 100644 empire/tycoon-dev.html diff --git a/README.md b/README.md index 60998e7..fc56e89 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,22 @@ An open, beginner-friendly book that follows one silicon wafer from sand, through the fab, test and packaging, until it becomes a chip. It skips heavy physics and focuses on process flow, terminology and know-how from the fab floor, with term boxes and know-how boxes in every chapter. No background in semiconductor physics is needed. +## 🎮 晶圓帝國 Wafer Empire + +A free browser game that goes with the book: run a fab, solve process-flow puzzles (deposition, lithography, etching, implant, CMP, metallization) and unlock more advanced nodes, learning real fab facts along the way. Plays on phones and desktops, no install needed. + +- **Play:** +- **Run locally:** + + ```bash + python -m http.server 8000 --directory empire + # then open http://localhost:8000/ + ``` + + (Use a local server rather than double-clicking `index.html`, because the game loads JSON files with `fetch`.) + +More details are in [empire/README.md](empire/README.md). + --- ## 目錄 · Contents · 目次 · 목차 diff --git a/empire/DESIGN.md b/empire/DESIGN.md new file mode 100644 index 0000000..3d36495 --- /dev/null +++ b/empire/DESIGN.md @@ -0,0 +1,519 @@ +# 《晶圓帝國》Wafer Empire — 設計文件 v1 + +純 HTML/CSS/JS(ES modules,無打包、無 CDN),部署於 GitHub Pages `/empire`。本文件只談設計,不含程式碼;兩位工程師依 §9 的介面並行實作: +**工程師 P** = `empire/js/puzzle/*`(製程解謎),**工程師 T** = `empire/js/tycoon/*`(經營主迴圈)。 + +內容來源:`empire/data/content.json`(processes 7、equipment 24、nodes 31、glossary 245、facts 73)。 +本文中 `fact#N` 指 `facts[N]`(0 起算),載入器須在讀入時補上 `id = "fact#"+index`,關卡/事件只存 id。 + +--- + +## 1. 核心玩法 + +1. 玩家從 **Fab 1(180 nm)** 起家:買機台、排產線、賣晶圓賺錢。 +2. 想開新廠(更新節點,成本與毛利更高)必須先通過該廠的 **製程解謎組(3–5 關)**,再付錢、達到聲望門檻。 +3. 解謎星數永久轉成該廠的「初始良率加成」;金錢可買解謎提示。 +4. 每個隨機事件、每關結尾都會彈出一條來自 content.json 的真實知識(fact)。 + +教育目標:玩家玩完能說出「沉積→微影→蝕刻→佈植→CMP→金屬化」的順序與各步驟對剖面的作用。 + +--- + +## 2. 畫面流程 + +```mermaid +flowchart TD + A[Title 標題] -->|新遊戲| B[Tutorial 教學] + A -->|繼續| H + B --> H[Hub 總覽: 資源列 + 5 座廠卡片] + H -->|點已開通廠| F[Fab 產線畫面] + H -->|點未開通廠| U[Unlock 解鎖面板: 條件清單] + U -->|開始解謎| P[Puzzle Set: 關卡選單 → 關卡] + P -->|window.startPuzzleSet resolve| U + U -->|星數齊 + 錢 + 聲望| H + F -->|返回| H + F -->|事件彈窗| E[Event 事件: 選項 + 知識卡] + E --> F + H --> S[Settings: 存檔匯出/匯入/重置/音量] + H --> G[Glossary 圖鑑: 已解鎖 fact 與 glossary] +``` + +- 所有畫面是同一頁的 `
`,用 `data-screen` 切換,不使用多頁。URL hash:`#hub #fab/f2 #unlock/f3 #puzzle/f3-2`,供返回鍵與重新整理。 +- 解謎畫面為全螢幕覆蓋層,進入時暫停遊戲時鐘(見 §3.6)。 + +--- + +## 3. 經營層(工程師 T) + +### 3.1 資源 +| 資源 | 初始 | 說明 | +|---|---|---| +| 金錢 `money` | 5,000 | 買/升級機台、廠房解鎖、提示、事件處理 | +| 聲望 `rep` | 20(0–100) | 影響售價與解鎖門檻 | +| 晶圓庫存 `inv[fabId]` | 0 | 良品片數;每日結算時賣出,上限 600 片(超出報廢) | + +- 單位:1 Lot = 25 片(fact#2)。顯示一律用「片」,產線動畫用 Lot。 +- 售價 = 廠基準價 × (0.9 + 0.2 × rep/100) × 市場波動(每日 ±5% 隨機漫步,限 0.9–1.1)。 +- 每日需求上限 120 片/廠;賣不掉的留庫存。 + +### 3.2 五座廠(對應 content.json 的 nodes) +| id | 節點(nodes.name) | 晶圓 | 每片成本 | 基準價 | 標稱毛利(良率=1) | 解鎖金錢 | 解鎖聲望 | 日維運 | D0 | 產線槽數 | 該廠知識 | +|---|---|---|---|---|---|---|---|---|---|---|---| +| f1 | 180 nm | 200 mm | 30 | 60 | 50% | 0(起始) | 0 | 100 | 0.050 | 4 | fact#8 矽與氧化層 | +| f2 | 90 nm | 200 mm | 50 | 115 | 57% | 12,000 | 15 | 300 | 0.060 | 5 | fact#25 熱預算 | +| f3 | 28 nm(成熟製程) | 300 mm | 85 | 220 | 61% | 60,000 | 30 | 900 | 0.070 | 6 | fact#47 成熟製程仍是主力 | +| f4 | 7 nm(EUV 13.5 nm) | 300 mm | 190 | 540 | 65% | 300,000 | 50 | 3,000 | 0.085 | 7 | fact#17 EUV 要真空與反射鏡 | +| f5 | 3 nm(GAA) | 300 mm | 400 | 1,250 | 68% | 1,500,000 | 70 | 9,000 | 0.100 | 8 | fact#46 節點名只是代號 | + +- 盈虧平衡良率 = 成本/價 ≈ 0.50 / 0.43 / 0.39 / 0.35 / 0.32,故越新的廠對良率越寬容、但維運與機台更貴。 +- 所有數值放 `empire/data/balance.json`(T 維護),程式不得寫死,方便調數。 + +### 3.3 產線與機台 +- 每廠一條線,由 N 個**槽位**依序排列;每槽指定一個製程類別,須放入屬於該類別的機台(取自 content.json `equipment`,只用 `process_id` 非 TODO 者)。 +- 槽位模板: + - f1:清洗 → 薄膜 → 微影 → 蝕刻 + - f2:f1 + 離子佈植 + - f3:f2 + CMP + - f4:f3 + 金屬化 + - f5:f4 + 第二道微影(槽 8,須 `scanner` 才可放) +- 可選機台(`equipment.id`)與特性(`price` 為 L1 基價,實際 = price × 廠倍率 `[1,2,4,10,25]`): + +| 類別 process_id | 機台 id | price | speed | defMul | +|---|---|---|---|---| +| cleaning | wet_bench / single_wafer_cleaner | 600 / 900 | 1.2 / 1.0 | 1.15 / 0.90 | +| thin_film | furnace / cvd_chamber / pvd_sputter | 700 / 1000 / 900 | 1.3 / 1.0 / 1.0 | 1.20 / 0.95 / 1.00 | +| lithography | coater_developer(f1–f3)/ scanner(f4–f5 必用) | 1200 / 2500 | 1.0 / 1.0 | 1.00 / 0.85 | +| etching | etch_chamber | 1000 | 1.0 | 1.00 | +| ion_implantation | implanter / rta | 900 / 700 | 1.0 / 1.1 | 1.00 / 1.05 | +| cmp | cmp_tool | 1100 | 1.0 | 1.00 | +| metallization | electroplating_tool | 1300 | 1.0 | 1.00 | + +- 機台等級 L1–L5。升級費 = price × 廠倍率 × 1.8^L(L=目前等級)。出售退 50% 當前總投入。 +- 槽位處理時間 `p = 4 / (speed × (1 + 0.15×(L−1)))` 遊戲小時。線速度由**瓶頸槽**決定:每 `max(p)` 小時投入一個 Lot。 +- 空槽 = 產線不通(不投片);UI 顯示「缺機台」。 + +### 3.4 良率公式(精確) +對槽位 i(機台等級 `L_i`、機台係數 `defMul_i`): + +``` +d_i = D0[fab] × defMul_i × 0.80^(L_i − 1) × (1 − 0.5 × S) × (1 + Σ eventDef) +單槽良率 y_i = 1 − min(d_i, 0.5) +線良率 Y = clamp( Π y_i × E , 0.05 , 0.99 ) +``` +- `S` = 該廠解謎星數比 = 已得總星 / (3 × 關卡數),範圍 0–1(§8 連結)。全三星使缺陷率減半。 +- `eventDef`:事件對缺陷率的加算(見 3.5);`E`:事件對良率的乘數(無事件 = 1)。 +- 範例(f1,4 槽皆 L1、defMul=1、S=0、無事件):y=0.95,Y=0.95⁴=0.815。全 L5 且 S=1:d=0.05×0.4096×0.5=0.01024,Y≈0.96。 +- 每個 Lot 出貨時以種子 PRNG(mulberry32,種子存於存檔)對 25 片各抽一次 `rand < Y` 決定良品數,故同一良率仍有手感波動。 + +### 3.5 隨機事件 +每遊戲日每座已開通廠擲一次:8% 觸發(前 3 天保護期不觸發);事件類型等權重。同廠同時只能有一個事件。 + +| 事件 id | 效果 | 玩家選項 | 配對知識 | +|---|---|---|---| +| power_outage 停電 | 產線停 1 日;在製 Lot 全部報廢;rep −2 | 無(可預先買「UPS 升級」:fab 一次性 $ 廠倍率×2000,使報廢改為只損失 1 Lot) | fact#54 異常第一步是 Hold | +| particle 微粒污染 | 3 日內 `eventDef += 0.5`(缺陷率 ×1.5);rep −1 | 「停線清洗」$ 廠倍率×400,停 4 小時後立即解除 | fact#4 人是最大污染源 | +| rush_order 急件 | 3 日內第一個出貨 Lot 以 2.5× 價賣出;但期間產線 `p` ×1.25(急件擠壓其他批) | 接單/拒絕(拒絕無影響) | fact#7 Hot Lot 太多等於沒有急件 | +| tool_failure 機台故障 | 隨機一槽停擺,直到修復;rep −1 | 立刻修復 $ 該機台價×0.15;或等 3 日 | fact#6 保養後須先跑 Qual 才能放行產品 | + +- 事件彈窗:標題、效果、選項按鈕,下方「你知道嗎」知識卡(fact 原文,不改寫)。選項決定後才恢復時鐘。 +- 聲望每出貨一 Lot:`rep += (Y − 0.75) × 2`(單日加減上限 ±3)。 + +### 3.6 遊戲時鐘 +- 1 tick = 1 遊戲小時;1 日 = 24 tick。`setInterval`:1× = 250 ms/tick,2× = 125 ms/tick;暫停 = `clearInterval`。切速度時清掉重建,不累積誤差(以 tick 計而非以 Date 計)。 +- 解謎、事件彈窗、設定、`visibilitychange → hidden` 一律自動暫停;回到頁面不補算(無離線收益)。 +- 每 tick:推進 Lot 位置 → 槽位完成事件 → 投新 Lot。每 24 tick:售貨、維運扣款、事件擲骰、自動存檔。 +- 產線動畫:每個 Lot 是一個小圖示,沿槽位中心移動;位置由 `progress = elapsedTicks / p` 決定,用 CSS `transform` + `transition: transform ms linear`,暫停時 `animation-play-state` 同步停止。 + +### 3.7 解鎖與連結 +- 解鎖條件(三者全滿足才可按「開廠」):① 該廠 `puzzleSet` 全關 ≥1 星;② 金錢 ≥ 解鎖金;③ 聲望 ≥ 門檻。開廠扣款一次。 +- 星數 → 良率:見 §3.4 的 `S`;T 每次 `startPuzzleSet` resolve 後重算。 +- 金錢 → 提示:解謎端呼叫 `EmpireBridge.spendMoney`(§9.2)。 + +--- + +## 4. 解謎層(工程師 P) + +### 4.1 剖面格 +- 12 欄 × 8 列,`grid[r][c]`,r=0 為最上列、r=7 最下列;c=0 最左。 +- 材料碼(單字元,亦是 level JSON 用字): + +| 碼 | 材料 | 顏色建議 | 備註 | +|---|---|---|---| +| `.` | 空氣 | 透明 | | +| `S` | 矽基板 | 灰藍 | | +| `D` | 摻雜矽 | 藍紫 | 由 S 佈植而來,不可還原 | +| `O` | SiO₂ | 淺青 | 絕緣 | +| `P` | 多晶矽 | 橙 | 閘極 | +| `M` | 金屬(W/Cu) | 金 | | +| `R` | 光阻(未曝光) | 粉紅 | 犧牲材料 | +| `r` | 光阻(已曝光、可溶) | 淡粉+斜線 | 暫態,只存在於曝光後~顯影前 | + +- 每格同時用「顏色+圖樣(斜線/點/格)+單字母」顯示,色盲與黑白螢幕可辨。 +- **欄頂** `top(c)` = 該欄最上方非空格的列號;全空欄 `top = 8`。引擎維持不變式:每欄由底部連續堆疊、無懸空(見 strip 的 lift-off 規則)。 + +### 4.2 工具卡語意(精確的網格轉換) +所有卡每打出一張算 1 步。卡牌參數在打出前於彈出面板選定。**無效或不會改變任何格的步驟被拒絕**(顯示原因 toast、不計步),避免用空步湊數。上限 20 步。 + +| 卡 | 參數 | 轉換 | +|---|---|---| +| **沉積 deposit** | `mat∈{O,P,M}`、`t∈{1,2,3}` | 每一欄在 `top(c)` 之上疊 `t` 格 mat(保形:凹槽欄跟著長、凸起欄也長)。任一欄 `top(c)−t<0` → 拒絕(溢出)。 | +| **旋塗光阻 spin** | 無 | `m = min_c top(c) − 1`;每欄由列 `m` 到 `top(c)−1` 填 `R`(得到平坦頂面,比最高點高 1 格)。`m<0` 拒絕。 | +| **曝光 expose** | `mask`(關卡提供的光罩 id) | 光罩定義「透光欄」集合;透光欄內所有 `R` → `r`。若透光欄內無任何 `R` 拒絕。(正光阻:曝光處可溶。) | +| **顯影 develop** | 無 | 全部 `r` → `.`。無 `r` 拒絕。 | +| **蝕刻 etch** | `mat∈{S,D,O,P,M}`、`depth∈{1,2,3,4}` | 逐欄:若 `top` 格為 `R`/`r` → 該欄受保護、跳過;否則重複 `depth` 次:若 `top` 格材料 == mat 則移除,否則停止(選擇比 ∞:遇到不同材料即停)。 | +| **離子佈植 implant** | `depth∈{1,2}` | 逐欄:自 `top` 往下連續 `depth` 格,只要是 `S` 就改 `D`;遇到非 `S`(含 `R/r/O/P/M/D`)即停。故光阻、閘極、氧化層皆可擋住佈植(自對準)。 | +| **CMP** | `stop∈` 目前存在的材料 | `r* = min{ r : ∃c, grid[r][c]==stop }`(該材料的最高格列號);所有 `r < r*` 的非空格移除(整片磨平到 stop 材料最高點)。stop 材料不存在 → 拒絕。 | +| **去光阻 strip** | 無 | 每欄找到最低(最大列號)的 `R/r` 格,移除它與其**上方所有格**(lift-off),其下不動。無 `R/r` 拒絕。 | + +衍生規則: +- **移除事件 (removal)**:etch、CMP、strip(lift-off) 每移除一個**非光阻**格記 1 次(R/r 不計)。 +- 遊戲簡化聲明(教學文字須註明):不模擬孔洞、等向蝕刻、擴散;佈植為淺層規則化。 + +### 4.3 關卡流程與介面 +- 版面(375 px):上 = 目標剖面縮圖(可點放大並排比較);中 = 目前剖面(即時預覽);下 = 步驟列(chip 橫向捲動)+卡牌盤。桌機:左右並排。 +- 打出卡:點卡 → 若有參數則彈小面板(材料色塊/厚度/光罩縮圖選擇)→「預覽」格上以半透明顯示結果 → 確認才落地。預覽不計步。 +- **復原 undo**:彈出最後一步(無限次、不扣分);**重做 redo**;**重置**回初始。引擎為純函式重播,步驟陣列即狀態。 +- 即時差異提示:目前剖面與目標不同的格以紅色虛線框標示(可在設定關閉)。 +- 勝利條件:目前格 == 目標格(無 `R/r` 殘留)。達成後立即結算動畫與知識卡。 + +### 4.4 星數與「浪費」 +- ★1 完成(格完全吻合)。 +- ★2 完成且 `steps ≤ par`。 +- ★3 完成且 `steps ≤ par` 且 `waste == 0`(星數為累進)。 +- `waste = max(0, removals − minRemovals)`:`removals` = 玩家整段步驟中的總移除事件數(§4.2);`minRemovals` = 關卡預先算好的「最少必要移除數」(等於參考解的 removals,且經 §7.3 驗證為所有解中最小)。直觀意義:沉積多了又磨掉、蝕刻錯又補長,都算浪費材料。 +- 使用提示不降低星數,但記錄 `usedHint` 供統計(可選顯示徽章)。 +- 同一關重玩取歷史最佳星數。 + +### 4.5 提示(金錢購買) +價格 = `廠倍率 × 50 × 2^k`(k = 該關已買提示數,0 起): +1. 提示 1:顯示參考解「下一步」的卡牌種類。 +2. 提示 2:顯示該步全部參數(材料/厚度/光罩)。 +3. 若玩家目前步驟不是參考解的前綴,提示改為「請先復原到第 j 步」(j = 與參考解首個分歧處),仍收費。 +提示費用經 `EmpireBridge.spendMoney` 扣款;錢不足顯示「資金不足」。 + +--- + +## 5. 關卡總表(5 廠,共 21 關) + +各關 `par` 與 `minRemovals` 由 §7.3 的 solver 自動驗算,下表 par 為預期值。★ 為完整規格範例(§6)。 + +| 關 | 標題 | 教學重點 | par | 新機制 | 知識 | +|---|---|---|---|---|---| +| f1-1 | 長一層氧化 | 沉積 | 1 | deposit | fact#8 | +| f1-2 | 開窗 | 微影五步 | 5 | spin/expose/develop/etch/strip | fact#18 | +| f1-3 ★ | 氧化層圖形化 | 沉積+圖形化 | 6 | 光罩二選一 | fact#18 | +| f2-1 | N 井佈植 | 佈植 | 5 | implant、光阻擋佈植 | fact#24 | +| f2-2 | 淺溝隔離 STI | 蝕矽→填氧→磨平 | 8 | CMP stop=S | fact#25 | +| f2-3 | 閘極堆疊 | 多層沉積、選擇性蝕刻 | 8 | 先 etch P 再 etch O | fact#38 | +| f2-4 ★ | MOSFET | 自對準源汲極 | 9 | 閘極當佈植遮罩 | fact#23 | +| f3-1 | 兩次圖形化 | 兩道光罩 | 10 | 多光罩 | fact#36 | +| f3-2 | 接觸孔 | 蝕刻停止於不同材料 | 9 | 選擇比 | fact#21 | +| f3-3 | 鎢栓塞 | 沉積過量+CMP | 8 | CMP stop=O | fact#26 | +| f3-4 ★ | 銅大馬士革 | 先挖溝再填金屬 | 7 | 恰好填滿=零浪費 | fact#33/34 | +| f4-1 | 薄氧化+偽閘極 | 先做假的再換真的 | 12 | 暫時層 | fact#38 | +| f4-2 | 鰭 Fin | 蝕矽成鰭、填氧、磨平 | 10 | 窄圖形 | fact#40 | +| f4-3 | 兩層導線 | 兩次大馬士革 | 14 | 重複模組 | fact#34 | +| f4-4 | 通孔+導線 | 先通孔後溝槽 | 13 | 部分蝕刻深度 | fact#35 | +| f4-5 | 完整 MOSFET+金屬 | 前段+後段 | 16 | 綜合 | fact#36 | +| f5-1…5-5 | 先進整合 | 雙重圖形、全流程限步數、乾淨零浪費挑戰 | 12–18 | 綜合 | fact#46、#39、#43 | + +f5 以「限步數+零浪費」為主,不新增工具卡(GAA 的懸空通道超出本遊戲格模型,作為彩蛋說明文字,不做關卡)。 + +--- + +## 6. 範例關卡(完整規格,已用模擬器驗證) + +座標:欄 0–11、列 0–7。下列網格第一行 = 列 0。每關底部 3 列(5–7)為 `S`,除非另述。 + +### 6.1 f1-3 氧化層圖形化(par 6,minRemovals 16) +初始: +``` +............ r0 +............ +............ +............ +............ +SSSSSSSSSSSS r5 +SSSSSSSSSSSS +SSSSSSSSSSSS r7 +``` +目標(欄 4–7 留 2 格氧化層,其餘裸矽): +``` +............ +............ +............ +....OOOO.... r3 +....OOOO.... r4 +SSSSSSSSSSSS +SSSSSSSSSSSS +SSSSSSSSSSSS +``` +光罩:`A` 透光欄 0–3、8–11(正解);`B` 透光欄 4–7(陷阱)。 +參考解(6 步): +1. deposit O t=2(全面長 2 格,rows 3–4) +2. spin(`m = 3−1 = 2`,R 在列 2 全欄) +3. expose A(欄 0–3、8–11 的 R→r) +4. develop +5. etch O depth=2(8 欄 × 2 = 16 次移除;遇矽即停) +6. strip(去除欄 4–7 的 R) +結果吻合目標。removals = 16 = minRemovals → 零浪費。 + +### 6.2 f2-4 MOSFET(par 9,minRemovals 24) +初始同 6.1;目標: +``` +............ +............ +....PPPP.... r2 +....PPPP.... r3 多晶矽閘極 +....OOOO.... r4 閘極氧化層 +DDDDSSSSDDDD r5 源/汲極(D) 夾通道(S) +SSSSSSSSSSSS +SSSSSSSSSSSS +``` +光罩:`A` 透光欄 0–3、8–11(正解);`B` 透光欄 4–7(陷阱)。 +參考解(9 步): +1. deposit O t=1(列 4) +2. deposit P t=2(列 2–3) +3. spin(`m = 2−1 = 1`,R 在列 1 全欄) +4. expose A 5. develop +6. etch P depth=2(欄 0–3、8–11 的 P 清掉,停在 O) +7. etch O depth=1(露出矽,欄 4–7 因 R 保護不動) +8. strip(欄 4–7 的 R) +9. implant depth=1(欄 0–3、8–11 頂為 S → D;欄 4–7 頂為 P → 被擋=自對準) +removals = 16(P) + 8(O) = 24。常見陷阱:漏掉第 7 步(氧化層擋住佈植,欄 0–3 不會變 D);佈植放在 strip 之前也合法(光阻擋住閘極欄)— 步數相同。 + +### 6.3 f3-4 銅大馬士革(par 7,minRemovals 24) +初始(Si 兩列+氧化層兩列): +``` +............ r0–r3 空 +............ +............ +............ +OOOOOOOOOOOO r4 +OOOOOOOOOOOO r5 +SSSSSSSSSSSS r6 +SSSSSSSSSSSS r7 +``` +目標(欄 4–7 為金屬導線,埋在氧化層內、頂面平坦): +``` +............ ×4 +OOOOMMMMOOOO r4 +OOOOMMMMOOOO r5 +SSSSSSSSSSSS +SSSSSSSSSSSS +``` +光罩:`A` 透光欄 4–7(正解);`B` 透光欄 0–3;`C` 透光欄 0–3、8–11。 +參考解(7 步): +1. spin(`m = 4−1 = 3`,R 在列 3 全欄) +2. expose A 3. develop +4. etch O depth=2(欄 4–7 的氧化層清掉,停在矽;depth=3 結果相同,但表示不懂選擇比) +5. strip +6. deposit M **t=2**(溝槽欄長到列 4,剛好填滿;平地欄在列 2–3 上也長 2 格) +7. cmp stop=O(`r*=4`,磨掉列 <4:平地欄 8×2=16 次移除) +removals = 8(etch) + 16(cmp) = 24 → 零浪費。 +對照:deposit M t=3 亦可完成(★2),但 CMP 多磨 12 格 → removals=36,waste=12,只得 ★2。 +教學文字:真實製程要「過量電鍍」確保填滿(fact#35 底部先長),遊戲簡化為孔洞不會發生,所以最省料的是恰好填滿。 + +### 6.4 Level JSON 範本 +見 §7.1。 + +--- + +## 7. 資料結構 + +### 7.1 Level JSON(`empire/data/levels/.json` 為陣列) +``` +{ + "schema": 1, + "id": "f1-3", // fabId-序號,全域唯一 + "fab": "f1", + "order": 3, // 在該廠 puzzleSet 中的順序 + "title": "氧化層圖形化", + "intro": "…", // 一至兩句任務說明(繁中) + "teach": ["thin_film","lithography","etching"], // content.json processes.id + "factId": "fact#18", // 通關後顯示 + "w": 12, "h": 8, + "init": ["............", … ×8], // 每列 12 字元,材料碼見 §4.1,init 不得含 R/r + "target": ["............", … ×8], + "cards": [ // 本關可用的卡與參數範圍(沒列出的卡不出現在卡牌盤) + {"card":"deposit","mats":["O"],"t":[1,2,3]}, + {"card":"spin"}, {"card":"expose"}, {"card":"develop"}, + {"card":"etch","mats":["O"],"depth":[1,2,3]}, + {"card":"strip"} + ], + "masks": [ {"id":"A","open":[[0,3],[8,11]]}, {"id":"B","open":[[4,7]]} ], // 閉區間欄範圍 + "par": 6, + "minRemovals": 16, + "reference": [ // 參考解,同時是提示來源 + {"card":"deposit","mat":"O","t":2}, {"card":"spin"}, + {"card":"expose","mask":"A"}, {"card":"develop"}, + {"card":"etch","mat":"O","depth":2}, {"card":"strip"} + ], + "maxSteps": 20, + "tutorial": null // 或 tutorial 步驟 id(§10) +} +``` +步驟(玩家輸入與 reference 同格式):`{card, mat?, t?, depth?, mask?, stop?}`。 + +### 7.2 Save schema(localStorage key `waferEmpire.save`,單一 JSON) +``` +{ + "version": 1, // 遷移用;讀到舊版走 migrate(v→v+1),未知新版則拒讀並提示 + "savedAt": 1739000000000, + "clock": {"tick": 0, "speed": 1, "paused": true}, + "rng": {"seed": 123456789}, + "res": {"money": 5000, "rep": 20, "inv": {"f1":0,"f2":0,"f3":0,"f4":0,"f5":0}, "priceWalk": 1.0}, + "fabs": { + "f1": { + "unlocked": true, + "slots": [ {"equip":"furnace","level":1}, null, … ], // 長度 = 槽數,null = 空槽 + "ups": false, + "lots": [ {"slot":1,"progress":0.4,"good":null} ], // 在製 + "event": null // {"id":"particle","daysLeft":2,"extra":{…}} + } + }, + "puzzle": { + "stars": {"f1-1":3, "f1-2":2}, // 最佳星數,缺 = 未玩 + "hintsBought": {"f1-2":1} + }, + "seenFacts": ["fact#18","fact#4"], + "tutorial": {"done": ["intro","first-puzzle"]}, + "settings": {"sound":0.5,"diffHighlight":true,"reduceMotion":false} +} +``` +- 寫入:每遊戲日、每次解謎 resolve、每次購買後;`try/catch` 包住(隱私模式可能丟例外)。 +- 設定頁提供「匯出」(下載 .json)、「匯入」、「重置」(二次確認)。 + +### 7.3 開發驗證工具 +`empire/dev/verify.html`(不連入主選單):載入所有 level JSON,對每關重播 `reference`,斷言 ① 吻合 target ② 步數==par ③ removals==minRemovals ④ `init` 無 R/r ⑤ 所有 `mask` 欄位在 0–11。另提供有界 BFS(深度 ≤ par+1,參數離散)確認沒有更短解、沒有更少 removals 的解;失敗即阻擋合併。 + +--- + +## 8. 解謎 ↔ 經營連結 + +| 方向 | 內容 | +|---|---| +| 星數 → 良率 | `S = Σ stars(該廠各關) / (3 × 關數)`,代入 §3.4。每關最佳星數永久保留。 | +| 通關 → 解鎖 | 該廠 puzzleSet 全關 ≥1 星是開廠必要條件之一。重玩補星不需再付費。 | +| 金錢 → 提示 | §4.5。 | +| 知識 | 通關顯示 `factId`,同時加入 `seenFacts` 並在圖鑑可重看。 | +| 未開通廠的預覽 | 玩家可在聲望/金錢未達時先玩該廠解謎(鼓勵先學後買)。 | + +--- + +## 9. 程式模組與介面 + +### 9.1 檔案結構 +``` +empire/ + index.html # T 擁有:載入 css 與 js/main.js (type=module) + DESIGN.md + css/ + base.css tycoon.css puzzle.css # puzzle.css 全部選擇器前綴 .pz-,tycoon 用 .ty- + data/ + content.json # 既有,唯讀 + balance.json # T:§3 數值 + events.json # T:§3.5 + levels/f1.json … f5.json # P + js/ + main.js # T:啟動、載入資料、掛載 puzzle + shared/ # T 先交付、之後凍結;P 只能 import 這裡 + bus.js # 極簡 pub/sub + rng.js # mulberry32 + storage.js # load/save/migrate(§7.2) + content.js # 載入 content.json、補 fact id、查詢 + tycoon/ + state.js clock.js economy.js yield.js events.js + fab-view.js hub-view.js unlock-view.js tutorial.js bridge.js + puzzle/ + index.js # 註冊 window.startPuzzleSet + engine.js # 純函式:applyStep(grid,step,level)→{grid,removed,error};evaluate() + levels.js # 載入/驗證 level JSON + board-view.js cards-view.js stars.js hints.js + tutorial-hooks.js + dev/verify.html +``` +- 依賴方向:`puzzle/*` 不得 import `tycoon/*`;兩邊唯一耦合 = §9.2 的 window 介面。`engine.js` 無 DOM、無 window,可在 Node/verify.html 直接測。 + +### 9.2 介面合約 +**P 提供(在 `puzzle/index.js` 載入時註冊):** +``` +window.startPuzzleSet(fabId: string, opts?: { + levelId?: string, // 直接進指定關 + mount?: HTMLElement // 預設 #puzzle-root +}) : Promise + +PuzzleResult = { + fabId: string, + stars: { [levelId: string]: 0|1|2|3 }, // 本次 session 內有玩到的關的最佳星數;沒玩 = 缺 + cleared: boolean, // 該廠所有關是否皆 ≥1 星(含歷史星數) + hintsBought: { [levelId]: number } +} +``` +- Promise 永遠 resolve(玩家按「離開」也 resolve,cleared 依現況),不 reject;fabId 無資料則 reject `Error("no-levels")`。 +- 進入時 P 呼叫 `window.EmpireBridge.getStars(fabId)` 取得歷史星數以顯示與取最佳;P **不**自行寫存檔。 + +**T 提供(`tycoon/bridge.js`,main.js 載入後設定):** +``` +window.EmpireBridge = { + getMoney(): number, + spendMoney(amount: number, reason: string): boolean, // 不足回 false,不扣 + getStars(fabId): { [levelId]: 0|1|2|3 }, + getFactText(factId): string, // 取 content.json 原文 + markFactSeen(factId): void, + pauseClock(): void, resumeClock(): void, // P 進入/離開時各呼叫一次 + getSettings(): { diffHighlight: boolean, reduceMotion: boolean, sound: number } +} +``` +T 在 `startPuzzleSet` resolve 後:寫入星數、重算 `S`、刷新解鎖面板。若 `window.startPuzzleSet` 尚未存在(P 未完成),T 以 stub 回傳 `{cleared:true, stars:{}}` 供單獨開發。 + +### 9.3 分工與整合順序 +1. 第 0 天:T 交付 `shared/*` 與 `bridge.js` stub;P 交付 `engine.js` + level JSON 規格範例 3 關。 +2. 並行:P 做 UI/提示/f1–f5 關卡;T 做經濟、產線動畫、事件。 +3. 整合驗收:§12 清單。 + +--- + +## 10. 教學流程(強制,可跳過) + +| 步 | 觸發 | 內容 | +|---|---|---| +| intro | 新遊戲 | 3 句話說明目標;指向 Fab 1 | +| t-buy | Hub | 高亮 Fab 1 槽 1,要求買一台 `wet_bench`(教學中免費);依序補齊 4 槽 | +| t-run | 槽齊 | 高亮「開始」+速度鈕;看一個 Lot 走完,顯示良率算式(§3.4 簡化版) | +| t-puzzle | 第一次賣出後 | 彈出「想開 Fab 2 嗎?先過製程挑戰」→ 進 f1-1 | +| t-card | f1-1 | 高亮沉積卡 → 選 O、t=2 → 預覽 → 確認;講解 undo/目標 | +| t-litho | f1-2 | 逐步高亮 spin→expose→develop→etch→strip,每步一句說明(取自 processes.summary) | +| t-star | f1-1 通關 | 解釋三顆星與「浪費」,顯示它如何提高良率 | +| t-event | 首個事件(前三日保護期後強制第一個為 particle) | 引導「停線清洗」並展示知識卡 | + +- 教學狀態存 `save.tutorial.done`;設定頁「重看教學」。教學遮罩為一個半透明層+高亮框,只允許點高亮目標,並提供「跳過」。 +- 教學步驟的高亮由 `data-tut=""` 屬性鎖定元素;兩模組各自在自己的元素上標註(P:`data-tut="card-deposit"` 等;T:`data-tut="slot-1"` 等)。 + +--- + +## 11. 行動裝置(375 px)與觸控 + +- viewport:`width=device-width, initial-scale=1`;最小支援寬度 360。側邊留白 16 px,剖面格邊長 = `min(40, floor((viewport−32)/12))`(375 → 28 px,整格寬 336)。 +- 觸控目標 ≥ 44×44 px(卡牌、時鐘按鈕、確認/取消);剖面格本身只讀,不需精準點擊,所有操作走卡牌+面板。 +- 無 hover 依賴:資訊用「點一下展開」;提示、材料圖例固定在可展開的 `
`。 +- 產線:窄螢幕改為垂直蛇形排列(每槽一列 56 px);Lot 沿 y 軸移動。寬螢幕橫向。 +- 參數面板為 bottom sheet(自底部滑出,含 safe-area-inset-bottom),避免被拇指遮住剖面。 +- 步驟列水平捲動 `overflow-x:auto; scroll-snap-type:x proximity`;`touch-action: manipulation` 避免雙擊縮放;整頁 `overscroll-behavior: contain`。 +- 不使用拖放;若加入手勢,只作加速(長按卡牌=說明)。 +- `prefers-reduced-motion` 與設定 `reduceMotion`:停用 Lot 補間與勝利動畫,改為跳格。 +- 無障礙:每個按鈕 `aria-label`;時鐘狀態 `aria-live="polite"`;事件彈窗為 `role="dialog"`,焦點陷阱與 Esc 關閉。 +- 效能:剖面用 12×8 個 DOM 格(96 個),更新只切換 class;不需 canvas。 + +--- + +## 12. 驗收清單 + +- [ ] `verify.html` 全關通過(§7.3)。 +- [ ] 6.1–6.3 三個範例以參考解重播得到預期星數與 removals。 +- [ ] 良率公式單元測試:表 §3.4 範例(0.815、≈0.96)。 +- [ ] 暫停後 tick 不前進;2× 速度下一日耗時約 3 秒;切回分頁不補算。 +- [ ] 存檔可往返;`version` 為 2 的檔案被拒讀且有提示;清空 localStorage 仍可玩。 +- [ ] `startPuzzleSet` 離開/通關皆 resolve;星數寫入後該廠 `S` 立即更新。 +- [ ] 375 px 寬度無水平捲動,所有按鈕可單手點擊。 +- [ ] 全部 fact 顯示文字與 content.json 逐字相同。 diff --git a/empire/README.md b/empire/README.md new file mode 100644 index 0000000..5daa112 --- /dev/null +++ b/empire/README.md @@ -0,0 +1,36 @@ +# 晶圓帝國 Wafer Empire + +經營晶圓廠 + 製程解謎的網頁小遊戲。從 180 nm 的 Fab 1 起家:買機台、排產線、賣晶圓;通過製程解謎(沉積、微影、蝕刻、佈植、CMP、金屬化)來開通更先進的廠,解謎星數會永久降低缺陷率。每個事件與關卡都附一則取自《半導體製造概論》的知識。純 HTML / CSS / JS(ES modules),無打包、無外部資源。 + +- 線上玩: +- 本機執行(需用伺服器,因為會 `fetch` JSON): + + ```bash + python -m http.server 8000 --directory empire + # 開啟 http://localhost:8000/ + ``` + +設計文件見 [DESIGN.md](DESIGN.md)。 + +## 檔案 + +| 路徑 | 說明 | +|---|---| +| `index.html` | 入口:標題、遊戲外殼、`#puzzle-root` | +| `js/main.js` | 啟動、hash 路由(`#hub #fab/f2 #unlock/f3 #puzzle/f3`)、總覽、圖鑑、接線 | +| `js/tutorial.js` | 教學(`data-tut` 高亮、可跳過、進度存在存檔) | +| `js/audio.js` | WebAudio 合成音效與靜音(localStorage `waferEmpire.mute`) | +| `js/shared/` | `bus`、`rng`、`storage`(存檔)、`content`(載入 content.json) | +| `js/tycoon/` | 經營層:狀態、時鐘、模擬、事件、HUD、產線畫面、解鎖面板、`bridge.js`(`window.EmpireBridge`) | +| `js/puzzle/` | 解謎層:`engine.js`(純函式)、`renderer.js`、`ui.js`、`levels.json`(21 關);註冊 `window.startPuzzleSet` | +| `css/base.css` | 共用設計代幣 `--we-*`(深色科技底 + 無塵室黃光)與外殼樣式 `.we-` | +| `css/tycoon.css`、`css/puzzle.css` | 各層樣式(前綴 `.ty-` / `.pz-`),顏色讀取 `--we-*` | +| `data/` | `content.json`(知識)、`balance.json`、`events.json` | +| `tycoon-dev.html`、`puzzle-dev.html` | 各層獨立開發頁 | + +## 測試 + +```bash +node js/tycoon/economy.test.js +node js/puzzle/levels.test.js +``` diff --git a/empire/css/base.css b/empire/css/base.css new file mode 100644 index 0000000..9c9fa02 --- /dev/null +++ b/empire/css/base.css @@ -0,0 +1,99 @@ +/* 晶圓帝國 — 共用設計代幣(--we-*)與外殼樣式(.we-)。 + tycoon.css(.ty-)與 puzzle.css(.pz-)都讀取這些變數,因此兩層外觀一致。 + 主題:深色科技底 + 無塵室黃光(amber)強調色。純 CSS / SVG,無外部資源。 */ +:root { + color-scheme: dark; + --we-bg: #0c111c; + --we-panel: #151d2f; + --we-panel2: #1d2740; + --we-line: #2a3652; + --we-fg: #e9eefb; + --we-mut: #9aa8c6; + --we-accent: #ffc933; /* 無塵室黃光 */ + --we-accent-ink: #1a1300; + --we-info: #4aa8ff; + --we-good: #45d49a; + --we-bad: #ff6b6b; + --we-warn: #ffb347; + --we-star: #ffc933; + --we-radius: 12px; + --we-hint-bg: #3a3110; --we-hint-fg: #ffe9a0; --we-hint-line: #6b5a1c; + --we-font: system-ui, -apple-system, "Noto Sans TC", "PingFang TC", "Microsoft JhengHei", sans-serif; +} + +html { -webkit-text-size-adjust: 100%; } +body.we-body { + margin: 0; min-height: 100vh; overflow-x: hidden; + background: + radial-gradient(900px 380px at 50% -120px, rgba(255, 201, 51, .12), transparent 70%), + linear-gradient(180deg, #0c111c, #090d16 60%); + background-attachment: fixed; + color: var(--we-fg); font-family: var(--we-font); +} +[hidden] { display: none !important; } +.we-btn, .ty-btn, .pz-btn { -webkit-tap-highlight-color: transparent; } +.ty-btn--primary, .pz-btn-primary { box-shadow: 0 0 0 1px rgba(255, 201, 51, .25), 0 2px 12px rgba(255, 201, 51, .18); } + +/* ---------- 標題畫面 ---------- */ +.we-title { min-height: 100vh; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 24px 16px calc(24px + env(safe-area-inset-bottom)); position: relative; overflow: hidden; } +.we-title::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 6px; background: repeating-linear-gradient(90deg, var(--we-accent) 0 28px, transparent 28px 40px); opacity: .8; } +.we-wafer { width: min(58vw, 220px); height: auto; filter: drop-shadow(0 0 22px rgba(255, 201, 51, .25)); } +.we-wafer .we-sweep { transform-origin: 100px 100px; animation: we-spin 9s linear infinite; } +@keyframes we-spin { to { transform: rotate(360deg); } } +.we-h1 { margin: 14px 0 0; font-size: clamp(30px, 9vw, 44px); letter-spacing: .08em; } +.we-h1 em { font-style: normal; color: var(--we-accent); } +.we-sub { margin: 4px 0 18px; color: var(--we-mut); font-size: 14px; letter-spacing: .12em; } +.we-lead { max-width: 30em; color: var(--we-mut); margin: 0 0 18px; font-size: 14px; } +.we-menu { display: grid; gap: 10px; width: min(320px, 100%); } +.we-btn { min-height: 48px; padding: 10px 16px; border: 1px solid var(--we-line); border-radius: 12px; background: var(--we-panel2); color: var(--we-fg); font: inherit; font-weight: 600; cursor: pointer; touch-action: manipulation; } +.we-btn:disabled { opacity: .5; cursor: not-allowed; } +.we-btn:focus-visible, .we-card button:focus-visible { outline: 2px solid var(--we-accent); outline-offset: 2px; } +.we-btn--primary { background: var(--we-accent); border-color: var(--we-accent); color: var(--we-accent-ink); box-shadow: 0 0 18px rgba(255, 201, 51, .3); } +.we-btn--danger { border-color: var(--we-bad); color: var(--we-bad); } +.we-foot { margin-top: 18px; font-size: 12px; color: var(--we-mut); } +.we-err { color: var(--we-warn); margin: 12px 0 0; font-size: 14px; } + +/* ---------- 遊戲外殼 ---------- */ +.we-bar { display: flex; align-items: center; gap: 6px; padding: 8px 0 0; flex-wrap: wrap; } +.we-brand { font-weight: 800; letter-spacing: .06em; margin-right: auto; font-size: 15px; } +.we-brand i { font-style: normal; color: var(--we-accent); } +.we-bar .we-btn { min-height: 44px; min-width: 44px; padding: 6px 12px; font-size: 14px; } +.we-bar .we-btn[aria-pressed="true"] { border-color: var(--we-accent); color: var(--we-accent); } +.we-main { padding-top: 8px; } +.we-h2 { font-size: 16px; margin: 12px 0 6px; } + +/* ---------- 總覽 ---------- */ +.we-hub { display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr)); margin: 6px 0 18px; } +.we-card { background: var(--we-panel); border: 1px solid var(--we-line); border-radius: var(--we-radius); padding: 12px 14px; display: grid; gap: 6px; position: relative; overflow: hidden; } +.we-card::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 4px; background: var(--we-accent); opacity: .9; } +.we-card.is-locked::before { background: var(--we-line); } +.we-card.is-locked { border-style: dashed; } +.we-card h3 { margin: 0; font-size: 16px; display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; } +.we-card h3 small { color: var(--we-mut); font-weight: 400; font-size: 12px; } +.we-card dl { margin: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px 12px; font-size: 13px; } +.we-card dt { color: var(--we-mut); font-size: 11.5px; } +.we-card dd { margin: 0 0 4px; font-variant-numeric: tabular-nums; } +.we-card .we-btn { width: 100%; } +.we-tag { font-size: 11.5px; border: 1px solid var(--we-line); border-radius: 999px; padding: 1px 8px; color: var(--we-mut); } +.we-tag--on { border-color: var(--we-good); color: var(--we-good); } +.we-stars { color: var(--we-star); letter-spacing: 1px; } +.we-list { padding-left: 18px; margin: 6px 0; font-size: 14px; } +.we-list li { margin: 8px 0; } + +/* ---------- 教學 ---------- */ +.we-tut { position: fixed; inset: 0; z-index: 3000; pointer-events: none; } +.we-tut-dim { position: fixed; background: rgba(4, 8, 16, .74); pointer-events: auto; } +.we-tut-ring { position: fixed; border: 3px solid var(--we-accent); border-radius: 12px; box-shadow: 0 0 0 2px rgba(0, 0, 0, .4), 0 0 18px var(--we-accent); pointer-events: none; animation: we-pulse 1.3s ease-in-out infinite; } +@keyframes we-pulse { 50% { box-shadow: 0 0 0 2px rgba(0, 0, 0, .4), 0 0 30px var(--we-accent); } } +.we-tut-card { position: fixed; left: 12px; right: 12px; margin: 0 auto; max-width: 440px; box-sizing: border-box; background: var(--we-panel); border: 1px solid var(--we-accent); border-radius: 14px; padding: 12px 14px calc(12px + env(safe-area-inset-bottom)); box-shadow: 0 8px 30px rgba(0, 0, 0, .55); pointer-events: auto; font-size: 14px; } +.we-tut-card.at-bottom { bottom: 12px; } .we-tut-card.at-top { top: 12px; } +.we-tut-card h3 { margin: 0 0 4px; font-size: 15px; color: var(--we-accent); } +.we-tut-card p { margin: 0 0 8px; } +.we-tut-row { display: flex; gap: 8px; align-items: center; justify-content: space-between; flex-wrap: wrap; } +.we-tut-row .we-btn { min-height: 44px; padding: 6px 14px; } +.we-tut-prog { font-size: 12px; color: var(--we-mut); } +@media (prefers-reduced-motion: reduce) { .we-tut-ring, .we-wafer .we-sweep { animation: none; } } + +@media (max-width: 420px) { + .we-bar .we-btn { padding: 6px 8px; } +} diff --git a/empire/css/puzzle.css b/empire/css/puzzle.css new file mode 100644 index 0000000..ec259b7 --- /dev/null +++ b/empire/css/puzzle.css @@ -0,0 +1,169 @@ +/* 製程解謎 UI — 所有選擇器前綴 .pz-(DESIGN.md §9.1)。行動優先,375px 無水平捲動。 */ +.pz-root, .pz-sheet, .pz-result-back, .pz-toast { + --pz-bg: var(--we-bg, #f4f6fb); + --pz-panel: var(--we-panel, #ffffff); + --pz-ink: var(--we-fg, #1b2233); + --pz-sub: var(--we-mut, #5a6478); + --pz-line: var(--we-line, #d5dbe8); + --pz-accent: var(--we-accent, #2563eb); + --pz-accent-ink: var(--we-accent-ink, #ffffff); + --pz-good: var(--we-good, #15803d); + --pz-bad: var(--we-bad, #c0262d); + --pz-star: var(--we-star, #f5a800); + --pz-shadow: 0 6px 24px rgba(20, 30, 60, 0.18); +} +.pz-root { + box-sizing: border-box; + min-height: 100%; + background: var(--pz-bg); + color: var(--pz-ink); + font: 15px/1.5 system-ui, -apple-system, "Noto Sans TC", "Microsoft JhengHei", sans-serif; + -webkit-text-size-adjust: 100%; + touch-action: manipulation; + overscroll-behavior: contain; +} +/* 獨立開發頁(未載入 base.css)沿用系統深色;整合版由 --we-* 統一配色 */ +@media (prefers-color-scheme: dark) { + .pz-root, .pz-sheet, .pz-result-back, .pz-toast { + --pz-bg: var(--we-bg, #0f1420); + --pz-panel: var(--we-panel, #1a2133); + --pz-ink: var(--we-fg, #e6ebf7); + --pz-sub: var(--we-mut, #9aa6c2); + --pz-line: var(--we-line, #2c3652); + --pz-accent: var(--we-accent, #5b8cff); + --pz-accent-ink: var(--we-accent-ink, #0a1020); + --pz-good: var(--we-good, #4ade80); + --pz-bad: var(--we-bad, #ff7a80); + --pz-shadow: 0 6px 24px rgba(0, 0, 0, 0.55); + } +} +.pz-root *, .pz-root *::before, .pz-root *::after, .pz-sheet *, .pz-result-back * { box-sizing: border-box; } +.pz-sheet, .pz-result-back { font: 15px/1.5 system-ui, -apple-system, "Noto Sans TC", "Microsoft JhengHei", sans-serif; } +.pz-overlay { position: fixed; inset: 0; z-index: 1000; overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; } +.pz-wrap { max-width: 980px; margin: 0 auto; padding: 8px 16px calc(24px + env(safe-area-inset-bottom)); } + +.pz-head { display: flex; align-items: center; gap: 8px; padding: 8px 0; position: sticky; top: 0; z-index: 5; background: var(--pz-bg); } +.pz-head h2 { flex: 1; margin: 0; font-size: 17px; line-height: 1.25; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pz-btn { + appearance: none; border: 1px solid var(--pz-line); background: var(--pz-panel); color: var(--pz-ink); + min-height: 44px; min-width: 44px; padding: 8px 14px; border-radius: 10px; font: inherit; font-weight: 600; cursor: pointer; +} +.pz-btn:disabled { opacity: 0.4; cursor: not-allowed; } +.pz-btn:focus-visible { outline: 3px solid var(--pz-accent); outline-offset: 2px; } +.pz-btn-primary { background: var(--pz-accent); color: var(--pz-accent-ink); border-color: var(--pz-accent); } +.pz-btn-ghost { background: transparent; } + +.pz-intro { margin: 4px 0 8px; color: var(--pz-sub); font-size: 14px; } + +/* 關卡選單 */ +.pz-menu { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; } +.pz-menu li { margin: 0; } +.pz-level-btn { + width: 100%; text-align: left; display: grid; grid-template-columns: 1fr auto; gap: 2px 8px; align-items: center; + background: var(--pz-panel); border: 1px solid var(--pz-line); border-radius: 12px; padding: 12px 14px; min-height: 56px; + color: inherit; font: inherit; cursor: pointer; +} +.pz-level-btn:focus-visible { outline: 3px solid var(--pz-accent); outline-offset: 2px; } +.pz-level-title { font-weight: 700; } +.pz-level-meta { color: var(--pz-sub); font-size: 12.5px; grid-column: 1; } +.pz-level-stars { grid-row: 1 / span 2; grid-column: 2; font-size: 22px; letter-spacing: 1px; color: var(--pz-star); } +.pz-star-off { color: var(--pz-line); } +.pz-menu-foot { margin-top: 14px; color: var(--pz-sub); font-size: 13px; } + +/* 遊戲版面 */ +.pz-play { display: grid; gap: 10px; grid-template-columns: minmax(0, 1fr); } +.pz-col-board, .pz-col-ctrl { min-width: 0; } +@media (min-width: 760px) { + .pz-play { grid-template-columns: minmax(0, 480px) minmax(300px, 1fr); align-items: start; gap: 16px; } + .pz-col-board { grid-column: 1; } + .pz-col-ctrl { grid-column: 2; } +} +.pz-boards { display: grid; gap: 8px; justify-items: center; } +.pz-board-box { background: var(--pz-panel); border: 1px solid var(--pz-line); border-radius: 12px; padding: 8px; max-width: 100%; } +.pz-board-label { font-size: 12.5px; color: var(--pz-sub); margin: 0 0 4px; display: flex; justify-content: space-between; gap: 8px; } +.pz-canvas { display: block; max-width: 100%; border-radius: 6px; } +.pz-target-btn { appearance: none; border: 0; background: transparent; padding: 0; cursor: zoom-in; display: block; } +.pz-target-btn[aria-pressed="true"] { cursor: zoom-out; } + +.pz-stats { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 14px; } +.pz-stat b { font-variant-numeric: tabular-nums; } +.pz-over { color: var(--pz-bad); } +.pz-chips { display: flex; gap: 6px; overflow-x: auto; scroll-snap-type: x proximity; padding: 4px 0 8px; min-height: 44px; -webkit-overflow-scrolling: touch; } +.pz-chip { flex: 0 0 auto; scroll-snap-align: start; background: var(--pz-panel); border: 1px solid var(--pz-line); border-radius: 999px; padding: 6px 12px; font-size: 13px; white-space: nowrap; } +.pz-chip-empty { color: var(--pz-sub); border-style: dashed; } +.pz-toolbar { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; } +.pz-toolbar .pz-btn { padding: 8px 4px; font-size: 14px; } + +.pz-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 8px; margin-top: 10px; } +.pz-card { + appearance: none; min-height: 56px; border-radius: 12px; border: 1px solid var(--pz-line); background: var(--pz-panel); color: var(--pz-ink); + font: inherit; font-weight: 700; padding: 8px 6px; cursor: pointer; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; + border-top: 4px solid var(--pz-accent); +} +.pz-card small { font-weight: 400; color: var(--pz-sub); font-size: 11px; } +.pz-card:disabled { opacity: 0.4; } +.pz-card:focus-visible { outline: 3px solid var(--pz-accent); outline-offset: 2px; } +.pz-card[data-card="deposit"] { border-top-color: #e8c547; } +.pz-card[data-card="spin"], .pz-card[data-card="strip"] { border-top-color: #f4a0ba; } +.pz-card[data-card="expose"], .pz-card[data-card="develop"] { border-top-color: #c27bd6; } +.pz-card[data-card="etch"] { border-top-color: #e5484d; } +.pz-card[data-card="implant"] { border-top-color: #8a63d2; } +.pz-card[data-card="cmp"] { border-top-color: #6b86b4; } + +.pz-details { margin-top: 10px; background: var(--pz-panel); border: 1px solid var(--pz-line); border-radius: 10px; padding: 0 12px; } +.pz-details > summary { min-height: 44px; display: flex; align-items: center; cursor: pointer; font-weight: 600; } +.pz-details[open] { padding-bottom: 10px; } +.pz-legend { display: flex; flex-wrap: wrap; gap: 6px 14px; } +.pz-legend-item { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; } +.pz-legend-item canvas { border-radius: 3px; } +.pz-hint { margin-top: 10px; padding: 10px 12px; border-radius: 10px; background: var(--we-hint-bg, #fff7d6); color: var(--we-hint-fg, #5a4300); border: 1px solid var(--we-hint-line, #f1d776); font-size: 14px; } +@media (prefers-color-scheme: dark) { .pz-hint { background: var(--we-hint-bg, #3a3110); color: var(--we-hint-fg, #ffe9a0); border-color: var(--we-hint-line, #6b5a1c); } } + +/* 底部面板 */ +.pz-sheet-back { position: fixed; inset: 0; z-index: 1100; background: rgba(0, 0, 0, 0.25); } +.pz-sheet { + position: fixed; left: 0; right: 0; bottom: 0; z-index: 1101; max-height: 50vh; overflow-y: auto; + background: var(--pz-panel); color: var(--pz-ink); border-radius: 16px 16px 0 0; box-shadow: var(--pz-shadow); + padding: 14px 16px calc(14px + env(safe-area-inset-bottom)); animation: pz-up 0.18s ease-out; +} +@media (min-width: 760px) { .pz-sheet { left: 50%; right: auto; width: 560px; transform: translateX(-50%); } } +.pz-sheet h3 { margin: 0 0 6px; font-size: 16px; } +.pz-sheet .pz-explain { margin: 0 0 8px; color: var(--pz-sub); font-size: 13px; } +.pz-group { margin: 8px 0; } +.pz-group-label { font-size: 12.5px; color: var(--pz-sub); margin-bottom: 4px; } +.pz-opts { display: flex; flex-wrap: wrap; gap: 6px; } +.pz-opt { appearance: none; min-height: 44px; min-width: 44px; border-radius: 10px; border: 2px solid var(--pz-line); background: var(--pz-panel); color: var(--pz-ink); font: inherit; font-weight: 600; padding: 4px 12px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 6px; } +.pz-opt[aria-pressed="true"] { border-color: var(--pz-accent); box-shadow: inset 0 0 0 1px var(--pz-accent); } +.pz-opt:disabled { opacity: 0.35; } +.pz-opt-mask { flex-direction: column; gap: 3px; padding: 6px 8px; } +.pz-swatch { width: 18px; height: 18px; border-radius: 3px; border: 1px solid rgba(0, 0, 0, 0.3); display: inline-block; } +.pz-preview-msg { min-height: 22px; font-size: 13.5px; margin: 6px 0; } +.pz-preview-msg.pz-err { color: var(--pz-bad); font-weight: 600; } +.pz-sheet-actions { display: grid; grid-template-columns: 1fr 1.4fr; gap: 8px; margin-top: 6px; } + +/* 結算 */ +.pz-result-back { position: fixed; inset: 0; z-index: 1200; background: rgba(8, 12, 24, 0.55); display: flex; align-items: center; justify-content: center; padding: 16px; } +.pz-result { width: min(440px, 100%); max-height: 92vh; overflow-y: auto; background: var(--pz-panel); border-radius: 16px; box-shadow: var(--pz-shadow); padding: 18px 18px 14px; text-align: center; } +.pz-result h3 { margin: 0; font-size: 19px; } +.pz-big-stars { font-size: 44px; letter-spacing: 6px; color: var(--pz-star); line-height: 1.2; margin: 6px 0; } +.pz-big-stars .pz-star-off { color: var(--pz-line); } +.pz-pop .pz-star-on { display: inline-block; animation: pz-pop 0.5s ease-out both; } +.pz-pop .pz-star-on:nth-child(2) { animation-delay: 0.15s; } +.pz-pop .pz-star-on:nth-child(3) { animation-delay: 0.3s; } +.pz-crit { list-style: none; padding: 0; margin: 8px 0; text-align: left; font-size: 14px; } +.pz-crit li { padding: 2px 0; } +.pz-ok { color: var(--pz-good); font-weight: 700; } +.pz-no { color: var(--pz-sub); } +.pz-fact { text-align: left; background: var(--pz-bg); border-left: 4px solid var(--pz-accent); border-radius: 8px; padding: 10px 12px; margin: 10px 0; font-size: 14px; } +.pz-fact b { display: block; margin-bottom: 2px; font-size: 12.5px; color: var(--pz-sub); } +.pz-result-actions { display: grid; gap: 8px; margin-top: 10px; } +.pz-toast { position: fixed; left: 50%; bottom: calc(18px + env(safe-area-inset-bottom)); transform: translateX(-50%); z-index: 1300; background: #20263a; color: #fff; padding: 10px 16px; border-radius: 10px; font-size: 14px; max-width: 92vw; box-shadow: var(--pz-shadow); } +.pz-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } + +@keyframes pz-up { from { transform: translateY(30px); opacity: 0; } to { transform: none; opacity: 1; } } +@media (min-width: 760px) { @keyframes pz-up { from { opacity: 0; } to { opacity: 1; } } } +@keyframes pz-pop { 0% { transform: scale(0.2) rotate(-30deg); opacity: 0; } 70% { transform: scale(1.25); opacity: 1; } 100% { transform: scale(1); opacity: 1; } } +@media (prefers-reduced-motion: reduce) { + .pz-sheet, .pz-pop .pz-star-on { animation: none; } +} +.pz-reduce .pz-sheet, .pz-reduce .pz-pop .pz-star-on { animation: none; } diff --git a/empire/css/tycoon.css b/empire/css/tycoon.css new file mode 100644 index 0000000..a2b6dfa --- /dev/null +++ b/empire/css/tycoon.css @@ -0,0 +1,110 @@ +/* 經營層樣式:全部選擇器以 .ty- 為前綴 */ +:root { + --ty-bg: var(--we-bg, #0f1623); --ty-panel: var(--we-panel, #182236); --ty-panel2: var(--we-panel2, #1f2c45); --ty-line: var(--we-line, #2c3b5a); + --ty-fg: var(--we-fg, #e8eefc); --ty-mut: var(--we-mut, #93a4c6); --ty-acc: var(--we-accent, #4aa8ff); --ty-good: var(--we-good, #3ecf8e); --ty-bad: var(--we-bad, #ff6b6b); --ty-warn: var(--we-warn, #ffb84a); + --ty-gold: var(--we-accent, #f4c95d); +} +.ty-root, body.ty-body { background: var(--ty-bg); color: var(--ty-fg); font: 15px/1.5 system-ui, "Noto Sans TC", "PingFang TC", "Microsoft JhengHei", sans-serif; } +body.ty-body { margin: 0; padding: 0 16px 48px; overscroll-behavior: contain; touch-action: manipulation; } +.ty-wrap { max-width: 960px; margin: 0 auto; } +.ty-wrap h1 { font-size: 18px; margin: 12px 0 4px; } +.ty-wrap h2 { font-size: 16px; margin: 0; } +.ty-muted { color: var(--ty-mut); font-size: 13px; margin: 4px 0; } +.ty-err, .ty-warn { color: var(--ty-warn); font-size: 13px; margin: 6px 0; } + +/* buttons */ +.ty-btn { min-height: 44px; min-width: 44px; padding: 8px 14px; border: 1px solid var(--ty-line); border-radius: 10px; background: var(--ty-panel2); color: var(--ty-fg); font: inherit; cursor: pointer; touch-action: manipulation; } +.ty-btn:disabled { opacity: .45; cursor: not-allowed; } +.ty-btn:focus-visible, .ty-slot:focus-visible, .ty-tab:focus-visible, .ty-opt:focus-visible, .ty-stat--btn:focus-visible { outline: 2px solid var(--ty-acc); outline-offset: 2px; } +.ty-btn--primary { background: var(--ty-acc); color: #04101f; border-color: var(--ty-acc); font-weight: 600; } +.ty-btn--danger { border-color: var(--ty-bad); color: var(--ty-bad); } +.ty-clk { padding: 8px 10px; } +.ty-clk.is-on { background: var(--ty-acc); color: #04101f; } +.ty-choices { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0; } +.ty-choices .ty-btn { flex: 1 1 140px; } + +/* HUD */ +.ty-hud { position: sticky; top: 0; z-index: 20; background: var(--ty-bg); padding: 8px 0; border-bottom: 1px solid var(--ty-line); } +.ty-hud-row { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; } +.ty-stat { font-size: 14px; } .ty-stat b { font-variant-numeric: tabular-nums; color: var(--ty-gold); } +.ty-stat--btn { background: none; border: 0; color: inherit; font: inherit; min-height: 44px; padding: 0 4px; cursor: pointer; } +.ty-hud-clock { margin-top: 4px; } +.ty-time { font-variant-numeric: tabular-nums; margin-right: auto; font-size: 13px; } +.ty-inv-list { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; color: var(--ty-mut); } +.ty-inv-list[hidden] { display: none; } + +/* tabs & head */ +.ty-tabs { display: flex; gap: 6px; overflow-x: auto; padding: 10px 0 6px; scroll-snap-type: x proximity; } +.ty-tab { flex: 0 0 auto; min-width: 64px; min-height: 44px; display: flex; flex-direction: column; align-items: center; justify-content: center; border: 1px solid var(--ty-line); background: var(--ty-panel); color: var(--ty-fg); border-radius: 10px; font: inherit; cursor: pointer; scroll-snap-align: start; } +.ty-tab small { color: var(--ty-mut); font-size: 11px; } +.ty-tab.is-on { border-color: var(--ty-acc); background: var(--ty-panel2); } +.ty-tab.is-locked { opacity: .65; border-style: dashed; } +.ty-headline { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin: 6px 0; } +.ty-badge { font-size: 12px; padding: 2px 8px; border-radius: 999px; background: var(--ty-panel2); border: 1px solid var(--ty-line); } +.ty-badge--warn { border-color: var(--ty-warn); color: var(--ty-warn); } +.ty-kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(104px, 1fr)); gap: 6px; } +.ty-kpi { background: var(--ty-panel); border-radius: 10px; padding: 6px 10px; } +.ty-kpi-k { font-size: 12px; color: var(--ty-mut); } .ty-kpi-v { font-size: 17px; font-weight: 600; font-variant-numeric: tabular-nums; } +.ty-kpi-s { font-size: 11px; color: var(--ty-mut); } +.ty-kpi--good .ty-kpi-v { color: var(--ty-good); } .ty-kpi--bad .ty-kpi-v { color: var(--ty-bad); } + +/* line */ +.ty-line { position: relative; margin: 12px 0; background: var(--ty-panel); border-radius: 12px; padding: 8px; overflow: hidden; } +.ty-cells { display: flex; } +.ty-cellwrap { flex: 0 0 var(--cell); display: block; } +.ty-slot, .ty-exit { box-sizing: border-box; width: calc(var(--cell) - 8px); margin: 34px 4px 0; min-height: 64px; border: 1px solid var(--ty-line); border-radius: 10px; background: var(--ty-panel2); color: var(--ty-fg); font: inherit; text-align: center; padding: 4px; display: flex; flex-direction: column; justify-content: center; cursor: pointer; } +.ty-exit { cursor: default; align-items: center; border-style: dashed; color: var(--ty-mut); } +.ty-slot-cat { font-size: 11px; color: var(--ty-mut); } .ty-slot-name { font-size: 13px; font-weight: 600; } +.ty-slot-sub { font-size: 10.5px; color: var(--ty-mut); } .ty-slot-empty { color: var(--ty-warn); font-weight: 600; } +.ty-slot.is-empty { border: 2px dashed var(--ty-warn); } +.ty-slot.is-busy { border-color: var(--ty-acc); box-shadow: 0 0 0 1px var(--ty-acc) inset; } +.ty-slot.is-bad { border-color: var(--ty-bad); background: #3a1d24; } +.ty-lots { position: absolute; left: 8px; top: 8px; right: 8px; bottom: 8px; pointer-events: none; } +.ty-lot { position: absolute; left: -13px; top: 4px; width: 26px; height: 26px; border-radius: 50%; background: radial-gradient(circle at 35% 30%, #cfe6ff, #4aa8ff 60%, #2467a8); color: #04101f; font-size: 10px; font-weight: 700; display: grid; place-items: center; box-shadow: 0 1px 4px rgba(0,0,0,.5); will-change: transform; animation: ty-bob 1.2s ease-in-out infinite; } +.ty-lot.is-live { transition: transform var(--ty-tick, 250ms) linear, opacity .3s; } +.ty-lot--out { opacity: .2; } .ty-lot--scrap { background: var(--ty-bad); opacity: 0; transition: opacity .4s; } +.ty-paused .ty-lot { animation-play-state: paused; } +@keyframes ty-bob { 50% { filter: brightness(1.25); } } +.ty-still .ty-lot { animation: none; } .ty-still .ty-lot.is-live { transition: none; } +@media (prefers-reduced-motion: reduce) { .ty-lot { animation: none; } .ty-lot.is-live { transition: none; } } + +/* 窄螢幕:垂直蛇形,每槽一列 56px,Lot 沿 y 軸移動 */ +.ty-line[data-mode="v"] .ty-cells { flex-direction: column; } +.ty-line[data-mode="v"] .ty-cellwrap { flex: 0 0 var(--cell); height: var(--cell); display: grid; grid-template-columns: 36px 1fr; align-items: center; } +.ty-line[data-mode="v"] .ty-lane { height: 100%; border-left: 2px dashed var(--ty-line); margin-left: 17px; } +.ty-line[data-mode="v"] .ty-slot, .ty-line[data-mode="v"] .ty-exit { margin: 2px 0; width: auto; height: 52px; min-height: 44px; flex-direction: row; flex-wrap: wrap; align-items: center; justify-content: space-between; text-align: left; gap: 0 8px; padding: 2px 10px; } +.ty-line[data-mode="v"] .ty-exit { justify-content: center; } +.ty-line[data-mode="v"] .ty-lots { right: auto; width: 36px; } +.ty-line[data-mode="v"] .ty-lot { left: 5px; top: -13px; } +.ty-line[data-mode="h"] .ty-lot { top: 4px; } +.ty-line[data-mode="h"] .ty-lots { height: 34px; bottom: auto; } + +.ty-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } + +/* dialogs */ +.ty-overlay[hidden] { display: none; } +.ty-overlay { position: fixed; inset: 0; z-index: 100; background: rgba(5,10,20,.7); display: flex; align-items: flex-end; justify-content: center; } +.ty-dlg { box-sizing: border-box; width: 100%; max-width: 560px; max-height: 88vh; overflow: auto; background: var(--ty-panel); border: 1px solid var(--ty-line); border-radius: 16px 16px 0 0; padding: 14px 16px calc(16px + env(safe-area-inset-bottom)); animation: ty-up .2s ease-out; color: var(--ty-fg); } +.ty-dlg--modal { border-radius: 16px; margin: auto 12px; max-width: 520px; } +.ty-overlay:has(.ty-dlg--modal) { align-items: center; } +.ty-dlg:focus { outline: none; } +.ty-dlg-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; } +.ty-dlg-x { background: none; border: 0; font-size: 24px; color: var(--ty-fg); } +@keyframes ty-up { from { transform: translateY(24px); opacity: 0; } } +@media (prefers-reduced-motion: reduce) { .ty-dlg { animation: none; } } +.ty-opt { display: block; width: 100%; text-align: left; margin: 6px 0; padding: 10px 12px; min-height: 44px; border-radius: 10px; border: 1px solid var(--ty-line); background: var(--ty-panel2); color: var(--ty-fg); font: inherit; cursor: pointer; } +.ty-opt:disabled { opacity: .45; } +.ty-opt-t { display: flex; justify-content: space-between; gap: 8px; } .ty-opt-t span { color: var(--ty-gold); } +.ty-opt-s { font-size: 12px; color: var(--ty-mut); } +.ty-cond { list-style: none; padding: 0; margin: 8px 0; } .ty-cond li { padding: 3px 0; } +.ty-ok { color: var(--ty-good); } .ty-no { color: var(--ty-bad); } +.ty-factcard { margin-top: 12px; padding: 10px 12px; border-left: 4px solid var(--ty-gold); background: var(--ty-panel2); border-radius: 6px; } +.ty-factcard-h { font-size: 12px; color: var(--ty-gold); font-weight: 700; } .ty-factcard p { margin: 4px 0 0; } +.ty-event-effect { font-size: 16px; } +.ty-check { display: flex; align-items: center; gap: 8px; min-height: 44px; } + +.ty-toasts { position: fixed; left: 8px; right: 8px; bottom: calc(8px + env(safe-area-inset-bottom)); z-index: 200; display: flex; flex-direction: column; gap: 6px; align-items: center; pointer-events: none; } +.ty-toast { background: #000c; border: 1px solid var(--ty-line); padding: 8px 14px; border-radius: 10px; font-size: 13px; max-width: 520px; } + +.ty-dev { display: flex; flex-wrap: wrap; gap: 6px; margin: 16px 0; padding: 8px; border: 1px dashed var(--ty-line); border-radius: 10px; } +.ty-dev h2 { width: 100%; font-size: 13px; color: var(--ty-mut); } diff --git a/empire/data/balance.json b/empire/data/balance.json new file mode 100644 index 0000000..a0214ba --- /dev/null +++ b/empire/data/balance.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "start": { "money": 5000, "rep": 20 }, + "lotSize": 25, + "invCap": 600, + "demandCap": 120, + "ticksPerDay": 24, + "tickMs": { "1": 250, "2": 125 }, + "priceWalk": { "min": 0.9, "max": 1.1, "step": 0.05 }, + "yield": { "levelFactor": 0.8, "starHalf": 0.5, "dMax": 0.5, "yMin": 0.05, "yMax": 0.99 }, + "equip": { "maxLevel": 5, "speedPerLevel": 0.15, "upgradeBase": 1.8, "baseHours": 4, "sellRefund": 0.5 }, + "rep": { "neutralYield": 0.75, "perLotFactor": 2, "dailyCap": 3, "min": 0, "max": 100 }, + "fabMult": [1, 2, 4, 10, 25], + "fabOrder": ["f1", "f2", "f3", "f4", "f5"], + "fabs": { + "f1": { "node": "180 nm", "zh": "Fab 1", "wafer": "200 mm", "cost": 30, "price": 60, "unlockMoney": 0, "unlockRep": 0, "maintenance": 100, "D0": 0.050, "puzzleLevels": 3, "factId": "fact#8", "slots": ["cleaning","thin_film","lithography","etching"] }, + "f2": { "node": "90 nm", "zh": "Fab 2", "wafer": "200 mm", "cost": 50, "price": 115, "unlockMoney": 12000, "unlockRep": 15, "maintenance": 300, "D0": 0.060, "puzzleLevels": 4, "factId": "fact#25", "slots": ["cleaning","thin_film","lithography","etching","ion_implantation"] }, + "f3": { "node": "28 nm", "zh": "Fab 3", "wafer": "300 mm", "cost": 85, "price": 220, "unlockMoney": 60000, "unlockRep": 30, "maintenance": 900, "D0": 0.070, "puzzleLevels": 4, "factId": "fact#47", "slots": ["cleaning","thin_film","lithography","etching","ion_implantation","cmp"] }, + "f4": { "node": "7 nm", "zh": "Fab 4", "wafer": "300 mm", "cost": 190, "price": 540, "unlockMoney": 300000, "unlockRep": 50, "maintenance": 3000, "D0": 0.085, "puzzleLevels": 5, "factId": "fact#17", "slots": ["cleaning","thin_film","lithography","etching","ion_implantation","cmp","metallization"] }, + "f5": { "node": "3 nm", "zh": "Fab 5", "wafer": "300 mm", "cost": 400, "price": 1250, "unlockMoney": 1500000, "unlockRep": 70, "maintenance": 9000, "D0": 0.100, "puzzleLevels": 5, "factId": "fact#46", "slots": ["cleaning","thin_film","lithography","etching","ion_implantation","cmp","metallization","lithography"] } + }, + "categories": { + "cleaning": "清洗", "thin_film": "薄膜", "lithography": "微影", "etching": "蝕刻", + "ion_implantation": "離子佈植", "cmp": "CMP", "metallization": "金屬化" + }, + "equipment": { + "wet_bench": { "category": "cleaning", "price": 600, "speed": 1.2, "defMul": 1.15 }, + "single_wafer_cleaner": { "category": "cleaning", "price": 900, "speed": 1.0, "defMul": 0.90 }, + "furnace": { "category": "thin_film", "price": 700, "speed": 1.3, "defMul": 1.20 }, + "cvd_chamber": { "category": "thin_film", "price": 1000, "speed": 1.0, "defMul": 0.95 }, + "pvd_sputter": { "category": "thin_film", "price": 900, "speed": 1.0, "defMul": 1.00 }, + "coater_developer": { "category": "lithography", "price": 1200, "speed": 1.0, "defMul": 1.00, "fabs": ["f1","f2","f3"] }, + "scanner": { "category": "lithography", "price": 2500, "speed": 1.0, "defMul": 0.85, "fabs": ["f4","f5"] }, + "etch_chamber": { "category": "etching", "price": 1000, "speed": 1.0, "defMul": 1.00 }, + "implanter": { "category": "ion_implantation", "price": 900, "speed": 1.0, "defMul": 1.00 }, + "rta": { "category": "ion_implantation", "price": 700, "speed": 1.1, "defMul": 1.05 }, + "cmp_tool": { "category": "cmp", "price": 1100, "speed": 1.0, "defMul": 1.00 }, + "electroplating_tool": { "category": "metallization", "price": 1300, "speed": 1.0, "defMul": 1.00 } + }, + "events": { + "dailyChance": 0.08, + "protectDays": 3, + "upsCostPerMult": 2000, + "cleanCostPerMult": 400, + "cleanHaltTicks": 4, + "repairFactor": 0.15, + "particleDef": 0.5, + "particleDays": 3, + "rushDays": 3, + "rushPriceMul": 2.5, + "rushTimeMul": 1.25, + "toolFailureDays": 3, + "outageHaltTicks": 24, + "repLoss": { "power_outage": 2, "particle": 1, "tool_failure": 1 } + } +} diff --git a/empire/data/content.json b/empire/data/content.json new file mode 100644 index 0000000..2e8d3d4 --- /dev/null +++ b/empire/data/content.json @@ -0,0 +1,2177 @@ +{ + "processes": [ + { + "id": "lithography", + "zh": "微影", + "en": "Lithography", + "summary": "利用光把光罩上的電路圖形轉移到晶圓表面的光阻上。", + "effect": "pattern", + "source": "zh-TW/2-process-modules/04-lithography/README.md" + }, + { + "id": "thin_film", + "zh": "薄膜沉積", + "en": "Thin Film Deposition", + "summary": "在晶圓表面鋪上絕緣、導電或輔助用的薄膜,包括熱氧化、CVD、PVD、ALD 與磊晶。", + "effect": "add", + "source": "zh-TW/2-process-modules/05-thin-films/README.md" + }, + { + "id": "etching", + "zh": "蝕刻", + "en": "Etching", + "summary": "依照光阻上的圖形,把底下沒有被保護的材料挖掉。", + "effect": "remove", + "source": "zh-TW/2-process-modules/06-etching/README.md" + }, + { + "id": "ion_implantation", + "zh": "離子佈植與熱處理", + "en": "Ion Implantation and Annealing", + "summary": "把雜質離子加速射入矽中改變導電特性,再以退火修復晶格並活化雜質。", + "effect": "modify", + "source": "zh-TW/2-process-modules/07-ion-implantation/README.md" + }, + { + "id": "cmp", + "zh": "化學機械研磨", + "en": "Chemical Mechanical Planarization (CMP)", + "summary": "結合化學作用與機械研磨,把晶圓表面磨平或去除多餘的材料。", + "effect": "remove", + "source": "zh-TW/2-process-modules/08-cmp/README.md" + }, + { + "id": "cleaning", + "zh": "清洗與污染控制", + "en": "Cleaning and Contamination Control", + "summary": "去除晶圓表面的微粒、金屬、有機物與原生氧化層,幾乎出現在每個製程步驟前後。", + "effect": "remove", + "source": "zh-TW/2-process-modules/09-cleaning/README.md" + }, + { + "id": "metallization", + "zh": "金屬化", + "en": "Metallization", + "summary": "以接觸窗與導通孔連接電晶體,並用大馬士革製程做出多層銅導線。", + "effect": "add", + "source": "zh-TW/2-process-modules/10-metallization/README.md" + } + ], + "equipment": [ + { + "id": "scanner", + "zh": "曝光機", + "en": "Scanner", + "process_id": "lithography", + "purpose": "把光罩圖形縮小投影到晶圓上,一次曝光一個曝光區,逐區重複直到整片曝完。", + "source": "zh-TW/2-process-modules/04-lithography/README.md" + }, + { + "id": "reticle", + "zh": "光罩", + "en": "Reticle / Mask", + "process_id": "lithography", + "purpose": "帶有一層電路圖形的石英板,每一層電路需要一片,由曝光機投影。", + "source": "zh-TW/2-process-modules/04-lithography/README.md" + }, + { + "id": "coater_developer", + "zh": "塗佈與顯影機台", + "en": "TODO", + "process_id": "lithography", + "purpose": "負責在晶圓上塗佈光阻與曝光後的顯影,與曝光機直接相連,晶圓在中間自動傳送。", + "source": "zh-TW/2-process-modules/04-lithography/README.md" + }, + { + "id": "furnace", + "zh": "爐管", + "en": "Furnace", + "process_id": "thin_film", + "purpose": "高溫批次式機台,用於熱氧化與部分 CVD,一次可處理上百片晶圓。", + "source": "zh-TW/2-process-modules/05-thin-films/README.md" + }, + { + "id": "cvd_chamber", + "zh": "CVD 腔體", + "en": "TODO", + "process_id": "thin_film", + "purpose": "讓氣體在晶圓表面反應形成固態薄膜,廢氣由抽氣系統排出(LPCVD、PECVD)。", + "source": "zh-TW/2-process-modules/05-thin-films/README.md" + }, + { + "id": "pvd_sputter", + "zh": "濺鍍機台(PVD)", + "en": "Sputtering (PVD) tool", + "process_id": "thin_film", + "purpose": "在真空腔體中用氬離子撞擊靶材,使被撞出的原子沉積成金屬薄膜。", + "source": "zh-TW/2-process-modules/05-thin-films/README.md" + }, + { + "id": "etch_chamber", + "zh": "蝕刻機(電漿腔體)", + "en": "Etch chamber", + "process_id": "etching", + "purpose": "通入特定氣體並以射頻電力產生電漿進行乾蝕刻;腔體發出的光可用於終點偵測。", + "source": "zh-TW/2-process-modules/06-etching/README.md" + }, + { + "id": "implanter", + "zh": "離子佈植機", + "en": "Implanter", + "process_id": "ion_implantation", + "purpose": "經離子源產生離子、質量分析、加速與掃描後,把雜質離子射入晶圓指定區域。", + "source": "zh-TW/2-process-modules/07-ion-implantation/README.md" + }, + { + "id": "rta", + "zh": "快速熱退火機台(RTA)", + "en": "RTA (Rapid Thermal Anneal)", + "process_id": "ion_implantation", + "purpose": "用強光在幾秒內把晶圓加熱到高溫,完成雜質活化。", + "source": "zh-TW/2-process-modules/07-ion-implantation/README.md" + }, + { + "id": "cmp_tool", + "zh": "CMP 機台", + "en": "CMP tool", + "process_id": "cmp", + "purpose": "由研磨墊、研磨液、研磨頭與修整器組成,研磨後通常整合清洗模組。", + "source": "zh-TW/2-process-modules/08-cmp/README.md" + }, + { + "id": "wet_bench", + "zh": "批次式濕式清洗槽", + "en": "Wet bench", + "process_id": "cleaning", + "purpose": "把整批晶圓依序浸入化學槽與清水槽,像洗車的流水線。", + "source": "zh-TW/2-process-modules/09-cleaning/README.md" + }, + { + "id": "single_wafer_cleaner", + "zh": "單片式清洗機", + "en": "Single-wafer cleaner", + "process_id": "cleaning", + "purpose": "一次處理一片晶圓,旋轉時噴灑化學品,控制精準且可避免晶圓間互相污染。", + "source": "zh-TW/2-process-modules/09-cleaning/README.md" + }, + { + "id": "electroplating_tool", + "zh": "電鍍機台", + "en": "TODO", + "process_id": "metallization", + "purpose": "把晶圓放進電鍍液,讓銅填滿導通孔與溝槽。", + "source": "zh-TW/2-process-modules/10-metallization/README.md" + }, + { + "id": "cd_sem", + "zh": "線寬量測電子顯微鏡(CD-SEM)", + "en": "CD-SEM", + "process_id": "TODO", + "purpose": "用電子顯微鏡從上方量測線寬(屬量測與檢測,非製程模組)。", + "source": "zh-TW/4-yield-and-quality/15-metrology/README.md" + }, + { + "id": "ocd_tool", + "zh": "光學關鍵尺寸量測機台(OCD)", + "en": "OCD", + "process_id": "TODO", + "purpose": "分析光的散射,搭配模型推算立體結構的輪廓。", + "source": "zh-TW/4-yield-and-quality/15-metrology/README.md" + }, + { + "id": "defect_inspection", + "zh": "缺陷掃描機台", + "en": "TODO", + "process_id": "TODO", + "purpose": "用光學或電子束掃描整片晶圓,比對相鄰晶粒找出差異,標出缺陷位置。", + "source": "zh-TW/4-yield-and-quality/15-metrology/README.md" + }, + { + "id": "probe_card", + "zh": "探針卡", + "en": "Probe card", + "process_id": "TODO", + "purpose": "上有極細探針,排列對應晶粒焊墊位置,用於晶圓針測(CP)。", + "source": "zh-TW/5-backend/19-wafer-test/README.md" + }, + { + "id": "prober", + "zh": "針測機", + "en": "Prober", + "process_id": "TODO", + "purpose": "移動晶圓,讓探針精準落在每一顆晶粒的焊墊上。", + "source": "zh-TW/5-backend/19-wafer-test/README.md" + }, + { + "id": "tester", + "zh": "測試機", + "en": "Tester", + "process_id": "TODO", + "purpose": "送出測試訊號並判斷晶粒或晶片的反應是否正確。", + "source": "zh-TW/5-backend/19-wafer-test/README.md" + }, + { + "id": "handler", + "zh": "分類機", + "en": "Handler", + "process_id": "TODO", + "purpose": "最終測試時搬運封裝好的晶片進出測試座,並依結果分類。", + "source": "zh-TW/5-backend/22-final-test-and-reliability/README.md" + }, + { + "id": "foup", + "zh": "前開式晶圓傳送盒(FOUP)", + "en": "FOUP", + "process_id": "TODO", + "purpose": "300 mm 晶圓的密封搬運盒,最多裝 25 片,裝滿約八公斤。", + "source": "zh-TW/0-before-you-start/01-how-a-fab-works/README.md" + }, + { + "id": "oht", + "zh": "天車(OHT)", + "en": "Overhead Hoist Transport (OHT)", + "process_id": "TODO", + "purpose": "在天花板軌道上行駛,把 FOUP 從一台機台送到另一台。", + "source": "zh-TW/0-before-you-start/01-how-a-fab-works/README.md" + }, + { + "id": "stocker", + "zh": "倉儲(Stocker)", + "en": "Stocker", + "process_id": "TODO", + "purpose": "暫時存放還在等待的 FOUP。", + "source": "zh-TW/0-before-you-start/01-how-a-fab-works/README.md" + }, + { + "id": "load_port", + "zh": "晶圓盒裝載埠(Load Port)", + "en": "Load Port", + "process_id": "TODO", + "purpose": "機台前方放置 FOUP 的平台,機台從這裡取出晶圓。", + "source": "zh-TW/0-before-you-start/01-how-a-fab-works/README.md" + } + ], + "nodes": [ + { + "name": "180 nm", + "note": "節點名稱數列之一(早期名稱大致對應閘極長度或最密線寬)。", + "source": "zh-TW/3-integration/14-process-nodes/README.md" + }, + { + "name": "130 nm", + "note": "節點名稱數列之一。", + "source": "zh-TW/3-integration/14-process-nodes/README.md" + }, + { + "name": "90 nm", + "note": "節點名稱數列之一。", + "source": "zh-TW/3-integration/14-process-nodes/README.md" + }, + { + "name": "65 nm", + "note": "節點名稱數列之一。", + "source": "zh-TW/3-integration/14-process-nodes/README.md" + }, + { + "name": "45 nm", + "note": "節點名稱數列之一。", + "source": "zh-TW/3-integration/14-process-nodes/README.md" + }, + { + "name": "40 nm", + "note": "半世代節點,書中提及。", + "source": "zh-TW/3-integration/14-process-nodes/README.md" + }, + { + "name": "32 nm", + "note": "節點名稱數列之一。", + "source": "zh-TW/3-integration/14-process-nodes/README.md" + }, + { + "name": "28 nm", + "note": "半世代節點,書中提及;屬於現場常說的成熟製程範圍(二十多奈米以上)。", + "source": "zh-TW/3-integration/14-process-nodes/README.md" + }, + { + "name": "22 nm", + "note": "節點名稱數列之一。", + "source": "zh-TW/3-integration/14-process-nodes/README.md" + }, + { + "name": "20 nm", + "note": "半世代節點,書中提及。", + "source": "zh-TW/3-integration/14-process-nodes/README.md" + }, + { + "name": "16 nm", + "note": "半世代節點,書中提及。", + "source": "zh-TW/3-integration/14-process-nodes/README.md" + }, + { + "name": "14 nm", + "note": "節點名稱數列之一。", + "source": "zh-TW/3-integration/14-process-nodes/README.md" + }, + { + "name": "10 nm", + "note": "節點名稱數列之一;DRAM 的 1x 至 1γ 世代也屬於 10 奈米級。", + "source": "zh-TW/3-integration/14-process-nodes/README.md" + }, + { + "name": "7 nm", + "note": "節點名稱數列之一。", + "source": "zh-TW/3-integration/14-process-nodes/README.md" + }, + { + "name": "5 nm", + "note": "節點名稱數列之一。", + "source": "zh-TW/3-integration/14-process-nodes/README.md" + }, + { + "name": "3 nm", + "note": "節點名稱數列的最後一個數字;書中指出此世代的閘極間距約四、五十奈米,晶片上找不到 3 奈米的結構。", + "source": "zh-TW/3-integration/14-process-nodes/README.md" + }, + { + "name": "埃(Å,0.1 nm)世代", + "note": "部分公司改用埃作為新世代的命名單位,仍只是世代代號。", + "source": "zh-TW/3-integration/14-process-nodes/README.md" + }, + { + "name": "汞燈 436 / 365 nm 微影", + "note": "早期微影光源波長。", + "source": "zh-TW/2-process-modules/04-lithography/README.md" + }, + { + "name": "KrF 雷射 248 nm(DUV)", + "note": "深紫外光微影光源。", + "source": "zh-TW/2-process-modules/04-lithography/README.md" + }, + { + "name": "ArF 雷射 193 nm(DUV)", + "note": "深紫外光微影光源,用了很多年,之後靠浸潤式與多重曝光繼續微縮。", + "source": "zh-TW/2-process-modules/04-lithography/README.md" + }, + { + "name": "EUV 13.5 nm", + "note": "極紫外光微影,最先進製程的關鍵層使用;從研究到量產花了二十多年。", + "source": "zh-TW/2-process-modules/04-lithography/README.md" + }, + { + "name": "150 mm(6 吋)晶圓", + "note": "功率元件、化合物半導體等特殊產品常用。", + "source": "zh-TW/1-fundamentals/03-sand-to-wafer/README.md" + }, + { + "name": "200 mm(8 吋)晶圓", + "note": "成熟製程常用,例如類比、功率與感測器。", + "source": "zh-TW/1-fundamentals/03-sand-to-wafer/README.md" + }, + { + "name": "300 mm(12 吋)晶圓", + "note": "先進邏輯、記憶體與大量成熟製程使用,是目前最先進工廠的主流尺寸。", + "source": "zh-TW/1-fundamentals/03-sand-to-wafer/README.md" + }, + { + "name": "450 mm 晶圓(未普及)", + "note": "產業曾研究,但因設備開發成本太高而沒有普及。", + "source": "zh-TW/1-fundamentals/03-sand-to-wafer/README.md" + }, + { + "name": "DRAM 1x / 1y / 1z / 1α / 1β / 1γ 世代", + "note": "DRAM 的世代代號,都屬於 10 奈米級,不能直接和邏輯製程的「幾奈米」比較。", + "source": "zh-TW/3-integration/13-memory/README.md" + }, + { + "name": "平面電晶體(一面閘極)", + "note": "閘極只從上方控制通道;縮小後出現短通道效應與閘極漏電問題。", + "source": "zh-TW/3-integration/12-advanced-transistors/README.md" + }, + { + "name": "FinFET(三面閘極)", + "note": "2010 年代初進入量產,成為十多年先進製程的主流。", + "source": "zh-TW/3-integration/12-advanced-transistors/README.md" + }, + { + "name": "GAA 奈米片(四面閘極)", + "note": "2020 年代開始進入量產,通道為幾片水平堆疊的薄片。", + "source": "zh-TW/3-integration/12-advanced-transistors/README.md" + }, + { + "name": "背面供電世代", + "note": "把電源網路搬到晶圓背面,2020 年代中期開始導入量產。", + "source": "zh-TW/3-integration/12-advanced-transistors/README.md" + }, + { + "name": "SLC / MLC / TLC / QLC 儲存單元世代", + "note": "NAND 每個記憶單元分別存 1、2、3、4 個位元;位元越多,可抹寫次數越少、所需 ECC 越強。", + "source": "zh-TW/6-beyond-the-fab/24-process-and-firmware/README.md" + } + ], + "glossary": [ + { + "term": "活化", + "en": "Activation", + "definition": "透過退火讓植入的雜質原子進入晶格位置、開始導電", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "先進製程控制", + "en": "Advanced Process Control (APC)", + "definition": "R2R、FDC 等自動化製程控制方法的統稱", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "顯影後檢查", + "en": "After Develop Inspection (ADI)", + "definition": "微影完成後量測線寬、對準與缺陷", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "蝕刻後檢查", + "en": "After Etch Inspection (AEI)", + "definition": "蝕刻完成後量測線寬與缺陷", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "對準記號", + "en": "Alignment Mark", + "definition": "前一層留下、供曝光機確認位置的圖形", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "埃", + "en": "Angstrom (Å)", + "definition": "0.1 奈米,部分新世代節點的命名單位", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "非等向", + "en": "Anisotropic", + "definition": "主要往單一方向進行的蝕刻", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "退火", + "en": "Anneal", + "definition": "加熱修復晶格並活化雜質", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "去光阻(灰化)", + "en": "Ashing", + "definition": "用氧電漿去除光阻", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "深寬比", + "en": "Aspect Ratio", + "definition": "孔洞或溝槽的深度與寬度之比", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "原子層沉積", + "en": "Atomic Layer Deposition (ALD)", + "definition": "一次只長一層原子、極均勻的沉積方法", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "自動物料搬運系統", + "en": "Automated Material Handling System (AMHS)", + "definition": "由天車與倉儲組成,自動搬運晶圓盒", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "後段製程", + "en": "Back End of Line (BEOL)", + "definition": "晶圓廠內製作多層金屬導線的階段", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "背面供電", + "en": "Backside Power Delivery", + "definition": "把電源導線做在晶圓背面", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "壞區塊管理", + "en": "Bad Block Management", + "definition": "標記並避開 NAND 中無法使用的區塊", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "球柵陣列", + "en": "Ball Grid Array (BGA)", + "definition": "底部布滿錫球的封裝形式", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "阻障層", + "en": "Barrier Layer", + "definition": "防止銅擴散到絕緣層與矽中的薄層", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "批次式機台", + "en": "Batch Tool", + "definition": "一次處理多片晶圓的機台,例如爐管", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "浴缸曲線", + "en": "Bathtub Curve", + "definition": "失效率隨時間變化的曲線,兩端高、中間低", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "偏壓溫度不穩定性", + "en": "Bias Temperature Instability (BTI)", + "definition": "高溫與電壓讓 Vt 隨時間漂移", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "分類", + "en": "Bin", + "definition": "測試後依結果把晶粒分成的類別", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "分級", + "en": "Binning", + "definition": "依速度或功耗把同一種晶片分成不同等級", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "位元線", + "en": "Bit Line", + "definition": "記憶陣列中負責讀寫資料的導線", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "焊墊", + "en": "Bond Pad (Pad)", + "definition": "晶片上供測試探針與封裝連接的金屬接點", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "凸塊", + "en": "Bump", + "definition": "覆晶封裝中連接晶粒與基板的金屬凸點", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "預燒", + "en": "Burn-in", + "definition": "在高溫與較高電壓下運作,篩除早期失效", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "覆蓋層", + "en": "Cap Layer", + "definition": "銅 CMP 後蓋在銅表面、防止氧化與擴散的薄膜", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "線寬量測電子顯微鏡", + "en": "CD-SEM", + "definition": "用電子顯微鏡從上方量測線寬", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "腔體", + "en": "Chamber", + "definition": "機台中實際進行製程的密閉空間", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "通道", + "en": "Channel", + "definition": "電晶體中源極與汲極之間、受閘極控制的區域", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "通道孔", + "en": "Channel Hole", + "definition": "3D NAND 中貫穿整個堆疊的垂直孔", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "通道效應", + "en": "Channeling", + "definition": "離子沿著晶格的空隙鑽得太深的現象", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "化學機械研磨", + "en": "Chemical Mechanical Planarization (CMP)", + "definition": "結合化學與機械作用,把表面磨平或去除多餘材料", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "化學氣相沉積", + "en": "Chemical Vapor Deposition (CVD)", + "definition": "讓氣體在晶圓表面反應形成薄膜", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "晶圓針測", + "en": "Chip Probing (CP)", + "definition": "在晶圓上逐顆測試晶粒的功能", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "小晶片", + "en": "Chiplet", + "definition": "把大型晶片拆成多顆功能獨立的小晶片", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "無塵室(潔淨室)", + "en": "Cleanroom", + "definition": "嚴格控制微粒與環境的生產空間", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "共通性分析", + "en": "Commonality Analysis", + "definition": "找出異常晶圓共同經過的機台、腔體或時間", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "互補式金屬氧化物半導體", + "en": "Complementary Metal-Oxide-Semiconductor (CMOS)", + "definition": "NMOS 與 PMOS 成對使用的電路技術", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "接觸窗", + "en": "Contact", + "definition": "連接電晶體與第一層金屬的金屬孔", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "閘極間距", + "en": "Contacted Poly Pitch (CPP)", + "definition": "相鄰兩個閘極之間的距離", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "管制界限", + "en": "Control Limit", + "definition": "根據製程平常的表現算出的範圍,用來判斷製程是否異常", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "關鍵尺寸", + "en": "Critical Dimension (CD)", + "definition": "圖形最需要控制的尺寸,例如線寬", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "生產週期", + "en": "Cycle Time", + "definition": "一批晶圓從投片到完成所需的時間", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "柴可拉斯基長晶法", + "en": "Czochralski Method (CZ)", + "definition": "從熔融矽中拉出單晶晶棒的方法", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "大馬士革製程", + "en": "Damascene", + "definition": "先挖溝、再填金屬、最後研磨的導線做法", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "每層光罩天數", + "en": "Days per Mask Layer", + "definition": "生產週期除以光罩層數,用來比較生產效率", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "深紫外光", + "en": "Deep Ultraviolet (DUV)", + "definition": "248 nm 與 193 nm 的微影光源", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "缺陷", + "en": "Defect", + "definition": "晶圓上任何不該出現的異常", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "複檢", + "en": "Defect Review", + "definition": "用電子顯微鏡拍攝缺陷近照並分類", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "實驗設計", + "en": "Design of Experiments (DOE)", + "definition": "系統化地同時改變多個因子,找出最佳條件", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "設計與製程共同最佳化", + "en": "Design-Technology Co-Optimization (DTCO)", + "definition": "同時調整設計與製程,提升密度與效能", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "顯影", + "en": "Develop", + "definition": "用顯影液溶掉不要的光阻,讓圖形出現", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "切割", + "en": "Dicing", + "definition": "沿著切割道把晶圓切成晶粒", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "晶粒", + "en": "Die", + "definition": "晶圓上一個個相同的電路單元", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "碟化", + "en": "Dishing", + "definition": "寬金屬導線中央被磨得凹陷", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "摻雜", + "en": "Doping", + "definition": "刻意加入少量元素,改變半導體的導電特性", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "劑量", + "en": "Dose", + "definition": "離子佈植時單位面積植入的原子數量", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "汲極", + "en": "Drain", + "definition": "電晶體中電流流出的一端", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "乾蝕刻", + "en": "Dry Etch", + "definition": "用電漿進行的蝕刻", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "假圖形", + "en": "Dummy Fill", + "definition": "為了讓圖形密度均勻而加入的不通電圖形", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "擋片", + "en": "Dummy Wafer", + "definition": "用來填補機台空位或調整機台狀態的非產品晶圓", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "動態隨機存取記憶體", + "en": "Dynamic Random Access Memory (DRAM)", + "definition": "以電容存放電荷、需要定期刷新的記憶體", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "早期失效", + "en": "Early Failure", + "definition": "使用初期就發生的失效,可用預燒篩除", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "電遷移", + "en": "Electromigration (EM)", + "definition": "電流長期推動金屬原子,造成導線斷裂", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "電鍍", + "en": "Electroplating", + "definition": "在電鍍液中讓金屬沉積到晶圓上", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "靜電放電", + "en": "Electrostatic Discharge (ESD)", + "definition": "靜電瞬間放電造成的損傷,也是可靠度測試項目", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "終點偵測", + "en": "Endpoint Detection", + "definition": "判斷蝕刻或研磨是否到達目標的方法", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "工程變更通知", + "en": "Engineering Change Notice (ECN)", + "definition": "工廠內部的變更申請與審核流程", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "磊晶", + "en": "Epitaxy", + "definition": "長出延續底層晶格的單晶薄膜", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "侵蝕", + "en": "Erosion", + "definition": "導線密集的整個區域被磨得比周圍低", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "勘誤表", + "en": "Errata", + "definition": "晶片已知問題與避開方法的清單", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "錯誤更正碼", + "en": "Error Correction Code (ECC)", + "definition": "偵測並更正少量位元錯誤的編碼", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "蝕刻率", + "en": "Etch Rate", + "definition": "單位時間內蝕刻的深度", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "蝕刻停止層", + "en": "Etch Stop Layer", + "definition": "讓蝕刻停在正確位置的耐蝕刻薄層", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "曝光區", + "en": "Exposure Field (Shot)", + "definition": "曝光機一次曝光的區域", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "極紫外光", + "en": "Extreme Ultraviolet (EUV)", + "definition": "波長 13.5 nm 的微影光源", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "晶圓廠", + "en": "Fab", + "definition": "製造晶圓的工廠", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "IC 設計公司", + "en": "Fabless", + "definition": "只設計晶片、沒有自己工廠的公司", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "故障分析", + "en": "Failure Analysis (FA)", + "definition": "找出晶片失效位置與原因的分析", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "TODO", + "en": "Failures In Time (FIT)", + "definition": "每十億個元件小時中發生的失效次數", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "扇出型封裝", + "en": "Fan-Out Packaging", + "definition": "用重佈線層把接點擴展到晶粒範圍外的封裝", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "錯誤偵測與分類", + "en": "Fault Detection and Classification (FDC)", + "definition": "即時監控機台感測器數據、偵測異常的系統", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "鰭式場效電晶體", + "en": "Fin Field-Effect Transistor (FinFET)", + "definition": "閘極從三面包覆鰭狀通道的電晶體", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "最終測試", + "en": "Final Test (FT)", + "definition": "封裝後、出貨前的功能與電性測試", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "快閃記憶體轉換層", + "en": "Flash Translation Layer (FTL)", + "definition": "管理 NAND 邏輯位址與實體位址對應的韌體", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "覆晶", + "en": "Flip Chip", + "definition": "晶粒正面朝下,以凸塊直接連接基板", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "聚焦離子束", + "en": "Focused Ion Beam (FIB)", + "definition": "在指定位置精準切割的分析工具", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "晶圓代工", + "en": "Foundry", + "definition": "替客戶製造晶片的商業模式", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "前段製程", + "en": "Front End of Line (FEOL)", + "definition": "晶圓廠內製作電晶體的階段", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "前開式晶圓傳送盒", + "en": "Front Opening Unified Pod (FOUP)", + "definition": "300 mm 晶圓的密封搬運盒,最多裝 25 片", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "爐管", + "en": "Furnace", + "definition": "用於氧化、退火與部分 CVD 的高溫批次式機台", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "熔絲", + "en": "Fuse", + "definition": "用來切換備用電路或記錄晶片資料的可程式元件", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "預約 Hold", + "en": "Future Hold", + "definition": "讓晶圓走到指定步驟時自動被擋下", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "垃圾回收", + "en": "Garbage Collection", + "definition": "NAND 韌體整理無效資料、回收可用空間的機制", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "閘極", + "en": "Gate", + "definition": "控制電晶體開關的電極", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "閘極氧化層", + "en": "Gate Oxide", + "definition": "閘極與通道之間的薄絕緣層", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "環繞式閘極", + "en": "Gate-All-Around (GAA)", + "definition": "閘極從四面包覆通道的電晶體結構", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "半間距", + "en": "Half Pitch", + "definition": "間距的一半", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "分類機", + "en": "Handler", + "definition": "最終測試時搬運並分類封裝晶片的機台", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "硬遮罩", + "en": "Hard Mask", + "definition": "比光阻更耐蝕刻的遮罩薄膜", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "高頻寬記憶體", + "en": "High Bandwidth Memory (HBM)", + "definition": "以 TSV 堆疊多顆 DRAM 的記憶體", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "高溫操作壽命測試", + "en": "High Temperature Operating Life (HTOL)", + "definition": "在高溫下長時間通電運作的可靠度測試", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "高介電常數金屬閘極", + "en": "High-k Metal Gate (HKMG)", + "definition": "高介電常數絕緣層搭配金屬閘極的結構", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "高加速溫濕度測試", + "en": "Highly Accelerated Stress Test (HAST)", + "definition": "以高溫、高濕與加壓加速濕氣入侵的可靠度測試", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "扣留", + "en": "Hold", + "definition": "暫停晶圓或機台的生產,現場直接說 Hold", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "熱載子注入", + "en": "Hot Carrier Injection (HCI)", + "definition": "高能量電子進入閘極絕緣層,造成電晶體老化", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "急件", + "en": "Hot Lot", + "definition": "被設定為優先生產的批次", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "混合鍵合", + "en": "Hybrid Bonding", + "definition": "銅接點與絕緣層同時直接接合的晶片堆疊技術", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "浸潤式微影", + "en": "Immersion Lithography", + "definition": "在鏡頭與晶圓之間加水,提升解析能力", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "晶棒", + "en": "Ingot", + "definition": "長晶拉出的圓柱狀單晶矽", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "內側間隔物", + "en": "Inner Spacer", + "definition": "GAA 結構中隔開閘極與源極、汲極的內部絕緣層", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "檢測", + "en": "Inspection", + "definition": "在晶圓上尋找缺陷", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "整合元件製造廠", + "en": "Integrated Device Manufacturer (IDM)", + "definition": "自行設計、製造與銷售晶片的公司", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "防呆", + "en": "Interlock", + "definition": "讓錯誤操作無法發生的系統檢查規則", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "中介層", + "en": "Interposer", + "definition": "連接多顆晶片與封裝基板的轉接板", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "離子佈植", + "en": "Ion Implantation", + "definition": "把雜質離子加速後射入矽中", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "等向", + "en": "Isotropic", + "definition": "往各方向速度相同的蝕刻", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "致命缺陷", + "en": "Killer Defect", + "definition": "會造成晶片失效的缺陷", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "已知良好晶粒", + "en": "Known Good Die (KGD)", + "definition": "經過測試確認良好的晶粒", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "導線架", + "en": "Leadframe", + "definition": "承載晶粒並提供外部接腳的金屬框架", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "微影", + "en": "Lithography", + "definition": "用光把光罩圖形轉移到光阻上的製程,現場常叫黃光", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "晶圓盒裝載埠", + "en": "Load Port", + "definition": "機台前方放置晶圓盒的平台", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "負載效應", + "en": "Loading Effect", + "definition": "圖形疏密不同,造成蝕刻速度不同", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "批", + "en": "Lot", + "definition": "一起生產的一組晶圓,最多 25 片", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "履歷", + "en": "Lot History", + "definition": "晶圓經過的步驟、機台、時間與量測結果的完整紀錄", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "低壓化學氣相沉積", + "en": "Low Pressure CVD (LPCVD)", + "definition": "靠高溫提供能量、薄膜品質好的 CVD", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "低介電常數材料", + "en": "Low-k Dielectric (Low-k)", + "definition": "用來降低導線之間干擾的絕緣材料", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "製造執行系統", + "en": "Manufacturing Execution System (MES)", + "definition": "管理生產現場、記錄晶圓狀態與歷程的核心系統", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "光罩", + "en": "Mask / Reticle", + "definition": "帶有一層電路圖形的石英板", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "物料審查會議", + "en": "Material Review Board (MRB)", + "definition": "跨部門判定不合格品處置方式的會議", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "成熟製程", + "en": "Mature Node", + "definition": "已量產多年、成本較低的製程世代", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "金屬氧化物半導體場效電晶體", + "en": "Metal-Oxide-Semiconductor Field-Effect Transistor (MOSFET)", + "definition": "以閘極電壓控制通道導通的電晶體", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "量測", + "en": "Metrology", + "definition": "測量膜厚、線寬等具體數值", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "微凸塊", + "en": "Micro Bump", + "definition": "間距約數十微米的細小凸塊,用於晶片堆疊", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "中段製程", + "en": "Middle of Line (MOL)", + "definition": "把電晶體接到金屬層的階段", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "迷你環境", + "en": "Mini-environment", + "definition": "只讓晶圓所在的小空間維持極高潔淨度的設計", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "最小金屬間距", + "en": "Minimum Metal Pitch (MMP)", + "definition": "最密集那層金屬導線的間距", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "濕度敏感等級", + "en": "Moisture Sensitivity Level (MSL)", + "definition": "依封裝對濕氣的敏感程度所做的分級", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "控片", + "en": "Monitor Wafer", + "definition": "用來檢查機台狀態的非產品晶圓", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "摩爾定律", + "en": "Moore's Law", + "definition": "晶片上的電晶體數量大約每兩年增加一倍的趨勢", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "NAND 快閃記憶體", + "en": "NAND Flash", + "definition": "斷電後資料仍會保留的記憶體", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "奈米片", + "en": "Nanosheet", + "definition": "GAA 電晶體中水平堆疊的薄片通道", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "原生氧化層", + "en": "Native Oxide", + "definition": "矽表面接觸空氣後自然長出的薄氧化層", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "晶圓缺口", + "en": "Notch", + "definition": "晶圓邊緣標示方向的小缺口", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "光學關鍵尺寸量測", + "en": "Optical Critical Dimension (OCD)", + "definition": "分析光的散射,搭配模型推算立體輪廓", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "光學鄰近效應修正", + "en": "Optical Proximity Correction (OPC)", + "definition": "修改光罩圖形,補償成像時的變形", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "超出管制界限", + "en": "Out of Control (OOC)", + "definition": "數據超出管制界限,代表製程出現異常訊號", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "異常處理計畫", + "en": "Out of Control Action Plan (OCAP)", + "definition": "異常發生時事先規定好的處理步驟", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "超出規格", + "en": "Out of Spec (OOS)", + "definition": "數據超出規格界限,代表產品不合格", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "封裝測試廠", + "en": "Outsourced Semiconductor Assembly and Test (OSAT)", + "definition": "提供封裝與測試服務的公司", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "過度蝕刻", + "en": "Over Etch", + "definition": "到達終點後再多蝕刻一點,確保材料清乾淨", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "天車", + "en": "Overhead Hoist Transport (OHT)", + "definition": "在天花板軌道上搬運晶圓盒的設備", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "疊對誤差", + "en": "Overlay", + "definition": "上下兩層圖形之間的對準誤差", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "微粒", + "en": "Particle", + "definition": "落在晶圓上的微小顆粒,是最常見的缺陷來源", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "保護層", + "en": "Passivation", + "definition": "晶片最上方保護電路、只在焊墊處開口的絕緣層", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "光阻", + "en": "Photoresist (PR)", + "definition": "對光敏感、用來轉印圖形的材料", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "物理氣相沉積", + "en": "Physical Vapor Deposition (PVD)", + "definition": "以濺鍍等物理方式沉積薄膜", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "間距", + "en": "Pitch", + "definition": "重複圖形中一條線加上一個間隔的總寬度", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "電漿", + "en": "Plasma", + "definition": "被激發成離子、電子與活潑原子團混合的氣體", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "電漿輔助化學氣相沉積", + "en": "Plasma-Enhanced CVD (PECVD)", + "definition": "用電漿提供能量、可以在較低溫度進行的 CVD", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "PN 接面", + "en": "PN Junction", + "definition": "P 型與 N 型區域的交界", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "多晶矽", + "en": "Polysilicon (Poly)", + "definition": "由許多排列方向不同的小晶粒組成的矽", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "曝後烤", + "en": "Post-Exposure Bake (PEB)", + "definition": "曝光後加熱,讓光阻的化學反應完成", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "預測性保養", + "en": "Predictive Maintenance", + "definition": "從機台數據預測零件何時需要更換", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "預防保養", + "en": "Preventive Maintenance (PM)", + "definition": "定期停機保養機台", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "探針卡", + "en": "Probe Card", + "definition": "晶圓測試時接觸晶粒焊墊的探針組", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "針測機", + "en": "Prober", + "definition": "CP 測試時移動晶圓、讓探針對準晶粒的機台", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "製程能力指標", + "en": "Process Capability Index (Cpk)", + "definition": "描述製程落在規格內的從容程度", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "製程設計套件", + "en": "Process Design Kit (PDK)", + "definition": "晶圓廠提供給設計者的設計規則與電性模型", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "製程節點", + "en": "Process Node", + "definition": "代表某一代製程技術的名稱", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "產品/製程變更通知", + "en": "Product / Process Change Notification (PCN)", + "definition": "通知客戶產品或製程的變更", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "等待時間限制", + "en": "Queue Time (Q-Time)", + "definition": "兩個步驟之間允許等待的最長時間", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "快速熱退火", + "en": "Rapid Thermal Anneal (RTA)", + "definition": "在幾秒內完成的高溫退火", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "重複進入流程", + "en": "Re-entrant Flow", + "definition": "晶圓在流程中多次回到同一類機台的生產方式", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "配方", + "en": "Recipe", + "definition": "機台執行製程時使用的參數組合", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "重佈線層", + "en": "Redistribution Layer (RDL)", + "definition": "把接點重新分佈到其他位置的導線層", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "修補", + "en": "Redundancy Repair", + "definition": "用備用電路取代壞掉的記憶單元", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "刷新", + "en": "Refresh", + "definition": "定期重寫 DRAM 資料,避免電荷流失", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "放行", + "en": "Release", + "definition": "解除 Hold,讓晶圓繼續生產", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "可靠度", + "en": "Reliability", + "definition": "晶片在預定的使用條件下能正常工作多久", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "重工", + "en": "Rework", + "definition": "去除部分結果後重新製作", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "根本原因", + "en": "Root Cause", + "definition": "造成異常的最根本原因", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "流程", + "en": "Route", + "definition": "產品從頭到尾要經過的步驟與順序", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "TODO", + "en": "Row Hammer", + "definition": "反覆存取 DRAM 的同一列,造成相鄰列的位元翻轉", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "批次間控制", + "en": "Run-to-Run Control (R2R)", + "definition": "根據量測結果自動調整下一批的製程參數", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "犧牲層", + "en": "Sacrificial Layer", + "definition": "先做出來佔住位置、之後再移除的材料", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "抽樣", + "en": "Sampling", + "definition": "只量測部分批次、晶圓或位置", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "曝光機", + "en": "Scanner", + "definition": "把光罩圖形縮小投影到晶圓上的機台", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "報廢", + "en": "Scrap", + "definition": "晶圓不再繼續生產", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "切割道", + "en": "Scribe Line", + "definition": "晶粒之間預留給切割與測試鍵的區域", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "腔體調理", + "en": "Seasoning", + "definition": "保養後先跑擋片,讓腔體狀態穩定", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "機台通訊標準", + "en": "SECS/GEM", + "definition": "機台與工廠系統之間的共同通訊標準", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "晶種層", + "en": "Seed Layer", + "definition": "電鍍前先鋪的一層薄金屬,作為電鍍的起點", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "選擇比", + "en": "Selectivity", + "definition": "目標材料與其他材料的蝕刻速度比", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "淺溝槽隔離", + "en": "Shallow Trench Isolation (STI)", + "definition": "以填滿絕緣材料的淺溝槽隔開元件", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "片電阻", + "en": "Sheet Resistance (Rs)", + "definition": "薄層的電阻值,反映摻雜或膜厚", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "短通道效應", + "en": "Short-Channel Effect (SCE)", + "definition": "通道太短時閘極控制力下降、漏電增加", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "金屬矽化物", + "en": "Silicide", + "definition": "金屬與矽反應形成的低電阻化合物", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "絕緣層上覆矽", + "en": "Silicon on Insulator (SOI)", + "definition": "薄矽層下方埋有絕緣層的晶圓", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "單層/多層/三層/四層式儲存單元", + "en": "SLC / MLC / TLC / QLC", + "definition": "NAND 每個記憶單元分別存 1、2、3、4 個位元", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "研磨液", + "en": "Slurry", + "definition": "CMP 使用、含研磨顆粒與化學品的液體", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "軟錯誤", + "en": "Soft Error", + "definition": "由輻射粒子等造成、非永久性的位元錯誤", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "源極", + "en": "Source", + "definition": "電晶體中電流流入的一端", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "側壁間隔物", + "en": "Spacer", + "definition": "閘極兩側的絕緣結構", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "規格界限", + "en": "Specification Limit", + "definition": "產品要求的範圍,超出就代表不合格", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "分批實驗", + "en": "Split Lot", + "definition": "把同一批晶圓分組,使用不同條件的實驗", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "濺鍍", + "en": "Sputtering", + "definition": "用離子撞擊靶材,沉積金屬薄膜", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "階梯結構", + "en": "Staircase", + "definition": "3D NAND 邊緣一階一階的結構,讓接觸窗分別接到每一層", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "靜態隨機存取記憶體", + "en": "Static Random-Access Memory (SRAM)", + "definition": "常被拿來比較製程密度的記憶單元", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "統計製程管制", + "en": "Statistical Process Control (SPC)", + "definition": "以管制圖監控製程的穩定性", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "隱形切割", + "en": "Stealth Dicing", + "definition": "用雷射在晶圓內部形成改質層,再讓晶粒分開", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "階梯覆蓋", + "en": "Step Coverage", + "definition": "薄膜在高低起伏表面上的覆蓋均勻度", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "晶片版本", + "en": "Stepping", + "definition": "晶片修正設計後的版本代號", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "倉儲", + "en": "Stocker", + "definition": "暫時存放晶圓盒的自動倉庫", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "應變矽", + "en": "Strained Silicon", + "definition": "讓矽晶格輕微變形,使電子或電洞跑得更快", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "系統級測試", + "en": "System Level Test (SLT)", + "definition": "在接近實際產品的環境中測試晶片", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "靶材", + "en": "Target", + "definition": "PVD 濺鍍時被離子撞擊的材料來源", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "溫度循環", + "en": "Temperature Cycling (TC)", + "definition": "反覆在高溫與低溫之間切換的可靠度測試", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "測試鍵", + "en": "Test Key", + "definition": "切割道上專供電性量測的測試結構", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "測試機", + "en": "Tester", + "definition": "送出測試訊號並判斷晶片反應的機台", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "熱預算", + "en": "Thermal Budget", + "definition": "晶圓在製程中經歷的高溫總量", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "熱氧化", + "en": "Thermal Oxidation", + "definition": "在高溫下讓矽表面長出氧化層", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "臨界電壓", + "en": "Threshold Voltage (Vt)", + "definition": "讓電晶體導通所需的最低閘極電壓", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "矽穿孔", + "en": "Through-Silicon Via (TSV)", + "definition": "垂直貫穿晶片的金屬導線", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "時間相依介電崩潰", + "en": "Time-Dependent Dielectric Breakdown (TDDB)", + "definition": "絕緣層長期承受電場後被擊穿", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "進站與出站", + "en": "Track-in / Track-out", + "definition": "在系統上登記開始加工與完成加工", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "超純水", + "en": "Ultrapure Water (UPW)", + "definition": "幾乎不含任何雜質的清洗用水", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "底部填充", + "en": "Underfill", + "definition": "填入覆晶晶粒與基板之間的膠材", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "導通孔", + "en": "Via", + "definition": "連接上下兩層金屬的金屬孔", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "虛擬量測", + "en": "Virtual Metrology (VM)", + "definition": "用機台加工時的數據推估量測結果", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "晶圓", + "en": "Wafer", + "definition": "用來製造晶片的圓形矽薄片", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "晶圓允收測試", + "en": "Wafer Acceptance Test (WAT)", + "definition": "量測切割道上的測試鍵,確認製程結果", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "晶圓編號", + "en": "Wafer ID", + "definition": "雷射刻在晶圓上、用來追蹤每一片晶圓的編號", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "晶圓圖", + "en": "Wafer Map", + "definition": "晶圓上每顆晶粒測試結果的分佈圖", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "投片", + "en": "Wafer Start", + "definition": "把空白晶圓投入生產", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "晶圓級封裝", + "en": "Wafer-Level Packaging (WLP)", + "definition": "直接在晶圓上完成封裝,尺寸接近晶粒本身", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "平均抹寫", + "en": "Wear Leveling", + "definition": "讓 NAND 各區塊磨損平均的韌體技術", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "井", + "en": "Well", + "definition": "為了做出 NMOS 或 PMOS 而摻雜的區域", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "濕蝕刻", + "en": "Wet Etch", + "definition": "用化學溶液進行的蝕刻", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "打線接合", + "en": "Wire Bonding", + "definition": "用細金屬線連接晶粒與封裝", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "字元線", + "en": "Word Line", + "definition": "記憶陣列中控制記憶單元開關的導線", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "在製品", + "en": "Work in Process (WIP)", + "definition": "還在生產線上、尚未完成的晶圓", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "良率", + "en": "Yield", + "definition": "好產品占全部產品的比例", + "source": "zh-TW/appendix/glossary.md" + }, + { + "term": "良率學習曲線", + "en": "Yield Learning Curve", + "definition": "新製程良率隨時間逐步爬升的過程", + "source": "zh-TW/appendix/glossary.md" + } + ], + "facts": [ + { + "text": "一顆指甲大小的晶片上,可能有數十億個電晶體,全部在一片矽晶圓上製造出來。", + "source": "zh-TW/README.md" + }, + { + "text": "晶圓從沙子到可用的晶片,要經過數百到上千個步驟,前段製程通常要兩到三個月甚至更久。", + "source": "zh-TW/0-before-you-start/00-overview/README.md" + }, + { + "text": "Lot 是一起移動、一起生產的一組晶圓,最多 25 片,裝在同一個晶圓盒裡。", + "source": "zh-TW/0-before-you-start/00-overview/README.md" + }, + { + "text": "晶片要提前好幾個月投片,所以需求突然增加時,供貨無法馬上恢復。", + "source": "zh-TW/0-before-you-start/00-overview/README.md" + }, + { + "text": "晶圓廠裡人是最大的污染源,皮屑、頭髮、汗水與化妝品都會帶來微粒,所以進無塵室前要穿無塵衣並經過風淋室。", + "source": "zh-TW/0-before-you-start/01-how-a-fab-works/README.md" + }, + { + "text": "裝滿晶圓的 FOUP 大約有八公斤重,因此晶圓搬運交給天車與倉儲等自動化系統。", + "source": "zh-TW/0-before-you-start/01-how-a-fab-works/README.md" + }, + { + "text": "機台保養完成後,要先跑測試晶圓確認合格(現場叫 Qual)才能開放跑產品;第一批產品若跑進有問題的機台,可能整批報廢。", + "source": "zh-TW/0-before-you-start/01-how-a-fab-works/README.md" + }, + { + "text": "工廠裡的急件(Hot Lot)太多,會讓其他晶圓排隊更久;如果每批都是急件,等於沒有急件。", + "source": "zh-TW/0-before-you-start/01-how-a-fab-works/README.md" + }, + { + "text": "矽是最主要的半導體材料,沙子的主要成分就是矽的氧化物,而矽表面能長出穩定的二氧化矽絕緣層。", + "source": "zh-TW/1-fundamentals/02-transistor-basics/README.md" + }, + { + "text": "在幾百萬個矽原子中只加入一個雜質原子,導電能力就會大幅改變。", + "source": "zh-TW/1-fundamentals/02-transistor-basics/README.md" + }, + { + "text": "同一製程常會提供不同臨界電壓(Vt)的電晶體:低 Vt 快但漏電多,高 Vt 慢但省電,設計者可在同一顆晶片上混用。", + "source": "zh-TW/1-fundamentals/02-transistor-basics/README.md" + }, + { + "text": "電子級多晶矽的純度可達「11 個 9」,相當於一千億個矽原子中雜質不到一個。", + "source": "zh-TW/1-fundamentals/03-sand-to-wafer/README.md" + }, + { + "text": "一根 300 mm 晶棒可以長達兩公尺左右、重達數百公斤,長晶時還會加入硼或磷來決定晶圓的導電型態。", + "source": "zh-TW/1-fundamentals/03-sand-to-wafer/README.md" + }, + { + "text": "晶圓邊緣的小 V 形缺口(Notch)用來讓機台把晶圓轉到固定方向,因為矽在不同方向的性質不同。", + "source": "zh-TW/1-fundamentals/03-sand-to-wafer/README.md" + }, + { + "text": "300 mm 晶圓的面積是 200 mm 的 2.25 倍,但加工成本沒有增加那麼多,所以平均每顆晶粒更便宜。", + "source": "zh-TW/1-fundamentals/03-sand-to-wafer/README.md" + }, + { + "text": "工廠的很多晶圓不是產品:控片用來檢查機台,擋片用來填滿批次機台的空位,用過的可以研磨回收再用。", + "source": "zh-TW/1-fundamentals/03-sand-to-wafer/README.md" + }, + { + "text": "微影區的燈光是黃色的,因為光阻對紫外光和藍光敏感,黃光幾乎不會讓它起反應。", + "source": "zh-TW/2-process-modules/04-lithography/README.md" + }, + { + "text": "EUV 的波長只有 13.5 nm,空氣和玻璃都會吸收它,所以整台機器的光路要在真空中,並只能用反射鏡而不能用透鏡。", + "source": "zh-TW/2-process-modules/04-lithography/README.md" + }, + { + "text": "微影是整個製程中唯一可以「重來」的關卡:光阻可以去除後重做(Rework),一旦進入蝕刻,錯誤圖形就會永久刻進材料。", + "source": "zh-TW/2-process-modules/04-lithography/README.md" + }, + { + "text": "先進製程中,層與層之間容許的對準誤差只有幾奈米,大約是幾十個原子的寬度。", + "source": "zh-TW/2-process-modules/04-lithography/README.md" + }, + { + "text": "原子層沉積(ALD)一次只長出約一層原子厚的薄膜,極均勻但速度慢,常用在先進電晶體的閘極絕緣層。", + "source": "zh-TW/2-process-modules/05-thin-films/README.md" + }, + { + "text": "蝕刻的終點可以從電漿發出的光判斷:碰到下一層材料時,電漿成分改變,光的顏色和強度也跟著變。", + "source": "zh-TW/2-process-modules/06-etching/README.md" + }, + { + "text": "記憶體的高深寬比蝕刻,孔深可以是孔寬的數十倍,是目前最困難的製程之一。", + "source": "zh-TW/2-process-modules/06-etching/README.md" + }, + { + "text": "離子佈植的錯誤從外觀看不出來,往往要等幾週後的電性測試才會發現,而且無法重做。", + "source": "zh-TW/2-process-modules/07-ion-implantation/README.md" + }, + { + "text": "佈植時會讓晶圓稍微傾斜,因為離子若沿著晶格的「隧道」方向射入,會一路滑得太深,稱為通道效應。", + "source": "zh-TW/2-process-modules/07-ion-implantation/README.md" + }, + { + "text": "每一次高溫步驟都會讓先前佈植的雜質再擴散一點,因此需要高溫的步驟要盡量排在前面,這就是熱預算的概念。", + "source": "zh-TW/2-process-modules/07-ion-implantation/README.md" + }, + { + "text": "CMP 的研磨墊、研磨液都是消耗品,研磨墊用久了研磨速度會慢慢改變,工廠會依前幾批的量測結果微調下一批的研磨時間。", + "source": "zh-TW/2-process-modules/08-cmp/README.md" + }, + { + "text": "晶片上很多沒有連到電路的小方塊是「假圖形」,用來讓圖形密度平均,確保 CMP 磨得均勻。", + "source": "zh-TW/2-process-modules/08-cmp/README.md" + }, + { + "text": "CMP 後晶圓要立刻清洗,因為研磨液一旦在表面乾掉,顆粒會牢牢黏住,很難再洗掉;很多 CMP 機台內建清洗模組,做到「乾進乾出」。", + "source": "zh-TW/2-process-modules/08-cmp/README.md" + }, + { + "text": "RCA 清洗是 1960 年代發展出來的經典方法,至今仍是很多清洗配方的基礎。", + "source": "zh-TW/2-process-modules/09-cleaning/README.md" + }, + { + "text": "汗水中的鈉離子若進入閘極氧化層,會到處移動,讓電晶體的 Vt 漂移。", + "source": "zh-TW/2-process-modules/09-cleaning/README.md" + }, + { + "text": "Q-Time 不只用在清洗:銅 CMP 後到鋪覆蓋層之間同樣有等待時間限制。", + "source": "zh-TW/2-process-modules/09-cleaning/README.md" + }, + { + "text": "銅在矽中擴散得非常快,一點點跑進電晶體區域就會造成漏電,所以銅製程的機台與晶圓盒要和前段嚴格分開。", + "source": "zh-TW/2-process-modules/09-cleaning/README.md" + }, + { + "text": "「大馬士革製程」的名字來自古代大馬士革的鑲嵌工藝:在器物表面刻出凹槽、填入金屬,再把表面磨平。", + "source": "zh-TW/2-process-modules/10-metallization/README.md" + }, + { + "text": "銅導線很難用乾蝕刻挖出圖形,所以產業改用「先挖溝、再填銅」的完全相反做法。", + "source": "zh-TW/2-process-modules/10-metallization/README.md" + }, + { + "text": "電鍍銅時,電鍍液會加入特殊添加劑,讓孔洞底部的銅長得比開口快,才能由下往上填滿。", + "source": "zh-TW/2-process-modules/10-metallization/README.md" + }, + { + "text": "一個簡單的 CMOS 製程大約需要二十層左右的光罩,最先進的製程加上多重曝光可以達到數十層以上。", + "source": "zh-TW/3-integration/11-cmos-process-flow/README.md" + }, + { + "text": "業界常用「每層光罩天數」(生產週期除以光罩層數)來公平比較不同產品的生產效率,常見水準約每層一到兩天。", + "source": "zh-TW/3-integration/11-cmos-process-flow/README.md" + }, + { + "text": "先進製程常用「先做假的、再換成真的」的做法:先用多晶矽做假閘極,高溫步驟後再換成金屬閘極。", + "source": "zh-TW/3-integration/11-cmos-process-flow/README.md" + }, + { + "text": "GAA 製程中最關鍵的一步,是用選擇比極高的蝕刻把夾在中間的矽鍺層挖掉,留下懸空的矽通道。", + "source": "zh-TW/3-integration/12-advanced-transistors/README.md" + }, + { + "text": "FinFET 的驅動能力取決於用了幾根鰭,所以設計時只能選「一根、兩根、三根」,無法任意調整。", + "source": "zh-TW/3-integration/12-advanced-transistors/README.md" + }, + { + "text": "3D NAND 早期靠縮小不再可行,產業改為往上疊,現在的堆疊可達數百層。", + "source": "zh-TW/3-integration/13-memory/README.md" + }, + { + "text": "現代 DRAM 的電容是高度為寬度數十倍的細長圓柱,像一根根摩天大樓。", + "source": "zh-TW/3-integration/13-memory/README.md" + }, + { + "text": "數百層薄膜累積的應力足以讓整片晶圓翹曲,嚴重時機械手臂甚至無法正常取放晶圓。", + "source": "zh-TW/3-integration/13-memory/README.md" + }, + { + "text": "HBM 把多顆 DRAM 垂直疊起來,晶片之間靠貫穿矽基板的 TSV 連接,常見 8 顆、12 顆甚至更多。", + "source": "zh-TW/3-integration/13-memory/README.md" + }, + { + "text": "記憶體的備用行與列可以用熔絲切換來取代壞掉的單元,這就是修補(Repair),能把原本該報廢的晶片救回來。", + "source": "zh-TW/3-integration/13-memory/README.md" + }, + { + "text": "「3 奈米製程」的晶片上找不到 3 奈米的結構,節點名稱只是世代代號,不同公司的名稱也無法直接比較。", + "source": "zh-TW/3-integration/14-process-nodes/README.md" + }, + { + "text": "大量的晶片仍使用二十多奈米以上的成熟製程,例如汽車電子、電源管理、微控制器與感測器驅動晶片。", + "source": "zh-TW/3-integration/14-process-nodes/README.md" + }, + { + "text": "PDK(製程設計套件)是晶圓廠提供給設計者的設計規則與電性模型,設計者依此設計才能確保晶片做得出來。", + "source": "zh-TW/3-integration/14-process-nodes/README.md" + }, + { + "text": "每顆晶粒的 CP 結果會畫成晶圓圖,壞晶粒的形狀(中心、邊緣、刮痕、每個曝光區相同位置等)常是找出原因的第一條線索。", + "source": "zh-TW/4-yield-and-quality/16-yield-analysis/README.md" + }, + { + "text": "整體良率是各階段良率相乘,例如 98% × 90% × 99%,整體只剩大約 87%。", + "source": "zh-TW/4-yield-and-quality/16-yield-analysis/README.md" + }, + { + "text": "若每個曝光區的同一位置都壞掉,往往是光罩缺陷,因為曝光機每次都使用同一片光罩。", + "source": "zh-TW/4-yield-and-quality/16-yield-analysis/README.md" + }, + { + "text": "製程控制中,管制界限通常比規格界限窄:超出管制界限是預警(OOC),超出規格界限才是產品不合格(OOS)。", + "source": "zh-TW/4-yield-and-quality/17-process-control/README.md" + }, + { + "text": "R2R 會自動補償機台偏移,但補償可能掩蓋問題,所以也要監控調整量本身,避免機台持續惡化而不自知。", + "source": "zh-TW/4-yield-and-quality/17-process-control/README.md" + }, + { + "text": "異常發生時,第一件事通常是 Hold:先擋住晶圓、停用機台,而不是邊查邊生產。", + "source": "zh-TW/4-yield-and-quality/18-abnormality-handling/README.md" + }, + { + "text": "影響範圍要從機台「上一次確認正常」的時間點算起,因為異常可能早在被發現之前就開始了。", + "source": "zh-TW/4-yield-and-quality/18-abnormality-handling/README.md" + }, + { + "text": "任何工程變更,即使只是調整一個溫度參數,都可能影響可靠度,所以都要經過評估與驗證。", + "source": "zh-TW/4-yield-and-quality/18-abnormality-handling/README.md" + }, + { + "text": "WAT 全部合格不代表每顆晶粒都是好的,WAT 只量切割道上少數測試鍵,無法代表整片晶圓。", + "source": "zh-TW/5-backend/19-wafer-test/README.md" + }, + { + "text": "通過 CP 的晶粒稱為已知良好晶粒(KGD),封裝時只挑出這些晶粒。", + "source": "zh-TW/5-backend/19-wafer-test/README.md" + }, + { + "text": "封裝吸收的水氣在焊接時會瞬間汽化膨脹,造成內部脫層甚至爆裂,這種現象被形象地稱為「爆米花效應」。", + "source": "zh-TW/5-backend/20-packaging/README.md" + }, + { + "text": "晶背研磨後晶圓可能只剩一百微米左右,薄得像紙一樣容易彎曲與破裂。", + "source": "zh-TW/5-backend/20-packaging/README.md" + }, + { + "text": "隱形切割是用雷射在晶圓內部打出改質層,再撐開膠膜讓晶粒沿改質層分開。", + "source": "zh-TW/5-backend/20-packaging/README.md" + }, + { + "text": "曝光機一次曝光的範圍約為 26 mm × 33 mm,這限制了單顆晶粒的大小,因此中介層有時需要多次曝光拼接。", + "source": "zh-TW/5-backend/21-advanced-packaging/README.md" + }, + { + "text": "8 顆晶片每顆良率都是 99% 時,全部都好的機率只剩大約 92%,所以先進封裝一定要使用已知良好晶粒。", + "source": "zh-TW/5-backend/21-advanced-packaging/README.md" + }, + { + "text": "晶片的失效率隨時間變化像一個浴缸:早期失效、穩定期、老化期;預燒就是把浴缸左邊那段先在工廠裡燒掉。", + "source": "zh-TW/5-backend/22-final-test-and-reliability/README.md" + }, + { + "text": "可靠度常用 FIT 表示,代表每十億個元件小時中發生的失效次數,是從大量樣本推算出來的統計結果。", + "source": "zh-TW/5-backend/22-final-test-and-reliability/README.md" + }, + { + "text": "車用晶片要求更寬的工作溫度與更長壽命,目標是「零缺陷」,驗證標準也比消費性產品嚴格。", + "source": "zh-TW/5-backend/22-final-test-and-reliability/README.md" + }, + { + "text": "MES 中的 Track-in 和 Track-out 是系統上的登記;系統顯示與實體晶圓不一致,現場稱為「帳實不符」。", + "source": "zh-TW/6-beyond-the-fab/23-fab-systems/README.md" + }, + { + "text": "工廠的防呆規則,很多都是從過去的事故學來的:規則看起來麻煩,往往是因為它擋下的錯誤很少再發生。", + "source": "zh-TW/6-beyond-the-fab/23-fab-systems/README.md" + }, + { + "text": "DRAM 的電容會漏電,所以必須定期刷新,通常每幾十毫秒就要把所有資料刷新一輪;溫度越高,刷新越頻繁。", + "source": "zh-TW/6-beyond-the-fab/24-process-and-firmware/README.md" + }, + { + "text": "2014 年公開發表的 Row Hammer 現象:反覆快速存取同一列記憶體,可能讓相鄰列的資料翻轉,也成為安全問題。", + "source": "zh-TW/6-beyond-the-fab/24-process-and-firmware/README.md" + }, + { + "text": "晶片的 Stepping 版本號:字母改變代表底層電晶體也改了、需要重做大部分光罩;數字改變代表只改上層金屬導線。", + "source": "zh-TW/6-beyond-the-fab/24-process-and-firmware/README.md" + }, + { + "text": "NAND 不能直接覆寫、必須先抹除,而每個單元存的位元越多,可抹寫的次數越少、所需的 ECC 越強。", + "source": "zh-TW/6-beyond-the-fab/24-process-and-firmware/README.md" + } + ] +} diff --git a/empire/data/events.json b/empire/data/events.json new file mode 100644 index 0000000..0fa13bb --- /dev/null +++ b/empire/data/events.json @@ -0,0 +1,39 @@ +{ + "version": 1, + "events": { + "power_outage": { + "name": "停電", + "effect": "產線停擺 1 日,在製 Lot 全部報廢,聲望 -2。", + "effectUps": "已安裝 UPS:產線停擺 1 日,但只損失 1 個 Lot,聲望 -2。", + "factId": "fact#54", + "choices": [ { "id": "ok", "label": "確定" } ] + }, + "particle": { + "name": "微粒污染", + "effect": "3 日內缺陷率 x1.5(良率下降),聲望 -1。", + "factId": "fact#4", + "choices": [ + { "id": "clean", "label": "停線清洗(停 4 小時後立即解除)" }, + { "id": "keep", "label": "硬撐下去" } + ] + }, + "rush_order": { + "name": "急件訂單", + "effect": "3 日內第一個出貨的 Lot 以 2.5 倍價格賣出;但期間所有製程時間 x1.25。", + "factId": "fact#7", + "choices": [ + { "id": "accept", "label": "接單" }, + { "id": "decline", "label": "拒絕(無影響)" } + ] + }, + "tool_failure": { + "name": "機台故障", + "effect": "「{tool}」故障停擺,聲望 -1。", + "factId": "fact#6", + "choices": [ + { "id": "repair", "label": "立刻修復" }, + { "id": "wait", "label": "等待自行修復(3 日)" } + ] + } + } +} diff --git a/empire/index.html b/empire/index.html new file mode 100644 index 0000000..062d0e3 --- /dev/null +++ b/empire/index.html @@ -0,0 +1,55 @@ + + + + + + + + 晶圓帝國 Wafer Empire + + + + + + + +
+ +

晶圓帝國

+

WAFER EMPIRE

+

從 180 nm 的小廠起家:買機台、排產線、賣晶圓;通過製程解謎,開通更先進的晶圓廠。

+
+ +

純 HTML / CSS / JS · 存檔保存在本機瀏覽器 · 內容取自《半導體製造概論》

+
+ + + + +
+ + + + diff --git a/empire/js/audio.js b/empire/js/audio.js new file mode 100644 index 0000000..120aac2 --- /dev/null +++ b/empire/js/audio.js @@ -0,0 +1,70 @@ +// WebAudio 合成音效(無音檔):click / clear / star / alarm / purchase。 +// 靜音狀態存 localStorage(全部 try/catch;隱私模式下僅記憶體)。 +const KEY = 'waferEmpire.mute'; +let ctx = null; +let master = null; +let muted = false; +let volume = () => 0.5; +const listeners = new Set(); + +try { muted = localStorage.getItem(KEY) === '1'; } catch (e) { /* ignore */ } + +export const isMuted = () => muted; +export function onMuteChange(fn) { listeners.add(fn); return () => listeners.delete(fn); } +export function setVolumeSource(fn) { volume = fn; } +export function setMuted(m) { + muted = !!m; + try { localStorage.setItem(KEY, muted ? '1' : '0'); } catch (e) { /* ignore */ } + for (const fn of [...listeners]) { try { fn(muted); } catch (e) { /* ignore */ } } +} +export const toggleMute = () => { setMuted(!muted); if (!muted) sfx.click(); return muted; }; + +function ensure() { + if (ctx) return ctx; + try { + const AC = window.AudioContext || window.webkitAudioContext; + if (!AC) return null; + ctx = new AC(); + master = ctx.createGain(); + master.connect(ctx.destination); + } catch (e) { ctx = null; } + return ctx; +} + +/** 須在使用者手勢內呼叫一次(pointerdown / keydown) */ +export function unlockAudio() { + const c = ensure(); + if (c && c.state === 'suspended') c.resume().catch(() => {}); +} + +function tone(freq, at, dur, { type = 'sine', vol = 0.2, to = 0 } = {}) { + const c = ctx; + const t0 = c.currentTime + at; + const o = c.createOscillator(); + const g = c.createGain(); + o.type = type; + o.frequency.setValueAtTime(freq, t0); + if (to) o.frequency.exponentialRampToValueAtTime(to, t0 + dur); + g.gain.setValueAtTime(0.0001, t0); + g.gain.exponentialRampToValueAtTime(vol, t0 + 0.008); + g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); + o.connect(g); g.connect(master); + o.start(t0); o.stop(t0 + dur + 0.02); +} + +function play(fn) { + if (muted) return; + const v = Math.max(0, Math.min(1, Number(volume()) || 0)); + if (v === 0) return; + const c = ensure(); + if (!c || c.state !== 'running') return; + try { master.gain.value = v; fn(); } catch (e) { /* ignore */ } +} + +export const sfx = { + click: () => play(() => tone(720, 0, 0.05, { type: 'square', vol: 0.08, to: 520 })), + purchase: () => play(() => { tone(988, 0, 0.09, { type: 'square', vol: 0.12 }); tone(1319, 0.08, 0.2, { type: 'square', vol: 0.12 }); }), + clear: () => play(() => { [523, 659, 784, 1047].forEach((f, i) => tone(f, i * 0.09, 0.22, { type: 'triangle', vol: 0.2 })); }), + star: (i = 0) => play(() => tone(1175 * Math.pow(1.122, i * 2), 0, 0.28, { type: 'sine', vol: 0.22, to: 1175 * Math.pow(1.122, i * 2) * 1.01 })), + alarm: () => play(() => { for (let i = 0; i < 4; i++) tone(i % 2 ? 660 : 880, i * 0.16, 0.14, { type: 'sawtooth', vol: 0.1 }); }), +}; diff --git a/empire/js/main.js b/empire/js/main.js new file mode 100644 index 0000000..b1cec2a --- /dev/null +++ b/empire/js/main.js @@ -0,0 +1,278 @@ +// 晶圓帝國:啟動、標題畫面、hash 路由(DESIGN §2)、總覽、音效、教學; +// 把經營層(tycoon)與解謎層(puzzle)接在一起。 +import { bus } from './shared/bus.js'; +import { loadSave } from './shared/storage.js'; +import { getFactText } from './shared/content.js'; +import { bootTycoon } from './tycoon/boot.js'; +import * as Clock from './tycoon/clock.js'; +import { fabStatsNow } from './tycoon/sim.js'; +import { openUnlockSheet } from './tycoon/unlock-view.js'; +import { el, fill, openDialog, toast, money, pct } from './tycoon/ui.js'; +import './puzzle/index.js'; // 註冊 window.startPuzzleSet,取代 bridge 的 stub +import * as Audio from './audio.js'; +import { createTutorial } from './tutorial.js'; + +const $ = id => document.getElementById(id); +const parse = h => { const p = (h || '').replace(/^#/, '').split('/'); return { name: p[0] || 'title', arg: p[1] || '' }; }; +const replaceHash = h => { try { history.replaceState(null, '', h); } catch (e) { location.hash = h; } }; + +let S = null, view = null, tut = null; +let screen = 'title'; // 'title' | 'game' +let curView = 'hub'; // 'hub' | 'fab' +let titleLock = false; +let unlockDlg = null, unlockPushed = false, goFab = null; +let puzzleActive = false; + +// ---------------- 音效 ---------------- +function installAudio() { + const arm = () => Audio.unlockAudio(); + document.addEventListener('pointerdown', arm, true); + document.addEventListener('keydown', arm, true); + document.addEventListener('click', e => { + const b = e.target.closest?.('button, [role="button"], a[href]'); + if (b && !b.disabled) Audio.sfx.click(); + }, true); + bus.on('money:spent', () => Audio.sfx.purchase()); + bus.on('event:trigger', () => Audio.sfx.alarm()); + bus.on('fab:unlocked', () => Audio.sfx.clear()); + // 解謎過關:結算層加入 body 時播放;依星數播放星星音 + new MutationObserver(muts => { + for (const m of muts) for (const n of m.addedNodes) { + if (n.nodeType === 1 && n.classList.contains('pz-result-back')) { + Audio.sfx.clear(); + const stars = n.querySelectorAll('.pz-big-stars .pz-star-on').length; + for (let i = 0; i < stars; i++) setTimeout(() => Audio.sfx.star(i), 350 + i * 180); + } + } + }).observe(document.body, { childList: true }); +} +function muteButton(cls = 'we-btn') { + const b = el('button', { class: cls + ' we-mute', type: 'button' }); + const sync = () => { + const m = Audio.isMuted(); + b.textContent = m ? '音效:關' : '音效:開'; + b.setAttribute('aria-pressed', String(!m)); + b.setAttribute('aria-label', m ? '開啟音效' : '關閉音效'); + }; + b.addEventListener('click', () => Audio.toggleMute()); + Audio.onMuteChange(sync); + sync(); + return b; +} + +// ---------------- 畫面切換 ---------------- +function setTitleLock(on) { + if (on && !titleLock) { Clock.lock(); titleLock = true; } + else if (!on && titleLock) { Clock.unlock(); titleLock = false; } +} +function showTitle() { + screen = 'title'; + $('screen-title').hidden = false; + $('screen-game').hidden = true; + setTitleLock(true); + renderTitle(); +} +function enterGame() { + screen = 'game'; + $('screen-title').hidden = true; + $('screen-game').hidden = false; + setTitleLock(false); +} +function showView(name) { + curView = name; + $('view-hub').hidden = name !== 'hub'; + $('view-fab').hidden = name !== 'fab'; + document.querySelectorAll('[data-nav]').forEach(b => b.setAttribute('aria-current', String(b.dataset.nav === name))); + if (name === 'hub') renderHub(); +} + +// ---------------- 路由 ---------------- +function closeUnlock() { unlockDlg?.close(); } +function exitPuzzle() { document.querySelector('#puzzle-root [data-tut="btn-exit"]')?.click(); } + +function route() { + const { name, arg } = parse(location.hash); + if (!S) return; + if (name === 'title') { if (puzzleActive) exitPuzzle(); closeUnlock(); showTitle(); return; } + if (screen !== 'game') { + if (!loadSave().ok && !S.getState().savedAt) { replaceHash('#title'); showTitle(); return; } + enterGame(); + } + const bal = S.getBal(); + const fabId = arg.split('-')[0]; + const validFab = bal.fabOrder.includes(fabId); + + if (name === 'puzzle') { + if (puzzleActive) return; + replaceHash(validFab ? '#unlock/' + fabId : '#hub'); + route(); return; + } + if (puzzleActive) exitPuzzle(); + if (name === 'unlock' && validFab) { + if (!$('view-hub').hidden || !$('view-fab').hidden) { /* 保持底下畫面 */ } else showView('hub'); + if (!unlockDlg || unlockDlg.fabId !== fabId) { closeUnlock(); openUnlockSheet(fabId); } + return; + } + closeUnlock(); + if (name === 'fab' && validFab) { + if (!S.isUnlocked(fabId)) { replaceHash('#unlock/' + fabId); route(); return; } + view.setFab(fabId); + showView('fab'); + return; + } + if (name !== 'hub') replaceHash('#hub'); + showView('hub'); +} + +function installRouterEvents() { + window.addEventListener('hashchange', route); + bus.on('unlock:open', ({ fabId, dlg }) => { + unlockDlg = dlg; dlg.fabId = fabId; + const h = '#unlock/' + fabId; + if (location.hash !== h) { unlockPushed = true; location.hash = h; } else unlockPushed = false; + }); + bus.on('unlock:close', () => { + unlockDlg = null; + if (parse(location.hash).name === 'unlock') { + if (goFab) { const f = goFab; goFab = null; replaceHash('#fab/' + f); route(); return; } + if (unlockPushed) history.back(); + else { replaceHash(curView === 'fab' ? '#fab/' + view.getFab() : '#hub'); } + } + }); + bus.on('fab:unlocked', ({ fabId }) => { goFab = fabId; }); + bus.on('puzzle:start', ({ fabId }) => { + puzzleActive = true; + const h = '#puzzle/' + fabId; + if (location.hash !== h) location.hash = h; + }); + bus.on('puzzle:end', () => { + puzzleActive = false; + if (parse(location.hash).name === 'puzzle') history.back(); + }); + bus.on('puzzle:applied', ({ fabId }) => { + const bal = S.getBal(); + const r = S.starRatio(fabId); + toast(`${bal.fabs[fabId].zh} 解謎星數比 S = ${pct(r, 0)},缺陷率 ×${(1 - bal.yield.starHalf * r).toFixed(2)}`, 3600); + }); +} + +// ---------------- 標題 ---------------- +let hadSave = false; +function renderTitle() { + const menu = $('title-menu'); + const items = []; + const startNew = () => { + Audio.unlockAudio(); + S.resetGame(); + S.save(); + enterGame(); + location.hash = '#fab/f1'; + route(); + tut.start({ fresh: true }); + }; + if (hadSave) { + items.push(el('button', { class: 'we-btn we-btn--primary', type: 'button', text: '繼續遊戲', onclick: () => { + Audio.unlockAudio(); + S.getState().clock.paused = true; + enterGame(); + if (tut.needsResume()) { location.hash = '#fab/f1'; route(); tut.start(); } + else { location.hash = '#hub'; route(); } + } })); + items.push(el('button', { class: 'we-btn we-btn--danger', type: 'button', text: '新遊戲(覆蓋存檔)', onclick: function () { + if (this.dataset.armed) startNew(); + else { this.dataset.armed = '1'; this.textContent = '再按一次確認:舊存檔將被清除'; } + } })); + } else { + items.push(el('button', { class: 'we-btn we-btn--primary', type: 'button', text: '新遊戲', onclick: startNew })); + } + items.push(muteButton()); + fill(menu, items); +} + +// ---------------- 工具列 / 總覽 / 圖鑑 ---------------- +function mountBar() { + const nav = (txt, h, key) => el('button', { class: 'we-btn', type: 'button', text: txt, 'data-nav': key, onclick: () => { location.hash = h; } }); + fill($('we-bar'), + el('span', { class: 'we-brand' }, '晶圓', el('i', { text: '帝國' })), + nav('總覽', '#hub', 'hub'), + nav('產線', '#fab/f1', 'fab-link'), + el('button', { class: 'we-btn', type: 'button', text: '圖鑑', onclick: openCodex }), + el('button', { class: 'we-btn', type: 'button', text: '教學', 'aria-label': '重看教學', onclick: () => { + location.hash = '#fab/f1'; route(); tut.start({ fresh: true }); + } }), + muteButton(), + el('button', { class: 'we-btn', type: 'button', text: '標題', onclick: () => { location.hash = '#title'; } })); + // 「產線」按鈕:回到目前選的廠 + $('we-bar').querySelector('[data-nav="fab-link"]').onclick = () => { location.hash = '#fab/' + view.getFab(); }; +} + +function renderHub() { + if ($('view-hub').hidden) return; + const bal = S.getBal(), s = S.getState(); + const cards = bal.fabOrder.map(id => { + const f = bal.fabs[id], open = S.isUnlocked(id), r = S.starRatio(id); + const nStars = Object.entries(s.puzzle.stars).filter(([k]) => k.startsWith(id + '-')).reduce((a, [, n]) => a + n, 0); + const starTxt = '★'.repeat(nStars) + '☆'.repeat(f.puzzleLevels * 3 - nStars); + let body; + if (open) { + const st = fabStatsNow(id); + body = [ + ['線良率 Y', st.complete ? pct(st.Y) : '缺機台'], ['產能', st.interval ? `${Math.round(st.wafersPerDay)} 片/日` : '—'], + ['庫存', `${s.res.inv[id]} 片`], ['解謎星數比 S', pct(r, 0)]]; + } else { + const u = S.unlockStatus(id); + body = [ + ['解謎', `${u.puzzle.have} / ${u.puzzle.need} 關`], ['解鎖金', money(f.unlockMoney)], + ['聲望門檻', String(f.unlockRep)], ['星數比 S', pct(r, 0)]]; + } + return el('article', { class: 'we-card' + (open ? '' : ' is-locked'), 'aria-label': `${f.zh} ${f.node}` }, + el('h3', {}, f.zh, el('small', { text: `${f.node} · ${f.wafer}` }), el('span', { class: 'we-tag' + (open ? ' we-tag--on' : ''), text: open ? '已開通' : '未開通' })), + el('dl', {}, body.flatMap(([k, v]) => [el('dt', { text: k }), el('dd', { text: v })])), + el('div', { class: 'we-stars', 'aria-label': `星數 ${nStars} / ${f.puzzleLevels * 3}`, text: starTxt }), + el('button', { class: 'we-btn ' + (open ? 'we-btn--primary' : ''), type: 'button', 'data-tut': 'hub-' + id, + text: open ? '進入產線' : '解鎖條件 / 製程解謎', onclick: () => { location.hash = open ? '#fab/' + id : '#unlock/' + id; } })); + }); + fill($('hub-cards'), cards); +} + +function openCodex() { + Clock.lock(); + const dlg = openDialog({ title: '知識圖鑑', onClose: () => Clock.unlock() }); + const ids = S.getState().seenFacts; + fill(dlg.body, + el('p', { class: 'ty-muted', text: `已解鎖 ${ids.length} 則知識(通關與事件會解鎖)。` }), + ids.length ? el('ul', { class: 'we-list' }, ids.map(id => el('li', { text: getFactText(id) }))) : el('p', { text: '還沒有。去玩一關或等待一個事件吧。' })); +} + +// ---------------- 啟動 ---------------- +async function main() { + const err = $('title-err'); + hadSave = loadSave().ok; + installAudio(); + try { + const r = await bootTycoon({ hudRoot: $('hud'), fabRoot: $('fab') }); + S = r.S; view = r.view; + } catch (e) { + console.error(e); + err.hidden = false; + err.textContent = location.protocol === 'file:' + ? '無法載入資料:請用本機伺服器開啟(例如 python -m http.server),不要直接雙擊 index.html。' + : '載入失敗,請重新整理頁面。'; + fill($('title-menu')); + return; + } + // 以標題畫面開始:時鐘暫停並鎖住,直到玩家進入遊戲 + S.getState().clock.paused = true; + Clock.setPaused(true); + Audio.setVolumeSource(() => S.getState().settings.sound); + tut = createTutorial(); + mountBar(); + installRouterEvents(); + bus.on('state:changed', renderHub); + bus.on('day', renderHub); + bus.on('tick', () => { if (S.getState().clock.tick % 4 === 0) renderHub(); }); + bus.on('state:reset', () => { if (screen === 'game') { location.hash = '#hub'; } }); + window.EmpireApp = { S, route, tut }; // 方便除錯與驗證 + route(); +} +main(); diff --git a/empire/js/puzzle/engine.js b/empire/js/puzzle/engine.js new file mode 100644 index 0000000..27a0eae --- /dev/null +++ b/empire/js/puzzle/engine.js @@ -0,0 +1,406 @@ +// 《晶圓帝國》製程解謎引擎 — 純函式、無 DOM、無 window,可直接在 Node 執行。 +// 格模型與卡牌語意見 empire/DESIGN.md §4。 +// grid 一律是長度 h 的字串陣列(列 0 在最上),每字元是材料碼(§4.1)。 + +export const W = 12; +export const H = 8; + +export const MATERIALS = { + '.': { name: '空氣' }, + S: { name: '矽基板' }, + D: { name: '摻雜矽' }, + O: { name: 'SiO₂' }, + P: { name: '多晶矽' }, + M: { name: '金屬' }, + R: { name: '光阻' }, + r: { name: '已曝光光阻' }, +}; + +export const CARD_NAMES = { + deposit: '沉積', + spin: '旋塗光阻', + expose: '曝光', + develop: '顯影', + etch: '蝕刻', + implant: '離子佈植', + cmp: 'CMP 研磨', + strip: '去光阻', +}; + +export const CARD_ORDER = ['deposit', 'spin', 'expose', 'develop', 'etch', 'implant', 'cmp', 'strip']; + +const DEFAULT_RANGES = { + deposit: { mats: ['O', 'P', 'M'], t: [1, 2, 3] }, + etch: { mats: ['S', 'D', 'O', 'P', 'M'], depth: [1, 2, 3, 4] }, + implant: { depth: [1, 2] }, + cmp: { stops: ['S', 'D', 'O', 'P', 'M'] }, +}; + +export const isResist = (ch) => ch === 'R' || ch === 'r'; + +const fail = (grid, error) => ({ grid, removed: 0, error }); + +function toCells(grid) { + return grid.map((row) => row.split('')); +} +function fromCells(cells) { + return cells.map((row) => row.join('')); +} +function topOf(cells, c) { + for (let r = 0; r < cells.length; r++) if (cells[r][c] !== '.') return r; + return cells.length; +} + +export function maskColumns(level, maskId) { + const m = (level.masks || []).find((x) => x.id === maskId); + if (!m) return null; + const set = new Set(); + for (const [a, b] of m.open) for (let c = a; c <= b; c++) set.add(c); + return set; +} + +export function gridEquals(a, b) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +export function hasResist(grid) { + return grid.some((row) => /[Rr]/.test(row)); +} + +export function diffCells(grid, target) { + const out = []; + for (let r = 0; r < grid.length; r++) + for (let c = 0; c < grid[r].length; c++) if (grid[r][c] !== target[r][c]) out.push([r, c]); + return out; +} + +// 卡牌是否被本關允許、參數是否在範圍內。回傳錯誤字串或 null。 +function checkAllowed(step, level) { + const card = step && step.card; + if (!CARD_NAMES[card]) return '未知的卡牌'; + let def = null; + if (level && Array.isArray(level.cards)) { + def = level.cards.find((c) => c.card === card); + if (!def) return '本關不能使用這張卡'; + } + const inList = (v, list) => Array.isArray(list) && list.includes(v); + const rng = (key, k) => (def && def[k]) || (DEFAULT_RANGES[key] && DEFAULT_RANGES[key][k]); + switch (card) { + case 'deposit': + if (!inList(step.mat, rng('deposit', 'mats'))) return '請選擇沉積材料'; + if (!inList(step.t, rng('deposit', 't'))) return '請選擇沉積厚度'; + break; + case 'etch': + if (!inList(step.mat, rng('etch', 'mats'))) return '請選擇蝕刻材料'; + if (!inList(step.depth, rng('etch', 'depth'))) return '請選擇蝕刻深度'; + break; + case 'implant': + if (!inList(step.depth, rng('implant', 'depth'))) return '請選擇佈植深度'; + break; + case 'cmp': { + const stops = (def && (def.stops || def.mats)) || DEFAULT_RANGES.cmp.stops; + if (!inList(step.stop, stops)) return '請選擇研磨停止層'; + break; + } + case 'expose': + if (!level || !(level.masks || []).some((m) => m.id === step.mask)) return '請選擇光罩'; + break; + default: + } + return null; +} + +// 核心:套用一步。無效或不改變任何格 → { grid(原樣), removed:0, error }。 +export function applyStep(grid, step, level) { + const bad = checkAllowed(step, level); + if (bad) return fail(grid, bad); + const cells = toCells(grid); + const h = cells.length; + const w = cells[0].length; + let removed = 0; + let changed = false; + const remove = (r, c) => { + if (!isResist(cells[r][c])) removed++; + cells[r][c] = '.'; + changed = true; + }; + + switch (step.card) { + case 'deposit': { + // 光阻怕高溫,且若允許「在光阻上沉積再 strip」會出現 lift-off 捷徑,所以有光阻時不能沉積。 + if (hasResist(grid)) return fail(grid, '晶圓上還有光阻,請先去光阻再沉積'); + const t = step.t; + for (let c = 0; c < w; c++) if (topOf(cells, c) - t < 0) return fail(grid, '材料溢出剖面上緣,請減少厚度'); + for (let c = 0; c < w; c++) { + const top = topOf(cells, c); + for (let k = 1; k <= t; k++) cells[top - k][c] = step.mat; + } + changed = true; + break; + } + case 'spin': { + if (hasResist(grid)) return fail(grid, '晶圓上已有光阻,請先去光阻'); + let min = h; + for (let c = 0; c < w; c++) min = Math.min(min, topOf(cells, c)); + const m = min - 1; + if (m < 0) return fail(grid, '剖面太高,光阻塗不下'); + for (let c = 0; c < w; c++) { + const top = topOf(cells, c); + for (let r = m; r < top; r++) cells[r][c] = 'R'; + } + changed = true; + break; + } + case 'expose': { + const cols = maskColumns(level, step.mask); + for (const c of cols) + for (let r = 0; r < h; r++) + if (cells[r][c] === 'R') { + cells[r][c] = 'r'; + changed = true; + } + if (!changed) return fail(grid, '透光區內沒有未曝光的光阻'); + break; + } + case 'develop': { + for (let c = 0; c < w; c++) + for (let r = 0; r < h; r++) + if (cells[r][c] === 'r') { + cells[r][c] = '.'; + changed = true; + } + if (!changed) return fail(grid, '沒有已曝光的光阻可顯影'); + break; + } + case 'etch': { + for (let c = 0; c < w; c++) { + const top = topOf(cells, c); + if (top >= h || isResist(cells[top][c])) continue; + for (let k = 0; k < step.depth; k++) { + const t = topOf(cells, c); + if (t >= h || cells[t][c] !== step.mat) break; + remove(t, c); + } + } + if (!changed) return fail(grid, '沒有可蝕刻的' + MATERIALS[step.mat].name + '(光阻保護或材料不符)'); + break; + } + case 'implant': { + for (let c = 0; c < w; c++) { + const top = topOf(cells, c); + for (let k = 0; k < step.depth; k++) { + const r = top + k; + if (r >= h || cells[r][c] !== 'S') break; + cells[r][c] = 'D'; + changed = true; + } + } + if (!changed) return fail(grid, '離子被擋住了,沒有任何矽被摻雜'); + break; + } + case 'cmp': { + let rs = -1; + for (let r = 0; r < h && rs < 0; r++) for (let c = 0; c < w; c++) if (cells[r][c] === step.stop) { rs = r; break; } + if (rs < 0) return fail(grid, '剖面上沒有' + MATERIALS[step.stop].name + '可當停止層'); + for (let r = 0; r < rs; r++) for (let c = 0; c < w; c++) if (cells[r][c] !== '.') remove(r, c); + if (!changed) return fail(grid, '表面已經平坦,研磨沒有效果'); + break; + } + case 'strip': { + for (let c = 0; c < w; c++) { + let low = -1; + for (let r = h - 1; r >= 0; r--) if (isResist(cells[r][c])) { low = r; break; } + if (low < 0) continue; + for (let r = 0; r <= low; r++) if (cells[r][c] !== '.') remove(r, c); + } + if (!changed) return fail(grid, '沒有光阻可去除'); + break; + } + default: + return fail(grid, '未知的卡牌'); + } + return { grid: fromCells(cells), removed, error: null }; +} + +// 重播步驟序列。遇到無效步驟即停止並回報。 +export function replay(level, steps) { + let grid = level.init; + let removals = 0; + const history = [{ grid, removals: 0 }]; + for (let i = 0; i < steps.length; i++) { + const res = applyStep(grid, steps[i], level); + if (res.error) return { grid, removals, steps: i, history, error: res.error, errorIndex: i }; + grid = res.grid; + removals += res.removed; + history.push({ grid, removals }); + } + return { grid, removals, steps: steps.length, history, error: null }; +} + +export function isWin(grid, level) { + return gridEquals(grid, level.target) && !hasResist(grid); +} + +export function starsFor(level, won, steps, removals) { + const waste = Math.max(0, removals - level.minRemovals); + if (!won) return { stars: 0, waste }; + let stars = 1; + if (steps <= level.par) { + stars = 2; + if (waste === 0) stars = 3; + } + return { stars, waste }; +} + +// 一次評估整段步驟。 +export function evaluate(level, steps) { + const r = replay(level, steps); + const won = !r.error && isWin(r.grid, level); + const { stars, waste } = starsFor(level, won, r.steps, r.removals); + return { won, stars, waste, steps: r.steps, removals: r.removals, grid: r.grid, error: r.error }; +} + +export function stepsEqual(a, b) { + return ( + a.card === b.card && a.mat === b.mat && a.t === b.t && a.depth === b.depth && a.mask === b.mask && a.stop === b.stop + ); +} + +// 列舉某卡在本關的所有參數組合(供 UI 與 BFS)。 +export function paramOptions(level, card) { + const def = (level.cards || []).find((c) => c.card === card) || {}; + const base = DEFAULT_RANGES[card] || {}; + switch (card) { + case 'deposit': { + const out = []; + for (const mat of def.mats || base.mats) for (const t of def.t || base.t) out.push({ card, mat, t }); + return out; + } + case 'etch': { + const out = []; + for (const mat of def.mats || base.mats) for (const depth of def.depth || base.depth) out.push({ card, mat, depth }); + return out; + } + case 'implant': + return (def.depth || base.depth).map((depth) => ({ card, depth })); + case 'cmp': + return (def.stops || def.mats || base.stops).map((stop) => ({ card, stop })); + case 'expose': + return (level.masks || []).map((m) => ({ card, mask: m.id })); + default: + return [{ card }]; + } +} + +export function availableCards(level) { + return (level.cards || CARD_ORDER.map((card) => ({ card }))).map((c) => c.card); +} + +export function allSteps(level) { + const out = []; + for (const card of availableCards(level)) out.push(...paramOptions(level, card)); + return out; +} + +export function describeStep(step) { + const n = CARD_NAMES[step.card] || step.card; + const m = (c) => (MATERIALS[c] ? MATERIALS[c].name : c); + switch (step.card) { + case 'deposit': return `${n} ${m(step.mat)} 厚 ${step.t}`; + case 'etch': return `${n} ${m(step.mat)} 深 ${step.depth}`; + case 'implant': return `${n} 深 ${step.depth}`; + case 'cmp': return `${n} 停於${m(step.stop)}`; + case 'expose': return `${n} 光罩 ${step.mask}`; + default: return n; + } +} + +// 提示(§4.5)。hintIndex = 此關已購買的提示數(0 起)。純函式。 +export function hintInfo(level, steps, hintIndex) { + const ref = level.reference; + let j = 0; + while (j < steps.length && j < ref.length && stepsEqual(steps[j], ref[j])) j++; + if (j < steps.length) return { kind: 'rewind', to: j, text: `請先復原到第 ${j} 步(第 ${j + 1} 步偏離了參考解)` }; + if (j >= ref.length) return { kind: 'done', text: '參考解的步驟都已完成。' }; + const s = ref[j]; + if (hintIndex <= 0) return { kind: 'card', step: { card: s.card }, text: `下一步:使用「${CARD_NAMES[s.card]}」` }; + return { kind: 'full', step: s, text: `下一步:${describeStep(s)}` }; +} + +export function hintPrice(fabMultiplier, k) { + return fabMultiplier * 50 * Math.pow(2, k); +} + +// 含復原/重做堆疊的對局。步驟陣列即狀態。 +export function createSession(level) { + const hist = [{ grid: level.init, removals: 0 }]; + let steps = []; + let redoStack = []; + const cur = () => hist[hist.length - 1]; + const api = { + level, + get grid() { return cur().grid; }, + get removals() { return cur().removals; }, + get steps() { return steps.slice(); }, + get stepCount() { return steps.length; }, + get won() { return isWin(cur().grid, level); }, + get canUndo() { return steps.length > 0; }, + get canRedo() { return redoStack.length > 0; }, + preview(step) { + if (api.won) return { grid: cur().grid, removed: 0, error: '本關已完成' }; + if (steps.length >= (level.maxSteps || 20)) return { grid: cur().grid, removed: 0, error: '已達步數上限' }; + return applyStep(cur().grid, step, level); + }, + push(step) { + const res = api.preview(step); + if (res.error) return { ok: false, error: res.error }; + const before = cur().grid; + steps.push(step); + redoStack = []; + hist.push({ grid: res.grid, removals: cur().removals + res.removed }); + return { ok: true, removed: res.removed, before, after: res.grid }; + }, + undo() { + if (!steps.length) return false; + redoStack.push(steps.pop()); + hist.pop(); + return true; + }, + redo() { + if (!redoStack.length) return false; + const s = redoStack.pop(); + const res = applyStep(cur().grid, s, level); + if (res.error) { redoStack = []; return false; } + steps.push(s); + hist.push({ grid: res.grid, removals: cur().removals + res.removed }); + return true; + }, + reset() { + steps = []; + redoStack = []; + hist.length = 1; + }, + result() { + const won = api.won; + return { won, steps: steps.length, removals: cur().removals, ...starsFor(level, won, steps.length, cur().removals) }; + }, + }; + return api; +} + +// 驗證 level 結構(供 loader/測試)。回傳錯誤字串陣列。 +export function validateLevel(level) { + const errs = []; + const need = (cond, msg) => { if (!cond) errs.push(`${level.id}: ${msg}`); }; + need(level.w === W && level.h === H, 'w/h 必須是 12x8'); + for (const key of ['init', 'target']) { + need(Array.isArray(level[key]) && level[key].length === H, `${key} 需 ${H} 列`); + for (const row of level[key] || []) need(row.length === W, `${key} 列長度需 ${W}`); + } + need(!hasResist(level.init || []), 'init 不得含 R/r'); + need(!hasResist(level.target || []), 'target 不得含 R/r'); + for (const m of level.masks || []) for (const [a, b] of m.open) need(a >= 0 && b <= W - 1 && a <= b, `光罩 ${m.id} 欄位超出 0–11`); + return errs; +} diff --git a/empire/js/puzzle/index.js b/empire/js/puzzle/index.js new file mode 100644 index 0000000..e96c00f --- /dev/null +++ b/empire/js/puzzle/index.js @@ -0,0 +1,5 @@ +// 註冊 window.startPuzzleSet(DESIGN.md §9.1/§9.2)。main.js 只需 import 此檔。 +import { startPuzzleSet } from './ui.js'; + +window.startPuzzleSet = startPuzzleSet; +export { startPuzzleSet }; diff --git a/empire/js/puzzle/levels.json b/empire/js/puzzle/levels.json new file mode 100644 index 0000000..161d76b --- /dev/null +++ b/empire/js/puzzle/levels.json @@ -0,0 +1,2673 @@ +[ + { + "schema": 1, + "id": "f1-1", + "fab": "f1", + "order": 1, + "title": "長一層氧化", + "intro": "矽表面能長出穩定的二氧化矽絕緣層。用「沉積」在晶圓上鋪一層 2 格厚的 SiO₂,讓剖面和右上的目標一樣。", + "teach": [ + "thin_film" + ], + "factId": "fact#8", + "w": 12, + "h": 8, + "init": [ "............", "............", "............", "............", "............", "SSSSSSSSSSSS", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "target": [ "............", "............", "............", "OOOOOOOOOOOO", "OOOOOOOOOOOO", "SSSSSSSSSSSS", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "cards": [ + { + "card": "deposit", + "mats": [ + "O" + ], + "t": [ + 1, + 2, + 3 + ] + } + ], + "masks": [], + "par": 1, + "minRemovals": 0, + "reference": [ + { + "card": "deposit", + "mat": "O", + "t": 2 + } + ], + "maxSteps": 20, + "tutorial": "t-card" + }, + { + "schema": 1, + "id": "f1-2", + "fab": "f1", + "order": 2, + "title": "開窗", + "intro": "氧化層中央要開一個窗。微影五步:旋塗光阻、曝光、顯影、蝕刻、去光阻。注意:正光阻是「曝光處」變可溶,光罩透光的地方會被蝕刻。", + "teach": [ + "lithography", + "etching" + ], + "factId": "fact#18", + "w": 12, + "h": 8, + "init": [ "............", "............", "............", "OOOOOOOOOOOO", "OOOOOOOOOOOO", "SSSSSSSSSSSS", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "target": [ "............", "............", "............", "OOOO....OOOO", "OOOO....OOOO", "SSSSSSSSSSSS", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "cards": [ + { + "card": "spin" + }, + { + "card": "expose" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mats": [ + "O" + ], + "depth": [ + 1, + 2, + 3 + ] + }, + { + "card": "strip" + } + ], + "masks": [ + { + "id": "A", + "open": [ + [ + 4, + 7 + ] + ] + }, + { + "id": "B", + "open": [ + [ + 0, + 3 + ], + [ + 8, + 11 + ] + ] + } + ], + "par": 5, + "minRemovals": 8, + "reference": [ + { + "card": "spin" + }, + { + "card": "expose", + "mask": "A" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mat": "O", + "depth": 2 + }, + { + "card": "strip" + } + ], + "maxSteps": 20, + "tutorial": "t-litho" + }, + { + "schema": 1, + "id": "f1-3", + "fab": "f1", + "order": 3, + "title": "氧化層圖形化", + "intro": "先沉積氧化層,再用微影把它圖形化,只保留中間 4 欄。想一想:光罩 A 與 B 哪個透光區會被蝕刻掉?", + "teach": [ + "thin_film", + "lithography", + "etching" + ], + "factId": "fact#18", + "w": 12, + "h": 8, + "init": [ "............", "............", "............", "............", "............", "SSSSSSSSSSSS", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "target": [ "............", "............", "............", "....OOOO....", "....OOOO....", "SSSSSSSSSSSS", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "cards": [ + { + "card": "deposit", + "mats": [ + "O" + ], + "t": [ + 1, + 2, + 3 + ] + }, + { + "card": "spin" + }, + { + "card": "expose" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mats": [ + "O" + ], + "depth": [ + 1, + 2, + 3 + ] + }, + { + "card": "strip" + } + ], + "masks": [ + { + "id": "A", + "open": [ + [ + 0, + 3 + ], + [ + 8, + 11 + ] + ] + }, + { + "id": "B", + "open": [ + [ + 4, + 7 + ] + ] + } + ], + "par": 6, + "minRemovals": 16, + "reference": [ + { + "card": "deposit", + "mat": "O", + "t": 2 + }, + { + "card": "spin" + }, + { + "card": "expose", + "mask": "A" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mat": "O", + "depth": 2 + }, + { + "card": "strip" + } + ], + "maxSteps": 20, + "tutorial": null + }, + { + "schema": 1, + "id": "f2-1", + "fab": "f2", + "order": 1, + "title": "N 井佈植", + "intro": "離子佈植會把頂部的矽變成摻雜矽 D。光阻能擋住離子:在中間 6 欄佈植 2 格深,其餘位置要被光阻保護。", + "teach": [ + "ion_implantation", + "lithography" + ], + "factId": "fact#24", + "w": 12, + "h": 8, + "init": [ "............", "............", "............", "............", "............", "SSSSSSSSSSSS", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "target": [ "............", "............", "............", "............", "............", "SSSDDDDDDSSS", "SSSDDDDDDSSS", "SSSSSSSSSSSS" ], + "cards": [ + { + "card": "spin" + }, + { + "card": "expose" + }, + { + "card": "develop" + }, + { + "card": "implant", + "depth": [ + 1, + 2 + ] + }, + { + "card": "strip" + } + ], + "masks": [ + { + "id": "A", + "open": [ + [ + 3, + 8 + ] + ] + }, + { + "id": "B", + "open": [ + [ + 0, + 2 + ], + [ + 9, + 11 + ] + ] + } + ], + "par": 5, + "minRemovals": 0, + "reference": [ + { + "card": "spin" + }, + { + "card": "expose", + "mask": "A" + }, + { + "card": "develop" + }, + { + "card": "implant", + "depth": 2 + }, + { + "card": "strip" + } + ], + "maxSteps": 20, + "tutorial": null + }, + { + "schema": 1, + "id": "f2-2", + "fab": "f2", + "order": 2, + "title": "淺溝隔離 STI", + "intro": "先在矽上蝕出溝槽,再沉積氧化層填滿,最後用 CMP 磨平,停在矽上。溝槽要剛好被氧化層填滿並與矽表面齊平。", + "teach": [ + "etching", + "thin_film", + "cmp" + ], + "factId": "fact#25", + "w": 12, + "h": 8, + "init": [ "............", "............", "............", "............", "............", "SSSSSSSSSSSS", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "target": [ "............", "............", "............", "............", "............", "SSOOSSSSOOSS", "SSOOSSSSOOSS", "SSSSSSSSSSSS" ], + "cards": [ + { + "card": "spin" + }, + { + "card": "expose" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mats": [ + "S" + ], + "depth": [ + 1, + 2, + 3 + ] + }, + { + "card": "strip" + }, + { + "card": "deposit", + "mats": [ + "O" + ], + "t": [ + 1, + 2, + 3 + ] + }, + { + "card": "cmp", + "stops": [ + "S", + "O" + ] + } + ], + "masks": [ + { + "id": "A", + "open": [ + [ + 2, + 3 + ], + [ + 8, + 9 + ] + ] + }, + { + "id": "B", + "open": [ + [ + 4, + 7 + ] + ] + } + ], + "par": 7, + "minRemovals": 24, + "reference": [ + { + "card": "spin" + }, + { + "card": "expose", + "mask": "A" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mat": "S", + "depth": 2 + }, + { + "card": "strip" + }, + { + "card": "deposit", + "mat": "O", + "t": 2 + }, + { + "card": "cmp", + "stop": "S" + } + ], + "maxSteps": 20, + "tutorial": null + }, + { + "schema": 1, + "id": "f2-3", + "fab": "f2", + "order": 3, + "title": "閘極堆疊", + "intro": "在矽上疊「閘極氧化層+多晶矽」兩層,只留下中間 2 欄的閘極。蝕刻有選擇性:一次只吃一種材料,要先吃多晶矽,再吃氧化層。", + "teach": [ + "thin_film", + "etching" + ], + "factId": "fact#38", + "w": 12, + "h": 8, + "init": [ "............", "............", "............", "............", "............", "SSSSSSSSSSSS", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "target": [ "............", "............", ".....PP.....", ".....PP.....", ".....OO.....", "SSSSSSSSSSSS", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "cards": [ + { + "card": "deposit", + "mats": [ + "O", + "P" + ], + "t": [ + 1, + 2, + 3 + ] + }, + { + "card": "spin" + }, + { + "card": "expose" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mats": [ + "P", + "O" + ], + "depth": [ + 1, + 2, + 3 + ] + }, + { + "card": "strip" + } + ], + "masks": [ + { + "id": "A", + "open": [ + [ + 0, + 4 + ], + [ + 7, + 11 + ] + ] + }, + { + "id": "B", + "open": [ + [ + 5, + 6 + ] + ] + }, + { + "id": "C", + "open": [ + [ + 2, + 9 + ] + ] + } + ], + "par": 8, + "minRemovals": 30, + "reference": [ + { + "card": "deposit", + "mat": "O", + "t": 1 + }, + { + "card": "deposit", + "mat": "P", + "t": 2 + }, + { + "card": "spin" + }, + { + "card": "expose", + "mask": "A" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mat": "P", + "depth": 2 + }, + { + "card": "etch", + "mat": "O", + "depth": 1 + }, + { + "card": "strip" + } + ], + "maxSteps": 20, + "tutorial": null + }, + { + "schema": 1, + "id": "f2-4", + "fab": "f2", + "order": 4, + "title": "MOSFET", + "intro": "自對準源汲極:閘極本身就是佈植的遮罩。先疊出閘極,再佈植,兩側的矽變成源/汲極 D,閘極下方的通道維持 S。", + "teach": [ + "thin_film", + "lithography", + "etching", + "ion_implantation" + ], + "factId": "fact#23", + "w": 12, + "h": 8, + "init": [ "............", "............", "............", "............", "............", "SSSSSSSSSSSS", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "target": [ "............", "............", "....PPPP....", "....PPPP....", "....OOOO....", "DDDDSSSSDDDD", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "cards": [ + { + "card": "deposit", + "mats": [ + "O", + "P" + ], + "t": [ + 1, + 2, + 3 + ] + }, + { + "card": "spin" + }, + { + "card": "expose" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mats": [ + "O", + "P" + ], + "depth": [ + 1, + 2, + 3 + ] + }, + { + "card": "implant", + "depth": [ + 1, + 2 + ] + }, + { + "card": "strip" + } + ], + "masks": [ + { + "id": "A", + "open": [ + [ + 0, + 3 + ], + [ + 8, + 11 + ] + ] + }, + { + "id": "B", + "open": [ + [ + 4, + 7 + ] + ] + } + ], + "par": 9, + "minRemovals": 24, + "reference": [ + { + "card": "deposit", + "mat": "O", + "t": 1 + }, + { + "card": "deposit", + "mat": "P", + "t": 2 + }, + { + "card": "spin" + }, + { + "card": "expose", + "mask": "A" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mat": "P", + "depth": 2 + }, + { + "card": "etch", + "mat": "O", + "depth": 1 + }, + { + "card": "strip" + }, + { + "card": "implant", + "depth": 1 + } + ], + "maxSteps": 20, + "tutorial": null + }, + { + "schema": 1, + "id": "f3-1", + "fab": "f3", + "order": 1, + "title": "兩次圖形化", + "intro": "兩道光罩、兩種佈植深度:左邊佈植 2 格深,右邊只佈植 1 格深。一次光阻只能做一件事,記得每輪之間要去光阻。", + "teach": [ + "lithography", + "ion_implantation" + ], + "factId": "fact#36", + "w": 12, + "h": 8, + "init": [ "............", "............", "............", "............", "............", "SSSSSSSSSSSS", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "target": [ "............", "............", "............", "............", "............", "SDDDSSSSDDDS", "SDDDSSSSSSSS", "SSSSSSSSSSSS" ], + "cards": [ + { + "card": "spin" + }, + { + "card": "expose" + }, + { + "card": "develop" + }, + { + "card": "implant", + "depth": [ + 1, + 2 + ] + }, + { + "card": "strip" + } + ], + "masks": [ + { + "id": "A", + "open": [ + [ + 1, + 3 + ] + ] + }, + { + "id": "B", + "open": [ + [ + 8, + 10 + ] + ] + }, + { + "id": "C", + "open": [ + [ + 1, + 10 + ] + ] + } + ], + "par": 8, + "minRemovals": 0, + "reference": [ + { + "card": "spin" + }, + { + "card": "expose", + "mask": "A" + }, + { + "card": "develop" + }, + { + "card": "expose", + "mask": "B" + }, + { + "card": "implant", + "depth": 2 + }, + { + "card": "develop" + }, + { + "card": "implant", + "depth": 1 + }, + { + "card": "strip" + } + ], + "maxSteps": 20, + "tutorial": null + }, + { + "schema": 1, + "id": "f3-2", + "fab": "f3", + "order": 2, + "title": "接觸孔", + "intro": "同一次蝕刻要同時打到兩種深度:左邊的孔要穿過全部氧化層到矽,右邊遇到多晶矽閘極就該停。這就是蝕刻的選擇比。孔打開後順便佈植源極。", + "teach": [ + "etching", + "ion_implantation" + ], + "factId": "fact#21", + "w": 12, + "h": 8, + "init": [ "............", "............", "............", "OOOOOOOOOOOO", "OOOOOPPOOOOO", "OOOOOOOOOOOO", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "target": [ "............", "............", "............", "O..OO..OOOOO", "O..OOPPOOOOO", "O..OOOOOOOOO", "SDDSSSSSSSSS", "SSSSSSSSSSSS" ], + "cards": [ + { + "card": "spin" + }, + { + "card": "expose" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mats": [ + "O" + ], + "depth": [ + 1, + 2, + 3 + ] + }, + { + "card": "implant", + "depth": [ + 1 + ] + }, + { + "card": "strip" + } + ], + "masks": [ + { + "id": "A", + "open": [ + [ + 1, + 2 + ], + [ + 5, + 6 + ] + ] + }, + { + "id": "B", + "open": [ + [ + 1, + 2 + ] + ] + }, + { + "id": "C", + "open": [ + [ + 5, + 6 + ] + ] + } + ], + "par": 6, + "minRemovals": 8, + "reference": [ + { + "card": "spin" + }, + { + "card": "expose", + "mask": "A" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mat": "O", + "depth": 3 + }, + { + "card": "implant", + "depth": 1 + }, + { + "card": "strip" + } + ], + "maxSteps": 20, + "tutorial": null + }, + { + "schema": 1, + "id": "f3-3", + "fab": "f3", + "order": 3, + "title": "鎢栓塞", + "intro": "打開接觸孔後填入金屬做成栓塞。孔深不一,必須「過量沉積」才填得滿深孔,再用 CMP 停在氧化層把多餘的金屬磨掉。", + "teach": [ + "etching", + "metallization", + "cmp" + ], + "factId": "fact#26", + "w": 12, + "h": 8, + "init": [ "............", "............", "............", "OOOOOOOOOOOO", "OOOOOOOPPOOO", "OOOOOOOOOOOO", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "target": [ "............", "............", "............", "OOMMOOOMMOOO", "OOMMOOOPPOOO", "OOMMOOOOOOOO", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "cards": [ + { + "card": "spin" + }, + { + "card": "expose" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mats": [ + "O" + ], + "depth": [ + 1, + 2, + 3 + ] + }, + { + "card": "strip" + }, + { + "card": "deposit", + "mats": [ + "M" + ], + "t": [ + 1, + 2, + 3 + ] + }, + { + "card": "cmp", + "stops": [ + "O", + "P", + "M" + ] + } + ], + "masks": [ + { + "id": "A", + "open": [ + [ + 2, + 3 + ], + [ + 7, + 8 + ] + ] + }, + { + "id": "B", + "open": [ + [ + 2, + 3 + ] + ] + }, + { + "id": "C", + "open": [ + [ + 7, + 8 + ] + ] + } + ], + "par": 7, + "minRemovals": 36, + "reference": [ + { + "card": "spin" + }, + { + "card": "expose", + "mask": "A" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mat": "O", + "depth": 3 + }, + { + "card": "strip" + }, + { + "card": "deposit", + "mat": "M", + "t": 3 + }, + { + "card": "cmp", + "stop": "O" + } + ], + "maxSteps": 20, + "tutorial": null + }, + { + "schema": 1, + "id": "f3-4", + "fab": "f3", + "order": 4, + "title": "銅大馬士革", + "intro": "銅很難蝕刻,所以改成「先挖溝、再填金屬、最後磨平」。溝槽有多深,沉積就剛好多厚——恰好填滿,不浪費材料。", + "teach": [ + "etching", + "metallization", + "cmp" + ], + "factId": "fact#33", + "w": 12, + "h": 8, + "init": [ "............", "............", "............", "............", "OOOOOOOOOOOO", "OOOOOOOOOOOO", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "target": [ "............", "............", "............", "............", "OOOOMMMMOOOO", "OOOOMMMMOOOO", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "cards": [ + { + "card": "spin" + }, + { + "card": "expose" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mats": [ + "O" + ], + "depth": [ + 1, + 2, + 3 + ] + }, + { + "card": "strip" + }, + { + "card": "deposit", + "mats": [ + "M" + ], + "t": [ + 1, + 2, + 3 + ] + }, + { + "card": "cmp", + "stops": [ + "O", + "S", + "M" + ] + } + ], + "masks": [ + { + "id": "A", + "open": [ + [ + 4, + 7 + ] + ] + }, + { + "id": "B", + "open": [ + [ + 0, + 3 + ] + ] + }, + { + "id": "C", + "open": [ + [ + 0, + 3 + ], + [ + 8, + 11 + ] + ] + } + ], + "par": 7, + "minRemovals": 24, + "reference": [ + { + "card": "spin" + }, + { + "card": "expose", + "mask": "A" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mat": "O", + "depth": 2 + }, + { + "card": "strip" + }, + { + "card": "deposit", + "mat": "M", + "t": 2 + }, + { + "card": "cmp", + "stop": "O" + } + ], + "maxSteps": 20, + "tutorial": null, + "note": "真實製程要「過量電鍍」確保填滿(底部先長),遊戲簡化為孔洞不會發生,所以最省料的是恰好填滿。" + }, + { + "schema": 1, + "id": "f4-1", + "fab": "f4", + "order": 1, + "title": "薄氧化+偽閘極", + "intro": "先用多晶矽做「假閘極」,完成自對準佈植與氧化層填充後,再把假閘極挖掉、換成真正的金屬閘極。", + "teach": [ + "thin_film", + "etching", + "cmp", + "metallization" + ], + "factId": "fact#38", + "w": 12, + "h": 8, + "init": [ "............", "............", "PPPPPPPPPPPP", "PPPPPPPPPPPP", "OOOOOOOOOOOO", "SSSSSSSSSSSS", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "target": [ "............", "............", "............", "OOOOOMMOOOOO", "OOOOOOOOOOOO", "DDDDDSSDDDDD", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "cards": [ + { + "card": "spin" + }, + { + "card": "expose" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mats": [ + "P", + "O" + ], + "depth": [ + 1, + 2 + ] + }, + { + "card": "implant", + "depth": [ + 1 + ] + }, + { + "card": "strip" + }, + { + "card": "deposit", + "mats": [ + "O", + "M" + ], + "t": [ + 1, + 2 + ] + }, + { + "card": "cmp", + "stops": [ + "P", + "O" + ] + } + ], + "masks": [ + { + "id": "A", + "open": [ + [ + 0, + 4 + ], + [ + 7, + 11 + ] + ] + }, + { + "id": "B", + "open": [ + [ + 5, + 6 + ] + ] + } + ], + "par": 12, + "minRemovals": 48, + "reference": [ + { + "card": "spin" + }, + { + "card": "expose", + "mask": "A" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mat": "P", + "depth": 2 + }, + { + "card": "etch", + "mat": "O", + "depth": 1 + }, + { + "card": "strip" + }, + { + "card": "implant", + "depth": 1 + }, + { + "card": "deposit", + "mat": "O", + "t": 2 + }, + { + "card": "cmp", + "stop": "P" + }, + { + "card": "etch", + "mat": "P", + "depth": 2 + }, + { + "card": "deposit", + "mat": "M", + "t": 1 + }, + { + "card": "cmp", + "stop": "O" + } + ], + "maxSteps": 20, + "tutorial": null + }, + { + "schema": 1, + "id": "f4-2", + "fab": "f4", + "order": 2, + "title": "鰭 Fin", + "intro": "先蝕刻矽留下兩根窄鰭,填入氧化層後磨平到鰭頂,再把氧化層回蝕一格,讓鰭突出來。", + "teach": [ + "etching", + "cmp" + ], + "factId": "fact#40", + "w": 12, + "h": 8, + "init": [ "............", "............", "............", "............", "SSSSSSSSSSSS", "SSSSSSSSSSSS", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "target": [ "............", "............", "............", "............", "...SS..SS...", "OOOSSOOSSOOO", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "cards": [ + { + "card": "spin" + }, + { + "card": "expose" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mats": [ + "S", + "O" + ], + "depth": [ + 1, + 2, + 3 + ] + }, + { + "card": "strip" + }, + { + "card": "deposit", + "mats": [ + "O" + ], + "t": [ + 1, + 2, + 3 + ] + }, + { + "card": "cmp", + "stops": [ + "S", + "O" + ] + } + ], + "masks": [ + { + "id": "A", + "open": [ + [ + 0, + 2 + ], + [ + 5, + 6 + ], + [ + 9, + 11 + ] + ] + }, + { + "id": "B", + "open": [ + [ + 3, + 4 + ], + [ + 7, + 8 + ] + ] + } + ], + "par": 7, + "minRemovals": 20, + "reference": [ + { + "card": "spin" + }, + { + "card": "expose", + "mask": "A" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mat": "S", + "depth": 2 + }, + { + "card": "strip" + }, + { + "card": "deposit", + "mat": "O", + "t": 1 + }, + { + "card": "cmp", + "stop": "S" + } + ], + "maxSteps": 20, + "tutorial": null + }, + { + "schema": 1, + "id": "f4-3", + "fab": "f4", + "order": 3, + "title": "兩層導線", + "intro": "重複大馬士革模組:先做第一層導線,再蓋一層氧化層,在另一個位置做第二層導線。每一層都要「挖溝、填金屬、磨平」。", + "teach": [ + "etching", + "metallization", + "cmp" + ], + "factId": "fact#34", + "w": 12, + "h": 8, + "init": [ "............", "............", "............", "............", "OOOOOOOOOOOO", "OOOOOOOOOOOO", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "target": [ "............", "............", "OOOOOOOMMMMO", "OOOOOOOMMMMO", "OMMMMOOOOOOO", "OMMMMOOOOOOO", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "cards": [ + { + "card": "deposit", + "mats": [ + "O", + "M" + ], + "t": [ + 1, + 2, + 3 + ] + }, + { + "card": "spin" + }, + { + "card": "expose" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mats": [ + "O" + ], + "depth": [ + 1, + 2, + 3 + ] + }, + { + "card": "strip" + }, + { + "card": "cmp", + "stops": [ + "O", + "S", + "M" + ] + } + ], + "masks": [ + { + "id": "A", + "open": [ + [ + 1, + 4 + ] + ] + }, + { + "id": "B", + "open": [ + [ + 7, + 10 + ] + ] + }, + { + "id": "C", + "open": [ + [ + 1, + 4 + ], + [ + 7, + 10 + ] + ] + } + ], + "par": 13, + "minRemovals": 48, + "reference": [ + { + "card": "deposit", + "mat": "O", + "t": 2 + }, + { + "card": "spin" + }, + { + "card": "expose", + "mask": "A" + }, + { + "card": "develop" + }, + { + "card": "expose", + "mask": "B" + }, + { + "card": "etch", + "mat": "O", + "depth": 2 + }, + { + "card": "develop" + }, + { + "card": "etch", + "mat": "O", + "depth": 2 + }, + { + "card": "strip" + }, + { + "card": "deposit", + "mat": "M", + "t": 2 + }, + { + "card": "cmp", + "stop": "O" + }, + { + "card": "deposit", + "mat": "O", + "t": 2 + }, + { + "card": "cmp", + "stop": "M" + } + ], + "maxSteps": 20, + "tutorial": null + }, + { + "schema": 1, + "id": "f4-4", + "fab": "f4", + "order": 4, + "title": "通孔+導線", + "intro": "雙大馬士革:先蝕出通孔打到下層導線,再蝕出較淺的溝槽,最後一次填金屬並磨平。溝槽只蝕 1 格——蝕刻深度要拿捏。", + "teach": [ + "etching", + "metallization", + "cmp" + ], + "factId": "fact#35", + "w": 12, + "h": 8, + "init": [ "............", "............", "............", "............", "OOMMMMOOOOOO", "OOMMMMOOOOOO", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "target": [ "............", "............", "OOOMMMMMMOOO", "OOOMMOOOOOOO", "OOMMMMOOOOOO", "OOMMMMOOOOOO", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "cards": [ + { + "card": "deposit", + "mats": [ + "O", + "M" + ], + "t": [ + 1, + 2, + 3 + ] + }, + { + "card": "spin" + }, + { + "card": "expose" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mats": [ + "O" + ], + "depth": [ + 1, + 2, + 3 + ] + }, + { + "card": "strip" + }, + { + "card": "cmp", + "stops": [ + "O", + "M" + ] + } + ], + "masks": [ + { + "id": "V", + "open": [ + [ + 3, + 4 + ] + ] + }, + { + "id": "T", + "open": [ + [ + 3, + 8 + ] + ] + }, + { + "id": "X", + "open": [ + [ + 2, + 5 + ] + ] + } + ], + "par": 11, + "minRemovals": 24, + "reference": [ + { + "card": "deposit", + "mat": "O", + "t": 2 + }, + { + "card": "spin" + }, + { + "card": "expose", + "mask": "V" + }, + { + "card": "develop" + }, + { + "card": "expose", + "mask": "T" + }, + { + "card": "etch", + "mat": "O", + "depth": 1 + }, + { + "card": "develop" + }, + { + "card": "etch", + "mat": "O", + "depth": 1 + }, + { + "card": "strip" + }, + { + "card": "deposit", + "mat": "M", + "t": 2 + }, + { + "card": "cmp", + "stop": "O" + } + ], + "maxSteps": 20, + "tutorial": null + }, + { + "schema": 1, + "id": "f4-5", + "fab": "f4", + "order": 5, + "title": "完整 MOSFET+金屬", + "intro": "前段+後段整合:先做閘極與自對準源汲極,再蓋層間氧化層、打接觸孔、填金屬,最後 CMP 停在閘極多晶矽,讓接觸和閘極頂面齊平。", + "teach": [ + "thin_film", + "ion_implantation", + "etching", + "metallization", + "cmp" + ], + "factId": "fact#36", + "w": 12, + "h": 8, + "init": [ "............", "............", "............", "............", "............", "............", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "target": [ "............", "............", "............", "............", "OMMOOPPOOMMO", "OMMOOOOOOMMO", "DDDDDSSDDDDD", "SSSSSSSSSSSS" ], + "cards": [ + { + "card": "deposit", + "mats": [ + "O", + "P", + "M" + ], + "t": [ + 1, + 2 + ] + }, + { + "card": "spin" + }, + { + "card": "expose" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mats": [ + "O", + "P" + ], + "depth": [ + 1, + 2 + ] + }, + { + "card": "implant", + "depth": [ + 1 + ] + }, + { + "card": "strip" + }, + { + "card": "cmp", + "stops": [ + "P", + "O" + ] + } + ], + "masks": [ + { + "id": "A", + "open": [ + [ + 0, + 4 + ], + [ + 7, + 11 + ] + ] + }, + { + "id": "C", + "open": [ + [ + 1, + 2 + ], + [ + 9, + 10 + ] + ] + } + ], + "par": 17, + "minRemovals": 48, + "reference": [ + { + "card": "deposit", + "mat": "O", + "t": 1 + }, + { + "card": "deposit", + "mat": "P", + "t": 1 + }, + { + "card": "spin" + }, + { + "card": "expose", + "mask": "A" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mat": "P", + "depth": 1 + }, + { + "card": "etch", + "mat": "O", + "depth": 1 + }, + { + "card": "strip" + }, + { + "card": "implant", + "depth": 1 + }, + { + "card": "deposit", + "mat": "O", + "t": 2 + }, + { + "card": "spin" + }, + { + "card": "expose", + "mask": "C" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mat": "O", + "depth": 2 + }, + { + "card": "strip" + }, + { + "card": "deposit", + "mat": "M", + "t": 2 + }, + { + "card": "cmp", + "stop": "P" + } + ], + "maxSteps": 20, + "tutorial": null + }, + { + "schema": 1, + "id": "f5-1", + "fab": "f5", + "order": 1, + "title": "雙重圖形:深淺兩道", + "intro": "一道光罩一個任務:第一道把左邊氧化層蝕穿,第二道只把右邊蝕掉一半。兩個圖形不能擠在同一次曝光裡。", + "teach": [ + "lithography", + "etching" + ], + "factId": "fact#46", + "w": 12, + "h": 8, + "init": [ "............", "............", "............", "OOOOOOOOOOOO", "OOOOOOOOOOOO", "SSSSSSSSSSSS", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "target": [ "............", "............", "............", "O..OOO..OOOO", "O..OOOOOOOOO", "SSSSSSSSSSSS", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "cards": [ + { + "card": "spin" + }, + { + "card": "expose" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mats": [ + "O" + ], + "depth": [ + 1, + 2 + ] + }, + { + "card": "strip" + } + ], + "masks": [ + { + "id": "A", + "open": [ + [ + 1, + 2 + ] + ] + }, + { + "id": "B", + "open": [ + [ + 6, + 7 + ] + ] + }, + { + "id": "C", + "open": [ + [ + 1, + 2 + ], + [ + 6, + 7 + ] + ] + } + ], + "par": 8, + "minRemovals": 6, + "reference": [ + { + "card": "spin" + }, + { + "card": "expose", + "mask": "A" + }, + { + "card": "develop" + }, + { + "card": "expose", + "mask": "B" + }, + { + "card": "etch", + "mat": "O", + "depth": 1 + }, + { + "card": "develop" + }, + { + "card": "etch", + "mat": "O", + "depth": 1 + }, + { + "card": "strip" + } + ], + "maxSteps": 20, + "tutorial": null + }, + { + "schema": 1, + "id": "f5-2", + "fab": "f5", + "order": 2, + "title": "零浪費:雙深度溝槽", + "intro": "左邊溝槽深 3 格、右邊深 1 格,要一次填滿金屬並磨平。每多磨掉一格材料就多一分浪費,挑戰零浪費。", + "teach": [ + "etching", + "metallization", + "cmp" + ], + "factId": "fact#39", + "w": 12, + "h": 8, + "init": [ "............", "............", "............", "OOOOOOOOOOOO", "OOOOOOOOOOOO", "OOOOOOOOOOOO", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "target": [ "............", "............", "............", "OMMMOOOOMMMO", "OMMMOOOOOOOO", "OMMMOOOOOOOO", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "cards": [ + { + "card": "spin" + }, + { + "card": "expose" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mats": [ + "O" + ], + "depth": [ + 1, + 2, + 3 + ] + }, + { + "card": "strip" + }, + { + "card": "deposit", + "mats": [ + "M" + ], + "t": [ + 1, + 2, + 3 + ] + }, + { + "card": "cmp", + "stops": [ + "O", + "S", + "M" + ] + } + ], + "masks": [ + { + "id": "A", + "open": [ + [ + 1, + 3 + ] + ] + }, + { + "id": "B", + "open": [ + [ + 8, + 10 + ] + ] + }, + { + "id": "C", + "open": [ + [ + 1, + 3 + ], + [ + 8, + 10 + ] + ] + } + ], + "par": 10, + "minRemovals": 36, + "reference": [ + { + "card": "spin" + }, + { + "card": "expose", + "mask": "A" + }, + { + "card": "develop" + }, + { + "card": "expose", + "mask": "B" + }, + { + "card": "etch", + "mat": "O", + "depth": 2 + }, + { + "card": "develop" + }, + { + "card": "etch", + "mat": "O", + "depth": 1 + }, + { + "card": "strip" + }, + { + "card": "deposit", + "mat": "M", + "t": 3 + }, + { + "card": "cmp", + "stop": "O" + } + ], + "maxSteps": 20, + "tutorial": null + }, + { + "schema": 1, + "id": "f5-3", + "fab": "f5", + "order": 3, + "title": "鰭式閘極", + "intro": "做出鰭之後,鋪上閘極介電層與多晶矽,再用微影把多晶矽只留在左邊那根鰭上。", + "teach": [ + "etching", + "cmp", + "thin_film", + "lithography" + ], + "factId": "fact#43", + "w": 12, + "h": 8, + "init": [ "............", "............", "............", "............", "SSSSSSSSSSSS", "SSSSSSSSSSSS", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "target": [ "............", "............", "...PP.......", "..POOP.OO...", "OOOSSOOSSOOO", "OOOSSOOSSOOO", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "cards": [ + { + "card": "spin" + }, + { + "card": "expose" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mats": [ + "S", + "O", + "P" + ], + "depth": [ + 1, + 2 + ] + }, + { + "card": "strip" + }, + { + "card": "deposit", + "mats": [ + "O", + "P" + ], + "t": [ + 1, + 2 + ] + }, + { + "card": "cmp", + "stops": [ + "S", + "O" + ] + } + ], + "masks": [ + { + "id": "A", + "open": [ + [ + 0, + 2 + ], + [ + 5, + 6 + ], + [ + 9, + 11 + ] + ] + }, + { + "id": "B", + "open": [ + [ + 3, + 4 + ], + [ + 7, + 8 + ] + ] + }, + { + "id": "G", + "open": [ + [ + 0, + 1 + ], + [ + 6, + 11 + ] + ] + } + ], + "par": 14, + "minRemovals": 28, + "reference": [ + { + "card": "spin" + }, + { + "card": "expose", + "mask": "A" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mat": "S", + "depth": 2 + }, + { + "card": "strip" + }, + { + "card": "deposit", + "mat": "O", + "t": 1 + }, + { + "card": "cmp", + "stop": "S" + }, + { + "card": "deposit", + "mat": "O", + "t": 1 + }, + { + "card": "deposit", + "mat": "P", + "t": 1 + }, + { + "card": "spin" + }, + { + "card": "expose", + "mask": "G" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mat": "P", + "depth": 1 + }, + { + "card": "strip" + } + ], + "maxSteps": 20, + "tutorial": null + }, + { + "schema": 1, + "id": "f5-4", + "fab": "f5", + "order": 4, + "title": "STI+MOSFET 全流程", + "intro": "先做隔離(STI),再做閘極與自對準源汲極。隔離區的氧化層會擋住佈植,所以只有中間的矽會變成源/汲極。", + "teach": [ + "etching", + "cmp", + "thin_film", + "ion_implantation" + ], + "factId": "fact#47", + "w": 12, + "h": 8, + "init": [ "............", "............", "............", "............", "SSSSSSSSSSSS", "SSSSSSSSSSSS", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "target": [ "............", "............", ".....PP.....", ".....OO.....", "OODDDSSDDDOO", "OOSSSSSSSSOO", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "cards": [ + { + "card": "spin" + }, + { + "card": "expose" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mats": [ + "S", + "O", + "P" + ], + "depth": [ + 1, + 2 + ] + }, + { + "card": "strip" + }, + { + "card": "deposit", + "mats": [ + "O", + "P" + ], + "t": [ + 1, + 2 + ] + }, + { + "card": "cmp", + "stops": [ + "S", + "O" + ] + }, + { + "card": "implant", + "depth": [ + 1 + ] + } + ], + "masks": [ + { + "id": "T", + "open": [ + [ + 0, + 1 + ], + [ + 10, + 11 + ] + ] + }, + { + "id": "A", + "open": [ + [ + 0, + 4 + ], + [ + 7, + 11 + ] + ] + } + ], + "par": 16, + "minRemovals": 44, + "reference": [ + { + "card": "spin" + }, + { + "card": "expose", + "mask": "T" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mat": "S", + "depth": 2 + }, + { + "card": "strip" + }, + { + "card": "deposit", + "mat": "O", + "t": 2 + }, + { + "card": "cmp", + "stop": "S" + }, + { + "card": "deposit", + "mat": "O", + "t": 1 + }, + { + "card": "deposit", + "mat": "P", + "t": 1 + }, + { + "card": "spin" + }, + { + "card": "expose", + "mask": "A" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mat": "P", + "depth": 1 + }, + { + "card": "etch", + "mat": "O", + "depth": 1 + }, + { + "card": "strip" + }, + { + "card": "implant", + "depth": 1 + } + ], + "maxSteps": 20, + "tutorial": null + }, + { + "schema": 1, + "id": "f5-5", + "fab": "f5", + "order": 5, + "title": "後閘極+接觸 全流程", + "intro": "終章:假閘極換真閘極,再蓋層間介電層……不,這次直接在平坦表面打接觸孔、填金屬、磨平。步數上限很緊,每一步都要有意義。", + "teach": [ + "thin_film", + "ion_implantation", + "etching", + "cmp", + "metallization" + ], + "factId": "fact#46", + "w": 12, + "h": 8, + "init": [ "............", "............", "PPPPPPPPPPPP", "PPPPPPPPPPPP", "OOOOOOOOOOOO", "SSSSSSSSSSSS", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "target": [ "............", "............", "............", "OMMOOMMOOMMO", "OMMOOOOOOMMO", "DDDDDSSDDDDD", "SSSSSSSSSSSS", "SSSSSSSSSSSS" ], + "cards": [ + { + "card": "spin" + }, + { + "card": "expose" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mats": [ + "P", + "O" + ], + "depth": [ + 1, + 2 + ] + }, + { + "card": "implant", + "depth": [ + 1 + ] + }, + { + "card": "strip" + }, + { + "card": "deposit", + "mats": [ + "O", + "M" + ], + "t": [ + 1, + 2 + ] + }, + { + "card": "cmp", + "stops": [ + "P", + "O" + ] + } + ], + "masks": [ + { + "id": "A", + "open": [ + [ + 0, + 4 + ], + [ + 7, + 11 + ] + ] + }, + { + "id": "B", + "open": [ + [ + 5, + 6 + ] + ] + }, + { + "id": "C", + "open": [ + [ + 1, + 2 + ], + [ + 9, + 10 + ] + ] + } + ], + "par": 16, + "minRemovals": 60, + "reference": [ + { + "card": "spin" + }, + { + "card": "expose", + "mask": "A" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mat": "P", + "depth": 2 + }, + { + "card": "etch", + "mat": "O", + "depth": 1 + }, + { + "card": "implant", + "depth": 1 + }, + { + "card": "cmp", + "stop": "O" + }, + { + "card": "deposit", + "mat": "O", + "t": 2 + }, + { + "card": "spin" + }, + { + "card": "expose", + "mask": "B" + }, + { + "card": "expose", + "mask": "C" + }, + { + "card": "develop" + }, + { + "card": "etch", + "mat": "O", + "depth": 2 + }, + { + "card": "strip" + }, + { + "card": "deposit", + "mat": "M", + "t": 2 + }, + { + "card": "cmp", + "stop": "O" + } + ], + "maxSteps": 20, + "tutorial": null + } +] diff --git a/empire/js/puzzle/levels.test.js b/empire/js/puzzle/levels.test.js new file mode 100644 index 0000000..393c04c --- /dev/null +++ b/empire/js/puzzle/levels.test.js @@ -0,0 +1,218 @@ +// 執行:node empire/js/puzzle/levels.test.js [--no-bfs] [--bfs-full] [--bfs-ms=<每關毫秒預算>] [--only=f2-4] +// 預設每關 BFS 預算 6 秒(小關窮舉、大關回報部分深度);--bfs-full 約 2–3 分鐘,21 關全部窮舉驗證 par 與 minRemovals。 +// 1) 重播每關參考解:吻合 target、步數 == par、removals == minRemovals。 +// 2) DESIGN.md §6 三個完整規格關卡逐格比對。 +// 3) 引擎規則/星數/復原的單元測試。 +// 4) 有界 BFS:確認沒有比 par 更短的解、沒有 ≤par 步且 removals 更少的解(預算內能跑完才算窮舉;否則回報部分深度)。 +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { + applyStep, replay, evaluate, isWin, allSteps, createSession, hintInfo, validateLevel, describeStep, +} from './engine.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const levels = JSON.parse(readFileSync(join(here, 'levels.json'), 'utf8')); +const args = process.argv.slice(2); +const noBfs = args.includes('--no-bfs'); +const bfsMs = args.includes('--bfs-full') ? 900000 : Number((args.find((a) => a.startsWith('--bfs-ms=')) || '--bfs-ms=6000').split('=')[1]); +const only = (args.find((a) => a.startsWith('--only=')) || '').split('=')[1]; + +let failures = 0; +let checks = 0; +function ok(cond, msg) { + checks++; + if (!cond) { + failures++; + console.log(' FAIL: ' + msg); + } +} +const eq = (a, b, msg) => ok(JSON.stringify(a) === JSON.stringify(b), `${msg}(期望 ${JSON.stringify(b)},實得 ${JSON.stringify(a)})`); + +// ---------------------------------------------------------------- 1. 結構與參考解 +console.log('== 參考解重播 =='); +eq(levels.length, 21, '關卡總數'); +eq(levels.map((l) => l.fab).reduce((a, f) => ((a[f] = (a[f] || 0) + 1), a), {}), { f1: 3, f2: 4, f3: 4, f4: 5, f5: 5 }, '各廠關數'); +const ids = new Set(); +for (const lv of levels) { + if (only && lv.id !== only) continue; + ids.add(lv.id); + const errs = validateLevel(lv); + ok(errs.length === 0, errs.join('; ')); + ok(/^f[1-5]-\d$/.test(lv.id), lv.id + ' id 格式'); + ok(/^fact#\d+$/.test(lv.factId), lv.id + ' factId'); + const ev = evaluate(lv, lv.reference); + ok(!ev.error, `${lv.id} 參考解有效:${ev.error}`); + ok(ev.won, `${lv.id} 參考解到達目標`); + ok(lv.reference.length <= lv.par, `${lv.id} 步數 ${lv.reference.length} <= par ${lv.par}`); + ok(lv.reference.length === lv.par, `${lv.id} par 等於參考解長度`); + ok(ev.removals === lv.minRemovals, `${lv.id} removals ${ev.removals} == minRemovals ${lv.minRemovals}`); + ok(ev.stars === 3 && ev.waste === 0, `${lv.id} 參考解 3 星零浪費`); + ok(lv.par <= lv.maxSteps, `${lv.id} par <= maxSteps`); + console.log(` ${ev.won && ev.stars === 3 ? 'ok ' : 'FAIL'} ${lv.id} ${lv.title} steps=${ev.steps}/par ${lv.par} removals=${ev.removals}`); +} +if (!only) eq(ids.size, 21, 'id 不重複'); + +// ---------------------------------------------------------------- 2. DESIGN.md §6 規格關卡 +console.log('== DESIGN.md §6 規格關卡 =='); +const E = '............', SS = 'SSSSSSSSSSSS'; +const pad = (rows) => Array(8 - rows.length).fill(E).concat(rows); +const get = (id) => levels.find((l) => l.id === id); +{ + const l = get('f1-3'); + eq(l.init, pad([SS, SS, SS]), 'f1-3 init'); + eq(l.target, pad(['....OOOO....', '....OOOO....', SS, SS, SS]), 'f1-3 target'); + eq([l.par, l.minRemovals], [6, 16], 'f1-3 par/minRemovals'); + eq(l.masks, [{ id: 'A', open: [[0, 3], [8, 11]] }, { id: 'B', open: [[4, 7]] }], 'f1-3 masks'); + eq(l.reference, [ + { card: 'deposit', mat: 'O', t: 2 }, { card: 'spin' }, { card: 'expose', mask: 'A' }, { card: 'develop' }, + { card: 'etch', mat: 'O', depth: 2 }, { card: 'strip' }], 'f1-3 reference'); +} +{ + const l = get('f2-4'); + eq(l.init, pad([SS, SS, SS]), 'f2-4 init'); + eq(l.target, pad(['....PPPP....', '....PPPP....', '....OOOO....', 'DDDDSSSSDDDD', SS, SS]), 'f2-4 target'); + eq([l.par, l.minRemovals], [9, 24], 'f2-4 par/minRemovals'); + eq(l.masks, [{ id: 'A', open: [[0, 3], [8, 11]] }, { id: 'B', open: [[4, 7]] }], 'f2-4 masks'); + eq(l.reference, [ + { card: 'deposit', mat: 'O', t: 1 }, { card: 'deposit', mat: 'P', t: 2 }, { card: 'spin' }, { card: 'expose', mask: 'A' }, + { card: 'develop' }, { card: 'etch', mat: 'P', depth: 2 }, { card: 'etch', mat: 'O', depth: 1 }, { card: 'strip' }, + { card: 'implant', depth: 1 }], 'f2-4 reference'); + // 佈植放在 strip 之前也合法,步數相同 + const alt = [...l.reference.slice(0, 7), { card: 'implant', depth: 1 }, { card: 'strip' }]; + const ev = evaluate(l, alt); + ok(ev.won && ev.steps === 9 && ev.stars === 3, 'f2-4 先佈植後 strip 仍 3 星'); + // 陷阱:漏掉蝕刻 O(步驟 7)→ 佈植被氧化層擋住 + const trap = [...l.reference.slice(0, 6), l.reference[7], { card: 'implant', depth: 1 }]; + const t = evaluate(l, trap); + ok(t.error || !t.won, 'f2-4 漏蝕 O 不得過關'); +} +{ + const l = get('f3-4'); + eq(l.init, pad([OOrow(), OOrow(), SS, SS]), 'f3-4 init'); + eq(l.target, pad(['OOOOMMMMOOOO', 'OOOOMMMMOOOO', SS, SS]), 'f3-4 target'); + eq([l.par, l.minRemovals], [7, 24], 'f3-4 par/minRemovals'); + eq(l.masks.map((m) => m.id + JSON.stringify(m.open)), ['A[[4,7]]', 'B[[0,3]]', 'C[[0,3],[8,11]]'], 'f3-4 masks'); + eq(l.reference.map(describeKey), ['spin', 'expose:A', 'develop', 'etch:O:2', 'strip', 'deposit:M:2', 'cmp:O'], 'f3-4 reference'); + // 對照:deposit M t=3 → ★2、removals 36、waste 12 + const t3 = l.reference.map((s) => (s.card === 'deposit' ? { ...s, t: 3 } : s)); + const ev = evaluate(l, t3); + ok(ev.won && ev.removals === 36 && ev.waste === 12 && ev.stars === 2, `f3-4 t=3 對照 removals=${ev.removals} waste=${ev.waste} stars=${ev.stars}`); + // etch depth=3 結果相同 + const d3 = l.reference.map((s) => (s.card === 'etch' ? { ...s, depth: 3 } : s)); + const ed = evaluate(l, d3); + ok(ed.won && ed.stars === 3, 'f3-4 etch depth=3 結果相同'); +} +function OOrow() { return 'OOOOOOOOOOOO'; } +function describeKey(s) { return [s.card, s.mask ?? s.mat ?? s.stop, s.t ?? s.depth].filter((x) => x !== undefined).join(':'); } + +// ---------------------------------------------------------------- 3. 引擎單元測試 +console.log('== 引擎單元測試 =='); +{ + const l = get('f1-3'); + // 陷阱光罩 B:把中間蝕掉,不會吻合 + const trap = [{ card: 'deposit', mat: 'O', t: 2 }, { card: 'spin' }, { card: 'expose', mask: 'B' }, { card: 'develop' }, + { card: 'etch', mat: 'O', depth: 2 }, { card: 'strip' }]; + ok(!evaluate(l, trap).won, 'f1-3 光罩 B 不得過關'); + // 無效步驟被拒絕 + ok(applyStep(l.init, { card: 'spin' }, l).error === null, '空剖面可 spin'); + ok(applyStep(l.init, { card: 'develop' }, l).error, '無 r 時 develop 被拒'); + ok(applyStep(l.init, { card: 'strip' }, l).error, '無光阻時 strip 被拒'); + ok(applyStep(l.init, { card: 'etch', mat: 'O', depth: 1 }, l).error, '沒有 O 時 etch 被拒'); + ok(applyStep(l.init, { card: 'expose', mask: 'A' }, l).error, '無 R 時 expose 被拒'); + ok(applyStep(l.init, { card: 'deposit', mat: 'O', t: 5 }, l).error, '非法厚度被拒'); + ok(applyStep(l.init, { card: 'cmp', stop: 'O' }, l).error, 'cmp 停止層不存在被拒'); + ok(applyStep(l.init, { card: 'implant', depth: 1 }, l).error, '本關無 implant 卡被拒'); + let g = l.init; + for (let i = 0; i < 3; i++) g = applyStep(g, { card: 'deposit', mat: 'O', t: 3 }, l).grid; + ok(applyStep(g, { card: 'deposit', mat: 'O', t: 3 }, l).error, '溢出被拒'); + ok(applyStep(applyStep(l.init, { card: 'spin' }, l).grid, { card: 'spin' }, l).error, '已有光阻不可再 spin'); + // 復原/重做 + const s = createSession(l); + ok(s.push({ card: 'deposit', mat: 'O', t: 2 }).ok, 'session push'); + ok(!s.push({ card: 'develop' }).ok && s.stepCount === 1, '無效步驟不計步'); + s.push({ card: 'spin' }); + ok(s.undo() && s.stepCount === 1, 'undo'); + ok(s.canRedo && s.redo() && s.stepCount === 2, 'redo'); + s.reset(); + ok(s.stepCount === 0 && s.grid.join() === l.init.join(), 'reset'); + for (const st of l.reference) s.push(st); + ok(s.won, 'session 重播參考解得勝'); + const r = s.result(); + ok(r.stars === 3 && r.waste === 0, 'session 星數'); + ok(!s.push({ card: 'spin' }).ok, '勝利後不可再出牌'); + // 超過 par → 2 星不可得 + const slow = [{ card: 'deposit', mat: 'O', t: 1 }, ...l.reference.map((x, i) => (i === 0 ? { ...x, t: 1 } : x))]; + const ev = evaluate(l, slow); + ok(ev.won && ev.steps === 7 && ev.stars === 1, `超過 par 只有 1 星(${ev.stars})`); + // 提示 + eq(hintInfo(l, [], 0).kind, 'card', '提示 1 種類'); + eq(hintInfo(l, [], 1).step, l.reference[0], '提示 2 完整參數'); + const h = hintInfo(l, [{ card: 'deposit', mat: 'O', t: 1 }], 0); + eq([h.kind, h.to], ['rewind', 0], '偏離參考解 → 請復原'); + eq(hintInfo(l, l.reference.slice(0, 2), 1).step, l.reference[2], '提示指向下一步'); +} + +// ---------------------------------------------------------------- 4. 有界 BFS +function bfs(level, ms) { + const moves = allSteps(level); + const target = level.target.join('|'); + const t0 = Date.now(); + let best = new Map([[level.init.join('|'), 0]]); // 歷來最低 removals + let layer = new Map([[level.init.join('|'), { grid: level.init, rem: 0 }]]); + let states = 1; + let firstGoalDepth = level.init.join('|') === target ? 0 : null; + const goalRemByDepth = {}; + let depth = 0; + for (; depth < level.par; depth++) { + const next = new Map(); + for (const { grid, rem } of layer.values()) { + for (const mv of moves) { + const res = applyStep(grid, mv, level); + if (res.error) continue; + const nr = rem + res.removed; + const key = res.grid.join('|'); + const prev = best.get(key); + if (prev !== undefined && prev <= nr) continue; + const inNext = next.get(key); + if (inNext && inNext.rem <= nr) continue; + next.set(key, { grid: res.grid, rem: nr }); + } + if (Date.now() - t0 > ms) return { exhaustive: false, depthDone: depth, states, firstGoalDepth, goalRemByDepth }; + } + for (const [k, v] of next) best.set(k, Math.min(best.get(k) ?? Infinity, v.rem)); + states += next.size; + layer = next; + const goal = layer.get(target); + if (goal) { + goalRemByDepth[depth + 1] = goal.rem; + if (firstGoalDepth === null) firstGoalDepth = depth + 1; + } + if (!layer.size) break; + } + return { exhaustive: true, depthDone: depth, states, firstGoalDepth, goalRemByDepth }; +} + +if (!noBfs) { + console.log(`== BFS(每關預算 ${bfsMs} ms;--bfs-ms= 可調)==`); + const summary = { exhaustive: 0, partial: 0 }; + for (const lv of levels) { + if (only && lv.id !== only) continue; + const r = bfs(lv, bfsMs); + if (r.firstGoalDepth !== null) ok(r.firstGoalDepth === lv.par, `${lv.id} BFS 找到更短的解:${r.firstGoalDepth} < par ${lv.par}`); + if (r.exhaustive) { + summary.exhaustive++; + const minRem = Math.min(...Object.values(r.goalRemByDepth)); + ok(r.firstGoalDepth === lv.par, `${lv.id} BFS 窮舉:par ${lv.par} 為最短(得 ${r.firstGoalDepth})`); + ok(minRem === lv.minRemovals, `${lv.id} BFS 窮舉:minRemovals ${lv.minRemovals} 為 ≤par 步內最小(得 ${minRem})`); + console.log(` exhaustive ${lv.id} par=${lv.par} 最短=${r.firstGoalDepth} minRemovals=${minRem} 狀態數=${r.states}`); + } else { + summary.partial++; + console.log(` partial ${lv.id} 已窮舉到深度 ${r.depthDone}/${lv.par},無更短解 狀態數=${r.states}`); + } + } + console.log(`BFS:窮舉 ${summary.exhaustive} 關,部分 ${summary.partial} 關(部分=預算內只確認到所列深度沒有更短解)`); +} + +console.log(`\n${checks - failures}/${checks} 檢查通過` + (failures ? `,${failures} 項失敗` : '')); +process.exit(failures ? 1 : 0); diff --git a/empire/js/puzzle/renderer.js b/empire/js/puzzle/renderer.js new file mode 100644 index 0000000..4af911e --- /dev/null +++ b/empire/js/puzzle/renderer.js @@ -0,0 +1,280 @@ +// 剖面 Canvas 繪製:每種材料一個顏色+圖樣+單字母(色盲/黑白也能辨識)。無外部相依,僅在瀏覽器使用。 +import { diffCells } from './engine.js'; + +export const MAT_STYLE = { + '.': { fill: null, ink: '#8a93a6', name: '空氣', pattern: 'none' }, + S: { fill: '#6b86b4', ink: '#ffffff', name: '矽基板', pattern: 'dots' }, + D: { fill: '#8a63d2', ink: '#ffffff', name: '摻雜矽', pattern: 'dots2' }, + O: { fill: '#8fe0dc', ink: '#0b4a47', name: 'SiO₂', pattern: 'slash' }, + P: { fill: '#f0953a', ink: '#4a2400', name: '多晶矽', pattern: 'vbars' }, + M: { fill: '#e8c547', ink: '#4a3a00', name: '金屬', pattern: 'grid' }, + R: { fill: '#f4a0ba', ink: '#5a1030', name: '光阻', pattern: 'backslash' }, + r: { fill: '#fbdde8', ink: '#8a2a52', name: '已曝光光阻', pattern: 'backslash-dash' }, +}; +const LEGEND_ORDER = ['S', 'D', 'O', 'P', 'M', 'R', 'r']; + +export const COLS = 12; +export const ROWS = 8; + +export function cellSizeFor(viewportWidth, max = 40) { + return Math.max(16, Math.min(max, Math.floor((viewportWidth - 32) / COLS))); +} + +function pattern(ctx, kind, x, y, s, ink) { + ctx.save(); + ctx.beginPath(); + ctx.rect(x, y, s, s); + ctx.clip(); + ctx.strokeStyle = ink; + ctx.fillStyle = ink; + ctx.globalAlpha *= 0.35; + ctx.lineWidth = Math.max(1, s / 20); + const step = Math.max(4, s / 4); + switch (kind) { + case 'dots': + case 'dots2': { + const st = kind === 'dots' ? s / 3 : s / 4.5; + for (let i = st / 2; i < s; i += st) for (let j = st / 2; j < s; j += st) ctx.fillRect(x + i, y + j, 1.5, 1.5); + break; + } + case 'slash': + for (let i = -s; i < s * 2; i += step) { ctx.beginPath(); ctx.moveTo(x + i, y + s); ctx.lineTo(x + i + s, y); ctx.stroke(); } + break; + case 'backslash': + case 'backslash-dash': + if (kind === 'backslash-dash') ctx.setLineDash([3, 3]); + for (let i = -s; i < s * 2; i += step) { ctx.beginPath(); ctx.moveTo(x + i, y); ctx.lineTo(x + i + s, y + s); ctx.stroke(); } + break; + case 'vbars': + for (let i = step / 2; i < s; i += step) { ctx.beginPath(); ctx.moveTo(x + i, y); ctx.lineTo(x + i, y + s); ctx.stroke(); } + break; + case 'grid': + for (let i = step / 2; i < s; i += step) { + ctx.beginPath(); ctx.moveTo(x + i, y); ctx.lineTo(x + i, y + s); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(x, y + i); ctx.lineTo(x + s, y + i); ctx.stroke(); + } + break; + default: + } + ctx.restore(); +} + +export function drawCell(ctx, ch, x, y, s, { alpha = 1, letters = true } = {}) { + const st = MAT_STYLE[ch]; + if (!st || !st.fill) return; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = st.fill; + ctx.fillRect(x, y, s, s); + pattern(ctx, st.pattern, x, y, s, st.ink); + ctx.strokeStyle = 'rgba(0,0,0,0.25)'; + ctx.lineWidth = 1; + ctx.strokeRect(x + 0.5, y + 0.5, s - 1, s - 1); + if (letters && s >= 18) { + ctx.fillStyle = st.ink; + ctx.font = `bold ${Math.floor(s * 0.45)}px system-ui, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(ch, x + s / 2, y + s / 2 + 1); + } + ctx.restore(); +} + +function drawBackdrop(ctx, cell, w, h, dark) { + ctx.save(); + ctx.fillStyle = dark ? '#141a26' : '#f3f6fb'; + ctx.fillRect(0, 0, w * cell, h * cell); + ctx.strokeStyle = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'; + ctx.lineWidth = 1; + for (let c = 1; c < w; c++) { ctx.beginPath(); ctx.moveTo(c * cell + 0.5, 0); ctx.lineTo(c * cell + 0.5, h * cell); ctx.stroke(); } + for (let r = 1; r < h; r++) { ctx.beginPath(); ctx.moveTo(0, r * cell + 0.5); ctx.lineTo(w * cell, r * cell + 0.5); ctx.stroke(); } + ctx.restore(); +} + +const prefersDark = () => typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches; + +function setupCanvas(canvas, cell) { + const dpr = Math.min(3, (typeof devicePixelRatio === 'number' && devicePixelRatio) || 1); + canvas.width = COLS * cell * dpr; + canvas.height = ROWS * cell * dpr; + canvas.style.width = COLS * cell + 'px'; + canvas.style.height = ROWS * cell + 'px'; + const ctx = canvas.getContext('2d'); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + return ctx; +} + +// 靜態縮圖(目標預覽等) +export function drawThumb(canvas, grid, cell) { + const ctx = setupCanvas(canvas, cell); + drawBackdrop(ctx, cell, COLS, ROWS, prefersDark()); + grid.forEach((row, r) => [...row].forEach((ch, c) => drawCell(ctx, ch, c * cell, r * cell, cell, { letters: cell >= 22 }))); +} + +// 光罩縮圖:12 欄,透光欄亮起。回傳 canvas。 +export function maskThumb(mask, cell = 14) { + const cv = document.createElement('canvas'); + const ctx = setupCanvas(cv, cell); + cv.style.height = cell + 'px'; + cv.height = Math.ceil(cell * ((typeof devicePixelRatio === 'number' && devicePixelRatio) || 1)); + const dpr = cv.height / cell; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + const open = new Set(); + for (const [a, b] of mask.open) for (let c = a; c <= b; c++) open.add(c); + for (let c = 0; c < COLS; c++) { + ctx.fillStyle = open.has(c) ? '#fff3b0' : '#3a3f4b'; + ctx.fillRect(c * cell + 1, 1, cell - 2, cell - 2); + if (open.has(c)) { ctx.strokeStyle = '#d4a900'; ctx.strokeRect(c * cell + 1.5, 1.5, cell - 3, cell - 3); } + } + cv.setAttribute('role', 'img'); + cv.setAttribute('aria-label', `光罩 ${mask.id}:透光欄 ` + mask.open.map(([a, b]) => (a === b ? a : `${a}–${b}`)).join('、')); + return cv; +} + +// 圖例:只列出傳入的材料碼(預設全部固體材料)。 +export function renderLegend(el, codes = LEGEND_ORDER) { + el.textContent = ''; + for (const ch of codes) { + const st = MAT_STYLE[ch]; + if (!st || !st.fill) continue; + const item = document.createElement('span'); + item.className = 'pz-legend-item'; + const cv = document.createElement('canvas'); + setupCanvas(cv, 18); + cv.width = cv.height = 18 * (cv.width / (COLS * 18)); + const dpr = cv.width / 18; + cv.style.width = cv.style.height = '18px'; + const ctx = cv.getContext('2d'); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + drawCell(ctx, ch, 0, 0, 18, { letters: true }); + cv.setAttribute('aria-hidden', 'true'); + item.append(cv, document.createTextNode(` ${ch} ${st.name}`)); + el.append(item); + } +} + +export function materialsIn(...grids) { + const set = new Set(); + for (const g of grids) for (const row of g) for (const ch of row) if (ch !== '.') set.add(ch); + return LEGEND_ORDER.filter((c) => set.has(c)); +} + +// 互動剖面:目前剖面/預覽/差異框/轉場動畫 +export function createBoardRenderer(canvas, opts = {}) { + let cell = opts.cell || 28; + let grid = opts.grid || Array(ROWS).fill('.'.repeat(COLS)); + let target = null; + let showDiff = opts.showDiff !== false; + let preview = null; + let reduceMotion = !!opts.reduceMotion; + let raf = 0; + let animState = null; // { from, to, t } + let ctx = setupCanvas(canvas, cell); + + function paint() { + const dark = prefersDark(); + drawBackdrop(ctx, cell, COLS, ROWS, dark); + const base = animState ? animState.to : grid; + for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + const x = c * cell, y = r * cell; + if (animState) { + const a = animState.from[r][c], b = animState.to[r][c], t = animState.t; + if (a === b) { drawCell(ctx, b, x, y, cell); continue; } + if (a !== '.') drawCell(ctx, a, x, y, cell, { alpha: 1 - t }); + if (b !== '.') drawCell(ctx, b, x, y, cell, { alpha: t }); + } else { + drawCell(ctx, base[r][c], x, y, cell); + } + } + } + if (preview && !animState) { + for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + const a = grid[r][c], b = preview[r][c]; + if (a === b) continue; + const x = c * cell, y = r * cell; + if (b === '.') { + ctx.save(); + ctx.fillStyle = 'rgba(255,60,60,0.45)'; + ctx.fillRect(x, y, cell, cell); + ctx.strokeStyle = '#ff2a2a'; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(x + 4, y + 4); ctx.lineTo(x + cell - 4, y + cell - 4); + ctx.moveTo(x + cell - 4, y + 4); ctx.lineTo(x + 4, y + cell - 4); ctx.stroke(); + ctx.restore(); + } else { + ctx.clearRect(x, y, cell, cell); + drawBackdropCell(ctx, x, y, cell, dark); + drawCell(ctx, b, x, y, cell, { alpha: 0.65 }); + ctx.save(); + ctx.strokeStyle = '#ffd400'; + ctx.lineWidth = 2; + ctx.strokeRect(x + 1, y + 1, cell - 2, cell - 2); + ctx.restore(); + } + } + } + } + if (showDiff && target && !animState) { + const ref = preview || grid; + ctx.save(); + ctx.strokeStyle = 'rgba(230,40,40,0.9)'; + ctx.lineWidth = 1.5; + ctx.setLineDash([4, 3]); + for (const [r, c] of diffCells(ref, target)) ctx.strokeRect(c * cell + 2, r * cell + 2, cell - 4, cell - 4); + ctx.restore(); + } + } + function drawBackdropCell(c2, x, y, s, dark) { + c2.save(); + c2.fillStyle = dark ? '#141a26' : '#f3f6fb'; + c2.fillRect(x, y, s, s); + c2.restore(); + } + + const api = { + get cell() { return cell; }, + setCell(n) { cell = n; ctx = setupCanvas(canvas, cell); paint(); }, + setGrid(g) { cancelAnimationFrame(raf); animState = null; grid = g; preview = null; paint(); }, + setTarget(t) { target = t; paint(); }, + setShowDiff(b) { showDiff = !!b; paint(); }, + setReduceMotion(b) { reduceMotion = !!b; }, + setPreview(g) { preview = g; paint(); }, + refresh: paint, + // 轉場:舊→新逐格淡入淡出(reduceMotion 時直接跳格)。回傳 Promise。 + transition(from, to, ms = 380) { + cancelAnimationFrame(raf); + grid = to; + preview = null; + if (reduceMotion || from === to) { animState = null; paint(); return Promise.resolve(); } + return new Promise((resolve) => { + const t0 = performance.now(); + let done = false; + const end = () => { + if (done) return; + done = true; + clearTimeout(guard); + cancelAnimationFrame(raf); + animState = null; + paint(); + resolve(); + }; + // rAF 在背景分頁會暫停,用計時器保底,避免 UI 卡在 busy。 + const guard = setTimeout(end, ms + 250); + const tick = (now) => { + if (done) return; + const t = Math.min(1, (now - t0) / ms); + animState = { from, to, t: t * t * (3 - 2 * t) }; + paint(); + if (t < 1) raf = requestAnimationFrame(tick); + else end(); + }; + raf = requestAnimationFrame(tick); + }); + }, + destroy() { cancelAnimationFrame(raf); }, + }; + paint(); + return api; +} diff --git a/empire/js/puzzle/ui.js b/empire/js/puzzle/ui.js new file mode 100644 index 0000000..98813f3 --- /dev/null +++ b/empire/js/puzzle/ui.js @@ -0,0 +1,506 @@ +// 製程解謎 UI:關卡選單、卡牌盤、參數面板、預覽、復原、提示、星數結算。 +// 對外介面:window.startPuzzleSet(fabId, opts) → Promise(DESIGN.md §9.2)。 +// 依賴 window.EmpireBridge(若不存在則降級:星數存記憶體、提示免費、知識取自 content.json)。 +import { + createSession, paramOptions, availableCards, describeStep, CARD_NAMES, MATERIALS, hintInfo, hintPrice, diffCells, +} from './engine.js'; +import { createBoardRenderer, drawThumb, renderLegend, materialsIn, maskThumb, MAT_STYLE, cellSizeFor, COLS, ROWS } from './renderer.js'; + +const FAB_MULT = { f1: 1, f2: 2, f3: 4, f4: 10, f5: 25 }; +const CARD_SUB = { + deposit: '長一層膜', spin: '塗光阻', expose: '光罩照光', develop: '洗掉曝光處', etch: '挖掉材料', implant: '打進離子', cmp: '磨平', strip: '去光阻', +}; +const CARD_HELP = { + deposit: '保形沉積:每一欄都在最上方長出所選厚度的材料。', + spin: '光阻鋪成平坦頂面,比最高點再高 1 格。', + expose: '光罩透光的欄,其中的光阻變成可溶(正光阻)。', + develop: '把已曝光(可溶)的光阻洗掉。', + etch: '只吃所選材料;遇到不同材料或光阻保護就停。', + implant: '把頂部的矽 S 打成摻雜矽 D;光阻、閘極、氧化層都擋得住。', + cmp: '整片磨平到「停止層」材料的最高點。', + strip: '把光阻(及其上方的東西)整個剝掉。', +}; +const PARAM_LABEL = { mat: '材料', t: '厚度(格)', depth: '深度(格)', stop: '停止層', mask: '光罩' }; + +const CSS_URL = new URL('../../css/puzzle.css', import.meta.url).href; +const LEVELS_URL = new URL('./levels.json', import.meta.url).href; +const CONTENT_URL = new URL('../../data/content.json', import.meta.url).href; + +let levelsPromise = null; +let contentPromise = null; +const memStars = {}; // 無 EmpireBridge 時的降級星數(僅記憶體) +let active = null; + +export function loadLevels() { + if (!levelsPromise) levelsPromise = fetch(LEVELS_URL).then((r) => { if (!r.ok) throw new Error('no-levels'); return r.json(); }); + return levelsPromise; +} + +function ensureStyles() { + if (document.querySelector('link[data-pz-css]')) return; + const l = document.createElement('link'); + l.rel = 'stylesheet'; + l.href = CSS_URL; + l.setAttribute('data-pz-css', ''); + document.head.append(l); +} + +const bridge = () => (typeof window !== 'undefined' && window.EmpireBridge) || null; + +async function factText(factId) { + const b = bridge(); + if (b && typeof b.getFactText === 'function') { + try { const t = b.getFactText(factId); if (t) return t; } catch (e) { /* 降級 */ } + } + try { + if (!contentPromise) contentPromise = fetch(CONTENT_URL).then((r) => r.json()); + const c = await contentPromise; + const n = Number(String(factId).replace('fact#', '')); + return (c.facts[n] && c.facts[n].text) || ''; + } catch (e) { + return ''; + } +} + +function el(tag, attrs = {}, ...kids) { + const n = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (v === false || v == null) continue; + if (k === 'class') n.className = v; + else if (k === 'text') n.textContent = v; + else if (k.startsWith('on')) n.addEventListener(k.slice(2), v); + else n.setAttribute(k, v === true ? '' : v); + } + for (const kid of kids.flat()) if (kid != null) n.append(kid.nodeType ? kid : document.createTextNode(kid)); + return n; +} + +const starsText = (n, total = 3) => el('span', { 'aria-label': `${n} 顆星,滿 ${total} 顆` }, + ...Array.from({ length: total }, (_, i) => el('span', { class: i < n ? 'pz-star-on' : 'pz-star-off', 'aria-hidden': 'true', text: '★' }))); + +function settings() { + const b = bridge(); + let s = {}; + try { s = (b && b.getSettings && b.getSettings()) || {}; } catch (e) { /* ignore */ } + const prm = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; + return { diffHighlight: s.diffHighlight !== false, reduceMotion: !!s.reduceMotion || prm }; +} + +export function startPuzzleSet(fabId, opts = {}) { + if (active) return active.promise; + const ctl = {}; + ctl.promise = run(fabId, opts).finally(() => { active = null; }); + active = ctl; + return ctl.promise; +} + +async function run(fabId, opts) { + let all; + try { all = await loadLevels(); } catch (e) { throw new Error('no-levels'); } + const levels = all.filter((l) => l.fab === fabId).sort((a, b) => a.order - b.order); + if (!levels.length) throw new Error('no-levels'); + ensureStyles(); + + return new Promise((resolve) => { + const b = bridge(); + const hasBridge = !!b; + const call = (name, ...a) => { try { return b && typeof b[name] === 'function' ? b[name](...a) : undefined; } catch (e) { return undefined; } }; + call('pauseClock'); + + // ---- 掛載 + let host = opts.mount || document.getElementById('puzzle-root'); + let ownHost = false; + let hostWasHidden = null; + if (!host) { host = el('div'); document.body.append(host); ownHost = true; } + const prevHostHTML = host.innerHTML; + host.innerHTML = ''; + if (host.hidden) { hostWasHidden = true; host.hidden = false; } + const root = el('div', { class: 'pz-root' + (opts.mount && !ownHost ? '' : ' pz-overlay'), role: 'region', 'aria-label': '製程解謎' }); + host.append(root); + let st = settings(); + if (st.reduceMotion) root.classList.add('pz-reduce'); + + const hist = () => ({ ...(memStars[fabId] || {}), ...(call('getStars', fabId) || {}) }); + const sessionStars = {}; + const hintsBought = { ...(call('getHintsBought', fabId) || {}) }; + const best = (id) => Math.max(sessionStars[id] || 0, hist()[id] || 0); + const mult = FAB_MULT[fabId] || 1; + + let cleanup = () => {}; + let finished = false; + function finish() { + if (finished) return; + finished = true; + cleanup(); + document.removeEventListener('keydown', onKey); + root.remove(); + host.innerHTML = prevHostHTML; + if (ownHost) host.remove(); + else if (hostWasHidden) host.hidden = true; + call('resumeClock'); + const h = hist(); + const merged = { ...h }; + for (const k of Object.keys(sessionStars)) merged[k] = Math.max(merged[k] || 0, sessionStars[k]); + resolve({ + fabId, + stars: { ...sessionStars }, + cleared: levels.every((l) => (merged[l.id] || 0) >= 1), + hintsBought: { ...hintsBought }, + }); + } + + function toast(msg) { + document.querySelectorAll('.pz-toast').forEach((n) => n.remove()); + const t = el('div', { class: 'pz-toast', role: 'status', text: msg }); + document.body.append(t); + setTimeout(() => t.remove(), 2600); + } + + let onEsc = null; + function onKey(e) { + if (e.key === 'Escape' && onEsc) { e.preventDefault(); onEsc(); } + } + document.addEventListener('keydown', onKey); + + // ================= 關卡選單 ================= + function showMenu() { + cleanup(); + cleanup = () => {}; + onEsc = null; + root.innerHTML = ''; + const h = hist(); + const list = el('ul', { class: 'pz-menu' }); + for (const lv of levels) { + const s = Math.max(sessionStars[lv.id] || 0, h[lv.id] || 0); + list.append(el('li', {}, el('button', { + class: 'pz-level-btn', 'data-level': lv.id, 'aria-label': `${lv.title},目前 ${s} 顆星,標準步數 ${lv.par}`, + onclick: () => showLevel(lv), + }, + el('span', { class: 'pz-level-title', text: `${lv.order}. ${lv.title}` }), + el('span', { class: 'pz-level-stars' }, starsText(s)), + el('span', { class: 'pz-level-meta', text: `標準 ${lv.par} 步 · ${lv.teach.length ? '重點:' + teachNames(lv.teach) : ''}` })))); + } + const done = levels.every((l) => best(l.id) >= 1); + root.append(el('div', { class: 'pz-wrap' }, + el('div', { class: 'pz-head' }, + el('h2', { text: `Fab ${fabId.slice(1)} 製程挑戰` }), + el('button', { class: 'pz-btn', 'data-tut': 'btn-exit', 'aria-label': '離開解謎', onclick: finish, text: '離開' })), + el('p', { class: 'pz-intro', text: done ? '全部關卡都已過關,可以回去重玩補星。' : '每一關至少拿 1 顆星才算過關;步數不超過標準並且零浪費可拿 3 顆星。' }), + list, + el('p', { class: 'pz-menu-foot', text: '簡化聲明:本遊戲不模擬孔洞、等向蝕刻與擴散;離子佈植為淺層規則化。' }))); + const first = root.querySelector('.pz-level-btn'); + if (first) first.focus({ preventScroll: true }); + onEsc = finish; + root.scrollTop = 0; + } + const PROC = { lithography: '微影', thin_film: '薄膜', etching: '蝕刻', ion_implantation: '離子佈植', cmp: 'CMP', cleaning: '清洗', metallization: '金屬化' }; + const teachNames = (arr) => arr.map((x) => PROC[x] || x).join('、'); + + // ================= 單關 ================= + function showLevel(level) { + cleanup(); + st = settings(); + if (st.reduceMotion) root.classList.add('pz-reduce'); else root.classList.remove('pz-reduce'); + if (sessionStars[level.id] == null) sessionStars[level.id] = 0; + const session = createSession(level); + let diffOn = st.diffHighlight; + let targetBig = false; + let hintMsg = ''; + let busy = false; + let sheetEl = null; + const lastParams = {}; + + const cell = cellSizeFor(Math.min(window.innerWidth || 375, 560)); + const smallCell = Math.max(12, Math.floor(cell * 0.5)); + + const boardCv = el('canvas', { class: 'pz-canvas', role: 'img', 'aria-label': '目前剖面' }); + const targetCv = el('canvas', { class: 'pz-canvas', role: 'img', 'aria-label': '目標剖面' }); + const board = createBoardRenderer(boardCv, { cell, grid: session.grid, showDiff: diffOn, reduceMotion: st.reduceMotion }); + board.setTarget(level.target); + const targetBtn = el('button', { class: 'pz-target-btn', 'aria-pressed': 'false', 'data-tut': 'target', 'aria-label': '放大或縮小目標剖面', onclick: () => { targetBig = !targetBig; drawTarget(); } }, targetCv); + const targetLabel = el('p', { class: 'pz-board-label' }, el('span', { text: '目標剖面(點一下放大)' })); + function drawTarget() { + drawThumb(targetCv, level.target, targetBig ? cell : smallCell); + targetBtn.setAttribute('aria-pressed', String(targetBig)); + } + drawTarget(); + + const statsEl = el('div', { class: 'pz-stats', 'aria-live': 'polite' }); + const chipsEl = el('div', { class: 'pz-chips', 'aria-label': '已出的步驟' }); + const hintEl = el('div', { class: 'pz-hint', role: 'status', hidden: true }); + const btnUndo = el('button', { class: 'pz-btn', 'data-tut': 'btn-undo', 'aria-label': '復原上一步', onclick: () => doUndo(), text: '復原' }); + const btnRedo = el('button', { class: 'pz-btn', 'data-tut': 'btn-redo', 'aria-label': '重做', onclick: () => doRedo(), text: '重做' }); + const btnReset = el('button', { class: 'pz-btn', 'data-tut': 'btn-reset', 'aria-label': '重置本關', onclick: () => doReset(), text: '重置' }); + const btnHint = el('button', { class: 'pz-btn', 'data-tut': 'btn-hint', onclick: () => buyHint() }); + const btnDiff = el('button', { class: 'pz-btn pz-btn-ghost', 'aria-pressed': String(diffOn), 'aria-label': '切換差異提示框', onclick: () => { diffOn = !diffOn; board.setShowDiff(diffOn); btnDiff.setAttribute('aria-pressed', String(diffOn)); btnDiff.textContent = diffOn ? '差異框:開' : '差異框:關'; }, text: diffOn ? '差異框:開' : '差異框:關' }); + + const cardsEl = el('div', { class: 'pz-cards', role: 'group', 'aria-label': '工具卡' }); + for (const card of availableCards(level)) { + cardsEl.append(el('button', { + class: 'pz-card', 'data-card': card, 'data-tut': `card-${card}`, 'aria-label': `${CARD_NAMES[card]}:${CARD_SUB[card]}`, + onclick: () => openSheet(card), + }, el('span', { text: CARD_NAMES[card] }), el('small', { text: CARD_SUB[card] }))); + } + + const legend = el('div', { class: 'pz-legend' }); + const mats = materialsIn(level.init, level.target); + const legendCodes = [...new Set([...mats, ...((level.cards || []).some((c) => c.card === 'spin') ? ['R', 'r'] : [])])]; + renderLegend(legend, legendCodes); + + const head = el('div', { class: 'pz-head' }, + el('button', { class: 'pz-btn', 'aria-label': '回關卡選單', 'data-tut': 'btn-menu', onclick: () => showMenu(), text: '‹ 關卡' }), + el('h2', { text: `${level.id.toUpperCase()} ${level.title}` }), + el('button', { class: 'pz-btn', 'data-tut': 'btn-exit', 'aria-label': '離開解謎', onclick: finish, text: '離開' })); + + const noteBox = el('details', { class: 'pz-details' }, el('summary', { text: '規則提示與簡化聲明' }), + el('div', {}, + el('p', { text: '蝕刻只吃指定的一種材料,遇到不同材料或光阻就停。光阻只有「沒有其他材料時」才能旋塗;有光阻時不能沉積。' }), + el('p', { text: '簡化:不模擬孔洞、等向蝕刻與擴散;佈植為淺層規則化。' }))); + + const colBoard = el('div', { class: 'pz-col-board' }, + el('p', { class: 'pz-intro', text: level.intro }), + el('div', { class: 'pz-boards' }, + el('div', { class: 'pz-board-box' }, targetLabel, targetBtn), + el('div', { class: 'pz-board-box' }, el('p', { class: 'pz-board-label' }, el('span', { text: '目前剖面' }), btnDiff), boardCv)), + el('details', { class: 'pz-details' }, el('summary', { text: '材料圖例' }), legend)); + const colCtrl = el('div', { class: 'pz-col-ctrl' }, + statsEl, chipsEl, + el('div', { class: 'pz-toolbar' }, btnUndo, btnRedo, btnReset, btnHint), + hintEl, cardsEl, noteBox); + + root.innerHTML = ''; + root.append(el('div', { class: 'pz-wrap' }, head, el('div', { class: 'pz-play' }, colBoard, colCtrl))); + root.scrollTop = 0; + onEsc = () => { if (sheetEl) closeSheet(); else showMenu(); }; + + function hintCost() { return hintPrice(mult, hintsBought[level.id] || 0); } + function refresh() { + const r = session.result(); + const over = r.steps > level.par; + statsEl.replaceChildren( + el('span', { class: 'pz-stat' }, '步數 ', el('b', { class: over ? 'pz-over' : '', text: `${r.steps}` }), ` / 標準 ${level.par}(上限 ${level.maxSteps || 20})`), + el('span', { class: 'pz-stat' }, '移除 ', el('b', { text: `${r.removals}` }), ` / 最少 ${level.minRemovals}`), + el('span', { class: 'pz-stat' }, '最佳 ', starsText(best(level.id)))); + chipsEl.replaceChildren(); + const steps = session.steps; + if (!steps.length) chipsEl.append(el('span', { class: 'pz-chip pz-chip-empty', text: '還沒出牌' })); + steps.forEach((s, i) => chipsEl.append(el('span', { class: 'pz-chip', text: `${i + 1}. ${describeStep(s)}` }))); + chipsEl.scrollLeft = chipsEl.scrollWidth; + btnUndo.disabled = busy || !session.canUndo; + btnRedo.disabled = busy || !session.canRedo; + btnReset.disabled = busy || (!session.canUndo && !session.canRedo); + const cost = hintCost(); + btnHint.textContent = hasBridge ? `提示 $${cost}` : '提示'; + btnHint.setAttribute('aria-label', hasBridge ? `買提示,花費 ${cost} 元` : '提示(測試模式免費)'); + cardsEl.querySelectorAll('.pz-card').forEach((c) => { c.disabled = busy; }); + hintEl.hidden = !hintMsg; + hintEl.textContent = hintMsg; + } + + async function animateTo(before, after) { + busy = true; + refresh(); + await board.transition(before, after); + busy = false; + } + async function doUndo() { + const before = session.grid; + if (!session.undo()) return; + hintMsg = ''; + await animateTo(before, session.grid); + refresh(); + } + async function doRedo() { + const before = session.grid; + if (!session.redo()) return; + hintMsg = ''; + await animateTo(before, session.grid); + refresh(); + checkWin(); + } + async function doReset() { + const before = session.grid; + session.reset(); + hintMsg = ''; + await animateTo(before, session.grid); + refresh(); + } + + async function buyHint() { + if (busy) return; + const k = hintsBought[level.id] || 0; + const cost = hintCost(); + if (hasBridge) { + let paid = false; + try { paid = b.spendMoney(cost, `puzzle-hint:${level.id}`); } catch (e) { paid = false; } + if (!paid) { toast('資金不足'); return; } + } + hintsBought[level.id] = k + 1; + const info = hintInfo(level, session.steps, k); + hintMsg = info.text; + if (info.kind === 'rewind') hintMsg += `(目前有 ${session.stepCount - info.to} 步要復原)`; + refresh(); + } + + // ---- 參數面板 + function dimsFor(card) { + const opts2 = paramOptions(level, card); + const dims = {}; + for (const o of opts2) for (const [k, v] of Object.entries(o)) if (k !== 'card') (dims[k] = dims[k] || []).includes(v) || dims[k].push(v); + return dims; + } + function closeSheet() { + if (!sheetEl) return; + sheetEl.back.remove(); + sheetEl.node.remove(); + root.style.paddingBottom = ''; + board.setPreview(null); + const c = cardsEl.querySelector(`[data-card="${sheetEl.card}"]`); + sheetEl = null; + if (c) c.focus({ preventScroll: true }); + } + function openSheet(card) { + if (busy || sheetEl) return; + if (session.won) { toast('本關已完成'); return; } + const dims = dimsFor(card); + const sel = {}; + const present = new Set(session.grid.join('')); + for (const k of Object.keys(dims)) { + let pick = lastParams[card + k]; + if (pick == null || !dims[k].includes(pick)) pick = dims[k][0]; + if (k === 'stop' && !present.has(pick)) pick = dims[k].find((v) => present.has(v)) ?? pick; + sel[k] = pick; + } + const msg = el('div', { class: 'pz-preview-msg', role: 'status', 'aria-live': 'polite' }); + const btnOk = el('button', { class: 'pz-btn pz-btn-primary', 'data-tut': 'btn-confirm', text: '確認出牌' }); + const btnNo = el('button', { class: 'pz-btn', 'data-tut': 'btn-cancel', onclick: closeSheet, text: '取消' }); + const groups = el('div'); + + function currentStep() { return { card, ...sel }; } + function updatePreview() { + const step = currentStep(); + const res = session.preview(step); + if (res.error) { + board.setPreview(null); + msg.className = 'pz-preview-msg pz-err'; + msg.textContent = '無法出牌:' + res.error; + btnOk.disabled = true; + } else { + board.setPreview(res.grid); + const add = diffCells(session.grid, res.grid).length; + msg.className = 'pz-preview-msg'; + msg.textContent = `預覽:改變 ${add} 格,移除材料 ${res.removed} 格(預覽不計步)`; + btnOk.disabled = false; + } + groups.querySelectorAll('[data-k]').forEach((btn) => btn.setAttribute('aria-pressed', String(String(sel[btn.dataset.k]) === btn.dataset.v))); + } + function buildGroups() { + groups.replaceChildren(); + for (const [k, vals] of Object.entries(dims)) { + const row = el('div', { class: 'pz-opts' }); + for (const v of vals) { + let content; + const attrs = { class: 'pz-opt', 'data-k': k, 'data-v': String(v), 'aria-pressed': 'false', 'data-tut': `param-${k}-${v}` }; + if (k === 'mat' || k === 'stop') { + const stl = MAT_STYLE[v]; + content = [el('span', { class: 'pz-swatch', style: `background:${stl.fill}`, 'aria-hidden': 'true' }), `${v} ${MATERIALS[v].name}`]; + attrs['aria-label'] = MATERIALS[v].name; + if (k === 'stop' && !present.has(v)) attrs.disabled = true; + } else if (k === 'mask') { + const mk = level.masks.find((x) => x.id === v); + attrs.class += ' pz-opt-mask'; + content = [maskThumb(mk, 14), `光罩 ${v}`]; + } else content = [String(v)]; + attrs.onclick = () => { sel[k] = v; lastParams[card + k] = v; updatePreview(); }; + row.append(el('button', attrs, ...content)); + } + groups.append(el('div', { class: 'pz-group' }, el('div', { class: 'pz-group-label', text: PARAM_LABEL[k] || k }), row)); + } + } + buildGroups(); + btnOk.addEventListener('click', async () => { + const step = currentStep(); + const res = session.push(step); + if (!res.ok) { toast(res.error); updatePreview(); return; } + closeSheet(); + hintMsg = ''; + await animateTo(res.before, res.after); + refresh(); + checkWin(); + }); + + const back = el('div', { class: 'pz-sheet-back', onclick: closeSheet }); + const node = el('div', { class: 'pz-sheet', role: 'dialog', 'aria-modal': 'true', 'aria-label': `${CARD_NAMES[card]} 參數` }, + el('h3', { text: `${CARD_NAMES[card]}` }), + el('p', { class: 'pz-explain', text: CARD_HELP[card] }), + groups, msg, el('div', { class: 'pz-sheet-actions' }, btnNo, btnOk)); + document.body.append(back, node); + sheetEl = { back, node, card }; + updatePreview(); + btnOk.focus({ preventScroll: true }); + // 讓目前剖面停在面板上方,預覽才看得到。 + root.style.paddingBottom = node.offsetHeight + 'px'; + const scroller = root.classList.contains('pz-overlay') ? root : window; + const over = boardCv.getBoundingClientRect().bottom - node.getBoundingClientRect().top + 8; + if (over > 0) scroller.scrollBy({ top: over, behavior: 'auto' }); + } + + // ---- 勝利 + function checkWin() { + if (!session.won) return; + const r = session.result(); + sessionStars[level.id] = Math.max(sessionStars[level.id] || 0, r.stars); + memStars[fabId] = { ...(memStars[fabId] || {}), [level.id]: Math.max((memStars[fabId] || {})[level.id] || 0, r.stars) }; + refresh(); + showResult(level, r, session, hintsBought[level.id] || 0); + } + + function showResult(lv, r, sess, hintsUsed) { + const idx = levels.indexOf(lv); + const next = levels[idx + 1]; + const factBox = el('div', { class: 'pz-fact', hidden: true }, el('b', { text: '你知道嗎?' }), el('span')); + factText(lv.factId).then((t) => { + if (!t) return; + factBox.hidden = false; + factBox.lastChild.textContent = t; + call('markFactSeen', lv.factId); + }); + const crit = el('ul', { class: 'pz-crit' }, + el('li', {}, el('span', { class: 'pz-ok', text: '★ 完成:剖面完全吻合' })), + el('li', {}, el('span', { class: r.steps <= lv.par ? 'pz-ok' : 'pz-no', text: `${r.steps <= lv.par ? '★' : '☆'} 步數 ${r.steps} ≤ 標準 ${lv.par}` })), + el('li', {}, el('span', { class: r.stars === 3 ? 'pz-ok' : 'pz-no', text: `${r.stars === 3 ? '★' : '☆'} 零浪費:移除 ${r.removals},最少 ${lv.minRemovals},浪費 ${r.waste}` }))); + const actions = el('div', { class: 'pz-result-actions' }); + if (next) actions.append(el('button', { class: 'pz-btn pz-btn-primary', 'data-tut': 'btn-next', onclick: () => { back.remove(); showLevel(next); }, text: `下一關:${next.title}` })); + actions.append( + el('button', { class: 'pz-btn', onclick: async () => { back.remove(); await doReset(); }, text: r.stars < 3 ? '再試一次,拿更多星' : '重玩這關' }), + el('button', { class: 'pz-btn', onclick: () => { back.remove(); showMenu(); }, text: '回關卡選單' })); + const box = el('div', { class: 'pz-result', role: 'dialog', 'aria-modal': 'true', 'aria-label': '過關結算' }, + el('h3', { text: `${lv.title} 完成!` }), + el('div', { class: 'pz-big-stars pz-pop' }, starsText(r.stars)), + crit, + hintsUsed ? el('p', { class: 'pz-no', text: `本關使用過 ${hintsUsed} 次提示` }) : null, + lv.note ? el('p', { class: 'pz-no', text: lv.note }) : null, + factBox, actions); + const back = el('div', { class: 'pz-result-back' }, box); + document.body.append(back); + (actions.firstChild).focus({ preventScroll: true }); + const prevEsc = onEsc; + onEsc = () => { back.remove(); onEsc = prevEsc; }; + const mo = new MutationObserver(() => { if (!back.isConnected) { onEsc = () => (sheetEl ? closeSheet() : showMenu()); mo.disconnect(); } }); + mo.observe(document.body, { childList: true }); + } + + cleanup = () => { + board.destroy(); + closeSheet(); + document.querySelectorAll('.pz-result-back, .pz-toast').forEach((n) => n.remove()); + }; + refresh(); + } + + if (opts.levelId && levels.some((l) => l.id === opts.levelId)) showLevel(levels.find((l) => l.id === opts.levelId)); + else showMenu(); + }); +} + +if (typeof window !== 'undefined') window.startPuzzleSet = startPuzzleSet; diff --git a/empire/js/shared/bus.js b/empire/js/shared/bus.js new file mode 100644 index 0000000..43d09d2 --- /dev/null +++ b/empire/js/shared/bus.js @@ -0,0 +1,20 @@ +// 極簡 pub/sub +export function createBus() { + const map = new Map(); + return { + on(evt, fn) { + if (!map.has(evt)) map.set(evt, new Set()); + map.get(evt).add(fn); + return () => map.get(evt)?.delete(fn); + }, + off(evt, fn) { map.get(evt)?.delete(fn); }, + emit(evt, payload) { + const set = map.get(evt); + if (!set) return; + for (const fn of [...set]) { + try { fn(payload); } catch (e) { console.error('[bus]', evt, e); } + } + } + }; +} +export const bus = createBus(); diff --git a/empire/js/shared/content.js b/empire/js/shared/content.js new file mode 100644 index 0000000..684da75 --- /dev/null +++ b/empire/js/shared/content.js @@ -0,0 +1,28 @@ +// 載入 content.json、補 fact id、查詢 +let content = null; + +export function parseContent(raw) { + const facts = raw.facts.map((f, i) => ({ ...f, id: 'fact#' + i })); + return { + ...raw, + facts, + factById: new Map(facts.map(f => [f.id, f])), + equipmentById: new Map(raw.equipment.map(e => [e.id, e])), + processById: new Map(raw.processes.map(p => [p.id, p])) + }; +} + +export async function loadContent(url) { + const u = url || new URL('../../data/content.json', import.meta.url); + const res = await fetch(u); + if (!res.ok) throw new Error('content-load-failed'); + content = parseContent(await res.json()); + return content; +} + +export function setContent(c) { content = c; } +export function getContent() { return content; } +export function getFact(id) { return content?.factById.get(id) || null; } +export function getFactText(id) { return getFact(id)?.text ?? ''; } +export function getEquipment(id) { return content?.equipmentById.get(id) || null; } +export function getProcess(id) { return content?.processById.get(id) || null; } diff --git a/empire/js/shared/rng.js b/empire/js/shared/rng.js new file mode 100644 index 0000000..c5fb679 --- /dev/null +++ b/empire/js/shared/rng.js @@ -0,0 +1,12 @@ +// mulberry32。rngState = {seed},nextRandom 會就地推進 seed,使狀態可存檔。 +export function nextRandom(rs) { + rs.seed = (rs.seed + 0x6D2B79F5) >>> 0; + let t = rs.seed; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +} +export function mulberry32(seed) { + const rs = { seed: seed >>> 0 }; + return () => nextRandom(rs); +} diff --git a/empire/js/shared/storage.js b/empire/js/shared/storage.js new file mode 100644 index 0000000..da83972 --- /dev/null +++ b/empire/js/shared/storage.js @@ -0,0 +1,49 @@ +// localStorage 存檔:key、version、migrate(§7.2) +export const SAVE_KEY = 'waferEmpire.save'; +export const SAVE_VERSION = 1; +// migrations[v] 把 version v 的存檔轉成 v+1 +export const migrations = {}; + +export function migrate(raw) { + if (!raw || typeof raw !== 'object' || !Number.isInteger(raw.version) || raw.version < 1) { + return { ok: false, reason: 'invalid' }; + } + if (raw.version > SAVE_VERSION) return { ok: false, reason: 'too-new', version: raw.version }; + let s = raw; + while (s.version < SAVE_VERSION) { + const f = migrations[s.version]; + if (!f) return { ok: false, reason: 'invalid' }; + s = f(s); + } + return { ok: true, save: s }; +} + +function store(storage) { + try { return storage || globalThis.localStorage || null; } catch (e) { return null; } +} + +export function parseSaveText(text) { + try { return migrate(JSON.parse(text)); } catch (e) { return { ok: false, reason: 'invalid' }; } +} + +export function loadSave(storage) { + try { + const st = store(storage); + const text = st && st.getItem(SAVE_KEY); + if (!text) return { ok: false, reason: 'none' }; + return parseSaveText(text); + } catch (e) { return { ok: false, reason: 'unavailable' }; } +} + +export function writeSave(save, storage) { + try { + const st = store(storage); + if (!st) return false; + st.setItem(SAVE_KEY, JSON.stringify(save)); + return true; + } catch (e) { return false; } +} + +export function clearSave(storage) { + try { store(storage)?.removeItem(SAVE_KEY); } catch (e) { /* ignore */ } +} diff --git a/empire/js/tutorial.js b/empire/js/tutorial.js new file mode 100644 index 0000000..a735d4b --- /dev/null +++ b/empire/js/tutorial.js @@ -0,0 +1,223 @@ +// 教學(DESIGN §10):以 data-tut 鎖定元素,半透明遮罩只開一個洞讓玩家點高亮目標;可隨時跳過。 +// 進度存在 save.tutorial.done(以 "tut:" 記錄),重新整理後可續玩。 +import { bus } from './shared/bus.js'; +import * as S from './tycoon/state.js'; +import { getProcess } from './shared/content.js'; + +const $ = sel => document.querySelector(sel); +const byTut = id => $(`[data-tut="${id}"]`); +const resolve = t => (typeof t === 'function' ? t() : t ? (/^[.\[#]/.test(t) ? $(t) : byTut(t)) : null); +const chips = () => document.querySelectorAll('.pz-chip:not(.pz-chip-empty)').length; +const levelHead = () => ($('.pz-head h2')?.textContent || '').trim().toUpperCase(); +const visible = e => { if (!e) return false; const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0; }; + +const isDone = id => S.getState().tutorial.done.includes('tut:' + id); +function mark(id) { + const d = S.getState().tutorial.done; + if (!d.includes('tut:' + id)) { d.push('tut:' + id); S.save(); } +} + +export function createTutorial() { + let rootEl = null, dims = [], ring = null, card = null; + let raf = 0, steps = [], idx = 0, cur = null, active = false, standalone = false; + let shipCount = 0, soldDays = 0, base = 0, lastTarget = null, shownKey = ''; + const refs = {}; + + bus.on('lot:shipped', () => { shipCount++; }); + bus.on('day', d => { if (d.sold > 0) soldDays++; }); + bus.on('state:reset', () => { if (active) stop(); }); + + // ---------------- 內容 ---------------- + const slotName = k => S.getBal().categories[S.getBal().fabs.f1.slots[k]]; + const buyBtn = () => document.querySelector('.ty-dlg [data-tut^="buy-"]:not(:disabled)'); + + function phases() { + const lith = getProcess('lithography')?.summary || ''; + const buy = [0, 1, 2, 3].map(k => ({ + title: `買機台 ${k + 1} / 4:${slotName(k)}`, + text: () => (buyBtn() ? '選一台機台買下(起始資金足夠買齊四個槽位)。' : `點亮起的槽位 ${k + 1},替「${slotName(k)}」買一台機台。`), + target: () => buyBtn() || byTut('slot-' + (k + 1)), + done: () => !!S.getState().fabs.f1.slots[k], + })); + const lithoCard = (name, k, title, text) => ({ + title, text, target: () => byTut('btn-confirm') || byTut('card-' + name), + done: () => chips() >= k + 1 && levelHead().startsWith('F1-2') || (k === 4 && !!$('.pz-result-back')), + }); + return [ + { id: 'intro', steps: [{ title: '歡迎來到晶圓帝國', dim: true, mode: 'next', nextLabel: '開始', + text: '你從 180 nm 的 Fab 1 起家:買機台、排產線、賣晶圓賺錢。想開更先進的新廠,必須先通過製程解謎;解謎星數會永久降低缺陷率。每個事件與關卡都附一則真實的半導體知識。' }] }, + { id: 't-buy', skipIf: () => S.getState().fabs.f1.slots.every(Boolean), steps: buy }, + { id: 't-run', steps: [ + { title: '開始生產', text: '產線齊了!按「開始」或速度鈕,讓時鐘跑起來。', target: 'clock-1x', done: () => !S.getState().clock.paused }, + { title: '看 Lot 走完', text: '圓點是一個 Lot(25 片),沿著槽位一站站加工。想快一點可按 2×。等第一批出貨。', target: 'clock-2x', block: false, + enter: () => { base = shipCount; }, done: () => shipCount > base }, + { title: '良率怎麼算', mode: 'next', pos: 'bottom', nextLabel: '懂了', + text: '每台機台有缺陷率 d,單槽良率 y = 1 − d;整條線良率 Y = 所有 y 相乘。槽越多、機台等級越低,Y 越低。升級機台與提高解謎星數都能讓 Y 變高。' }, + { title: '每日結算', text: '每 24 小時結算一次:庫存賣出、扣日維運。等第一次賣出。', enter: () => { base = soldDays; }, done: () => soldDays > base }, + ] }, + { id: 't-puzzle', steps: [ + { title: '想開 Fab 2 嗎?', mode: 'next', nextLabel: '去看看', + text: '開新廠要先通過它的製程解謎組。先從 Fab 1 的基礎挑戰練習:它同時能降低 Fab 1 的缺陷率。' }, + { title: '製程挑戰', text: '點「製程挑戰 / 解鎖資訊」。', target: 'btn-puzzle', done: () => !!byTut('unlock-puzzle') || !!$('.pz-root') }, + { title: '開始解謎', text: '按「玩製程挑戰」進入解謎。', target: 'unlock-puzzle', done: () => !!$('.pz-root') }, + ] }, + { id: 't-card', steps: [ + { title: '選第 1 關', text: '先從「長一層氧化」開始。', target: '[data-level="f1-1"]', done: () => levelHead().startsWith('F1-1') }, + { title: '打出沉積卡', text: '目標是在矽上長 2 格 SiO₂。點「沉積」卡。', target: 'card-deposit', done: () => !!$('.pz-sheet') || chips() > 0 }, + { title: '選參數', text: '材料選 SiO₂(O),厚度選 2 格。預覽只會在剖面上顯示結果、不計步;打錯了可按「復原」回到上一步。', + target: () => byTut('param-t-2') || byTut('card-deposit'), + done: () => !!$('[data-tut="param-t-2"][aria-pressed="true"]') || chips() > 0 }, + { title: '確認出牌', text: '預覽和目標一樣了,按「確認出牌」。', target: () => byTut('btn-confirm') || byTut('card-deposit'), done: () => chips() > 0 }, + ] }, + { id: 't-star', steps: [ + { done: () => !!$('.pz-result-back') || !levelHead().startsWith('F1-1') }, + { title: '三顆星與「浪費」', mode: 'next', pos: 'top', nextLabel: '了解', + text: '★1 剖面吻合;★2 步數不超過標準;★3 再加上零浪費(沒有多餘的材料移除)。星數會永久降低該廠缺陷率:d × (1 − 0.5S),全三星缺陷率減半。' }, + { title: '下一關', text: '接著玩「開窗」:微影五步驟。', target: 'btn-next', pos: 'top', + done: () => levelHead().startsWith('F1-2') || !$('.pz-result-back') }, + ] }, + { id: 't-litho', steps: [ + lithoCard('spin', 0, '微影 1/5:旋塗光阻', `${lith} 先用「旋塗光阻」在表面鋪一層光阻。`), + lithoCard('expose', 1, '微影 2/5:曝光', '光罩透光的欄位,光阻會變成可溶(正光阻)。想一想要打開哪些欄位的氧化層,選對光罩再確認。'), + lithoCard('develop', 2, '微影 3/5:顯影', '把已曝光的光阻洗掉,露出下面的氧化層。'), + lithoCard('etch', 3, '微影 4/5:蝕刻', '只蝕刻氧化層 O;光阻留著的地方受保護。深度要剛好穿過氧化層、不要吃到矽。'), + lithoCard('strip', 4, '微影 5/5:去光阻', '最後把剩下的光阻剝除,剖面就會和目標一樣。'), + { title: '教學完成', mode: 'next', pos: 'top', nextLabel: '完成', done: undefined, + text: '沉積 → 微影 → 蝕刻 → 佈植 → CMP → 金屬化,就是晶片的製程主線。湊齊 Fab 2 的星數、$12,000 與聲望 15 就能開 Fab 2。祝你成為晶圓大亨!' }, + ] }, + ]; + } + + // ---------------- 介面 ---------------- + function build() { + rootEl = document.createElement('div'); + rootEl.className = 'we-tut'; + rootEl.setAttribute('role', 'dialog'); rootEl.setAttribute('aria-label', '教學'); + dims = [0, 1, 2, 3].map(() => { const d = document.createElement('div'); d.className = 'we-tut-dim'; return d; }); + ring = document.createElement('div'); ring.className = 'we-tut-ring'; + card = document.createElement('div'); card.className = 'we-tut-card'; + refs.h = document.createElement('h3'); refs.p = document.createElement('p'); + refs.prog = document.createElement('span'); refs.prog.className = 'we-tut-prog'; + refs.next = document.createElement('button'); refs.next.className = 'we-btn we-btn--primary'; refs.next.type = 'button'; + refs.skip = document.createElement('button'); refs.skip.className = 'we-btn'; refs.skip.type = 'button'; refs.skip.textContent = '跳過教學'; + refs.next.addEventListener('click', () => advance()); + refs.skip.addEventListener('click', () => skip()); + const row = document.createElement('div'); row.className = 'we-tut-row'; + row.append(refs.prog, refs.skip, refs.next); + card.append(refs.h, refs.p, row); + rootEl.append(...dims, ring, card); + document.body.append(rootEl); + } + + function setRect(d, l, t, w, h) { d.style.cssText = `left:${l}px;top:${t}px;width:${Math.max(0, w)}px;height:${Math.max(0, h)}px;display:block`; } + + function render(t) { + const st = cur; + const W = window.innerWidth, H = window.innerHeight; + const block = st.block !== false; + dims.forEach(d => { d.style.display = 'none'; }); + ring.style.display = 'none'; + if (st.dim) setRect(dims[0], 0, 0, W, H); + else if (t && visible(t)) { + const r = t.getBoundingClientRect(), pad = 5; + const x0 = Math.max(0, r.left - pad), y0 = Math.max(0, r.top - pad), x1 = Math.min(W, r.right + pad), y1 = Math.min(H, r.bottom + pad); + if (block) { + setRect(dims[0], 0, 0, W, y0); + setRect(dims[1], 0, y1, W, H - y1); + setRect(dims[2], 0, y0, x0, y1 - y0); + setRect(dims[3], x1, y0, W - x1, y1 - y0); + } + ring.style.cssText = `display:block;left:${x0}px;top:${y0}px;width:${x1 - x0}px;height:${y1 - y0}px`; + } + // 卡片 + const text = typeof st.text === 'function' ? st.text() : st.text; + card.style.display = text || st.title ? '' : 'none'; + const key = `${st.title}|${text}`; + if (key !== shownKey) { + shownKey = key; + refs.h.textContent = st.title || ''; + refs.h.style.display = st.title ? '' : 'none'; + refs.p.textContent = text || ''; + } + refs.next.style.display = st.mode === 'next' ? '' : 'none'; + refs.next.textContent = st.nextLabel || '下一步'; + refs.prog.textContent = `${idx + 1} / ${steps.length}`; + let atTop = st.pos === 'top'; + if (!st.pos) { + if (t && visible(t)) { const r = t.getBoundingClientRect(); atTop = (r.top + r.bottom) / 2 > H * 0.5; } + } + card.classList.toggle('at-top', atTop); + card.classList.toggle('at-bottom', !atTop); + if (t && t !== lastTarget && visible(t)) { + lastTarget = t; + const r = t.getBoundingClientRect(); + if (r.top < 0 || r.bottom > H) { try { t.scrollIntoView({ block: 'center', behavior: 'auto' }); } catch (e) { /* ignore */ } } + } + } + + function enter() { + cur = steps[idx]; + shownKey = ''; lastTarget = null; + cur?.enter?.(); + } + function advance() { + const prev = cur; + if (prev && prev._last && !standalone) mark(prev._phase); + idx += 1; + if (idx >= steps.length) { finish(); return; } + enter(); + } + function frame() { + if (!active) return; + raf = requestAnimationFrame(frame); + if (!cur) return; + if (cur.done && cur.done()) { advance(); return; } + render(cur.target ? resolve(cur.target) : null); + } + + function run(list, solo = false) { + steps = []; + for (const ph of list) { + ph.steps.forEach((s, i) => steps.push({ ...s, _phase: ph.id, _last: i === ph.steps.length - 1 })); + } + if (!steps.length) return false; + stop(); + idx = 0; active = true; standalone = solo; + build(); enter(); + raf = requestAnimationFrame(frame); + return true; + } + function stop() { + active = false; cancelAnimationFrame(raf); + rootEl?.remove(); rootEl = null; cur = null; + } + function finish() { if (!standalone) mark('finished'); stop(); bus.emit('tutorial:end', {}); } + function skip() { if (!standalone) { mark('skipped'); mark('finished'); } stop(); bus.emit('tutorial:end', { skipped: true }); } + + // ---------------- 對外 ---------------- + function start({ fresh = false } = {}) { + if (fresh) { + const d = S.getState().tutorial.done; + S.getState().tutorial.done = d.filter(x => !x.startsWith('tut:')); + } + const todo = phases().filter(p => !isDone(p.id) && !(p.skipIf && p.skipIf())); + return run(todo); + } + /** 續玩:沒完成也沒跳過才自動接續 */ + const needsResume = () => !isDone('finished') && !isDone('skipped'); + + /** 首個事件的引導(事件彈窗已由 HUD 開啟);教學進行中或已跳過則不介入 */ + bus.on('event:trigger', () => { + if (active || isDone('skipped') || isDone('event')) return; + if (!document.querySelector('.ty-dlg--modal')) return; + mark('event'); + run([{ id: 'event-guide-run', steps: [{ + title: '事件發生了!', pos: 'top', + text: '事件會突然改變產線。例如「微粒污染」讓缺陷率上升:花錢「停線清洗」可立刻解除,也可以硬撐。選擇後下方還有一則真實的製程知識。', + target: () => byTut('event-clean') || document.querySelector('.ty-dlg--modal [data-tut^="event-"]'), + done: () => !document.querySelector('.ty-dlg--modal'), + }] }], true); + }); + + return { start, skip, needsResume, isActive: () => active, isDone }; +} diff --git a/empire/js/tycoon/boot.js b/empire/js/tycoon/boot.js new file mode 100644 index 0000000..f9ed596 --- /dev/null +++ b/empire/js/tycoon/boot.js @@ -0,0 +1,29 @@ +// 經營層啟動:載入資料 → 狀態 → bridge → HUD / 廠房畫面 → 時鐘 +import { loadContent } from '../shared/content.js'; +import * as S from './state.js'; +import { setEventDefs } from './events.js'; +import { installBridge } from './bridge.js'; +import { initClock } from './clock.js'; +import { mountHud } from './hud.js'; +import { mountFactoryView } from './factory-view.js'; +import { toast } from './ui.js'; + +const jsonUrl = rel => new URL(rel, import.meta.url); +async function fetchJson(rel) { + const r = await fetch(jsonUrl(rel)); + if (!r.ok) throw new Error('load-failed:' + rel); + return r.json(); +} + +export async function bootTycoon({ hudRoot, fabRoot }) { + const [bal, events] = await Promise.all([fetchJson('../../data/balance.json'), fetchJson('../../data/events.json')]); + await loadContent(); + setEventDefs(events); + S.initState(bal); + installBridge(); + mountHud(hudRoot); + const view = mountFactoryView(fabRoot); + initClock(); + if (S.getLoadNotice()) toast(S.getLoadNotice(), 6000); + return { S, view }; +} diff --git a/empire/js/tycoon/bridge.js b/empire/js/tycoon/bridge.js new file mode 100644 index 0000000..06c339a --- /dev/null +++ b/empire/js/tycoon/bridge.js @@ -0,0 +1,56 @@ +// window.EmpireBridge(§9.2)+ 啟動解謎組(無 window.startPuzzleSet 時用 stub) +import * as S from './state.js'; +import * as Clock from './clock.js'; +import { bus } from '../shared/bus.js'; +import { getFactText } from '../shared/content.js'; +import { levelIds } from './economy.js'; + +export function installBridge() { + window.EmpireBridge = { + getMoney: () => S.getState().res.money, + spendMoney: (amount, reason) => S.spend(amount, reason || 'puzzle'), + getStars: fabId => ({ ...(prefixStars(fabId)) }), + getHintsBought: fabId => ({ ...prefixOf(S.getState().puzzle.hintsBought, fabId) }), + getFactText: id => getFactText(id), + markFactSeen: id => S.markFactSeen(id), + pauseClock: () => Clock.lock(), + resumeClock: () => Clock.unlock(), + getSettings: () => ({ ...S.getState().settings }) + }; + return window.EmpireBridge; +} + +function prefixOf(map, fabId) { + const out = {}; + for (const [id, n] of Object.entries(map || {})) if (id.startsWith(fabId + '-')) out[id] = n; + return out; +} +const prefixStars = fabId => prefixOf(S.getState().puzzle.stars, fabId); + +/** + * 玩家要玩某廠解謎組。若 P 已註冊 window.startPuzzleSet 就用它; + * 否則用 stub:confirm 後回傳假結果(每關 window.EmpireStubStars || 2 星)。 + * resolve 後寫入星數(S 立即重算並廣播 state:changed)。 + */ +export async function runPuzzleSet(fabId, opts) { + const bal = S.getBal(); + let res; + bus.emit('puzzle:start', { fabId, levelId: opts?.levelId }); + try { + if (typeof window.startPuzzleSet === 'function') { + res = await window.startPuzzleSet(fabId, opts); + } else { + Clock.lock(); + try { + const node = bal.fabs[fabId].node; + const ok = window.confirm(`【開發 stub】解謎層尚未載入。\n要模擬「通過 ${bal.fabs[fabId].zh}(${node})製程解謎組」嗎?`); + const n = window.EmpireStubStars ?? 2; + const stars = {}; + if (ok) for (const id of levelIds(bal, fabId)) stars[id] = n; + res = { fabId, stars, cleared: ok, hintsBought: {} }; + } finally { Clock.unlock(); } + } + } finally { bus.emit('puzzle:end', { fabId }); } + S.applyPuzzleResult(res); + return res; +} diff --git a/empire/js/tycoon/clock.js b/empire/js/tycoon/clock.js new file mode 100644 index 0000000..612db28 --- /dev/null +++ b/empire/js/tycoon/clock.js @@ -0,0 +1,50 @@ +// 遊戲時鐘(§3.6):以 tick 計,1×=250ms、2×=125ms;暫停 = clearInterval +import { bus } from '../shared/bus.js'; +import { getState, getBal, save } from './state.js'; +import { stepTick } from './sim.js'; + +let timer = null; +let locks = 0; // 解謎 / 彈窗 / 設定等暫時鎖,計數式 + +export function isRunning() { return timer !== null; } +export function tickMs() { const s = getState(); return getBal().tickMs[String(s.clock.speed)]; } +export function isUserPaused() { return getState().clock.paused; } + +function apply() { + if (timer) { clearInterval(timer); timer = null; } + const s = getState(); + if (!s.clock.paused && locks === 0 && !document.hidden) { + timer = setInterval(stepTick, tickMs()); + } + bus.emit('clock:changed', { running: isRunning(), speed: s.clock.speed, paused: s.clock.paused, locked: locks > 0 }); +} + +export function setPaused(p) { getState().clock.paused = !!p; save(); apply(); } +export function togglePause() { setPaused(!getState().clock.paused); } +export function setSpeed(n) { + const s = getState(); + s.clock.speed = n === 2 ? 2 : 1; + if (n && s.clock.paused) s.clock.paused = false; // 按速度鈕視為開始 + save(); apply(); +} +export function lock() { locks += 1; apply(); } +export function unlock() { locks = Math.max(0, locks - 1); apply(); } +export function resetLocks() { locks = 0; } + +export function initClock() { + apply(); + // 分頁隱藏 → 自動暫停;回到分頁時,若隱藏前正在跑就自動恢復(不補算離開期間的時間) + let resumeOnShow = false; + document.addEventListener('visibilitychange', () => { + const c = getState().clock; + if (document.hidden) { + if (!c.paused) resumeOnShow = true; + c.paused = true; save(); + } else if (resumeOnShow) { + resumeOnShow = false; + c.paused = false; save(); + } + apply(); + }); + bus.on('state:reset', () => { resumeOnShow = false; apply(); }); +} diff --git a/empire/js/tycoon/economy.js b/empire/js/tycoon/economy.js new file mode 100644 index 0000000..2985360 --- /dev/null +++ b/empire/js/tycoon/economy.js @@ -0,0 +1,128 @@ +// 純函式:良率 / 營收 / 成本公式(DESIGN.md §3)。所有數值由 bal (balance.json) 傳入,不寫死。 + +export const clamp = (x, a, b) => Math.min(b, Math.max(a, x)); + +export function fabIndex(bal, fabId) { return bal.fabOrder.indexOf(fabId); } +export function fabMult(bal, fabId) { return bal.fabMult[fabIndex(bal, fabId)]; } + +/** 可放入某槽位的機台 id 清單(類別相符,且受 fabs 限制,例如 f4–f5 微影只能 scanner) */ +export function allowedEquip(bal, fabId, slotIdx) { + const cat = bal.fabs[fabId].slots[slotIdx]; + return Object.keys(bal.equipment).filter(id => { + const e = bal.equipment[id]; + return e.category === cat && (!e.fabs || e.fabs.includes(fabId)); + }); +} + +/** 機台購買價 = price × 廠倍率 */ +export function equipPrice(bal, fabId, equipId) { + return bal.equipment[equipId].price * fabMult(bal, fabId); +} + +/** 升級費 = price × 廠倍率 × 1.8^L(L=目前等級),取整數 */ +export function upgradeCost(bal, fabId, equipId, level) { + return Math.round(equipPrice(bal, fabId, equipId) * Math.pow(bal.equip.upgradeBase, level)); +} + +/** 出售退款 = 50% 當前總投入 */ +export function sellRefund(bal, invested) { + return Math.round(invested * bal.equip.sellRefund); +} + +/** 槽位處理時間 p = 4 / (speed × (1 + 0.15×(L−1)))(遊戲小時 = tick) */ +export function slotHours(bal, equipId, level) { + const e = bal.equipment[equipId]; + return bal.equip.baseHours / (e.speed * (1 + bal.equip.speedPerLevel * (level - 1))); +} + +/** 線速度:每 max(p) 小時投入一個 Lot;slots 為 p 陣列(空槽以 null 表示 → 產線不通,回 null) */ +export function lineInterval(hours) { + if (!hours.length || hours.some(h => h == null)) return null; + return Math.max(...hours); +} + +/** 單槽缺陷率 d_i = D0 × defMul × 0.80^(L−1) × (1 − 0.5 S) × (1 + Σ eventDef) */ +export function slotDefect(bal, fabId, equipId, level, S, eventDef = 0) { + const y = bal.yield; + return bal.fabs[fabId].D0 * bal.equipment[equipId].defMul + * Math.pow(y.levelFactor, level - 1) * (1 - y.starHalf * S) * (1 + eventDef); +} + +/** 單槽良率 y_i = 1 − min(d_i, 0.5) */ +export function slotYield(bal, fabId, equipId, level, S, eventDef = 0) { + return 1 - Math.min(slotDefect(bal, fabId, equipId, level, S, eventDef), bal.yield.dMax); +} + +/** 線良率 Y = clamp(Π y_i × E, 0.05, 0.99);slots 為 [{equip, level}|null],空槽略過 */ +export function lineYield(bal, fabId, slots, S = 0, mods = {}) { + const eventDef = mods.eventDef || 0; + const E = mods.E == null ? 1 : mods.E; + let prod = 1; + for (const s of slots) { + if (!s) continue; + prod *= slotYield(bal, fabId, s.equip, s.level, S, eventDef); + } + return clamp(prod * E, bal.yield.yMin, bal.yield.yMax); +} + +/** 解謎星數比 S = Σ stars / (3 × 關數) */ +export function starRatio(bal, fabId, stars) { + const n = bal.fabs[fabId].puzzleLevels; + let sum = 0; + for (let i = 1; i <= n; i++) sum += clamp(Math.floor((stars && stars[`${fabId}-${i}`]) || 0), 0, 3); + return sum / (3 * n); +} + +export function levelIds(bal, fabId) { + return Array.from({ length: bal.fabs[fabId].puzzleLevels }, (_, i) => `${fabId}-${i + 1}`); +} + +/** 售價 = 廠基準價 × (0.9 + 0.2 × rep/100) × 市場波動 */ +export function sellPrice(bal, fabId, rep, walk = 1) { + return bal.fabs[fabId].price * (0.9 + 0.2 * rep / 100) * walk; +} + +/** 每出貨一 Lot:rep += (Y − 0.75) × 2 */ +export function repGain(bal, Y) { + return (Y - bal.rep.neutralYield) * bal.rep.perLotFactor; +} + +/** 標稱毛利(良率=1) = 1 − 成本/價;盈虧平衡良率 = 成本/價 */ +export function nominalMargin(bal, fabId) { const f = bal.fabs[fabId]; return 1 - f.cost / f.price; } +export function breakEvenYield(bal, fabId) { const f = bal.fabs[fabId]; return f.cost / f.price; } + +/** 市場波動:每日隨機漫步,限 min–max。r ∈ [0,1) */ +export function nextPriceWalk(bal, walk, r) { + const w = bal.priceWalk; + return clamp(walk + (r * 2 - 1) * w.step, w.min, w.max); +} + +/** 單 Lot 投片成本 */ +export function lotCost(bal, fabId) { return bal.fabs[fabId].cost * bal.lotSize; } + +/** 解鎖條件狀態 */ +export function unlockStatus(bal, fabId, { money, rep, stars }) { + const f = bal.fabs[fabId]; + const ids = levelIds(bal, fabId); + const cleared = ids.filter(id => (stars?.[id] || 0) >= 1).length; + const puzzle = { have: cleared, need: ids.length, ok: cleared >= ids.length }; + const m = { have: money, need: f.unlockMoney, ok: money >= f.unlockMoney }; + const r = { have: rep, need: f.unlockRep, ok: rep >= f.unlockRep }; + return { puzzle, money: m, rep: r, all: puzzle.ok && m.ok && r.ok }; +} + +/** 整合:某廠目前的產線統計(UI 與模擬共用) */ +export function fabStats(bal, fabId, fab, S, mods = {}, rep = 20, walk = 1) { + const pMul = mods.pMul == null ? 1 : mods.pMul; + const hours = fab.slots.map(s => (s ? slotHours(bal, s.equip, s.level) * pMul : null)); + const interval = lineInterval(hours); + const Y = lineYield(bal, fabId, fab.slots, S, mods); + const complete = fab.slots.every(Boolean); + const lotsPerDay = interval ? bal.ticksPerDay / interval : 0; + return { + hours, interval, Y, complete, lotsPerDay, + wafersPerDay: lotsPerDay * bal.lotSize, + price: sellPrice(bal, fabId, rep, walk), + breakEven: breakEvenYield(bal, fabId) + }; +} diff --git a/empire/js/tycoon/economy.test.js b/empire/js/tycoon/economy.test.js new file mode 100644 index 0000000..9f8df2e --- /dev/null +++ b/empire/js/tycoon/economy.test.js @@ -0,0 +1,130 @@ +// node js/tycoon/economy.test.js (於 empire/ 目錄下,或任何位置皆可) +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import assert from 'node:assert/strict'; +import * as E from './economy.js'; +import { nextRandom, mulberry32 } from '../shared/rng.js'; +import { migrate, SAVE_VERSION } from '../shared/storage.js'; + +const bal = JSON.parse(readFileSync(fileURLToPath(new URL('../../data/balance.json', import.meta.url)), 'utf8')); +let n = 0; +const t = (name, fn) => { fn(); n++; console.log('ok -', name); }; +const near = (a, b, eps, msg) => assert.ok(Math.abs(a - b) <= eps, `${msg || ''} expected ${b}, got ${a}`); + +const slotsF1 = (equips, level = 1) => equips.map(e => ({ equip: e, level })); +// 範例用「defMul = 1」的機台集合:pvd_sputter(1.00) / coater_developer(1.00) / etch_chamber(1.00);清洗無 1.00 機台, +// 所以範例以直接覆寫 defMul 的 bal 副本計算,與 DESIGN 範例(「4 槽皆 L1、defMul=1」)一致。 +const balUnit = JSON.parse(JSON.stringify(bal)); +for (const k of Object.keys(balUnit.equipment)) balUnit.equipment[k].defMul = 1; + +t('§3.4 範例 1:f1 四槽 L1、defMul=1、S=0 → y=0.95, Y=0.95^4≈0.815', () => { + const s = slotsF1(['wet_bench', 'pvd_sputter', 'coater_developer', 'etch_chamber']); + near(E.slotYield(balUnit, 'f1', 'etch_chamber', 1, 0), 0.95, 1e-12, 'y'); + near(E.lineYield(balUnit, 'f1', s, 0), 0.8145, 1e-4, 'Y'); + assert.equal(E.lineYield(balUnit, 'f1', s, 0).toFixed(3), '0.815'); +}); + +t('§3.4 範例 2:全 L5 且 S=1 → d=0.01024, Y≈0.96', () => { + const s = slotsF1(['wet_bench', 'pvd_sputter', 'coater_developer', 'etch_chamber'], 5); + near(E.slotDefect(balUnit, 'f1', 'etch_chamber', 5, 1), 0.01024, 1e-12, 'd'); + const Y = E.lineYield(balUnit, 'f1', s, 1); + near(Y, Math.pow(1 - 0.01024, 4), 1e-12); + assert.equal(Y.toFixed(2), '0.96'); +}); + +t('單槽良率 d 上限 0.5;線良率夾在 [0.05, 0.99]', () => { + assert.equal(E.slotYield(balUnit, 'f5', 'etch_chamber', 1, 0, 100), 0.5); + const many = Array(60).fill({ equip: 'etch_chamber', level: 1 }); + assert.equal(E.lineYield(balUnit, 'f5', many, 0), 0.05); + assert.equal(E.lineYield(balUnit, 'f1', slotsF1(['etch_chamber'], 5), 1, { E: 5 }), 0.99); +}); + +t('事件缺陷加算:particle eventDef=0.5 → d×1.5', () => { + const d0 = E.slotDefect(bal, 'f2', 'cvd_chamber', 3, 0.4, 0); + near(E.slotDefect(bal, 'f2', 'cvd_chamber', 3, 0.4, 0.5), d0 * 1.5, 1e-12); +}); + +t('盈虧平衡良率 0.50/0.43/0.39/0.35/0.32 與標稱毛利 50/57/61/65/68%', () => { + const be = bal.fabOrder.map(f => E.breakEvenYield(bal, f).toFixed(2)); + assert.deepEqual(be, ['0.50', '0.43', '0.39', '0.35', '0.32']); + const mg = bal.fabOrder.map(f => Math.round(E.nominalMargin(bal, f) * 100)); + assert.deepEqual(mg, [50, 57, 61, 65, 68]); +}); + +t('廠倍率 [1,2,4,10,25]、機台價與升級費 = price×倍率×1.8^L', () => { + assert.deepEqual(bal.fabOrder.map(f => E.fabMult(bal, f)), [1, 2, 4, 10, 25]); + assert.equal(E.equipPrice(bal, 'f1', 'wet_bench'), 600); + assert.equal(E.equipPrice(bal, 'f4', 'scanner'), 25000); + assert.equal(E.upgradeCost(bal, 'f1', 'wet_bench', 1), 1080); + assert.equal(E.upgradeCost(bal, 'f3', 'cmp_tool', 2), Math.round(1100 * 4 * 3.24)); + assert.equal(E.sellRefund(bal, 3000), 1500); +}); + +t('槽位時間 p = 4/(speed×(1+0.15(L−1)));線速度 = max p', () => { + near(E.slotHours(bal, 'wet_bench', 1), 4 / 1.2, 1e-12); + near(E.slotHours(bal, 'furnace', 5), 4 / (1.3 * 1.6), 1e-12); + const h = ['wet_bench', 'furnace', 'coater_developer', 'etch_chamber'].map(e => E.slotHours(bal, e, 1)); + assert.equal(E.lineInterval(h), 4); + assert.equal(E.lineInterval([1, null, 2]), null); +}); + +t('槽位可選機台:f1–f3 微影=coater_developer,f4–f5 微影=scanner(含 f5 槽 8)', () => { + assert.deepEqual(E.allowedEquip(bal, 'f1', 2), ['coater_developer']); + assert.deepEqual(E.allowedEquip(bal, 'f3', 2), ['coater_developer']); + assert.deepEqual(E.allowedEquip(bal, 'f4', 2), ['scanner']); + assert.deepEqual(E.allowedEquip(bal, 'f5', 7), ['scanner']); + assert.deepEqual(E.allowedEquip(bal, 'f1', 0), ['wet_bench', 'single_wafer_cleaner']); + assert.equal(bal.fabs.f5.slots.length, 8); + assert.deepEqual(bal.fabOrder.map(f => bal.fabs[f].slots.length), [4, 5, 6, 7, 8]); +}); + +t('售價 = 基準 × (0.9+0.2·rep/100) × 波動;聲望增量 (Y−0.75)×2', () => { + near(E.sellPrice(bal, 'f1', 20, 1), 60 * 0.94, 1e-12); + near(E.sellPrice(bal, 'f2', 100, 1.1), 115 * 1.1 * 1.1, 1e-12); + near(E.repGain(bal, 0.815), 0.13, 1e-12); + assert.ok(E.repGain(bal, 0.5) < 0); +}); + +t('市場波動限 0.9–1.1', () => { + let w = 1; + for (let i = 0; i < 500; i++) w = E.nextPriceWalk(bal, w, 1); + assert.equal(w, 1.1); + for (let i = 0; i < 500; i++) w = E.nextPriceWalk(bal, w, 0); + assert.equal(w, 0.9); +}); + +t('星數比 S 與解鎖條件', () => { + assert.equal(E.starRatio(bal, 'f2', { 'f2-1': 3, 'f2-2': 3, 'f2-3': 3, 'f2-4': 3 }), 1); + near(E.starRatio(bal, 'f1', { 'f1-1': 3, 'f1-2': 2 }), 5 / 9, 1e-12); + assert.equal(E.starRatio(bal, 'f3', {}), 0); + const u = E.unlockStatus(bal, 'f2', { money: 12000, rep: 15, stars: { 'f2-1': 1, 'f2-2': 1, 'f2-3': 1, 'f2-4': 1 } }); + assert.ok(u.all); + assert.ok(!E.unlockStatus(bal, 'f2', { money: 11999, rep: 15, stars: { 'f2-1': 1, 'f2-2': 1, 'f2-3': 1, 'f2-4': 1 } }).all); + assert.ok(!E.unlockStatus(bal, 'f2', { money: 99999, rep: 99, stars: { 'f2-1': 1 } }).all); +}); + +t('fabStats:f1 預設最便宜配置的日產能', () => { + const fab = { slots: slotsF1(['wet_bench', 'furnace', 'coater_developer', 'etch_chamber']) }; + const st = E.fabStats(bal, 'f1', fab, 0, {}, 20, 1); + assert.equal(st.interval, 4); + assert.equal(st.lotsPerDay, 6); + assert.equal(st.wafersPerDay, 150); + assert.ok(st.complete); +}); + +t('時鐘:2× 速度下一日 ≈ 3 秒', () => { + assert.equal(bal.tickMs['2'] * bal.ticksPerDay, 3000); + assert.equal(bal.tickMs['1'] * bal.ticksPerDay, 6000); +}); + +t('PRNG:同種子可重現,值域 [0,1);存檔 migrate 拒讀新版', () => { + const a = mulberry32(42), b = mulberry32(42); + for (let i = 0; i < 100; i++) { const x = a(); assert.equal(x, b()); assert.ok(x >= 0 && x < 1); } + const rs = { seed: 7 }; const first = nextRandom(rs); assert.notEqual(rs.seed, 7); assert.ok(first >= 0 && first < 1); + assert.equal(migrate({ version: 2 }).ok, false); + assert.equal(migrate({ version: 2 }).reason, 'too-new'); + assert.equal(migrate({ version: SAVE_VERSION, a: 1 }).ok, true); + assert.equal(migrate({}).ok, false); +}); + +console.log(`\n${n} tests passed`); diff --git a/empire/js/tycoon/events.js b/empire/js/tycoon/events.js new file mode 100644 index 0000000..0095a64 --- /dev/null +++ b/empire/js/tycoon/events.js @@ -0,0 +1,137 @@ +// 隨機事件(§3.5)。事件定義文字在 data/events.json,只存 factId,原文由 content.json 提供。 +import { nextRandom } from '../shared/rng.js'; +import { getFactText, getEquipment } from '../shared/content.js'; +import * as Eco from './economy.js'; + +let defs = null; +export function setEventDefs(d) { defs = d.events || d; } +export function getEventDefs() { return defs; } + +/** 事件對產線的修正:eventDef(缺陷加算)、E(良率乘數)、pMul(處理時間乘數) */ +export function eventModifiers(bal, fab) { + const ev = fab.event; + const m = { eventDef: 0, E: 1, pMul: 1 }; + if (!ev) return m; + if (ev.id === 'particle') m.eventDef += ev.extra?.def ?? bal.events.particleDef; + if (ev.id === 'rush_order' && ev.extra?.accepted) m.pMul = bal.events.rushTimeMul; + return m; +} + +/** 每日擲骰:回傳 [{fabId, id}](尚未套用)。day 為已過的遊戲日數 */ +export function rollDailyEvents(state, bal, day) { + const out = []; + if (day <= bal.events.protectDays) return out; + const ids = Object.keys(defs); + for (const fabId of bal.fabOrder) { + const fab = state.fabs[fabId]; + if (!fab.unlocked || fab.event) continue; + if (nextRandom(state.rng) >= bal.events.dailyChance) continue; + let id = ids[Math.floor(nextRandom(state.rng) * ids.length)]; + if (!state.tutorial.done.includes('t-event')) { id = 'particle'; state.tutorial.done.push('t-event'); } // 首個事件固定 particle + if (id === 'tool_failure' && !fab.slots.some(Boolean)) id = 'particle'; + out.push({ fabId, id }); + } + return out; +} + +/** 套用事件初始效果並寫入 fab.event;回傳事件實例 */ +export function triggerEvent(state, bal, fabId, id, addRep) { + const fab = state.fabs[fabId]; + const cfg = bal.events; + const loss = cfg.repLoss[id] || 0; + if (loss) addRep(-loss); + switch (id) { + case 'power_outage': { + fab.halt = cfg.outageHaltTicks; + if (fab.ups) { + // 只損失 1 Lot(最前面的在製批) + const lost = fab.lots.length ? fab.lots.sort((a, b) => (b.slot + b.progress) - (a.slot + a.progress))[0] : null; + if (lost) fab.lots = fab.lots.filter(l => l !== lost); + fab.event = { id, daysLeft: 1, extra: { scrapped: lost ? 1 : 0 } }; + } else { + fab.event = { id, daysLeft: 1, extra: { scrapped: fab.lots.length } }; + fab.lots = []; + } + break; + } + case 'particle': + fab.event = { id, daysLeft: cfg.particleDays, extra: { def: cfg.particleDef } }; + break; + case 'rush_order': + fab.event = { id, daysLeft: cfg.rushDays, extra: { accepted: false, pending: false } }; + break; + case 'tool_failure': { + const filled = fab.slots.map((s, i) => (s ? i : -1)).filter(i => i >= 0); + const slot = filled[Math.floor(nextRandom(state.rng) * filled.length)]; + fab.event = { id, daysLeft: cfg.toolFailureDays, extra: { slot } }; + break; + } + } + return { fabId, id, event: fab.event }; +} + +export function repairCost(bal, fabId, fab) { + const slot = fab.slots[fab.event?.extra?.slot]; + if (!slot) return 0; + return Math.round(Eco.equipPrice(bal, fabId, slot.equip) * bal.events.repairFactor); +} +export function cleanCost(bal, fabId) { return Eco.fabMult(bal, fabId) * bal.events.cleanCostPerMult; } + +/** 供彈窗使用:標題、效果文字、選項、知識卡文字 */ +export function describeEvent(state, bal, fabId) { + const fab = state.fabs[fabId]; + const ev = fab.event; + const def = defs[ev.id]; + let effect = def.effect; + if (ev.id === 'power_outage' && fab.ups) effect = def.effectUps; + if (ev.id === 'tool_failure') { + const s = fab.slots[ev.extra.slot]; + const eq = s && getEquipment(s.equip); + effect = effect.replace('{tool}', eq ? eq.zh : '機台'); + } + const choices = def.choices.map(c => { + let cost = 0; + if (c.id === 'clean') cost = cleanCost(bal, fabId); + if (c.id === 'repair') cost = repairCost(bal, fabId, fab); + return { id: c.id, label: c.label, cost, disabled: cost > state.res.money }; + }); + return { + fabId, id: ev.id, title: def.name, effect, choices, + factId: def.factId, factText: getFactText(def.factId) + }; +} + +/** 玩家選項。回傳 {ok, reason?}。spend(amount, reason)→bool 由呼叫端注入 */ +export function chooseOption(state, bal, fabId, choiceId, spend) { + const fab = state.fabs[fabId]; + const ev = fab.event; + if (!ev) return { ok: false, reason: 'no-event' }; + switch (ev.id + ':' + choiceId) { + case 'particle:clean': + if (!spend(cleanCost(bal, fabId), 'clean:' + fabId)) return { ok: false, reason: '資金不足' }; + fab.halt = Math.max(fab.halt, bal.events.cleanHaltTicks); + fab.event = null; + break; + case 'rush_order:accept': + ev.extra.accepted = true; ev.extra.pending = true; + break; + case 'rush_order:decline': + fab.event = null; + break; + case 'tool_failure:repair': + if (!spend(repairCost(bal, fabId, fab), 'repair:' + fabId)) return { ok: false, reason: '資金不足' }; + fab.event = null; + break; + default: // ok / keep / wait:無額外動作 + break; + } + return { ok: true }; +} + +/** 每日結束:事件倒數。回傳 true 表示事件結束 */ +export function tickEventDay(fab) { + if (!fab.event) return false; + fab.event.daysLeft -= 1; + if (fab.event.daysLeft <= 0) { fab.event = null; return true; } + return false; +} diff --git a/empire/js/tycoon/factory-view.js b/empire/js/tycoon/factory-view.js new file mode 100644 index 0000000..f3c8463 --- /dev/null +++ b/empire/js/tycoon/factory-view.js @@ -0,0 +1,270 @@ +// 廠房俯視圖:機台槽位(買/升級/賣)、Lot 沿產線動畫。寬螢幕橫向、窄螢幕(<560px)垂直排列。 +const enName = (eq) => (eq && eq.en && eq.en !== 'TODO' ? eq.en : ''); +import { bus } from '../shared/bus.js'; +import * as S from './state.js'; +import * as Eco from './economy.js'; +import * as Ev from './events.js'; +import * as Clock from './clock.js'; +import { fabStatsNow, fabMods, failedSlot } from './sim.js'; +import { getEquipment, getProcess } from '../shared/content.js'; +import { openUnlockSheet } from './unlock-view.js'; +import { el, fill, openDialog, toast, money, pct } from './ui.js'; + +const CELL_H = 88; // 橫向每格寬 +const CELL_V = 56; // 縱向每槽高(§11:每槽一列 56 px) + +export function mountFactoryView(root) { + const bal = S.getBal(); + let cur = 'f1'; + let vertical = false; + const lotEls = new Map(); // lotId → element + let lastShip = null; + + const tabs = el('div', { class: 'ty-tabs', role: 'tablist', 'aria-label': '選擇晶圓廠' }); + const head = el('div', { class: 'ty-fabhead' }); + const cells = el('div', { class: 'ty-cells' }); + const lots = el('div', { class: 'ty-lots', 'aria-hidden': 'true' }); + const line = el('div', { class: 'ty-line' }, cells, lots); + const actions = el('div', { class: 'ty-actions' }); + const wrap = el('section', { class: 'ty-fab', 'aria-label': '晶圓廠產線' }, tabs, head, line, actions); + root.append(wrap); + + // ---------- 版面模式 ---------- + const applyMode = () => { + const v = root.clientWidth < 560; + if (v !== vertical || !line.dataset.mode) { + vertical = v; + line.dataset.mode = v ? 'v' : 'h'; + renderLine(); + } + }; + new ResizeObserver(applyMode).observe(root); + + // ---------- Tabs ---------- + function renderTabs() { + const s = S.getState(); + fill(tabs, ...bal.fabOrder.map(id => { + const open = s.fabs[id].unlocked; + return el('button', { + class: 'ty-tab' + (id === cur ? ' is-on' : '') + (open ? '' : ' is-locked'), role: 'tab', + 'aria-selected': String(id === cur), 'data-tut': 'tab-' + id, + 'aria-label': `${bal.fabs[id].zh} ${bal.fabs[id].node}${open ? '' : '(未開通)'}`, + onclick: () => { if (open) { cur = id; clearLots(); renderAll(); } else openUnlockSheet(id); } + }, el('span', { text: bal.fabs[id].zh }), el('small', { text: open ? bal.fabs[id].node : '未開通' })); + })); + } + + // ---------- 抬頭資訊 ---------- + function renderHead() { + const s = S.getState(), fab = s.fabs[cur], f = bal.fabs[cur]; + const st = fabStatsNow(cur); + const missing = fab.slots.filter(x => !x).length; + const ev = fab.event; + let evText = null; + if (ev) { + const def = Ev.getEventDefs()[ev.id]; + evText = `事件:${def.name}(剩 ${ev.daysLeft} 日)`; + if (ev.id === 'rush_order') evText += ev.extra.accepted ? (ev.extra.pending ? ',待出貨' : ',已出貨') : ',未接單'; + } + const Y = st.Y; + const profitable = Y >= st.breakEven; + fill(head, + el('div', { class: 'ty-headline' }, + el('h2', { text: `${f.zh} ${f.node} ${f.wafer}` }), + evText && el('span', { class: 'ty-badge ty-badge--warn', text: evText })), + el('div', { class: 'ty-kpis' }, + kpi('線良率 Y', pct(Y), profitable ? 'good' : 'bad', `Y = Π(1−dᵢ)×E,盈虧平衡 ${pct(st.breakEven, 0)}`), + kpi('產能', st.interval ? `${Math.round(st.wafersPerDay)} 片/日` : '—', '', st.interval ? `每 ${st.interval.toFixed(1)} 小時投 1 Lot` : '產線不通'), + kpi('售價', money(st.price), '', `每片成本 ${money(f.cost)}`), + kpi('庫存', `${s.res.inv[cur]} 片`, '', `日需求上限 ${bal.demandCap}`), + kpi('日維運', money(f.maintenance), '', ''), + kpi('解謎星數 S', pct(S.starRatio(cur), 0), '', '缺陷率 ×(1−0.5S)')), + missing > 0 && el('p', { class: 'ty-warn', role: 'status', text: `缺機台:還有 ${missing} 個空槽,產線不通。點選空槽購買機台。` }), + lastShip && lastShip.fabId === cur && el('p', { class: 'ty-muted', text: `最近出貨:${lastShip.good} / ${bal.lotSize} 片良品${lastShip.rush ? '(急件 2.5 倍價)' : ''}` })); + } + const kpi = (k, v, cls, sub) => el('div', { class: 'ty-kpi' + (cls ? ' ty-kpi--' + cls : '') }, + el('div', { class: 'ty-kpi-k', text: k }), el('div', { class: 'ty-kpi-v', text: v }), sub && el('div', { class: 'ty-kpi-s', text: sub })); + + // ---------- 產線 ---------- + function renderLine() { + const s = S.getState(), fab = s.fabs[cur]; + const mods = fabMods(cur); + const Sr = S.starRatio(cur); + const bad = failedSlot(fab); + const cell = vertical ? CELL_V : CELL_H; + line.style.setProperty('--cell', cell + 'px'); + const busySlots = new Set(fab.lots.map(l => l.slot)); + const out = []; + fab.slots.forEach((slot, i) => { + const cat = bal.categories[bal.fabs[cur].slots[i]]; + let body; + if (!slot) { + body = [el('div', { class: 'ty-slot-cat', text: cat }), el('div', { class: 'ty-slot-empty', text: '缺機台' }), el('div', { class: 'ty-slot-sub', text: '點選購買' })]; + } else { + const eq = getEquipment(slot.equip); + const d = Eco.slotDefect(bal, cur, slot.equip, slot.level, Sr, mods.eventDef); + const y = 1 - Math.min(d, bal.yield.dMax); + const p = Eco.slotHours(bal, slot.equip, slot.level) * mods.pMul; + body = [ + el('div', { class: 'ty-slot-cat', text: cat }), + el('div', { class: 'ty-slot-name', text: `${eq ? eq.zh : slot.equip} L${slot.level}` }), + el('div', { class: 'ty-slot-sub', text: i === bad ? '故障停擺' : `${p.toFixed(1)} 小時 · 良率 ${pct(y, 1)}` }) + ]; + } + const cls = 'ty-slot' + (!slot ? ' is-empty' : '') + (i === bad ? ' is-bad' : '') + (busySlots.has(i) ? ' is-busy' : ''); + out.push(el('div', { class: 'ty-cellwrap' }, + vertical && el('div', { class: 'ty-lane', 'aria-hidden': 'true' }), + el('button', { class: cls, 'data-tut': 'slot-' + (i + 1), 'aria-label': slotLabel(i, slot, cat, i === bad), + onclick: () => openSlotSheet(i) }, body))); + }); + out.push(el('div', { class: 'ty-cellwrap ty-cellwrap--exit' }, + vertical && el('div', { class: 'ty-lane', 'aria-hidden': 'true' }), + el('div', { class: 'ty-exit', text: '出貨' }))); + fill(cells, ...out); + cells.style.setProperty('--n', String(out.length)); + updateLots(true); + } + function slotLabel(i, slot, cat, bad) { + if (!slot) return `槽位 ${i + 1},${cat},缺機台,點選購買`; + const eq = getEquipment(slot.equip); + return `槽位 ${i + 1},${cat},${eq ? eq.zh : slot.equip} 等級 ${slot.level}${bad ? ',故障' : ''},點選管理`; + } + + // ---------- Lot 動畫 ---------- + function lotPos(c) { + return vertical ? `translate(0px, ${c * CELL_V}px)` : `translate(${c * CELL_H}px, 0px)`; + } + function clearLots() { for (const e of lotEls.values()) e.remove(); lotEls.clear(); } + function updateLots(snap) { + const s = S.getState(), fab = s.fabs[cur]; + const ms = Clock.tickMs(); + root.style.setProperty('--ty-tick', ms + 'ms'); + root.classList.toggle('ty-still', !!s.settings.reduceMotion); + root.classList.toggle('ty-paused', !Clock.isRunning()); + const alive = new Set(); + for (const lot of fab.lots) { + alive.add(lot.id); + const c = lot.slot + 0.5 + lot.progress; + let e = lotEls.get(lot.id); + if (!e) { + e = el('div', { class: 'ty-lot', title: `Lot #${lot.id}`, text: String(lot.id % 100) }); + e.style.transform = lotPos(c); + lots.append(e); lotEls.set(lot.id, e); + void e.offsetWidth; + e.classList.add('is-live'); + } else { + if (snap) e.classList.remove('is-live'); + e.style.transform = lotPos(c); + if (snap) { void e.offsetWidth; e.classList.add('is-live'); } + } + } + for (const [id, e] of [...lotEls]) { + if (alive.has(id) || e.classList.contains('ty-lot--out')) continue; + e.classList.add('ty-lot--scrap'); lotEls.delete(id); + setTimeout(() => e.remove(), 500); + } + } + bus.on('lot:shipped', ({ fabId, lotId, good, rush }) => { + lastShip = { fabId, good, rush }; + if (fabId !== cur) return; + const e = lotEls.get(lotId); + if (!e) return; + e.classList.add('ty-lot--out'); + e.style.transform = lotPos(fabCells() + 0.5); + lotEls.delete(lotId); + setTimeout(() => e.remove(), Clock.tickMs() + 150); + }); + const fabCells = () => S.getState().fabs[cur].slots.length; + + // ---------- 動作區 ---------- + function renderActions() { + const s = S.getState(), fab = s.fabs[cur]; + const bits = []; + bits.push(el('button', { class: 'ty-btn', onclick: () => openUnlockSheet(cur), text: '製程挑戰 / 解鎖資訊', 'data-tut': 'btn-puzzle' })); + if (!fab.ups) { + const c = S.upsCost(cur); + bits.push(el('button', { class: 'ty-btn', disabled: s.res.money < c, text: `UPS 升級 ${money(c)}`, 'aria-label': `購買不斷電系統 ${money(c)},停電時只損失 1 個 Lot`, + onclick: () => { const r = S.buyUps(cur); toast(r.ok ? '已安裝 UPS' : r.reason); } })); + } else bits.push(el('span', { class: 'ty-badge', text: 'UPS 已安裝' })); + if (fab.event?.id === 'particle') { + const c = Ev.cleanCost(bal, cur); + bits.push(el('button', { class: 'ty-btn', disabled: s.res.money < c, text: `停線清洗 ${money(c)}`, 'data-tut': 'btn-clean', + onclick: () => { const r = Ev.chooseOption(S.getState(), bal, cur, 'clean', S.spend); if (!r.ok) toast(r.reason); S.commit(); } })); + } + if (fab.event?.id === 'rush_order' && !fab.event.extra.accepted) { + bits.push(el('button', { class: 'ty-btn', text: '接下急件', onclick: () => { Ev.chooseOption(S.getState(), bal, cur, 'accept', S.spend); S.commit(); } })); + } + if (fab.event?.id === 'tool_failure') { + const c = Ev.repairCost(bal, cur, fab); + bits.push(el('button', { class: 'ty-btn', disabled: s.res.money < c, text: `立刻修復 ${money(c)}`, + onclick: () => { const r = Ev.chooseOption(S.getState(), bal, cur, 'repair', S.spend); if (!r.ok) toast(r.reason); S.commit(); } })); + } + fill(actions, ...bits); + } + + // ---------- 槽位面板 ---------- + function openSlotSheet(i) { + Clock.lock(); + let off; + const dlg = openDialog({ title: `槽位 ${i + 1}:${bal.categories[bal.fabs[cur].slots[i]]}`, onClose: () => { off?.(); Clock.unlock(); } }); + const sRender = () => { + const s = S.getState(), fab = s.fabs[cur], slot = fab.slots[i]; + const Sr = S.starRatio(cur), mods = fabMods(cur); + const err = el('p', { class: 'ty-err', 'aria-live': 'polite' }); + const body = []; + if (!slot) { + body.push(el('p', { class: 'ty-muted', text: '選擇要放入的機台(價格 = 基價 × 廠倍率):' })); + for (const id of Eco.allowedEquip(bal, cur, i)) { + const b = bal.equipment[id], eq = getEquipment(id), price = Eco.equipPrice(bal, cur, id); + const d = Eco.slotDefect(bal, cur, id, 1, Sr, mods.eventDef); + body.push(el('button', { class: 'ty-opt', disabled: s.res.money < price, 'data-tut': 'buy-' + id, + onclick: () => { const r = S.buyEquip(cur, i, id); if (r.ok) dlg.close(); else err.textContent = r.reason; } }, + el('div', { class: 'ty-opt-t' }, el('b', { text: eq ? eq.zh : id }), el('span', { text: money(price) })), + el('div', { class: 'ty-opt-s', text: `${enName(eq)} 處理 ${Eco.slotHours(bal, id, 1).toFixed(1)} 小時 單槽良率 ${pct(1 - Math.min(d, 0.5))} 缺陷係數 ×${b.defMul}` }), + eq && el('div', { class: 'ty-opt-s', text: eq.purpose }))); + } + } else { + const eq = getEquipment(slot.equip); + const d = Eco.slotDefect(bal, cur, slot.equip, slot.level, Sr, mods.eventDef); + const up = S.upgradeCostOf(cur, i); + const refund = Eco.sellRefund(bal, slot.invested); + body.push( + el('p', {}, el('b', { text: `${eq ? eq.zh : slot.equip}${enName(eq) ? `(${enName(eq)})` : ''}` }), ` 等級 ${slot.level} / ${bal.equip.maxLevel}`), + eq && el('p', { class: 'ty-muted', text: eq.purpose }), + el('p', { class: 'ty-muted', text: `處理時間 ${(Eco.slotHours(bal, slot.equip, slot.level) * mods.pMul).toFixed(2)} 小時 缺陷率 d = ${pct(d, 2)} 單槽良率 ${pct(1 - Math.min(d, 0.5), 2)}` }), + up != null && el('p', { class: 'ty-muted', text: `升級後:處理 ${(Eco.slotHours(bal, slot.equip, slot.level + 1) * mods.pMul).toFixed(2)} 小時,缺陷率 d = ${pct(Eco.slotDefect(bal, cur, slot.equip, slot.level + 1, Sr, mods.eventDef), 2)}` }), + el('div', { class: 'ty-choices' }, + el('button', { class: 'ty-btn ty-btn--primary', disabled: up == null || s.res.money < up, 'data-tut': 'btn-upgrade', + text: up == null ? '已達最高等級' : `升級 ${money(up)}`, + onclick: () => { const r = S.upgradeEquip(cur, i); if (!r.ok) err.textContent = r.reason; } }), + el('button', { class: 'ty-btn ty-btn--danger', text: `出售(退 ${money(refund)})`, + onclick: function () { + if (this.dataset.armed) { S.sellEquip(cur, i); dlg.close(); } + else { this.dataset.armed = '1'; this.textContent = `再按一次確認出售(退 ${money(refund)})`; } + } }))); + if (failedSlot(fab) === i) { + const c = Ev.repairCost(bal, cur, fab); + body.push(el('button', { class: 'ty-btn', disabled: s.res.money < c, text: `立刻修復 ${money(c)}`, + onclick: () => { const r = Ev.chooseOption(S.getState(), bal, cur, 'repair', S.spend); if (!r.ok) err.textContent = r.reason; else S.commit(); } })); + } + } + body.push(err); + fill(dlg.body, ...body); + }; + off = bus.on('state:changed', sRender); + sRender(); + } + + // ---------- 總更新 ---------- + function renderAll() { renderTabs(); renderHead(); renderLine(); renderActions(); } + bus.on('state:changed', () => { renderTabs(); renderHead(); renderLine(); renderActions(); }); + bus.on('state:reset', () => { cur = 'f1'; clearLots(); }); + bus.on('tick', () => { updateLots(false); if (S.getState().clock.tick % 2 === 0) renderHead(); }); + bus.on('day', () => { renderHead(); renderActions(); renderLine(); }); + bus.on('event:end', () => { renderHead(); renderActions(); renderLine(); }); + bus.on('clock:changed', () => { root.classList.toggle('ty-paused', !Clock.isRunning()); }); + vertical = root.clientWidth < 560; + line.dataset.mode = vertical ? 'v' : 'h'; + renderAll(); + return { setFab(id) { cur = id; clearLots(); renderAll(); }, getFab: () => cur }; +} diff --git a/empire/js/tycoon/hud.js b/empire/js/tycoon/hud.js new file mode 100644 index 0000000..2f31ffe --- /dev/null +++ b/empire/js/tycoon/hud.js @@ -0,0 +1,140 @@ +// HUD:資金、聲望、庫存、時鐘(暫停 / 1× / 2×)、事件彈窗、存檔選單 +import { bus } from '../shared/bus.js'; +import * as S from './state.js'; +import * as Clock from './clock.js'; +import * as Ev from './events.js'; +import { el, fill, openDialog, toast, money, fmt } from './ui.js'; + +export function mountHud(root) { + const bal = S.getBal(); + const vMoney = el('b', { text: '' }); + const vRep = el('b', { text: '' }); + const vInv = el('b', { text: '' }); + const invList = el('div', { class: 'ty-inv-list', hidden: true }); + const vTime = el('span', { class: 'ty-time', 'aria-live': 'polite' }); + const btnPause = el('button', { class: 'ty-btn ty-clk', 'data-tut': 'clock-pause', onclick: () => Clock.togglePause() }); + const btn1 = el('button', { class: 'ty-btn ty-clk', 'aria-label': '一倍速', text: '1×', 'data-tut': 'clock-1x', onclick: () => Clock.setSpeed(1) }); + const btn2 = el('button', { class: 'ty-btn ty-clk', 'aria-label': '兩倍速', text: '2×', 'data-tut': 'clock-2x', onclick: () => Clock.setSpeed(2) }); + const btnMenu = el('button', { class: 'ty-btn ty-clk', 'aria-label': '存檔與設定', text: '選單', onclick: openMenu }); + const invBtn = el('button', { class: 'ty-stat ty-stat--btn', 'aria-label': '展開庫存明細', 'aria-expanded': 'false', onclick: () => { + invList.hidden = !invList.hidden; invBtn.setAttribute('aria-expanded', String(!invList.hidden)); + } }, '庫存 ', vInv, ' 片'); + + root.append( + el('div', { class: 'ty-hud', role: 'region', 'aria-label': '資源列' }, + el('div', { class: 'ty-hud-row' }, + el('div', { class: 'ty-stat' }, '資金 ', vMoney), + el('div', { class: 'ty-stat' }, '聲望 ', vRep), + invBtn), + invList, + el('div', { class: 'ty-hud-row ty-hud-clock' }, vTime, btnPause, btn1, btn2, btnMenu))); + + function renderRes() { + const s = S.getState(); + vMoney.textContent = money(s.res.money); + vRep.textContent = s.res.rep.toFixed(1) + ' / 100'; + vInv.textContent = fmt(S.totalInventory()); + fill(invList, ...bal.fabOrder.filter(f => s.fabs[f].unlocked).map(f => + el('span', { class: 'ty-inv-item', text: `${bal.fabs[f].zh}:${s.res.inv[f]} / ${bal.invCap}` }))); + } + function renderClock() { + const s = S.getState(); + const t = s.clock.tick, day = Math.floor(t / bal.ticksPerDay) + 1, hr = t % bal.ticksPerDay; + vTime.textContent = `第 ${day} 日 ${String(hr).padStart(2, '0')}:00`; + const running = Clock.isRunning(); + const stateText = s.clock.paused ? '已暫停' : (running ? '進行中' : '暫停(彈窗中)'); + btnPause.textContent = s.clock.paused ? '開始' : '暫停'; + btnPause.setAttribute('aria-label', s.clock.paused ? '開始時鐘' : '暫停時鐘'); + btnPause.title = stateText; + btn1.setAttribute('aria-pressed', String(s.clock.speed === 1)); + btn2.setAttribute('aria-pressed', String(s.clock.speed === 2)); + btn1.classList.toggle('is-on', s.clock.speed === 1); + btn2.classList.toggle('is-on', s.clock.speed === 2); + vTime.title = stateText; + } + function render() { renderRes(); renderClock(); } + bus.on('state:changed', render); + bus.on('tick', render); + bus.on('clock:changed', renderClock); + bus.on('day', d => toast(`第 ${d.day} 日結算:售出 ${d.sold} 片,營收 ${money(d.revenue)},維運 -${money(d.maint)}`)); + bus.on('toast', m => toast(m)); + bus.on('fab:unlocked', ({ fabId }) => toast(`${bal.fabs[fabId].zh}(${bal.fabs[fabId].node})開通了!`)); + render(); + + mountEventDialogs(); +} + +// ---------- 事件彈窗 ---------- +const queue = []; +let showing = false; + +function mountEventDialogs() { + bus.on('event:trigger', e => { queue.push(e); pump(); }); + bus.on('state:reset', () => { queue.length = 0; }); +} +function pump() { + if (showing || !queue.length) return; + const { fabId } = queue.shift(); + const s = S.getState(), bal = S.getBal(); + if (!s.fabs[fabId]?.event) return pump(); + const d = Ev.describeEvent(s, bal, fabId); + showing = true; + Clock.lock(); + const dlg = openDialog({ + title: `${bal.fabs[fabId].zh}:${d.title}`, kind: 'modal', dismissible: false, label: d.title, + onClose: () => { showing = false; Clock.unlock(); S.commit(); pump(); } + }); + const btns = el('div', { class: 'ty-choices' }); + const err = el('p', { class: 'ty-err', 'aria-live': 'polite' }); + for (const c of d.choices) { + const b = el('button', { class: 'ty-btn ty-btn--primary', disabled: c.disabled, 'data-tut': 'event-' + c.id, + onclick: () => { + const r = Ev.chooseOption(S.getState(), bal, fabId, c.id, S.spend); + if (!r.ok) { err.textContent = r.reason || '無法執行'; return; } + dlg.close(); + } }, + c.label + (c.cost ? ` ${money(c.cost)}` : '')); + btns.append(b); + } + S.markFactSeen(d.factId); + dlg.body.append( + el('p', { class: 'ty-event-effect', text: d.effect }), + btns, err, + el('div', { class: 'ty-factcard' }, + el('div', { class: 'ty-factcard-h', text: '你知道嗎' }), + el('p', { text: d.factText }))); + dlg.panel.querySelector('button:not([disabled])')?.focus(); +} + +// ---------- 存檔 / 設定選單 ---------- +function openMenu() { + Clock.lock(); + const dlg = openDialog({ title: '存檔與設定', onClose: () => Clock.unlock() }); + const s = S.getState(); + const notice = S.getLoadNotice(); + const file = el('input', { type: 'file', accept: 'application/json,.json', hidden: true }); + file.addEventListener('change', async () => { + const f = file.files?.[0]; if (!f) return; + const r = S.importJson(await f.text()); + if (r.ok) { toast('匯入成功'); dlg.close(); } + else toast(r.reason === 'too-new' ? `存檔版本(${r.version})過新,無法讀取` : '存檔格式錯誤'); + }); + const motion = el('input', { type: 'checkbox', id: 'ty-rm' }); + motion.checked = !!s.settings.reduceMotion; + motion.addEventListener('change', () => S.setSetting('reduceMotion', motion.checked)); + dlg.body.append( + notice && el('p', { class: 'ty-err', text: notice }), + el('label', { class: 'ty-check', for: 'ty-rm' }, motion, ' 減少動態(Lot 直接跳格)'), + el('div', { class: 'ty-choices' }, + el('button', { class: 'ty-btn', text: '匯出存檔', onclick: () => { + const url = URL.createObjectURL(new Blob([S.exportJson()], { type: 'application/json' })); + const a = el('a', { href: url, download: 'wafer-empire-save.json' }); document.body.append(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } }), + el('button', { class: 'ty-btn', text: '匯入存檔', onclick: () => file.click() }), + el('button', { class: 'ty-btn ty-btn--danger', text: '重置遊戲', onclick: function () { + if (this.dataset.armed) { S.resetGame(); dlg.close(); toast('已重置'); } + else { this.dataset.armed = '1'; this.textContent = '再按一次確認重置(無法復原)'; } + } })), + file); +} diff --git a/empire/js/tycoon/sim.js b/empire/js/tycoon/sim.js new file mode 100644 index 0000000..42ec9f0 --- /dev/null +++ b/empire/js/tycoon/sim.js @@ -0,0 +1,123 @@ +// 每 tick 的模擬:推進 Lot → 槽位完成 → 投新 Lot;每 24 tick 結算(§3.6) +import { bus } from '../shared/bus.js'; +import { nextRandom } from '../shared/rng.js'; +import * as Eco from './economy.js'; +import * as Ev from './events.js'; +import { getState, getBal, addRep, save } from './state.js'; + +export function fabMods(fabId) { + const s = getState(); + return Ev.eventModifiers(getBal(), s.fabs[fabId]); +} +export function fabStatsNow(fabId) { + const s = getState(), bal = getBal(); + return Eco.fabStats(bal, fabId, s.fabs[fabId], Eco.starRatio(bal, fabId, s.puzzle.stars), + fabMods(fabId), s.res.rep, s.res.priceWalk); +} +export function failedSlot(fab) { + return fab.event?.id === 'tool_failure' ? fab.event.extra.slot : -1; +} + +function shipLot(s, bal, fabId, fab, lot, st) { + const Y = st.Y; + let good = 0; + for (let i = 0; i < bal.lotSize; i++) if (nextRandom(s.rng) < Y) good++; + s.res.repAcc += Eco.repGain(bal, Y); + const price = Eco.sellPrice(bal, fabId, s.res.rep, s.res.priceWalk); + let rush = false; + const ev = fab.event; + if (ev?.id === 'rush_order' && ev.extra.accepted && ev.extra.pending) { + ev.extra.pending = false; rush = true; + s.res.money += Math.round(good * price * bal.events.rushPriceMul); + } else { + const room = Math.max(0, bal.invCap - s.res.inv[fabId]); + s.res.inv[fabId] += Math.min(good, room); + } + bus.emit('lot:shipped', { fabId, lotId: lot.id, good, Y, rush }); +} + +const starveAt = new Map(); // 不存檔:資金不足提示節流 + +function advanceFab(s, bal, fabId, fab) { + if (fab.halt > 0) { fab.halt -= 1; return; } + const mods = Ev.eventModifiers(bal, fab); + const st = Eco.fabStats(bal, fabId, fab, Eco.starRatio(bal, fabId, s.puzzle.stars), mods, s.res.rep, s.res.priceWalk); + const bad = failedSlot(fab); + const last = fab.slots.length - 1; + + // 由前往後處理(slot 大者優先),讓後方空出的位置能被補上 + fab.lots.sort((a, b) => b.slot - a.slot); + const done = []; + for (const lot of fab.lots) { + const h = st.hours[lot.slot]; + if (h == null || lot.slot === bad) continue; + lot.progress += 1 / h; + if (lot.progress < 1) continue; + if (lot.slot === last) { shipLot(s, bal, fabId, fab, lot, st); done.push(lot); continue; } + if (fab.lots.some(o => o !== lot && !done.includes(o) && o.slot === lot.slot + 1)) { lot.progress = 1; continue; } + lot.slot += 1; + lot.progress = Math.min(lot.progress - 1, 0.999); + } + if (done.length) fab.lots = fab.lots.filter(l => !done.includes(l)); + + // 投片 + if (fab.feed < 9999) fab.feed += 1; + if (st.complete && bad < 0 && st.interval != null && fab.feed >= st.interval - 1e-9 + && !fab.lots.some(l => l.slot === 0)) { + const cost = Eco.lotCost(bal, fabId); + if (s.res.money >= cost) { + s.res.money -= cost; + fab.lots.push({ id: s.nextLotId++, slot: 0, progress: 0, good: null }); + fab.feed = 0; + bus.emit('lot:started', { fabId }); + } else if ((starveAt.get(fabId) ?? -99) + bal.ticksPerDay <= s.clock.tick) { + starveAt.set(fabId, s.clock.tick); + bus.emit('toast', '資金不足,無法投片(可出售機台或等待庫存售出)'); + } + } +} + +function settleDay(s, bal) { + const day = s.clock.tick / bal.ticksPerDay; + const r = s.res; + r.priceWalk = Eco.nextPriceWalk(bal, r.priceWalk, nextRandom(s.rng)); + let revenue = 0, sold = 0, maint = 0; + for (const fabId of bal.fabOrder) { + const fab = s.fabs[fabId]; + if (!fab.unlocked) continue; + const n = Math.min(r.inv[fabId], bal.demandCap); + const price = Eco.sellPrice(bal, fabId, r.rep, r.priceWalk); + r.inv[fabId] -= n; sold += n; revenue += n * price; + maint += bal.fabs[fabId].maintenance; + } + r.money += Math.round(revenue); + r.money -= maint; + if (r.money < 0) { r.money = 0; addRep(-1); bus.emit('toast', '維運費付不出來,聲望 -1'); } + addRep(Eco.clamp(r.repAcc, -bal.rep.dailyCap, bal.rep.dailyCap)); + r.repAcc = 0; + + const triggered = []; + for (const fabId of bal.fabOrder) { + const fab = s.fabs[fabId]; + if (!fab.unlocked) continue; + const ended = Ev.tickEventDay(fab); + if (ended) bus.emit('event:end', { fabId }); + } + for (const t of Ev.rollDailyEvents(s, bal, day)) { + triggered.push(Ev.triggerEvent(s, bal, t.fabId, t.id, addRep)); + } + save(); + bus.emit('day', { day, sold, revenue: Math.round(revenue), maint }); + for (const t of triggered) bus.emit('event:trigger', { fabId: t.fabId, id: t.id }); +} + +export function stepTick() { + const s = getState(), bal = getBal(); + s.clock.tick += 1; + for (const fabId of bal.fabOrder) { + const fab = s.fabs[fabId]; + if (fab.unlocked) advanceFab(s, bal, fabId, fab); + } + if (s.clock.tick % bal.ticksPerDay === 0) settleDay(s, bal); + bus.emit('tick', { tick: s.clock.tick }); +} diff --git a/empire/js/tycoon/state.js b/empire/js/tycoon/state.js new file mode 100644 index 0000000..cd186bc --- /dev/null +++ b/empire/js/tycoon/state.js @@ -0,0 +1,206 @@ +// 遊戲狀態 + 存檔(§7.2)+ 玩家動作(買/升/賣機台、開廠、星數) +import { bus } from '../shared/bus.js'; +import { loadSave, writeSave, clearSave, parseSaveText, SAVE_VERSION } from '../shared/storage.js'; +import * as Eco from './economy.js'; + +let state = null; +let bal = null; +let loadNotice = null; // 讀檔失敗訊息(例如存檔版本過新) + +export const getState = () => state; +export const getBal = () => bal; +export const getLoadNotice = () => loadNotice; + +function newFab(fabId, unlocked) { + return { + unlocked, + slots: bal.fabs[fabId].slots.map(() => null), + ups: false, + lots: [], + event: null, + halt: 0, // 停線剩餘 tick + feed: 999 // 距上次投片已過 tick(>= 線速度才能再投) + }; +} + +export function createInitialState(balance, seed) { + bal = balance; + const fabs = {}, inv = {}; + for (const id of bal.fabOrder) { fabs[id] = newFab(id, id === 'f1'); inv[id] = 0; } + return { + version: SAVE_VERSION, + savedAt: 0, + clock: { tick: 0, speed: 1, paused: true }, + rng: { seed: (seed == null ? (Date.now() & 0x7fffffff) : seed) >>> 0 }, + res: { money: bal.start.money, rep: bal.start.rep, inv, priceWalk: 1.0, repAcc: 0 }, + fabs, + nextLotId: 1, + puzzle: { stars: {}, hintsBought: {} }, + seenFacts: [], + tutorial: { done: [] }, + settings: { sound: 0.5, diffHighlight: true, reduceMotion: false } + }; +} + +/** 補齊缺漏欄位(版本相同但缺欄位時的防禦) */ +function normalize(s) { + const base = createInitialState(bal, s.rng?.seed); + const out = { ...base, ...s }; + out.clock = { ...base.clock, ...s.clock }; + out.res = { ...base.res, ...s.res, inv: { ...base.res.inv, ...(s.res?.inv || {}) } }; + out.puzzle = { ...base.puzzle, ...s.puzzle }; + out.settings = { ...base.settings, ...s.settings }; + out.tutorial = { ...base.tutorial, ...s.tutorial }; + out.fabs = {}; + for (const id of bal.fabOrder) { + const f = { ...newFab(id, id === 'f1'), ...(s.fabs?.[id] || {}) }; + const len = bal.fabs[id].slots.length; + f.slots = Array.from({ length: len }, (_, i) => f.slots[i] || null); + out.fabs[id] = f; + } + return out; +} + +export function initState(balance, { fresh = false } = {}) { + bal = balance; + loadNotice = null; + if (!fresh) { + const r = loadSave(); + if (r.ok) { state = normalize(r.save); return state; } + if (r.reason === 'too-new') loadNotice = `存檔版本(${r.version})比本遊戲新,無法讀取;已改開新遊戲(不會覆寫舊存檔,直到你第一次存檔)。`; + else if (r.reason === 'invalid') loadNotice = '存檔已損毀,已改開新遊戲。'; + } + state = createInitialState(bal); + return state; +} + +export function save() { + if (!state) return false; + state.savedAt = Date.now(); + return writeSave(state); +} +export function changed() { bus.emit('state:changed', state); } +export function commit() { save(); changed(); } + +export function resetGame() { + clearSave(); + state = createInitialState(bal); + bus.emit('state:reset', state); + changed(); +} +export function exportJson() { return JSON.stringify(state, null, 2); } +export function importJson(text) { + const r = parseSaveText(text); + if (!r.ok) return r; + state = normalize(r.save); + state.clock.paused = true; + save(); + bus.emit('state:reset', state); + changed(); + return { ok: true }; +} + +// ---------- 查詢 ---------- +export const starRatio = fabId => Eco.starRatio(bal, fabId, state.puzzle.stars); +export const isUnlocked = fabId => !!state.fabs[fabId]?.unlocked; +export const unlockStatus = fabId => Eco.unlockStatus(bal, fabId, + { money: state.res.money, rep: state.res.rep, stars: state.puzzle.stars }); +export const totalInventory = () => Object.values(state.res.inv).reduce((a, b) => a + b, 0); + +// ---------- 資金 ---------- +export function spend(amount, reason) { + amount = Math.round(amount); + if (!(amount >= 0) || state.res.money < amount) return false; + state.res.money -= amount; + bus.emit('money:spent', { amount, reason }); + commit(); + return true; +} +export function earn(amount) { state.res.money += Math.round(amount); commit(); } +export function addRep(d) { + state.res.rep = Eco.clamp(state.res.rep + d, bal.rep.min, bal.rep.max); +} + +// ---------- 機台 ---------- +export function buyEquip(fabId, idx, equipId) { + const fab = state.fabs[fabId]; + if (!fab?.unlocked) return { ok: false, reason: '此廠尚未開通' }; + if (fab.slots[idx]) return { ok: false, reason: '槽位已有機台' }; + if (!Eco.allowedEquip(bal, fabId, idx).includes(equipId)) return { ok: false, reason: '此機台不適用於該槽位' }; + const price = Eco.equipPrice(bal, fabId, equipId); + if (state.res.money < price) return { ok: false, reason: '資金不足' }; + state.res.money -= price; + fab.slots[idx] = { equip: equipId, level: 1, invested: price }; + bus.emit('money:spent', { amount: price, reason: 'buy:' + equipId }); + commit(); + return { ok: true }; +} +export function upgradeCostOf(fabId, idx) { + const s = state.fabs[fabId].slots[idx]; + if (!s || s.level >= bal.equip.maxLevel) return null; + return Eco.upgradeCost(bal, fabId, s.equip, s.level); +} +export function upgradeEquip(fabId, idx) { + const s = state.fabs[fabId].slots[idx]; + if (!s) return { ok: false, reason: '空槽' }; + const cost = upgradeCostOf(fabId, idx); + if (cost == null) return { ok: false, reason: '已達最高等級' }; + if (state.res.money < cost) return { ok: false, reason: '資金不足' }; + state.res.money -= cost; + s.level += 1; + s.invested += cost; + bus.emit('money:spent', { amount: cost, reason: 'upgrade:' + s.equip }); + commit(); + return { ok: true }; +} +export function sellEquip(fabId, idx) { + const s = state.fabs[fabId].slots[idx]; + if (!s) return { ok: false, reason: '空槽' }; + const refund = Eco.sellRefund(bal, s.invested); + state.res.money += refund; + state.fabs[fabId].slots[idx] = null; + const ev = state.fabs[fabId].event; + if (ev?.id === 'tool_failure' && ev.extra?.slot === idx) state.fabs[fabId].event = null; + commit(); + return { ok: true, refund }; +} +export function upsCost(fabId) { return Eco.fabMult(bal, fabId) * bal.events.upsCostPerMult; } +export function buyUps(fabId) { + const fab = state.fabs[fabId]; + if (fab.ups) return { ok: false, reason: '已安裝' }; + if (!spend(upsCost(fabId), 'ups:' + fabId)) return { ok: false, reason: '資金不足' }; + fab.ups = true; + commit(); + return { ok: true }; +} + +// ---------- 開廠 / 解謎 ---------- +export function unlockFab(fabId) { + if (isUnlocked(fabId)) return { ok: false, reason: '已開通' }; + const u = unlockStatus(fabId); + if (!u.all) return { ok: false, reason: '條件未滿足', status: u }; + state.res.money -= bal.fabs[fabId].unlockMoney; + state.fabs[fabId].unlocked = true; + bus.emit('fab:unlocked', { fabId }); + commit(); + return { ok: true }; +} + +/** startPuzzleSet resolve 後呼叫:取歷史最佳星數、記錄提示、重算 S */ +export function applyPuzzleResult(res) { + if (!res) return; + const stars = state.puzzle.stars; + for (const [id, n] of Object.entries(res.stars || {})) { + stars[id] = Math.max(stars[id] || 0, Math.min(3, Math.max(0, Math.floor(n)))); + } + for (const [id, n] of Object.entries(res.hintsBought || {})) { + state.puzzle.hintsBought[id] = Math.max(state.puzzle.hintsBought[id] || 0, n); + } + commit(); + bus.emit('puzzle:applied', { fabId: res.fabId }); +} + +export function markFactSeen(factId) { + if (!state.seenFacts.includes(factId)) { state.seenFacts.push(factId); save(); } +} +export function setSetting(k, v) { state.settings[k] = v; commit(); } diff --git a/empire/js/tycoon/ui.js b/empire/js/tycoon/ui.js new file mode 100644 index 0000000..4052424 --- /dev/null +++ b/empire/js/tycoon/ui.js @@ -0,0 +1,70 @@ +// 小型 DOM 工具:el、bottom sheet、modal、toast、格式化 +export function el(tag, attrs, ...kids) { + const e = document.createElement(tag); + if (attrs) for (const [k, v] of Object.entries(attrs)) { + if (v == null || v === false) continue; + if (k === 'class') e.className = v; + else if (k === 'text') e.textContent = v; + else if (k.startsWith('on')) e.addEventListener(k.slice(2), v); + else if (k === 'dataset') Object.assign(e.dataset, v); + else e.setAttribute(k, v === true ? '' : v); + } + for (const k of kids.flat()) if (k != null && k !== false) e.append(k.nodeType ? k : document.createTextNode(String(k))); + return e; +} + +export const fmt = n => Math.round(n).toLocaleString('en-US'); +export const money = n => '$' + fmt(n); +export const pct = (x, d = 1) => (x * 100).toFixed(d) + '%'; + +let toastBox = null; +export function toast(msg, ms = 2600) { + if (!toastBox) { + toastBox = el('div', { class: 'ty-toasts', 'aria-live': 'polite' }); + document.body.append(toastBox); + } + const t = el('div', { class: 'ty-toast', text: msg }); + toastBox.append(t); + setTimeout(() => t.remove(), ms); +} + +/** + * 通用對話:kind='sheet'(底部滑出,可 Esc / 點背景關閉)或 'modal'(置中,需按選項)。 + * 回傳 {root, body, close}。onClose 於關閉時呼叫(用來解除時鐘鎖)。 + */ +export function openDialog({ title, kind = 'sheet', dismissible = true, onClose, label }) { + const prevFocus = document.activeElement; + const body = el('div', { class: 'ty-dlg-body' }); + const closeBtn = dismissible ? el('button', { class: 'ty-btn ty-dlg-x', 'aria-label': '關閉', text: '×' }) : null; + const panel = el('div', { class: 'ty-dlg ty-dlg--' + kind, role: 'dialog', 'aria-modal': 'true', 'aria-label': label || title, tabindex: '-1' }, + el('div', { class: 'ty-dlg-head' }, el('h2', { text: title }), closeBtn), body); + const root = el('div', { class: 'ty-overlay' }, panel); + let closed = false; + const close = () => { + if (closed) return; closed = true; + document.removeEventListener('keydown', onKey, true); + root.remove(); + try { prevFocus?.focus?.(); } catch (e) { /* ignore */ } + onClose?.(); + }; + const onKey = e => { + if (e.key === 'Escape' && dismissible) { e.stopPropagation(); close(); return; } + if (e.key !== 'Tab') return; + const f = [...panel.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])')].filter(x => !x.disabled); + if (!f.length) { e.preventDefault(); return; } + const first = f[0], last = f[f.length - 1]; + if (e.shiftKey && (document.activeElement === first || document.activeElement === panel)) { e.preventDefault(); last.focus(); } + else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } + }; + document.addEventListener('keydown', onKey, true); + closeBtn?.addEventListener('click', close); + if (dismissible) root.addEventListener('click', e => { if (e.target === root) close(); }); + document.body.append(root); + panel.focus(); + return { root, body, panel, close }; +} + +/** replaceChildren,但略過 false / null(讓 cond && el(...) 可用) */ +export function fill(node, ...kids) { + node.replaceChildren(...kids.flat().filter(k => k != null && k !== false)); +} diff --git a/empire/js/tycoon/unlock-view.js b/empire/js/tycoon/unlock-view.js new file mode 100644 index 0000000..55fda5f --- /dev/null +++ b/empire/js/tycoon/unlock-view.js @@ -0,0 +1,71 @@ +// 解鎖面板:條件清單(解謎 / 金錢 / 聲望)、開始解謎、開廠 +import { bus } from '../shared/bus.js'; +import * as S from './state.js'; +import * as Clock from './clock.js'; +import { runPuzzleSet } from './bridge.js'; +import { getFactText } from '../shared/content.js'; +import { el, fill, openDialog, toast, money, pct } from './ui.js'; +import { levelIds } from './economy.js'; + +export function openUnlockSheet(fabId) { + const bal = S.getBal(), f = bal.fabs[fabId]; + Clock.lock(); + let off = null; + const dlg = openDialog({ + title: `${f.zh}(${f.node},${f.wafer})`, + onClose: () => { off?.(); Clock.unlock(); bus.emit('unlock:close', { fabId }); } + }); + let busy = false; + + function render() { + const s = S.getState(); + const unlocked = S.isUnlocked(fabId); + const u = S.unlockStatus(fabId); + const stars = s.puzzle.stars; + const rows = []; + const row = (ok, text) => el('li', { class: ok ? 'ty-ok' : 'ty-no' }, el('span', { class: 'ty-mark', 'aria-hidden': 'true', text: ok ? '✔' : '✘' }), ' ', text); + if (!unlocked) { + rows.push( + row(u.puzzle.ok, `製程解謎組全關至少 1 星(${u.puzzle.have} / ${u.puzzle.need})`), + row(u.money.ok, `金錢 ${money(u.money.need)}(目前 ${money(u.money.have)})`), + row(u.rep.ok, `聲望 ${u.rep.need}(目前 ${u.rep.have.toFixed(1)})`)); + } + const ids = levelIds(bal, fabId); + const starTxt = ids.map(id => { + const n = stars[id] || 0; + return `${id}:${'★'.repeat(n)}${'☆'.repeat(3 - n)}`; + }).join(' '); + const Sratio = S.starRatio(fabId); + + const puzzleBtn = el('button', { class: 'ty-btn ty-btn--primary', disabled: busy, 'data-tut': 'unlock-puzzle', + text: unlocked ? '玩製程挑戰(補星可降低缺陷率)' : '開始解謎', + onclick: async () => { + busy = true; render(); + dlg.root.hidden = true; + try { await runPuzzleSet(fabId); } + catch (e) { toast(e?.message === 'no-levels' ? '此廠的關卡尚未開放' : '解謎層發生錯誤'); console.error(e); } + finally { busy = false; dlg.root.hidden = false; render(); } + } }); + const openBtn = !unlocked && el('button', { class: 'ty-btn ty-btn--primary', disabled: !u.all, 'data-tut': 'unlock-open', + text: `開廠(${money(f.unlockMoney)})`, + onclick: () => { + const r = S.unlockFab(fabId); + if (r.ok) dlg.close(); else toast(r.reason); + } }); + + fill(dlg.body, + el('p', { class: 'ty-muted', text: `每片成本 ${money(f.cost)},基準價 ${money(f.price)},日維運 ${money(f.maintenance)}` }), + !unlocked && el('ul', { class: 'ty-cond' }, rows), + el('p', { class: 'ty-stars', text: starTxt }), + el('p', { class: 'ty-muted', text: `星數比 S = ${pct(Sratio, 0)}(全三星使缺陷率減半)` }), + el('div', { class: 'ty-choices' }, puzzleBtn, openBtn), + el('div', { class: 'ty-factcard' }, + el('div', { class: 'ty-factcard-h', text: '你知道嗎' }), + el('p', { text: getFactText(f.factId) }))); + S.markFactSeen(f.factId); + } + off = bus.on('state:changed', () => { if (!busy) render(); }); + render(); + bus.emit('unlock:open', { fabId, dlg }); + return dlg; +} diff --git a/empire/puzzle-dev.html b/empire/puzzle-dev.html new file mode 100644 index 0000000..46323f4 --- /dev/null +++ b/empire/puzzle-dev.html @@ -0,0 +1,76 @@ + + + + + +製程解謎 — 開發測試頁 + + + +
+ 解謎測試 + + + + + + +
+
+

+
+
+
diff --git a/empire/tycoon-dev.html b/empire/tycoon-dev.html
new file mode 100644
index 0000000..018b4b2
--- /dev/null
+++ b/empire/tycoon-dev.html
@@ -0,0 +1,37 @@
+
+
+
+  
+  
+  晶圓帝國 · 經營層開發頁
+  
+
+
+  
+

晶圓帝國 Wafer Empire — 經營層(開發頁)

+

獨立測試頁:若解謎層(window.startPuzzleSet)尚未載入,「開始解謎」會使用 stub(確認後直接給每關 2 星)。

+
+
+
+

開發工具(不屬於正式遊戲)

+
+ +
+
+ + +