802MathCity / skyscraper.html
Lashtw's picture
Upload 2 files
c8e4a96 verified
<!DOCTYPE html>
<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>