Spaces:
Running
Running
| | |
| <html lang="zh-TW"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
| <title>鋼鐵輸送帶 - Steel Conveyor</title> | |
| <link | |
| href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Noto+Sans+TC:wght@400;700;900&family=JetBrains+Mono:wght@400;700&display=swap" | |
| rel="stylesheet"> | |
| <style> | |
| :root { | |
| --main: #22c55e; | |
| --accent: #f97316; | |
| --cyan: #06b6d4; | |
| --amber: #fbbf24; | |
| --fail: #ef4444; | |
| --bg: #071a0e; | |
| --glass: rgba(7, 26, 14, .88); | |
| --glass-b: rgba(34, 197, 94, .2) | |
| } | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box | |
| } | |
| .hidden { | |
| display: none !important | |
| } | |
| body { | |
| font-family: 'Noto Sans TC', sans-serif; | |
| background: var(--bg); | |
| color: #fff; | |
| overflow: hidden; | |
| touch-action: none; | |
| user-select: none; | |
| -webkit-user-select: none; | |
| width: 100vw; | |
| height: 100dvh; | |
| font-size: 0; | |
| line-height: 0 | |
| } | |
| body>* { | |
| font-size: 16px; | |
| line-height: normal | |
| } | |
| canvas { | |
| display: block; | |
| width: 100%; | |
| height: 100%; | |
| cursor: crosshair | |
| } | |
| .glass { | |
| background: var(--glass); | |
| backdrop-filter: blur(16px); | |
| -webkit-backdrop-filter: blur(16px); | |
| border: 1px solid var(--glass-b); | |
| box-shadow: 0 8px 32px rgba(0, 0, 0, .5) | |
| } | |
| .ft { | |
| font-family: 'Orbitron', sans-serif | |
| } | |
| .fm { | |
| font-family: 'JetBrains Mono', monospace | |
| } | |
| .glow { | |
| text-shadow: 0 0 8px rgba(34, 197, 94, .6) | |
| } | |
| .lbl { | |
| font-size: 11px; | |
| color: var(--cyan); | |
| text-transform: uppercase; | |
| letter-spacing: 1.5px; | |
| font-family: 'JetBrains Mono', monospace | |
| } | |
| #hud { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| padding: 12px 16px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: flex-start; | |
| pointer-events: none; | |
| z-index: 20 | |
| } | |
| #hud>* { | |
| pointer-events: auto | |
| } | |
| .hud-box { | |
| padding: 10px 14px; | |
| border-radius: 12px; | |
| min-width: 100px | |
| } | |
| #bottom-panel { | |
| position: absolute; | |
| bottom: 16px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| width: 92%; | |
| max-width: 520px; | |
| z-index: 30 | |
| } | |
| .abtn { | |
| width: 100%; | |
| padding: 14px 24px; | |
| border: none; | |
| border-radius: 14px; | |
| font-family: 'Orbitron'; | |
| font-size: 16px; | |
| font-weight: 700; | |
| letter-spacing: 2px; | |
| cursor: pointer; | |
| transition: all .2s; | |
| color: #fff | |
| } | |
| .abtn:active { | |
| transform: scale(.96) | |
| } | |
| .abtn:disabled { | |
| opacity: .4; | |
| cursor: not-allowed | |
| } | |
| .btn-go { | |
| background: linear-gradient(135deg, #22c55e, #16a34a); | |
| box-shadow: 0 4px 20px rgba(34, 197, 94, .35) | |
| } | |
| .btn-go:hover:not(:disabled) { | |
| box-shadow: 0 6px 30px rgba(34, 197, 94, .5); | |
| transform: translateY(-1px) | |
| } | |
| .btn-p, | |
| .btn-np { | |
| flex: 1; | |
| padding: 14px 16px; | |
| border: none; | |
| border-radius: 14px; | |
| font-family: 'Orbitron'; | |
| font-size: 14px; | |
| font-weight: 700; | |
| letter-spacing: 1px; | |
| cursor: pointer; | |
| transition: all .2s; | |
| color: #fff | |
| } | |
| .btn-p:active, | |
| .btn-np:active { | |
| transform: scale(.96) | |
| } | |
| .btn-p { | |
| background: linear-gradient(135deg, #22c55e, #16a34a); | |
| box-shadow: 0 4px 16px rgba(34, 197, 94, .3) | |
| } | |
| .btn-np { | |
| background: linear-gradient(135deg, #ef4444, #dc2626); | |
| box-shadow: 0 4px 16px rgba(239, 68, 68, .3) | |
| } | |
| .toast { | |
| position: fixed; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| padding: 16px 32px; | |
| border-radius: 16px; | |
| font-size: 18px; | |
| font-weight: 700; | |
| pointer-events: none; | |
| z-index: 100; | |
| animation: ti .3s ease, to .4s 1.2s ease forwards | |
| } | |
| @keyframes ti { | |
| from { | |
| opacity: 0; | |
| transform: translate(-50%, -50%) scale(.7) | |
| } | |
| to { | |
| opacity: 1; | |
| transform: translate(-50%, -50%) scale(1) | |
| } | |
| } | |
| @keyframes to { | |
| to { | |
| opacity: 0; | |
| transform: translate(-50%, -60%) scale(.9) | |
| } | |
| } | |
| .tok { | |
| background: rgba(34, 197, 94, .2); | |
| border: 2px solid #22c55e; | |
| color: #86efac; | |
| text-shadow: 0 0 12px rgba(34, 197, 94, .5) | |
| } | |
| .tng { | |
| background: rgba(239, 68, 68, .2); | |
| border: 2px solid #ef4444; | |
| color: #fca5a5; | |
| text-shadow: 0 0 12px rgba(239, 68, 68, .5) | |
| } | |
| .ov { | |
| position: absolute; | |
| inset: 0; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 50; | |
| padding: 24px; | |
| text-align: center | |
| } | |
| #start-screen { | |
| background: radial-gradient(ellipse at center, rgba(7, 26, 14, .94), #071a0e) | |
| } | |
| #brief-screen { | |
| background: radial-gradient(ellipse at center, rgba(7, 26, 14, .94), #071a0e) | |
| } | |
| #gameover-screen { | |
| background: rgba(0, 0, 0, .92) | |
| } | |
| #summary-screen { | |
| background: rgba(0, 0, 0, .92); | |
| overflow-y: auto | |
| } | |
| #hint-panel { | |
| position: absolute; | |
| top: 80px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| max-width: 440px; | |
| width: 90%; | |
| z-index: 25; | |
| pointer-events: none; | |
| transition: opacity .4s | |
| } | |
| /* Tutorial/Phase modal — centered with flex on parent */ | |
| #tut-modal { | |
| position: fixed; | |
| inset: 0; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 80; | |
| background: rgba(0, 0, 0, .65) | |
| } | |
| #tut-modal .tut-box { | |
| background: linear-gradient(160deg, #0c1e2e, #0a1628); | |
| border: 1.5px solid rgba(99, 179, 237, .35); | |
| border-radius: 20px; | |
| padding: 32px 28px; | |
| max-width: 420px; | |
| width: 90%; | |
| text-align: center; | |
| box-shadow: 0 0 50px rgba(99, 179, 237, .12), 0 20px 40px rgba(0, 0, 0, .5); | |
| animation: modalIn .35s ease | |
| } | |
| @keyframes modalIn { | |
| from { | |
| opacity: 0; | |
| transform: scale(.85) translateY(20px) | |
| } | |
| to { | |
| opacity: 1; | |
| transform: scale(1) translateY(0) | |
| } | |
| } | |
| /* Timer bar */ | |
| #timer-bar { | |
| position: absolute; | |
| top: 64px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| width: 70%; | |
| max-width: 500px; | |
| height: 8px; | |
| background: rgba(255, 255, 255, .08); | |
| border-radius: 4px; | |
| z-index: 22; | |
| overflow: hidden | |
| } | |
| #timer-fill { | |
| height: 100%; | |
| background: linear-gradient(90deg, #22c55e, #4ade80); | |
| border-radius: 4px; | |
| transition: width .15s linear | |
| } | |
| @media(max-width:640px) { | |
| .hud-box { | |
| padding: 8px 10px; | |
| min-width: 80px | |
| } | |
| .hud-box .ft { | |
| font-size: 18px !important | |
| } | |
| .abtn { | |
| font-size: 14px; | |
| padding: 12px 16px | |
| } | |
| #hint-panel { | |
| top: 64px | |
| } | |
| #summary-screen div[style*="grid-template-columns"] { | |
| grid-template-columns: 1fr !important; | |
| } | |
| #summary-screen div[style*="grid-column:span 2"] { | |
| grid-column: span 1 !important; | |
| } | |
| } | |
| .home-btn { | |
| position: fixed; | |
| top: 12px; | |
| right: 12px; | |
| z-index: 60; | |
| background: rgba(7, 26, 14, .8); | |
| border: 1px solid rgba(34, 197, 94, .3); | |
| color: #fbbf24; | |
| width: 44px; | |
| height: 44px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| border-radius: 12px; | |
| cursor: pointer; | |
| transition: all .2s; | |
| backdrop-filter: blur(8px); | |
| -webkit-backdrop-filter: blur(8px); | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, .3); | |
| text-decoration: none | |
| } | |
| .home-btn:hover { | |
| background: rgba(30, 41, 59, .9); | |
| transform: scale(1.05); | |
| box-shadow: 0 6px 20px rgba(0, 0, 0, .4) | |
| } | |
| .home-btn svg { | |
| width: 22px; | |
| height: 22px | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- HuggingFace 注入文字遮罩:用固定定位的同色遮罩蓋住頂部注入的文字,不修改任何 DOM --> | |
| <div id="hf-mask" | |
| style="position:fixed;top:0;left:0;width:100%;height:30px;background:var(--bg);z-index:10;pointer-events:none;"> | |
| </div> | |
| <canvas id="gameCanvas"></canvas> | |
| <div id="hud" class="hidden"> | |
| <div class="glass hud-box"> | |
| <div class="lbl" id="hud-lbl">鋼材運送</div> | |
| <div><span id="score-d" class="ft glow" style="font-size:22px;color:var(--main)">0</span><span | |
| id="score-unit" style="font-size:12px;color:#64748b"> 噸</span></div> | |
| </div> | |
| <div class="glass hud-box" style="text-align:center"> | |
| <div class="lbl" id="phase-lbl">教學</div><span id="level-d" class="ft glow" | |
| style="font-size:18px;color:var(--cyan)">1/2</span> | |
| </div> | |
| <div class="glass hud-box" id="combo-box"> | |
| <div class="lbl">連續</div><span id="combo-d" class="ft glow" | |
| style="font-size:22px;color:var(--amber)">0</span> | |
| </div> | |
| </div> | |
| <div id="timer-bar" class="hidden"> | |
| <div id="timer-fill" style="width:100%"></div> | |
| </div> | |
| <div id="hint-panel" class="hidden"> | |
| <div class="glass" style="padding:12px 18px;border-radius:14px;border-left:3px solid var(--cyan)"> | |
| <div | |
| style="font-size:12px;color:var(--cyan);font-weight:700;font-family:'Orbitron';letter-spacing:1px;margin-bottom:4px"> | |
| MISSION HINT</div> | |
| <p id="hint-text" style="font-size:15px;color:#e2e8f0;line-height:1.5"></p> | |
| </div> | |
| </div> | |
| <div id="bottom-panel" class="hidden"> | |
| <div id="draw-ctl"> | |
| <div class="glass" style="padding:16px;border-radius:16px;margin-bottom:8px"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px"> | |
| <span class="lbl">指定垂直距離</span> | |
| <span id="tgt-d" class="fm" style="font-size:20px;font-weight:700;color:var(--accent)">d = 60</span> | |
| </div> | |
| <div style="font-size:13px;color:#94a3b8">調整邊 B 的角度,讓右邊的垂直距離也等於 d</div> | |
| </div> | |
| <button id="btn-confirm" class="abtn btn-go" onclick="game.checkAnswer()">⚡ 確認輸送帶 CONFIRM</button> | |
| </div> | |
| <div id="judge-ctl" class="hidden"> | |
| <div class="glass" style="padding:16px;border-radius:16px;margin-bottom:8px"> | |
| <div style="font-size:14px;color:#e2e8f0;line-height:1.6;text-align:center">觀察各測量點的<span | |
| style="color:var(--cyan);font-weight:700">垂直距離</span>,<br>判斷這條輸送帶的兩邊是否<span | |
| style="color:var(--main);font-weight:700">平行</span>?</div> | |
| </div> | |
| <div style="display:flex;gap:10px"> | |
| <button class="btn-p" onclick="game.judgeAnswer(true)">✓ 平行</button> | |
| <button class="btn-np" onclick="game.judgeAnswer(false)">✗ 不平行</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="start-screen" class="ov"> | |
| <div style="margin-bottom:32px"> | |
| <div style="font-size:96px;margin-bottom:12px">🏗️</div> | |
| <h1 style="font-size:clamp(32px,7vw,52px);font-weight:900;color:#fff;line-height:1.2;margin-bottom:6px"> | |
| 鋼鐵輸送帶</h1> | |
| <p class="ft" style="font-size:clamp(14px,3vw,20px);color:var(--cyan);letter-spacing:4px;margin-top:4px"> | |
| STEEL CONVEYOR</p> | |
| </div> | |
| <button class="abtn btn-go" onclick="game.showBrief()" | |
| style="max-width:280px;font-size:18px;padding:16px">查看任務簡報 →</button> | |
| </div> | |
| <div id="brief-screen" class="ov hidden"> | |
| <div class="glass" | |
| style="padding:32px;border-radius:18px;max-width:520px;width:100%;text-align:left;margin-bottom:28px;border-color:rgba(6,182,212,.2)"> | |
| <h3 | |
| style="color:var(--cyan);font-weight:700;font-size:18px;letter-spacing:2px;border-bottom:1px solid rgba(255,255,255,.08);padding-bottom:10px;margin-bottom:18px;font-family:'Orbitron'"> | |
| ● 任務簡報 MISSION BRIEF</h3> | |
| <p style="color:#cbd5e1;font-size:20px;line-height:1.9;margin-bottom:20px">工程師,兩棟摩天大樓之間需要搭建<span | |
| style="color:var(--accent);font-weight:700">輸送帶</span>來運送 H 型鋼材。輸送帶的兩條邊必須<span | |
| style="color:#fff;font-weight:700">確定平行</span>,鋼材才能安全通過。</p> | |
| <ul style="color:#cbd5e1;font-size:18px;line-height:2.2;list-style:none;padding:0"> | |
| <li style="display:flex;align-items:flex-start;gap:10px"><span | |
| style="color:var(--main)">➤</span><span>利用「<span | |
| style="color:#fff;font-weight:700">平行線間垂直距離處處相等</span>」的原理來搭建輸送帶</span></li> | |
| <li style="display:flex;align-items:flex-start;gap:10px;margin-top:8px"><span | |
| style="color:var(--accent)">➤</span><span>有時需要<span | |
| style="color:var(--main)">繪製</span>第二條邊,有時需要<span | |
| style="color:var(--amber)">判斷</span>是否平行,必須確定平行才能讓鋼材通過!</span></li> | |
| </ul> | |
| </div> | |
| <button class="abtn btn-go" onclick="game.start()" style="max-width:280px;font-size:18px;padding:16px">開始搭建 | |
| START</button> | |
| </div> | |
| <!-- Tutorial / Phase complete modal --> | |
| <div id="tut-modal" class="hidden"> | |
| <div class="tut-box"> | |
| <div id="tut-icon" style="font-size:40px;margin-bottom:10px">✅</div> | |
| <h3 id="tut-title" style="font-size:22px;font-weight:900;color:#93c5fd;margin-bottom:10px">教學完成!</h3> | |
| <p id="tut-msg" | |
| style="font-size:17px;color:#cbd5e1;line-height:1.7;margin-bottom:24px;white-space:pre-line">垂直距離處處相等 = | |
| 平行</p> | |
| <button id="tut-btn" class="abtn" | |
| style="background:linear-gradient(135deg,#3b82f6,#2563eb);box-shadow:0 4px 20px rgba(59,130,246,.3);max-width:260px;margin:0 auto" | |
| onclick="game.tutNext()">確認,繼續 →</button> | |
| </div> | |
| </div> | |
| <!-- Summary screen after challenge --> | |
| <div id="summary-screen" class="ov hidden" | |
| style="justify-content:flex-start;padding-top:32px;padding-bottom:40px;align-items:stretch"> | |
| <!-- Header --> | |
| <div style="text-align:center;margin-bottom:20px;padding:0 24px"> | |
| <div style="font-size:48px;margin-bottom:6px">🏆</div> | |
| <h2 | |
| style="font-size:clamp(22px,5vw,32px);font-weight:900;margin-bottom:2px;background:linear-gradient(135deg,#fbbf24,#f97316);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text"> | |
| 任務總結 REPORT</h2> | |
| <p style="color:#94a3b8;font-size:12px;letter-spacing:3px;font-family:'Orbitron'">MISSION SUMMARY</p> | |
| </div> | |
| <!-- 60s Challenge + Best Record — side by side, compact --> | |
| <div | |
| style="display:flex;gap:16px;justify-content:center;align-items:stretch;margin-bottom:20px;padding:0 24px;flex-wrap:wrap;max-width:900px;width:100%;margin-left:auto;margin-right:auto"> | |
| <div class="glass" | |
| style="padding:16px 24px;border-radius:14px;flex:1;min-width:180px;max-width:300px;text-align:center"> | |
| <div style="display:flex;align-items:center;justify-content:center;gap:8px;margin-bottom:6px"> | |
| <span style="font-size:20px">⏱️</span> | |
| <span style="color:#94a3b8;font-size:14px;font-weight:700">60 秒挑戰</span> | |
| </div> | |
| <div class="ft" style="font-size:36px;font-weight:900;color:var(--amber)"> | |
| <span id="sum-challenge">—</span> | |
| </div> | |
| <div style="font-size:11px;color:#64748b;margin-top:2px">60 秒內通過的關卡數</div> | |
| </div> | |
| <div class="glass" | |
| style="padding:16px 24px;border-radius:14px;flex:1;min-width:180px;max-width:300px;text-align:center"> | |
| <div style="display:flex;align-items:center;justify-content:center;gap:8px;margin-bottom:6px"> | |
| <span style="font-size:20px">🏅</span> | |
| <span class="lbl" style="font-size:14px">最高紀錄</span> | |
| </div> | |
| <div class="ft" style="font-size:36px;font-weight:900;color:var(--amber)"> | |
| <span id="sum-best">0</span><span style="font-size:14px;color:#64748b;margin-left:4px">關</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 你學到了什麼 — WIDE container --> | |
| <div class="glass" | |
| style="padding:28px 32px;border-radius:18px;margin-bottom:20px;max-width:900px;width:94%;text-align:left;border-color:rgba(34,197,94,.2);margin-left:auto;margin-right:auto"> | |
| <h3 | |
| style="color:var(--main);font-weight:700;font-size:20px;letter-spacing:1.5px;margin-bottom:16px;font-family:'Orbitron';display:flex;align-items:center;gap:8px"> | |
| <span style="font-size:24px">📐</span> 你學到了什麼? | |
| </h3> | |
| <!-- 核心定理 — 在應用上方 --> | |
| <div | |
| style="background:rgba(34,197,94,.08);border:1px solid rgba(34,197,94,.2);border-radius:12px;padding:18px 20px;margin-bottom:20px"> | |
| <p style="color:#4ade80;font-weight:700;font-size:18px;margin-bottom:8px">⭐ 核心定理</p> | |
| <p style="color:#e2e8f0;font-size:17px;line-height:1.8">若兩直線的<span | |
| style="color:var(--amber);font-weight:700">垂直距離</span>處處相等,則兩直線<span | |
| style="color:var(--main);font-weight:700">互相平行</span>。<br>反之,平行線之間的垂直距離<span | |
| style="color:var(--cyan);font-weight:700">永遠不變</span>。</p> | |
| </div> | |
| <!-- 生活應用 — 2 欄 Grid --> | |
| <h4 | |
| style="color:var(--cyan);font-size:16px;font-weight:700;margin-bottom:14px;letter-spacing:1px;font-family:'Orbitron'"> | |
| 🌍 平行在生活中的應用</h4> | |
| <div | |
| style="display:grid;grid-template-columns:1fr 1fr;gap:14px 24px;color:#cbd5e1;font-size:16px;line-height:1.9;margin-bottom:10px"> | |
| <div style="background:rgba(255,255,255,.03);border-radius:10px;padding:14px 16px"> | |
| <span style="font-size:22px">🚂</span> <span style="color:#fff;font-weight:700">鐵軌</span><br> | |
| 火車的兩條鐵軌必須保持平行且等距,否則火車會脫軌。工程師用的正是「垂直距離處處相等」的原理來鋪設鐵道! | |
| </div> | |
| <div style="background:rgba(255,255,255,.03);border-radius:10px;padding:14px 16px"> | |
| <span style="font-size:22px">🏗️</span> <span style="color:#fff;font-weight:700">建築結構</span><br> | |
| 大樓的樓板、天花板與地板、扶手欄杆,都要保持平行才能穩固安全。 | |
| </div> | |
| <div style="background:rgba(255,255,255,.03);border-radius:10px;padding:14px 16px"> | |
| <span style="font-size:22px">🎵</span> <span style="color:#fff;font-weight:700">五線譜</span><br> | |
| 音樂的五線譜就是五條等距的平行線,音符放在不同的平行線上代表不同的音高。 | |
| </div> | |
| <div style="background:rgba(255,255,255,.03);border-radius:10px;padding:14px 16px"> | |
| <span style="font-size:22px">️</span> <span style="color:#fff;font-weight:700">電扶梯與跑道</span><br> | |
| 自動扶梯的兩側扶手、操場跑道的直線段等,都是平行線應用的例子。 | |
| </div> | |
| </div> | |
| <!-- 結語 — 放在最下方 --> | |
| <div | |
| style="margin-top:16px;padding-top:14px;border-top:1px solid rgba(255,255,255,.08);color:#e2e8f0;font-weight:700;font-size:17px;line-height:1.8"> | |
| 平行的概念看似簡單,卻是幾何學的<span style="color:var(--amber);font-weight:700">基石</span>之一。除了今天學到的「垂直距離處處相等 ⇔ | |
| 平行」,之後還會學到更多有趣的平行性質,例如<span style="color:var(--cyan);font-weight:700">同位角</span>、<span | |
| style="color:var(--cyan);font-weight:700">內錯角</span>和<span | |
| style="color:var(--cyan);font-weight:700">同側內角</span>等,細節等上課的時候再慢慢說吧~ 😄</div> | |
| </div> | |
| <!-- Buttons --> | |
| <div style="display:flex;gap:12px;justify-content:center;margin-top:8px;margin-bottom:24px;padding:0 24px"> | |
| <button class="abtn btn-go" onclick="game.restart()" style="max-width:200px">再次挑戰</button> | |
| <a href="index.html" class="abtn" | |
| style="max-width:200px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);text-align:center;text-decoration:none;display:flex;align-items:center;justify-content:center;gap:6px"><svg | |
| xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none" viewBox="0 0 24 24" | |
| stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | |
| d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /> | |
| </svg> 返回地圖</a> | |
| </div> | |
| </div> | |
| <a href="index.html" class="home-btn hidden" id="back-btn"><svg xmlns="http://www.w3.org/2000/svg" fill="none" | |
| viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | |
| d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /> | |
| </svg></a> | |
| <!-- 水印 --> | |
| <div id="watermark" | |
| style="position:fixed;bottom:4px;right:8px;text-align:right;font-size:10px;color:rgba(148,163,184,.35);z-index:9998;pointer-events:none;font-family:'Noto Sans TC',sans-serif;line-height:1.5"> | |
| <div>程式設計者:新竹縣精華國中 藍星宇</div> | |
| <div>FB教育社團:<a href="https://www.facebook.com/groups/1554372228718393" target="_blank" | |
| style="color:rgba(148,163,184,.5);pointer-events:auto;text-decoration:none;transition:color .2s" | |
| onmouseover="this.style.color='#fbbf24'" onmouseout="this.style.color='rgba(148,163,184,.5)'">萬物皆數</a> | |
| </div> | |
| </div> | |
| <script> | |
| const TUT_LEVELS = [ | |
| { type: 'tutorial-draw', angle: 0, dist: 70, msg: '教學:左邊的垂直距離已固定為 d,請拖曳橘色端點調整邊 B 的角度,讓右邊也等於 d' }, | |
| { type: 'tutorial-judge', angle: 0, dist: 70, parallel: true, msg: '教學:觀察各處的垂直距離是否都相等,相等就代表平行' } | |
| ]; | |
| const PRAC_LEVELS = [ | |
| { type: 'draw', angle: 5, dist: 65 }, { type: 'draw', angle: -8, dist: 60 }, | |
| { type: 'judge', angle: 6, dist: 55 }, { type: 'draw', angle: 12, dist: 55 }, | |
| { type: 'judge', angle: -10, dist: 50 }, { type: 'draw', angle: 15, dist: 50 }, | |
| { type: 'judge', angle: 18, dist: 45 }, { type: 'draw', angle: -20, dist: 45 } | |
| ]; | |
| // 60s challenge pool | |
| const CHAL_POOL = [ | |
| { type: 'draw', angle: 5, dist: 65 }, { type: 'draw', angle: -8, dist: 60 }, { type: 'draw', angle: 12, dist: 55 }, | |
| { type: 'draw', angle: 15, dist: 50 }, { type: 'draw', angle: -20, dist: 45 }, { type: 'draw', angle: 22, dist: 40 }, | |
| { type: 'judge', angle: 6, dist: 55 }, { type: 'judge', angle: -10, dist: 50 }, { type: 'judge', angle: 18, dist: 45 }, | |
| { type: 'judge', angle: 14, dist: 40 }, { type: 'judge', angle: -15, dist: 60 }, { type: 'draw', angle: -12, dist: 48 } | |
| ]; | |
| class ConveyorGame { | |
| constructor() { | |
| this.cv = document.getElementById('gameCanvas'); this.ctx = this.cv.getContext('2d'); | |
| this.dom = { | |
| hud: document.getElementById('hud'), score: document.getElementById('score-d'), | |
| level: document.getElementById('level-d'), combo: document.getElementById('combo-d'), | |
| bp: document.getElementById('bottom-panel'), dc: document.getElementById('draw-ctl'), | |
| jc: document.getElementById('judge-ctl'), hp: document.getElementById('hint-panel'), | |
| ht: document.getElementById('hint-text'), td: document.getElementById('tgt-d'), | |
| ss: document.getElementById('start-screen'), bs: document.getElementById('brief-screen'), | |
| tm: document.getElementById('tut-modal'), tt: document.getElementById('tut-title'), | |
| tmsg: document.getElementById('tut-msg'), ticon: document.getElementById('tut-icon'), | |
| tbtn: document.getElementById('tut-btn'), | |
| sum: document.getElementById('summary-screen'), sumCh: document.getElementById('sum-challenge'), | |
| sumBest: document.getElementById('sum-best'), | |
| bb: document.getElementById('back-btn'), phaseLbl: document.getElementById('phase-lbl'), | |
| hudLbl: document.getElementById('hud-lbl'), scoreUnit: document.getElementById('score-unit'), | |
| comboBox: document.getElementById('combo-box'), | |
| timerBar: document.getElementById('timer-bar'), timerFill: document.getElementById('timer-fill') | |
| }; | |
| this.s = { | |
| playing: false, phase: 'tutorial', lvIdx: 0, score: 0, combo: 0, maxCombo: 0, | |
| chalScore: 0, bestScore: 0, | |
| line1: null, pivot: null, dragEnd: null, angleOff: 0, type: 'draw', | |
| targetDist: 60, isParallel: true, markerTs: [], dragging: false, | |
| showHbeam: false, hbeamAnim: 0, frame: 0, stars: [], bL: {}, bR: {}, | |
| fixMode: false, lineB: null, line2: null, nx: 0, ny: 0, | |
| timerStart: 0, timerDur: 60000, timerActive: false, locked: false | |
| }; | |
| this._genStars(); this._resize(); | |
| window.addEventListener('resize', () => this._resize()); | |
| this.cv.addEventListener('pointerdown', e => this._pd(e)); | |
| this.cv.addEventListener('pointermove', e => this._pm(e)); | |
| this.cv.addEventListener('pointerup', () => { this.s.dragging = false }); | |
| this.cv.addEventListener('pointercancel', () => { this.s.dragging = false }); | |
| this.s.bestScore = parseInt(localStorage.getItem('math_city_score_parallel')) || 0; | |
| this._loop = this._loop.bind(this); requestAnimationFrame(this._loop); | |
| } | |
| _resize() { | |
| const d = window.devicePixelRatio || 1; this.W = window.innerWidth; this.H = window.innerHeight; | |
| this.cv.width = this.W * d; this.cv.height = this.H * d; this.ctx.setTransform(d, 0, 0, d, 0, 0); | |
| const m = Math.max(40, this.W * .06), bw = Math.max(70, Math.min(140, this.W * .14)); | |
| this.s.bL = { x: m, y: this.H * .15, w: bw, h: this.H * .78 }; | |
| this.s.bR = { x: this.W - m - bw, y: this.H * .1, w: bw, h: this.H * .83 }; | |
| } | |
| _genStars() { this.s.stars = []; for (let i = 0; i < 80; i++)this.s.stars.push({ x: Math.random(), y: Math.random() * .55, r: .5 + Math.random() * 1.5, tw: Math.random() * Math.PI * 2 }) } | |
| showBrief() { this.dom.ss.classList.add('hidden'); this.dom.bs.classList.remove('hidden') } | |
| start() { | |
| // 嘗試進入全螢幕 | |
| try { | |
| if (document.documentElement.requestFullscreen) { | |
| document.documentElement.requestFullscreen().catch(e => console.log('Fullscreen denied:', e)); | |
| } else if (document.documentElement.webkitRequestFullscreen) { | |
| document.documentElement.webkitRequestFullscreen(); | |
| } else if (document.documentElement.msRequestFullscreen) { | |
| document.documentElement.msRequestFullscreen(); | |
| } | |
| } catch (err) { console.log('Fullscreen not supported'); } | |
| this.s.playing = true; this.s.phase = 'tutorial'; this.s.lvIdx = 0; | |
| this.s.score = 0; this.s.combo = 0; this.s.maxCombo = 0; this.s.chalScore = 0; | |
| this.s.timerActive = false; | |
| this.dom.ss.classList.add('hidden'); this.dom.bs.classList.add('hidden'); | |
| this.dom.sum.classList.add('hidden'); this.dom.tm.classList.add('hidden'); | |
| this.dom.hud.classList.remove('hidden'); this.dom.bb.classList.remove('hidden'); | |
| this.dom.timerBar.classList.add('hidden'); | |
| this._genLevel(); | |
| } | |
| restart() { this.start() } | |
| _perpFoot(px, py, ax, ay, bx, by) { | |
| const dx = bx - ax, dy = by - ay, t = ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy); | |
| return { x: ax + t * dx, y: ay + t * dy, t }; | |
| } | |
| _perpDist(px, py, lp1, lp2) { | |
| const f = this._perpFoot(px, py, lp1.x, lp1.y, lp2.x, lp2.y); | |
| return Math.sqrt((px - f.x) ** 2 + (py - f.y) ** 2); | |
| } | |
| _getLevels() { | |
| if (this.s.phase === 'tutorial') return TUT_LEVELS; | |
| if (this.s.phase === 'practice') return PRAC_LEVELS; | |
| return null; // challenge mode uses random | |
| } | |
| _genLevel() { | |
| this.s.locked = false; | |
| this.s.fixMode = false; this._resize(); | |
| let lv; | |
| if (this.s.phase === 'challenge') { | |
| lv = { ...CHAL_POOL[Math.floor(Math.random() * CHAL_POOL.length)] }; | |
| // Randomize angle direction | |
| lv.angle = lv.angle * (Math.random() > .5 ? 1 : -1); | |
| if (lv.type === 'judge') lv.parallel = Math.random() < .5; | |
| } else { | |
| const levels = this._getLevels(); | |
| lv = levels[this.s.lvIdx]; | |
| if (!lv) { | |
| if (this.s.phase === 'tutorial') { | |
| this._showPhaseModal('🎓', '教學完成!', '你已學會如何判斷平行線。\n接下來進入練習關卡!', '開始練習 →', 'practice'); | |
| return; | |
| } | |
| if (this.s.phase === 'practice') { | |
| this._showPhaseModal('🔧', '練習完成!', '你已經很熟練了!\n接下來是 60 秒限時挑戰,\n看你能通過幾關!', '開始挑戰 ⏱️', 'challenge'); | |
| return; | |
| } | |
| return; | |
| } | |
| } | |
| this.s.type = lv.type; | |
| const bL = this.s.bL, bR = this.s.bR, lx = bL.x + bL.w, rx = bR.x; | |
| const yT = Math.max(bL.y, bR.y) + 60, yB = Math.min(bL.y + bL.h, bR.y + bR.h) - 60; | |
| const yM = (yT + yB) / 2, aRad = lv.angle * Math.PI / 180; | |
| const hL = (rx - lx) / 2, cy = yM + (Math.random() * 40 - 20); | |
| const l1p1 = { x: lx, y: cy - Math.sin(aRad) * hL }, l1p2 = { x: rx, y: cy + Math.sin(aRad) * hL }; | |
| this.s.line1 = { p1: l1p1, p2: l1p2 }; | |
| const d = lv.dist; this.s.targetDist = d; | |
| const ldx = l1p2.x - l1p1.x, ldy = l1p2.y - l1p1.y, len = Math.sqrt(ldx * ldx + ldy * ldy); | |
| let nx = -ldy / len, ny = ldx / len; if (ny < 0) { nx = -nx; ny = -ny } | |
| this.s.nx = nx; this.s.ny = ny; | |
| const nMk = (this.s.phase === 'tutorial' || this.s.lvIdx < 2) ? 2 : 3; | |
| const mkTs = []; for (let i = 0; i < nMk; i++)mkTs.push((i + 1) / (nMk + 1)); | |
| this.s.markerTs = mkTs; | |
| const isDraw = lv.type.includes('draw'); | |
| if (isDraw) { | |
| const tLeft = mkTs[0]; | |
| const pA = { x: l1p1.x + ldx * tLeft, y: l1p1.y + ldy * tLeft }; | |
| this.s.pivot = { x: pA.x + nx * d, y: pA.y + ny * d }; | |
| this.s.angleOff = (Math.random() > .5 ? 1 : -1) * (0.04 + Math.random() * 0.08); | |
| this._updateDragEnd(); | |
| this.dom.dc.classList.remove('hidden'); this.dom.jc.classList.add('hidden'); | |
| this.dom.td.textContent = `d = ${d}`; | |
| } else { | |
| this.s.isParallel = lv.parallel != null ? lv.parallel : Math.random() < .5; | |
| const cP1 = { x: l1p1.x + nx * d, y: l1p1.y + ny * d }, cP2 = { x: l1p2.x + nx * d, y: l1p2.y + ny * d }; | |
| if (this.s.isParallel) { this.s.line2 = { p1: { ...cP1 }, p2: { ...cP2 } } } | |
| else { | |
| const sk = (8 + Math.random() * 18) * (Math.random() < .5 ? 1 : -1); | |
| this.s.line2 = { p1: { x: cP1.x + nx * sk * .4, y: cP1.y + ny * sk * .4 }, p2: { x: cP2.x - nx * sk * .4, y: cP2.y - ny * sk * .4 } } | |
| } | |
| this.dom.dc.classList.add('hidden'); this.dom.jc.classList.remove('hidden'); | |
| } | |
| const hint = lv.msg || (isDraw ? '拖曳橘色端點調整邊 B 的角度,讓各處垂直距離都等於 d' : '觀察各測量點的垂直距離是否相等,判斷輸送帶是否平行'); | |
| this.dom.ht.textContent = hint; | |
| this.s.showHbeam = false; this.s.hbeamAnim = 0; | |
| this.dom.bp.classList.remove('hidden'); this.dom.hp.classList.remove('hidden'); this._upHUD(); | |
| } | |
| _updateDragEnd() { | |
| const l1 = this.s.line1, ldx = l1.p2.x - l1.p1.x, ldy = l1.p2.y - l1.p1.y; | |
| const baseAngle = Math.atan2(ldy, ldx), angle = baseAngle + this.s.angleOff; | |
| const lx = this.s.bL.x + this.s.bL.w, rx = this.s.bR.x, pivot = this.s.pivot; | |
| const cos = Math.cos(angle), sin = Math.sin(angle); | |
| const tL = cos !== 0 ? (lx - pivot.x) / cos : 0, tR = cos !== 0 ? (rx - pivot.x) / cos : 0; | |
| this.s.lineB = { p1: { x: pivot.x + tL * cos, y: pivot.y + tL * sin }, p2: { x: pivot.x + tR * cos, y: pivot.y + tR * sin } }; | |
| this.s.dragEnd = this.s.lineB.p2; | |
| } | |
| _enterFixMode() { | |
| this.s.locked = false; | |
| this.s.fixMode = true; this.s.type = 'draw'; | |
| const l1 = this.s.line1, l2 = this.s.line2, d = this.s.targetDist; | |
| const ldx = l1.p2.x - l1.p1.x, ldy = l1.p2.y - l1.p1.y; | |
| const tLeft = this.s.markerTs[0]; | |
| const pA = { x: l1.p1.x + ldx * tLeft, y: l1.p1.y + ldy * tLeft }; | |
| this.s.pivot = { x: pA.x + this.s.nx * d, y: pA.y + this.s.ny * d }; | |
| const baseAngle = Math.atan2(ldy, ldx); | |
| this.s.angleOff = Math.atan2(l2.p2.y - l2.p1.y, l2.p2.x - l2.p1.x) - baseAngle; | |
| this._updateDragEnd(); | |
| this.dom.jc.classList.add('hidden'); this.dom.dc.classList.remove('hidden'); | |
| this.dom.td.textContent = `d = ${d}`; | |
| this.dom.ht.textContent = '不平行!請調整邊 B,讓垂直距離都等於 d 才能通過'; | |
| } | |
| _getL2() { return (this.s.type.includes('draw') || this.s.fixMode) ? this.s.lineB : this.s.line2 } | |
| _showPhaseModal(icon, title, msg, btnText, nextPhase) { | |
| this.dom.ticon.textContent = icon; this.dom.tt.textContent = title; this.dom.tmsg.textContent = msg; | |
| this.dom.tbtn.textContent = btnText; this.dom.tbtn.onclick = () => { | |
| this.dom.tm.classList.add('hidden'); | |
| this.s.phase = nextPhase; this.s.lvIdx = 0; | |
| if (nextPhase === 'challenge') { this._startChallenge() } | |
| else this._genLevel(); | |
| }; | |
| this.dom.tm.classList.remove('hidden'); | |
| } | |
| _startChallenge() { | |
| this.s.chalScore = 0; this.s.combo = 0; this.s.timerStart = Date.now(); this.s.timerActive = true; | |
| this.dom.timerBar.classList.remove('hidden'); this._genLevel(); | |
| } | |
| _checkTimer() { | |
| if (!this.s.timerActive) return false; | |
| const elapsed = Date.now() - this.s.timerStart; | |
| const pct = Math.max(0, 1 - elapsed / this.s.timerDur); | |
| this.dom.timerFill.style.width = `${pct * 100}%`; | |
| if (pct <= .3) this.dom.timerFill.style.background = 'linear-gradient(90deg,#ef4444,#f97316)'; | |
| else if (pct <= .6) this.dom.timerFill.style.background = 'linear-gradient(90deg,#fbbf24,#22c55e)'; | |
| if (elapsed >= this.s.timerDur) { this.s.timerActive = false; this._endChallenge(); return true } | |
| return false; | |
| } | |
| _endChallenge() { | |
| this.s.playing = false; this.s.timerActive = false; | |
| this.dom.hud.classList.add('hidden'); this.dom.bp.classList.add('hidden'); | |
| this.dom.hp.classList.add('hidden'); this.dom.bb.classList.add('hidden'); | |
| this.dom.timerBar.classList.add('hidden'); | |
| // Save best | |
| if (this.s.chalScore > this.s.bestScore) { | |
| this.s.bestScore = this.s.chalScore; | |
| localStorage.setItem('math_city_score_parallel', String(this.s.bestScore)); | |
| } | |
| this.dom.sumCh.textContent = `${this.s.chalScore} 關`; | |
| this.dom.sumBest.textContent = this.s.bestScore; | |
| this.dom.sum.classList.remove('hidden'); | |
| } | |
| checkAnswer() { | |
| if (!this.s.playing || this.s.locked) return; | |
| if (this.s.timerActive && this._checkTimer()) return; | |
| const l2 = this._getL2(); if (!l2) return; | |
| const d = this.s.targetDist, l1 = this.s.line1; | |
| // 逐點檢查:每個測量點的四捨五入值必須完全等於 d | |
| let allMatch = true; | |
| let mismatchInfo = ''; | |
| this.s.markerTs.forEach((t, idx) => { | |
| const px = l1.p1.x + (l1.p2.x - l1.p1.x) * t, py = l1.p1.y + (l1.p2.y - l1.p1.y) * t; | |
| const measured = Math.round(this._perpDist(px, py, l2.p1, l2.p2)); | |
| if (measured !== d) { | |
| allMatch = false; | |
| mismatchInfo = `目標 d = ${d},但有測量點顯示 ${measured}`; | |
| } | |
| }); | |
| if (this.s.phase === 'tutorial') { | |
| if (allMatch) { | |
| this.s.locked = true; | |
| this._showPhaseModal('✅', '教學完成!', '垂直距離處處相等 = 平行!\n確認距離一致就代表兩條線平行', '確認,繼續 →', null); | |
| this.dom.tbtn.onclick = () => { this.dom.tm.classList.add('hidden'); this.s.lvIdx++; this._genLevel() } | |
| } | |
| else this._showToast(`還不是完全平行!${mismatchInfo}`, false); | |
| return; | |
| } | |
| if (this.s.fixMode) { | |
| if (allMatch) { this.s.locked = true; this._onCorrect(80) } | |
| else this._showToast(`還不是完全平行!${mismatchInfo}`, false); | |
| return; | |
| } | |
| if (allMatch) { this.s.locked = true; this._onCorrect(100); } | |
| else this._showToast(`還不是完全平行!${mismatchInfo}`, false); | |
| } | |
| judgeAnswer(says) { | |
| if (!this.s.playing || this.s.locked) return; | |
| if (this.s.timerActive && this._checkTimer()) return; | |
| if (this.s.phase === 'tutorial') { | |
| if (says === this.s.isParallel) { | |
| this.s.locked = true; | |
| this._showPhaseModal('✅', '教學完成!', '觀察垂直距離是否處處相等,\n相等就代表平行!', '確認,繼續 →', null); | |
| this.dom.tbtn.onclick = () => { this.dom.tm.classList.add('hidden'); this.s.lvIdx++; this._genLevel() } | |
| } else this._showToast(this.s.isParallel ? '看看各點垂直距離是否相同?' : '注意各點距離不同喔!', false); | |
| return; | |
| } | |
| if (says === this.s.isParallel) { | |
| this.s.locked = true; | |
| if (this.s.isParallel) { this._onCorrect(100) } | |
| else { this._showToast('正確!不平行。現在請調整讓它平行!', true); setTimeout(() => this._enterFixMode(), 1200) } | |
| } else { | |
| this.s.combo = 0; | |
| this._showToast(this.s.isParallel ? '其實是平行的!垂直距離處處相等。' : '其實不平行!各點垂直距離不同。', false); | |
| this._upHUD(); | |
| } | |
| } | |
| tutNext() { } | |
| _onCorrect(pts) { | |
| this.s.combo++; if (this.s.combo > this.s.maxCombo) this.s.maxCombo = this.s.combo; | |
| if (this.s.phase === 'challenge') { | |
| this.s.chalScore++; | |
| this._showToast(`+1 關!共 ${this.s.chalScore} 關`, true); | |
| } else { | |
| const tot = pts + Math.min(this.s.combo * 10, 50); this.s.score += tot; | |
| this._showToast(`+${tot} 噸 鋼材通過!`, true); | |
| } | |
| this.s.showHbeam = true; this.s.hbeamAnim = 0; | |
| setTimeout(() => { | |
| if (this.s.timerActive && this._checkTimer()) return; | |
| this.s.fixMode = false; this.s.lvIdx++; | |
| if (this.s.phase === 'challenge') { this._genLevel(); return } | |
| const levels = this._getLevels(); | |
| if (!levels || this.s.lvIdx >= levels.length) { this._genLevel(); return } // will trigger phase transition | |
| this._genLevel(); | |
| }, 2200); | |
| } | |
| _showToast(t, ok) { const e = document.createElement('div'); e.className = `toast ${ok ? 'tok' : 'tng'}`; e.textContent = t; document.body.appendChild(e); setTimeout(() => { try { e.remove() } catch (x) { } }, 1800) } | |
| _upHUD() { | |
| if (this.s.phase === 'challenge') { | |
| this.dom.hudLbl.textContent = '通過關卡'; this.dom.score.textContent = this.s.chalScore; this.dom.scoreUnit.textContent = ' 關'; | |
| this.dom.phaseLbl.textContent = '挑戰'; this.dom.level.textContent = '⏱️ 60s'; | |
| } else if (this.s.phase === 'practice') { | |
| this.dom.hudLbl.textContent = '鋼材運送'; this.dom.score.textContent = this.s.score; this.dom.scoreUnit.textContent = ' 噸'; | |
| this.dom.phaseLbl.textContent = '練習'; this.dom.level.textContent = `${this.s.lvIdx + 1}/${PRAC_LEVELS.length}`; | |
| } else { | |
| this.dom.hudLbl.textContent = '鋼材運送'; this.dom.score.textContent = this.s.score; this.dom.scoreUnit.textContent = ' 噸'; | |
| this.dom.phaseLbl.textContent = '教學'; this.dom.level.textContent = `${this.s.lvIdx + 1}/${TUT_LEVELS.length}`; | |
| } | |
| this.dom.combo.textContent = this.s.combo; | |
| } | |
| _gp(e) { const r = this.cv.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top } } | |
| _pd(e) { | |
| if (!(this.s.type.includes('draw') || this.s.fixMode) || !this.s.playing || !this.s.dragEnd) return; | |
| const pos = this._gp(e), dx = pos.x - this.s.dragEnd.x, dy = pos.y - this.s.dragEnd.y; | |
| if (dx * dx + dy * dy < 40 * 40) { this.s.dragging = true; this.cv.setPointerCapture(e.pointerId) } | |
| } | |
| _pm(e) { | |
| if (!this.s.dragging) return; | |
| const pos = this._gp(e), pivot = this.s.pivot; | |
| const newAngle = Math.atan2(pos.y - pivot.y, pos.x - pivot.x); | |
| const l1 = this.s.line1; | |
| this.s.angleOff = Math.max(-0.5, Math.min(0.5, newAngle - Math.atan2(l1.p2.y - l1.p1.y, l1.p2.x - l1.p1.x))); | |
| this._updateDragEnd(); | |
| } | |
| _loop() { | |
| this.s.frame++; | |
| if (this.s.timerActive) this._checkTimer(); | |
| this._draw(); requestAnimationFrame(this._loop); | |
| } | |
| _draw() { | |
| const ctx = this.ctx, W = this.W, H = this.H, f = this.s.frame; | |
| const sg = ctx.createLinearGradient(0, 0, 0, H); | |
| sg.addColorStop(0, '#030d06'); sg.addColorStop(.5, '#071a0e'); sg.addColorStop(1, '#0a2614'); | |
| ctx.fillStyle = sg; ctx.fillRect(0, 0, W, H); | |
| this.s.stars.forEach(s => { const tw = .4 + .6 * Math.abs(Math.sin(f * .02 + s.tw)); ctx.beginPath(); ctx.arc(s.x * W, s.y * H, s.r, 0, Math.PI * 2); ctx.fillStyle = `rgba(255,255,255,${tw * .7})`; ctx.fill() }); | |
| ctx.fillStyle = '#051208'; const bt = H * .88; | |
| [[.05, .3], [.1, .45], [.18, .25], [.25, .5], [.3, .35], [.38, .55], [.45, .28], [.52, .42], [.58, .32], [.65, .48], [.72, .38], [.78, .52], [.85, .3], [.92, .44]] | |
| .forEach(([xr, hr]) => { ctx.fillRect(xr * W, bt - hr * H * .2, W * .055, hr * H * .2) }); | |
| ctx.fillStyle = '#030d06'; ctx.fillRect(0, bt, W, H - bt); | |
| if (this.s.playing) { this._drawBld(ctx, this.s.bL, 'left', f); this._drawBld(ctx, this.s.bR, 'right', f); this._drawScene(ctx, f) } | |
| } | |
| _drawBld(ctx, b, side, f) { | |
| // === 施工中建築 === | |
| // 鋼骨框架背景(半透明深色) | |
| ctx.fillStyle = 'rgba(15, 40, 24, 0.4)'; | |
| ctx.fillRect(b.x, b.y, b.w, b.h); | |
| // 垂直鋼骨柱(左右兩根主柱 + 中間支撐柱) | |
| const steelColor = '#475569'; | |
| const steelHighlight = '#64748b'; | |
| const pillarW = 4; | |
| ctx.fillStyle = steelColor; | |
| ctx.fillRect(b.x, b.y, pillarW, b.h); // 左柱 | |
| ctx.fillRect(b.x + b.w - pillarW, b.y, pillarW, b.h); // 右柱 | |
| ctx.fillRect(b.x + b.w / 2 - pillarW / 2, b.y, pillarW, b.h); // 中柱 | |
| // 水平鋼骨樓板(每隔一段距離一條) | |
| const floorGap = Math.max(30, b.h / 10); | |
| const numFloors = Math.floor(b.h / floorGap); | |
| for (let i = 0; i <= numFloors; i++) { | |
| const fy = b.y + i * floorGap; | |
| if (fy > b.y + b.h) break; | |
| ctx.fillStyle = steelColor; | |
| ctx.fillRect(b.x, fy, b.w, 3); | |
| // 高亮線 | |
| ctx.fillStyle = steelHighlight; | |
| ctx.fillRect(b.x, fy, b.w, 1); | |
| } | |
| // 交叉鋼骨支撐(X 型斜撐,每隔一層交替方向) | |
| ctx.strokeStyle = 'rgba(100, 116, 139, 0.5)'; | |
| ctx.lineWidth = 1.5; | |
| for (let i = 0; i < numFloors; i++) { | |
| const y1 = b.y + i * floorGap; | |
| const y2 = Math.min(b.y + (i + 1) * floorGap, b.y + b.h); | |
| // 左半 X | |
| if (i % 2 === 0) { | |
| ctx.beginPath(); | |
| ctx.moveTo(b.x, y1); ctx.lineTo(b.x + b.w / 2, y2); | |
| ctx.stroke(); | |
| ctx.beginPath(); | |
| ctx.moveTo(b.x + b.w / 2, y1); ctx.lineTo(b.x, y2); | |
| ctx.stroke(); | |
| } else { | |
| // 右半 X | |
| ctx.beginPath(); | |
| ctx.moveTo(b.x + b.w / 2, y1); ctx.lineTo(b.x + b.w, y2); | |
| ctx.stroke(); | |
| ctx.beginPath(); | |
| ctx.moveTo(b.x + b.w, y1); ctx.lineTo(b.x + b.w / 2, y2); | |
| ctx.stroke(); | |
| } | |
| } | |
| // 鷹架(外側細格線) | |
| ctx.strokeStyle = 'rgba(148, 163, 184, 0.2)'; | |
| ctx.lineWidth = 1; | |
| const scaffoldGap = 15; | |
| for (let sy = b.y; sy < b.y + b.h; sy += scaffoldGap) { | |
| ctx.beginPath(); | |
| ctx.moveTo(b.x - 6, sy); ctx.lineTo(b.x, sy); | |
| ctx.stroke(); | |
| ctx.beginPath(); | |
| ctx.moveTo(b.x + b.w, sy); ctx.lineTo(b.x + b.w + 6, sy); | |
| ctx.stroke(); | |
| } | |
| // 鷹架垂直線 | |
| ctx.beginPath(); ctx.moveTo(b.x - 6, b.y); ctx.lineTo(b.x - 6, b.y + b.h); ctx.stroke(); | |
| ctx.beginPath(); ctx.moveTo(b.x + b.w + 6, b.y); ctx.lineTo(b.x + b.w + 6, b.y + b.h); ctx.stroke(); | |
| // 安全網(頂部附近,半透明綠色區域) | |
| const netH = Math.min(floorGap * 2, b.h * 0.2); | |
| ctx.fillStyle = 'rgba(34, 197, 94, 0.06)'; | |
| ctx.fillRect(b.x, b.y, b.w, netH); | |
| // 安全網格線 | |
| ctx.strokeStyle = 'rgba(34, 197, 94, 0.15)'; | |
| ctx.lineWidth = 0.5; | |
| for (let nx = b.x; nx < b.x + b.w; nx += 8) { | |
| ctx.beginPath(); ctx.moveTo(nx, b.y); ctx.lineTo(nx, b.y + netH); ctx.stroke(); | |
| } | |
| for (let ny = b.y; ny < b.y + netH; ny += 8) { | |
| ctx.beginPath(); ctx.moveTo(b.x, ny); ctx.lineTo(b.x + b.w, ny); ctx.stroke(); | |
| } | |
| // 未完成頂部(鋸齒狀不規則頂端) | |
| ctx.fillStyle = steelColor; | |
| const toothW = b.w / 5; | |
| for (let tx = b.x; tx < b.x + b.w; tx += toothW) { | |
| const th = 5 + Math.abs(Math.sin(tx * 0.7)) * 12; | |
| ctx.fillRect(tx, b.y - th, Math.min(pillarW, toothW), th); | |
| } | |
| // 起重機塔吊 | |
| const craneX = side === 'left' ? b.x + b.w * 0.3 : b.x + b.w * 0.7; | |
| const craneTopY = b.y - 40; | |
| // 塔身 | |
| ctx.strokeStyle = '#f97316'; | |
| ctx.lineWidth = 3; | |
| ctx.beginPath(); ctx.moveTo(craneX, b.y); ctx.lineTo(craneX, craneTopY); ctx.stroke(); | |
| // 吊臂(水平) | |
| const armLen = side === 'left' ? b.w * 1.2 : -b.w * 1.2; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); ctx.moveTo(craneX, craneTopY); ctx.lineTo(craneX + armLen, craneTopY); ctx.stroke(); | |
| // 支撐鋼索 | |
| ctx.strokeStyle = 'rgba(249, 115, 22, 0.4)'; ctx.lineWidth = 1; | |
| ctx.beginPath(); ctx.moveTo(craneX, craneTopY - 8); ctx.lineTo(craneX + armLen * 0.7, craneTopY); ctx.stroke(); | |
| ctx.beginPath(); ctx.moveTo(craneX, craneTopY - 8); ctx.lineTo(craneX + armLen * 0.3, craneTopY); ctx.stroke(); | |
| // 吊索 + 吊鉤(動態擺動) | |
| const hookX = craneX + armLen * (0.5 + Math.sin(f * 0.02) * 0.15); | |
| const hookY = craneTopY + 20 + Math.sin(f * 0.03) * 5; | |
| ctx.strokeStyle = 'rgba(249, 115, 22, 0.6)'; ctx.lineWidth = 1; | |
| ctx.beginPath(); ctx.moveTo(hookX, craneTopY); ctx.lineTo(hookX, hookY); ctx.stroke(); | |
| // 吊鉤 | |
| ctx.fillStyle = '#f97316'; | |
| ctx.beginPath(); ctx.arc(hookX, hookY, 3, 0, Math.PI * 2); ctx.fill(); | |
| ctx.beginPath(); ctx.arc(hookX, hookY + 5, 5, 0, Math.PI); ctx.stroke(); | |
| // 閃爍警示燈(頂部) | |
| if (Math.sin(f * .06) > 0) { | |
| ctx.beginPath(); ctx.arc(craneX, craneTopY - 8, 3, 0, Math.PI * 2); | |
| ctx.fillStyle = '#ef4444'; ctx.shadowBlur = 10; ctx.shadowColor = '#ef4444'; ctx.fill(); ctx.shadowBlur = 0; | |
| } | |
| // 底部建材堆放(矩形鋼筋堆) | |
| ctx.fillStyle = 'rgba(148, 163, 184, 0.3)'; | |
| const stackY = b.y + b.h - 15; | |
| for (let sx = 0; sx < 3; sx++) { | |
| ctx.fillRect(b.x + 8 + sx * (b.w / 4), stackY, b.w / 5, 10); | |
| ctx.strokeStyle = 'rgba(148, 163, 184, 0.5)'; ctx.lineWidth = 0.5; | |
| ctx.strokeRect(b.x + 8 + sx * (b.w / 4), stackY, b.w / 5, 10); | |
| } | |
| } | |
| _drawScene(ctx, f) { | |
| const l1 = this.s.line1; if (!l1) return; | |
| const d = this.s.targetDist, isDraw = this.s.type.includes('draw') || this.s.fixMode, l2 = this._getL2(); | |
| ctx.beginPath(); ctx.moveTo(l1.p1.x, l1.p1.y); ctx.lineTo(l1.p2.x, l1.p2.y); | |
| ctx.strokeStyle = '#22c55e'; ctx.lineWidth = 4; ctx.shadowBlur = 6; ctx.shadowColor = '#22c55e'; ctx.stroke(); ctx.shadowBlur = 0; | |
| ctx.font = 'bold 13px "Noto Sans TC"'; ctx.fillStyle = '#4ade80'; ctx.fillText('邊 A', l1.p1.x - 40, l1.p1.y - 8); | |
| if (l2) { | |
| ctx.beginPath(); ctx.moveTo(l2.p1.x, l2.p1.y); ctx.lineTo(l2.p2.x, l2.p2.y); | |
| ctx.strokeStyle = '#f97316'; ctx.lineWidth = 4; ctx.shadowBlur = 6; ctx.shadowColor = '#f97316'; ctx.stroke(); ctx.shadowBlur = 0; | |
| ctx.fillStyle = '#fb923c'; ctx.fillText('邊 B', l2.p1.x - 40, l2.p1.y + 20) | |
| } | |
| if (isDraw && this.s.dragEnd) { | |
| const p = this.s.dragEnd, pr = 16 + Math.sin(f * .08) * 3; | |
| ctx.beginPath(); ctx.arc(p.x, p.y, pr, 0, Math.PI * 2); ctx.fillStyle = 'rgba(249,115,22,.12)'; ctx.fill(); | |
| ctx.beginPath(); ctx.arc(p.x, p.y, 10, 0, Math.PI * 2); ctx.fillStyle = '#f97316'; ctx.shadowBlur = 10; ctx.shadowColor = '#f97316'; ctx.fill(); ctx.shadowBlur = 0; | |
| ctx.beginPath(); ctx.arc(p.x, p.y, 4, 0, Math.PI * 2); ctx.fillStyle = '#fff'; ctx.fill(); | |
| if (this.s.pivot) { | |
| const pv = this.s.pivot; | |
| ctx.beginPath(); ctx.arc(pv.x, pv.y, 6, 0, Math.PI * 2); ctx.fillStyle = '#06b6d4'; ctx.shadowBlur = 6; ctx.shadowColor = '#06b6d4'; ctx.fill(); ctx.shadowBlur = 0; | |
| ctx.beginPath(); ctx.arc(pv.x, pv.y, 3, 0, Math.PI * 2); ctx.fillStyle = '#fff'; ctx.fill() | |
| } | |
| } | |
| if (l2) { | |
| this.s.markerTs.forEach((t, idx) => { | |
| const pAx = l1.p1.x + (l1.p2.x - l1.p1.x) * t, pAy = l1.p1.y + (l1.p2.y - l1.p1.y) * t; | |
| let fx, fy, pd; | |
| if (isDraw && idx === 0 && this.s.pivot) { fx = this.s.pivot.x; fy = this.s.pivot.y; pd = Math.sqrt((pAx - fx) ** 2 + (pAy - fy) ** 2) } | |
| else { const foot = this._perpFoot(pAx, pAy, l2.p1.x, l2.p1.y, l2.p2.x, l2.p2.y); fx = foot.x; fy = foot.y; pd = Math.sqrt((pAx - fx) ** 2 + (pAy - fy) ** 2) } | |
| ctx.beginPath(); ctx.setLineDash([5, 4]); ctx.moveTo(pAx, pAy); ctx.lineTo(fx, fy); | |
| ctx.strokeStyle = 'rgba(255,255,255,.55)'; ctx.lineWidth = 1.5; ctx.stroke(); ctx.setLineDash([]); | |
| const mAngle = Math.atan2(fy - pAy, fx - pAx); | |
| const tkW = 7, txD = Math.cos(mAngle + Math.PI / 2) * tkW, tyD = Math.sin(mAngle + Math.PI / 2) * tkW; | |
| ctx.beginPath(); ctx.moveTo(pAx - txD, pAy - tyD); ctx.lineTo(pAx + txD, pAy + tyD); | |
| ctx.moveTo(fx - txD, fy - tyD); ctx.lineTo(fx + txD, fy + tyD); | |
| ctx.strokeStyle = 'rgba(255,255,255,.6)'; ctx.lineWidth = 2; ctx.stroke(); | |
| // Right angle at A | |
| const ras = 10; | |
| let rax = pAx + Math.cos(mAngle) * ras, ray = pAy + Math.sin(mAngle) * ras; | |
| ctx.beginPath(); ctx.moveTo(rax - txD * .5, ray - tyD * .5); ctx.lineTo(rax, ray); ctx.lineTo(pAx - txD * .5, pAy - tyD * .5); | |
| ctx.strokeStyle = 'rgba(255,255,255,.4)'; ctx.lineWidth = 1; ctx.stroke(); | |
| // Right angle at B | |
| rax = fx - Math.cos(mAngle) * ras; ray = fy - Math.sin(mAngle) * ras; | |
| ctx.beginPath(); ctx.moveTo(rax - txD * .5, ray - tyD * .5); ctx.lineTo(rax, ray); ctx.lineTo(fx - txD * .5, fy - tyD * .5); | |
| ctx.strokeStyle = 'rgba(255,255,255,.4)'; ctx.lineWidth = 1; ctx.stroke(); | |
| const label = Math.round(pd); ctx.font = 'bold 14px "JetBrains Mono"'; ctx.textAlign = 'center'; | |
| let col = '#e2e8f0'; | |
| if (isDraw) { if (idx === 0) col = '#06b6d4'; else { const err = Math.abs(pd - d); col = err < 3 ? '#4ade80' : err < 10 ? '#fbbf24' : '#f87171' } } | |
| ctx.fillStyle = col; ctx.fillText(`${label}`, (pAx + fx) / 2 + 20, (pAy + fy) / 2 + 5); ctx.textAlign = 'left'; | |
| ctx.beginPath(); ctx.arc(pAx, pAy, 4, 0, Math.PI * 2); ctx.fillStyle = '#4ade80'; ctx.fill(); | |
| ctx.beginPath(); ctx.arc(fx, fy, 4, 0, Math.PI * 2); ctx.fillStyle = '#fb923c'; ctx.fill(); | |
| }); | |
| } | |
| if (l2 && !this.s.showHbeam) { | |
| const ph = (f * .015) % 1; | |
| [l1, l2].forEach((ln, i) => { | |
| const c = i === 0 ? 'rgba(34,197,94,.25)' : 'rgba(249,115,22,.25)'; | |
| for (let j = 0; j < 12; j++) { const tt = ((j / 12) + ph) % 1; ctx.beginPath(); ctx.arc(ln.p1.x + (ln.p2.x - ln.p1.x) * tt, ln.p1.y + (ln.p2.y - ln.p1.y) * tt, 2, 0, Math.PI * 2); ctx.fillStyle = c; ctx.fill() } | |
| }) | |
| } | |
| if (this.s.showHbeam && l1 && l2) { | |
| this.s.hbeamAnim += 0.012; | |
| if (this.s.hbeamAnim <= 1.5) { | |
| const tt = Math.min(1, this.s.hbeamAnim); | |
| const hx = l1.p1.x + (l1.p2.x - l1.p1.x) * tt, hy1 = l1.p1.y + (l1.p2.y - l1.p1.y) * tt, hy2 = l2.p1.y + (l2.p2.y - l2.p1.y) * tt; | |
| const hc = (hy1 + hy2) / 2, bH = Math.abs(hy2 - hy1) * .5, bW = 18; | |
| ctx.save(); ctx.translate(hx, hc); | |
| ctx.fillStyle = '#94a3b8'; ctx.fillRect(-bW / 2, -bH / 2, 4, bH); ctx.fillRect(bW / 2 - 4, -bH / 2, 4, bH); ctx.fillRect(-bW / 2, -2, bW, 4); | |
| ctx.shadowBlur = 8; ctx.shadowColor = '#22c55e'; ctx.strokeStyle = '#22c55e'; ctx.lineWidth = 1; ctx.strokeRect(-bW / 2 - 1, -bH / 2 - 1, bW + 2, bH + 2); ctx.shadowBlur = 0; | |
| ctx.restore() | |
| } | |
| } | |
| } | |
| } | |
| let game; try { game = new ConveyorGame(); window.game = game } catch (e) { console.error('Init fail:', e) } | |
| </script> | |
| </body> | |
| </html> |