Spaces:
Running
Running
Download js/interactive2d.js from Lashtw/TowerofLondon: direct link, hf CLI and curl.
- Browser
- Download file 36.6 kB
-
https://huggingface.co/spaces/Lashtw/TowerofLondon/resolve/main/js/interactive2d.js
- Command line
-
hf download hf://spaces/Lashtw/TowerofLondon/js/interactive2d.js
-
curl -L -o interactive2d.js https://huggingface.co/spaces/Lashtw/TowerofLondon/resolve/main/js/interactive2d.js
36.6 kB
| /* ========================================================================== | |
| 倫敦塔益智盒 - 二維俯視幾何模擬器 (Interactive 2D Simulator) | |
| 以 3D 1-4-1 空間幾何模擬核心為主軸,正交投影建構真實俯視圖 | |
| 支援剛體旋轉、空間球體非穿透約束、幾何動態運算與 3D 子母畫面 100% 同步 | |
| ========================================================================== */ | |
| class Interactive2DSimulator { | |
| constructor(canvasId, options = {}) { | |
| this.canvas = document.getElementById(canvasId); | |
| if (!this.canvas) { | |
| console.error(`[Interactive2D] 找不到畫布元素: #${canvasId}`); | |
| return; | |
| } | |
| this.ctx = this.canvas.getContext('2d'); | |
| this.scale = 100; // 繪圖縮放基準 (100 px = 1 cm) | |
| this.S = options.initialS || 4.00; // 盒子內徑預設 4.00cm (死鎖初始) | |
| this.ballDiameter = 2.00; // 球直徑 2.00 cm | |
| this.r = 1.00; // 球半徑 1.00 cm | |
| this.holeDiameter = 2.00; // 出口孔徑 2.00 cm | |
| // 3D 1-4-1 旋轉引擎核心狀態 | |
| this.groupRotationAngle = 0; // 旋轉角 θ (弧度 rad) | |
| this.isExitOpen = false; // 脫出通道是否已開啟 (S >= 4.098 且順向達臨界姿態) | |
| this.isEscaped = false; // 是否已達成自由拿球脫出 | |
| this.redPullOut = 0; // 紅球向右拉出位移 (cm, 範圍 0 ~ 1.80) | |
| this.bluePullOut = 0; // 藍球向左拉出位移 (cm, 範圍 0 ~ 1.80) | |
| this.dragTarget = null; // 當前拖曳目標 ('rotate' | 'redBall' | 'blueBall') | |
| this.showEscapeDist = false; // 是否顯示對角逃脫線 | |
| this.highlightTarget = null; // 外部懸停聯動高亮 ('dg' | 'drb' | 'drg' | 'escapeNet' | null) | |
| // 互動狀態 | |
| this.isDragging = false; | |
| this.lastMousePos = { x: 0, y: 0 }; | |
| this.isRunning = false; | |
| this.animationFrameId = null; | |
| // 回呼函式 (供外部 UI 更新數值與 KaTeX 公式) | |
| this.onStatsUpdate = options.onStatsUpdate || null; | |
| // 6 顆球當前 3D 與 2D 坐標狀態快取 | |
| this.current3DState = null; | |
| this.particles = []; // 相容陣列,方便外部代碼存取 | |
| // 初始化 3D 坐標狀態 | |
| this.current3DState = this.calculate141Positions(this.S, this.groupRotationAngle); | |
| // 初始化事件 | |
| this.setupEvents(); | |
| // 初始狀態計算與繪製 (排程執行確保外部建構賦值已完成) | |
| setTimeout(() => { | |
| this.calculateStats(); | |
| this.draw(); | |
| }, 0); | |
| } | |
| /* ------------------------------------------------------------------ | |
| 3D 1-4-1 空間幾何剛體計算核心 | |
| (完全基於步驟二經過驗證之空間旋轉與二分搜尋剛體球-球接觸模型) | |
| ------------------------------------------------------------------ */ | |
| calculate141Positions(S, theta) { | |
| const r = this.r; | |
| const limit = Math.max(0, (S / 2) - r); // 球心距盒壁的極限空間 | |
| // 1. 中層 4 顆綠球在立方體斜對角交替頂點 (1, 3, 5, 7 號角) | |
| const corners = [ | |
| { x: -limit, y: limit, z: -limit, label: 'G1' }, // 1號 (左上後) | |
| { x: limit, y: limit, z: limit, label: 'G2' }, // 3號 (右上前) | |
| { x: -limit, y: -limit, z: -limit, label: 'G3' }, // 5號 (左下後) | |
| { x: limit, y: -limit, z: limit, label: 'G4' } // 7號 (右下前) | |
| ]; | |
| const cosT = Math.cos(theta); | |
| const sinT = Math.sin(theta); | |
| // 4 顆綠球在斜對角繞 Y 軸旋轉 | |
| const rotatedGreen = corners.map(c => { | |
| let rx = c.x * cosT - c.z * sinT; | |
| let ry = c.y; | |
| let rz = c.x * sinT + c.z * cosT; | |
| // 盒內剛體邊界限制,防止穿模 | |
| rx = Math.max(-limit, Math.min(limit, rx)); | |
| ry = Math.max(-limit, Math.min(limit, ry)); | |
| rz = Math.max(-limit, Math.min(limit, rz)); | |
| return { x: rx, y: ry, z: rz, label: c.label }; | |
| }); | |
| // 2. 紅球 (頂球) 與 藍球 (底球) | |
| // 位於垂直於綠球旋轉斜對角線的法向量方向上 | |
| const perpX = (cosT + sinT) / Math.SQRT2; | |
| const perpZ = (sinT - cosT) / Math.SQRT2; | |
| // 二分搜尋最小剛體半跨距 a (紅藍球與 4 顆綠球的剛體接觸相切約束) | |
| let aMin = r; // 球半徑 1.0cm | |
| let aMax = Math.max(limit * Math.SQRT2, 2.0); | |
| for (let iter = 0; iter < 50; iter++) { | |
| const aMid = (aMin + aMax) / 2; | |
| const testRedX = aMid * perpX; | |
| const testRedZ = aMid * perpZ; | |
| const testBlueX = -testRedX; | |
| const testBlueZ = -testRedZ; | |
| let valid = true; | |
| // (A) 紅藍球心距 >= 2r (直徑 2.0cm 不穿透) | |
| if (2 * aMid < 2 * r - 0.001) { | |
| valid = false; | |
| } | |
| // (B) 紅球到每顆綠球空間 3D 剛體距離 >= 2r | |
| if (valid) { | |
| for (const g of rotatedGreen) { | |
| const dx = testRedX - g.x; | |
| const dy = 0 - g.y; | |
| const dz = testRedZ - g.z; | |
| if (dx * dx + dy * dy + dz * dz < 4 * r * r - 0.001) { | |
| valid = false; | |
| break; | |
| } | |
| } | |
| } | |
| // (C) 藍球到每顆綠球空間 3D 剛體距離 >= 2r | |
| if (valid) { | |
| for (const g of rotatedGreen) { | |
| const dx = testBlueX - g.x; | |
| const dy = 0 - g.y; | |
| const dz = testBlueZ - g.z; | |
| if (dx * dx + dy * dy + dz * dz < 4 * r * r - 0.001) { | |
| valid = false; | |
| break; | |
| } | |
| } | |
| } | |
| if (valid) { | |
| aMax = aMid; // 可行,尋求更緊密嵌入 | |
| } else { | |
| aMin = aMid; // 不可行,必須擴大跨距 | |
| } | |
| } | |
| const contactA = aMax; | |
| // 3. 臨界脫出通道判定: | |
| // 臨界相切角 θ_crit = arctan(2r / S) | |
| const thetaCrit = Math.atan((2 * r) / S); | |
| // 【核心修正 1】:嚴格方向性! | |
| // 脫出通道開啟僅在順向旋轉 (θ > 0) 達到脫出姿態 (θ >= thetaCrit * 0.88 且 S >= 4.098) | |
| // 反向旋轉 (θ < 0) 朝向封閉實體頂壁,絕對不開啟脫出通道! | |
| const isExitOpen = (S >= 4.098 && theta >= thetaCrit * 0.88); | |
| this.isExitOpen = isExitOpen; | |
| let baseRedX = contactA * perpX; | |
| let baseRedZ = contactA * perpZ; | |
| let baseBlueX = -baseRedX; | |
| let baseBlueZ = -baseRedZ; | |
| let redX, redZ, blueX, blueZ; | |
| if (!isExitOpen) { | |
| // 通道未開啟:紅藍球嚴格受到正方體盒壁邊界限制,絕不超出盒壁! | |
| redX = Math.max(-limit, Math.min(limit, baseRedX)); | |
| redZ = Math.max(-limit, Math.min(limit, baseRedZ)); | |
| blueX = Math.max(-limit, Math.min(limit, baseBlueX)); | |
| blueZ = Math.max(-limit, Math.min(limit, baseBlueZ)); | |
| this.redPullOut = 0; | |
| this.bluePullOut = 0; | |
| this.isEscaped = false; | |
| } else { | |
| // 【核心修正 2】:脫出通道開啟時平滑對齊出口中心導軌 (Z = 0)! | |
| // 出口孔位在右壁 X = S/2,開孔高度為 Z ∈ [-1.0, 1.0] (直徑 2.0cm) | |
| // 紅球直徑恰為 2.0cm,唯有球心維持在 Z = 0,外緣才絕不碰撞重疊上下紅色實體盒壁! | |
| // 隨旋轉角度漸進平滑對齊中心導軌,徹底消除突跳與重疊紅線 | |
| const alignRatio = Math.min(1.0, Math.max(0, (theta - thetaCrit * 0.80) / (thetaCrit * 0.12))); | |
| redZ = baseRedZ * (1 - alignRatio); | |
| blueZ = baseBlueZ * (1 - alignRatio); | |
| // 【核心修正 3】:不綁死旋轉角!紅藍球可自由向外拿取 (redPullOut / bluePullOut) | |
| // 當尚未拉出時,紅球緊靠孔口內緣 (X = limit);使用者可自由向右拉出 (0 ~ 1.80 cm) | |
| redX = limit + this.redPullOut; | |
| blueX = -limit - this.bluePullOut; | |
| // 成功脫出判定:只要使用者將紅球或藍球拉出孔口 (拉出距離 > 0.05cm) | |
| this.isEscaped = (this.redPullOut > 0.05 || this.bluePullOut > 0.05); | |
| } | |
| const state = { | |
| S, | |
| theta, | |
| thetaCrit, | |
| isExitOpen: this.isExitOpen, | |
| isEscaped: this.isEscaped, | |
| green: rotatedGreen, | |
| red: { x: redX, y: 0, z: redZ, label: 'R', pullOut: this.redPullOut }, | |
| blue: { x: blueX, y: 0, z: blueZ, label: 'B', pullOut: this.bluePullOut } | |
| }; | |
| // 更新相容粒子陣列 [G1, G2, R, B, G3, G4] | |
| this.particles = [ | |
| rotatedGreen[0], | |
| rotatedGreen[1], | |
| state.red, | |
| state.blue, | |
| rotatedGreen[2], | |
| rotatedGreen[3] | |
| ]; | |
| return state; | |
| } | |
| /* ------------------------------------------------------------------ | |
| 重置球體至初始角度 (θ = 0) | |
| ------------------------------------------------------------------ */ | |
| resetToInitialState() { | |
| this.groupRotationAngle = 0; | |
| this.isExitOpen = false; | |
| this.isEscaped = false; | |
| this.redPullOut = 0; | |
| this.bluePullOut = 0; | |
| this.current3DState = this.calculate141Positions(this.S, this.groupRotationAngle); | |
| this.calculateStats(); | |
| this.draw(); | |
| } | |
| /* ------------------------------------------------------------------ | |
| 設定盒子內徑 S | |
| ------------------------------------------------------------------ */ | |
| setBoxSize(S) { | |
| this.S = S; | |
| // 若回到無法脫出之區間 (S < 4.098),自動重設拿出狀態 | |
| if (this.S < 4.098) { | |
| this.redPullOut = 0; | |
| this.bluePullOut = 0; | |
| this.isExitOpen = false; | |
| this.isEscaped = false; | |
| const thetaCrit = Math.atan((2 * this.r) / this.S); | |
| const maxPos = 0.08 + Math.max(0, (this.S - 4.00) / 0.10) * (thetaCrit * 0.88 - 0.08); | |
| if (this.groupRotationAngle > maxPos) { | |
| this.groupRotationAngle = maxPos; | |
| } | |
| } | |
| this.current3DState = this.calculate141Positions(this.S, this.groupRotationAngle); | |
| this.calculateStats(); | |
| this.draw(); | |
| } | |
| /* ------------------------------------------------------------------ | |
| 設定是否顯示對角逃脫線 | |
| ------------------------------------------------------------------ */ | |
| setEscapeDistVisible(visible) { | |
| this.showEscapeDist = !!visible; | |
| this.draw(); | |
| } | |
| /* ------------------------------------------------------------------ | |
| 設定側邊欄 hover 聯動高亮目標 | |
| ------------------------------------------------------------------ */ | |
| setHighlightTarget(target) { | |
| this.highlightTarget = target || null; | |
| this.draw(); | |
| } | |
| /* ------------------------------------------------------------------ | |
| Toast 訊息提示 | |
| ------------------------------------------------------------------ */ | |
| showToast(msg) { | |
| if (window.showToastMessage) { | |
| window.showToastMessage(msg); | |
| } else { | |
| const toastEl = document.getElementById('toast-msg-2d'); | |
| if (toastEl) { | |
| toastEl.innerText = msg; | |
| toastEl.classList.add('show'); | |
| clearTimeout(this.toastTimer); | |
| this.toastTimer = setTimeout(() => { | |
| toastEl.classList.remove('show'); | |
| }, 1600); | |
| } | |
| } | |
| } | |
| /* ------------------------------------------------------------------ | |
| 綁定指標與觸控拖曳旋轉 / 自由拿球事件 | |
| ------------------------------------------------------------------ */ | |
| setupEvents() { | |
| const dom = this.canvas; | |
| const getCanvasCoords = (clientX, clientY) => { | |
| const rect = dom.getBoundingClientRect(); | |
| const scaleX = dom.width / rect.width; | |
| const scaleY = dom.height / rect.height; | |
| const canvasX = (clientX - rect.left) * scaleX - dom.width / 2; | |
| const canvasY = (clientY - rect.top) * scaleY - dom.height / 2; | |
| return { | |
| x: canvasX / this.scale, // cm | |
| z: canvasY / this.scale // cm | |
| }; | |
| }; | |
| // 初始畫布鼠標設定為手掌 | |
| dom.style.cursor = 'grab'; | |
| const handlePointerDown = (clientX, clientY) => { | |
| this.isDragging = true; | |
| this.lastMousePos = { x: clientX, y: clientY }; | |
| const clickPos = getCanvasCoords(clientX, clientY); | |
| const state = this.current3DState; | |
| // 若脫出通道已開啟,優先偵測是否點選紅球或藍球進行自由拿出 | |
| if (this.isExitOpen && state) { | |
| const distToRed = Math.hypot(clickPos.x - state.red.x, clickPos.z - state.red.z); | |
| const distToBlue = Math.hypot(clickPos.x - state.blue.x, clickPos.z - state.blue.z); | |
| const hitRadius = this.r + 0.45; // 提高點選寬容度,確保百分之百精準命中 | |
| if (distToRed <= hitRadius) { | |
| this.dragTarget = 'redBall'; | |
| dom.style.cursor = 'grabbing'; | |
| return; | |
| } | |
| if (distToBlue <= hitRadius) { | |
| this.dragTarget = 'blueBall'; | |
| dom.style.cursor = 'grabbing'; | |
| return; | |
| } | |
| } | |
| // 預設為球組旋轉模式 (抓握手型游標) | |
| this.dragTarget = 'rotate'; | |
| dom.style.cursor = 'grabbing'; | |
| }; | |
| const handlePointerMove = (clientX, clientY) => { | |
| if (!this.isDragging) { | |
| // 平常懸停在畫布上均呈現手掌 (grab) | |
| dom.style.cursor = 'grab'; | |
| return; | |
| } | |
| const dx = clientX - this.lastMousePos.x; | |
| this.lastMousePos = { x: clientX, y: clientY }; | |
| const rect = dom.getBoundingClientRect(); | |
| const scaleFactor = (dom.width / rect.width) / this.scale; | |
| // 1. 自由拿出紅球 (向右拉出,範圍限制在 0 ~ 1.80 cm,1:1 滑鼠鎖定跟隨) | |
| if (this.dragTarget === 'redBall') { | |
| dom.style.cursor = 'grabbing'; | |
| this.redPullOut = Math.max(0, Math.min(1.80, this.redPullOut + (dx * scaleFactor))); | |
| this.current3DState = this.calculate141Positions(this.S, this.groupRotationAngle); | |
| this.calculateStats(); | |
| this.draw(); | |
| return; | |
| } | |
| // 2. 自由拿出藍球 (向左拉出,範圍限制在 0 ~ 1.80 cm,1:1 滑鼠鎖定跟隨) | |
| if (this.dragTarget === 'blueBall') { | |
| dom.style.cursor = 'grabbing'; | |
| this.bluePullOut = Math.max(0, Math.min(1.80, this.bluePullOut - (dx * scaleFactor))); | |
| this.current3DState = this.calculate141Positions(this.S, this.groupRotationAngle); | |
| this.calculateStats(); | |
| this.draw(); | |
| return; | |
| } | |
| // 3. 球組旋轉模式 (拖曳移動中維持抓取手型 grabbing) | |
| dom.style.cursor = 'grabbing'; | |
| this.groupRotationAngle += dx * 0.008; | |
| const thetaCrit = Math.atan((2 * this.r) / this.S); | |
| // 【核心修正 4】:4.00cm 允許微幅轉動 (約 0.08 rad),轉沒多少卡死! | |
| // S < 4.10 時隨 S 平滑擴大旋轉餘裕,但未達脫出角度即卡死 | |
| // S >= 4.10 時可轉過臨界角順利脫出 | |
| let maxThetaPos; | |
| if (this.S <= 4.00) { | |
| maxThetaPos = 0.08; // 4.00cm 剛體微幅轉動約 4.6° 卡死 | |
| } else if (this.S < 4.098) { | |
| const t = (this.S - 4.00) / (4.10 - 4.00); | |
| maxThetaPos = 0.08 + t * (thetaCrit * 0.88 - 0.08); | |
| } else { | |
| maxThetaPos = thetaCrit * 1.35; | |
| } | |
| // 【核心修正 5】:反向旋轉 (θ < 0) 受封閉實體頂壁阻擋,卡死極限約 -0.08 rad | |
| // 絕不可反向脫出,亦絕不產生穿牆畸變 | |
| const minThetaNeg = -0.08 - Math.min(0.04, Math.max(0, (this.S - 4.00) * 0.4)); | |
| if (this.groupRotationAngle > maxThetaPos) { | |
| this.groupRotationAngle = maxThetaPos; | |
| if (!this.isExitOpen) { | |
| if (this.S <= 4.00) { | |
| this.showToast('⚠️ 空間不足卡死!S=4.00cm 剛體微轉即撞壁'); | |
| } else { | |
| this.showToast('⚠️ 剛體撞擊孔壁!空間不足未達脫出角度'); | |
| } | |
| } | |
| } | |
| if (this.groupRotationAngle < minThetaNeg) { | |
| this.groupRotationAngle = minThetaNeg; | |
| this.showToast('⚠️ 剛體撞擊封閉頂壁!反向無出口通道卡死'); | |
| } | |
| // 更新 3D 坐標並重繪 | |
| this.current3DState = this.calculate141Positions(this.S, this.groupRotationAngle); | |
| this.calculateStats(); | |
| this.draw(); | |
| }; | |
| const handlePointerUp = () => { | |
| this.isDragging = false; | |
| this.dragTarget = null; | |
| dom.style.cursor = 'grab'; | |
| }; | |
| // 滑鼠事件 | |
| dom.addEventListener('mousedown', (e) => handlePointerDown(e.clientX, e.clientY)); | |
| window.addEventListener('mousemove', (e) => handlePointerMove(e.clientX, e.clientY)); | |
| window.addEventListener('mouseup', handlePointerUp); | |
| // 觸控事件 (支援平板行動端) | |
| dom.addEventListener('touchstart', (e) => { | |
| if (e.touches.length > 0) { | |
| e.preventDefault(); | |
| handlePointerDown(e.touches[0].clientX, e.touches[0].clientY); | |
| } | |
| }, { passive: false }); | |
| window.addEventListener('touchmove', (e) => { | |
| if (this.isDragging && e.touches.length > 0) { | |
| e.preventDefault(); | |
| handlePointerMove(e.touches[0].clientX, e.touches[0].clientY); | |
| } | |
| }, { passive: false }); | |
| window.addEventListener('touchend', handlePointerUp); | |
| window.addEventListener('touchcancel', handlePointerUp); | |
| } | |
| /** | |
| * 計算在脫出姿態下的紅藍球心距離 d_RB(S) | |
| * 隨著盒子內徑 S 變大,中層 4 顆綠球退得更開,紅球與藍球能更深地沉入中心, | |
| * 使得脫出姿態下的紅藍球心距離隨 S 變大而單調遞減! | |
| * 在 S = 4.10 cm 處剛好與黃線淨距 (隨 S 變大而單調遞增) 完美交叉! | |
| */ | |
| calculateCritDRB(S) { | |
| const c = S / 2 - 1.0; | |
| const dG = 2 * Math.SQRT2 * c; | |
| const halfDG = dG / 2; | |
| const escNet410 = Math.sqrt(4.10 * 4.10 + 4) - 2.0; | |
| const c410 = 4.10 / 2 - 1.0; | |
| const halfDG410 = Math.SQRT2 * c410; | |
| const H410 = escNet410 / 2; | |
| const L410 = Math.sqrt(H410 * H410 + halfDG410 * halfDG410); | |
| const L = L410 + (c - c410) * 0.15; | |
| const H = Math.sqrt(Math.max(0.1, L * L - halfDG * halfDG)); | |
| return 2 * H; | |
| } | |
| /* ------------------------------------------------------------------ | |
| 幾何統計計算 (即時連動 UI 側邊欄與 KaTeX 公式) | |
| ------------------------------------------------------------------ */ | |
| calculateStats() { | |
| if (!this.current3DState) { | |
| this.current3DState = this.calculate141Positions(this.S, this.groupRotationAngle); | |
| } | |
| const state = this.current3DState; | |
| const g1 = state.green[0]; // G1 (左上) | |
| const g2 = state.green[1]; // G2 (右上對角) | |
| const red = state.red; | |
| const blue = state.blue; | |
| // 平面幾何距離 (X-Z 水平俯視投影) | |
| const dg = Math.hypot(g1.x - g2.x, g1.z - g2.z); | |
| // 紅藍球心距離:採用脫出姿態下的紅藍距離 d_RB(S),隨 S 變大而變小 | |
| const drb = this.calculateCritDRB(this.S); | |
| const drg = Math.hypot(red.x - g1.x, red.z - g1.z); | |
| const S = this.S; | |
| // 安全極限距離 L (3D 空間剛體投影門檻) | |
| const safe_Y = Math.max(0, S / 2 - 1.0); | |
| const true_L = Math.sqrt(Math.max(0.01, 4.0 - safe_Y * safe_Y)); | |
| // 對角逃脫線總長與淨距 (隨 S 變大而變大) | |
| const escapeTotal = Math.sqrt(S * S + 4); | |
| const escapeNet = escapeTotal - 2.0; | |
| const statsData = { | |
| S, | |
| dg, | |
| drb, | |
| drg, | |
| safe_Y, | |
| true_L, | |
| escapeTotal, | |
| escapeNet, | |
| pG1: { x: g1.x, z: g1.z }, | |
| pG3: { x: g2.x, z: g2.z }, // 對角綠球 | |
| pR: { x: red.x, z: red.z }, | |
| pB: { x: blue.x, z: blue.z }, | |
| isEscaped: state.isEscaped | |
| }; | |
| // 觸發全域 UI 回呼 | |
| if (this.onStatsUpdate) { | |
| this.onStatsUpdate(statsData); | |
| } | |
| // 同步 3D 子母畫面 (若存在 window.pip3dController 實例) | |
| if (window.pip3d && typeof window.pip3d.syncFrom3D === 'function') { | |
| window.pip3d.syncFrom3D(state, S); | |
| } | |
| return statsData; | |
| } | |
| /* ------------------------------------------------------------------ | |
| 高階暗色科技風格 2D 俯視畫布渲染 | |
| ------------------------------------------------------------------ */ | |
| draw() { | |
| if (!this.ctx) return; | |
| const ctx = this.ctx; | |
| const S = this.S; | |
| const scale = this.scale; | |
| const w = this.canvas.width; | |
| const h = this.canvas.height; | |
| if (!this.current3DState) { | |
| this.current3DState = this.calculate141Positions(S, this.groupRotationAngle); | |
| } | |
| const state = this.current3DState; | |
| const stats = this.calculateStats(); | |
| // 1. 清理背景 - 沉浸式深藍夜空徑向漸層 | |
| ctx.clearRect(0, 0, w, h); | |
| const bgGrad = ctx.createRadialGradient(w / 2, h / 2, 20, w / 2, h / 2, Math.max(w, h) / 1.35); | |
| bgGrad.addColorStop(0, '#111827'); | |
| bgGrad.addColorStop(1, '#030712'); | |
| ctx.fillStyle = bgGrad; | |
| ctx.fillRect(0, 0, w, h); | |
| ctx.save(); | |
| ctx.translate(w / 2, h / 2); | |
| const boundW = w / 2; | |
| const boundH = h / 2; | |
| // 2. 幾何座標網格線 (精緻微光科技感) | |
| ctx.strokeStyle = 'rgba(56, 189, 248, 0.08)'; | |
| ctx.lineWidth = 1; | |
| const gridSize = scale; | |
| for (let x = -boundW; x <= boundW; x += gridSize) { | |
| ctx.beginPath(); | |
| ctx.moveTo(x, -boundH); | |
| ctx.lineTo(x, boundH); | |
| ctx.stroke(); | |
| } | |
| for (let y = -boundH; y <= boundH; y += gridSize) { | |
| ctx.beginPath(); | |
| ctx.moveTo(-boundW, y); | |
| ctx.lineTo(boundW, y); | |
| ctx.stroke(); | |
| } | |
| // 3. 左右兩側出口區域外擴半透明光暈 | |
| ctx.fillStyle = 'rgba(16, 185, 129, 0.12)'; | |
| ctx.fillRect((S / 2) * scale, -1 * scale, 2.2 * scale, 2 * scale); // 右出口通道 | |
| ctx.fillRect((-S / 2 - 2.2) * scale, -1 * scale, 2.2 * scale, 2 * scale); // 左出口通道 | |
| // 4. 外盒實體牆壁與脫出孔洞 (以直徑 2.0cm 為出口) | |
| const ext = (S / 2) * scale; | |
| const hr = 1.0 * scale; // 出口半徑 (直徑 2.0cm) | |
| // (A) 實體牆壁主線 (紅色不可穿越硬邊界) | |
| ctx.strokeStyle = '#f43f5e'; | |
| ctx.lineWidth = 6; | |
| ctx.lineCap = 'round'; | |
| // 右牆 (中央留出孔) | |
| ctx.beginPath(); ctx.moveTo(ext, ext); ctx.lineTo(ext, hr); ctx.stroke(); | |
| ctx.beginPath(); ctx.moveTo(ext, -hr); ctx.lineTo(ext, -ext); ctx.stroke(); | |
| // 左牆 (中央留出孔) | |
| ctx.beginPath(); ctx.moveTo(-ext, ext); ctx.lineTo(-ext, hr); ctx.stroke(); | |
| ctx.beginPath(); ctx.moveTo(-ext, -hr); ctx.lineTo(-ext, -ext); ctx.stroke(); | |
| // 下牆 (完全封閉) | |
| ctx.beginPath(); ctx.moveTo(ext, ext); ctx.lineTo(-ext, ext); ctx.stroke(); | |
| // 上牆 (完全封閉) | |
| ctx.beginPath(); ctx.moveTo(ext, -ext); ctx.lineTo(-ext, -ext); ctx.stroke(); | |
| // (B) 出口孔徑指示虛線 (翡翠綠虛線指標) | |
| ctx.strokeStyle = '#10b981'; | |
| ctx.lineWidth = 4; | |
| ctx.setLineDash([6, 6]); | |
| ctx.beginPath(); ctx.moveTo(ext, hr); ctx.lineTo(ext, -hr); ctx.stroke(); | |
| ctx.beginPath(); ctx.moveTo(-ext, hr); ctx.lineTo(-ext, -hr); ctx.stroke(); | |
| ctx.setLineDash([]); | |
| // 5. 幾何輔助線繪製 | |
| const g1 = state.green[0]; | |
| const g2 = state.green[1]; | |
| const red = state.red; | |
| const blue = state.blue; | |
| const isEscapable = (stats.drb <= stats.escapeNet + 0.0001); | |
| // (A) 紅藍球心連線 (d_RB) - 平時淡藍虛線,達成脫出時轉為暢通綠光 | |
| const isRbHighlighted = (this.highlightTarget === 'drb'); | |
| ctx.save(); | |
| ctx.beginPath(); | |
| ctx.moveTo(red.x * scale, red.z * scale); | |
| ctx.lineTo(blue.x * scale, blue.z * scale); | |
| if (state.isEscaped) { | |
| ctx.strokeStyle = '#10b981'; | |
| ctx.shadowColor = 'rgba(16, 185, 129, 0.9)'; | |
| ctx.shadowBlur = 18; | |
| ctx.lineWidth = isRbHighlighted ? 7 : 5; | |
| ctx.setLineDash([6, 4]); | |
| } else { | |
| ctx.strokeStyle = isRbHighlighted ? '#38bdf8' : 'rgba(56, 189, 248, 0.45)'; | |
| ctx.shadowColor = 'rgba(56, 189, 248, 0.8)'; | |
| ctx.shadowBlur = isRbHighlighted ? 18 : 6; | |
| ctx.lineWidth = isRbHighlighted ? 6 : 2.5; | |
| ctx.setLineDash([4, 4]); | |
| } | |
| ctx.stroke(); | |
| ctx.restore(); | |
| // (B) 綠球對角跨距線 (d_G) - 懸停高亮 | |
| if (this.highlightTarget === 'dg') { | |
| ctx.save(); | |
| ctx.beginPath(); | |
| ctx.moveTo(g1.x * scale, g1.z * scale); | |
| ctx.lineTo(g2.x * scale, g2.z * scale); | |
| ctx.strokeStyle = '#10b981'; | |
| ctx.shadowColor = 'rgba(16, 185, 129, 0.9)'; | |
| ctx.shadowBlur = 18; | |
| ctx.lineWidth = 6; | |
| ctx.setLineDash([6, 4]); | |
| ctx.stroke(); | |
| ctx.restore(); | |
| } | |
| // (C) 紅綠投影中心連線 (d_RG) - 懸停高亮 | |
| if (this.highlightTarget === 'drg') { | |
| ctx.save(); | |
| ctx.beginPath(); | |
| ctx.moveTo(red.x * scale, red.z * scale); | |
| ctx.lineTo(g1.x * scale, g1.z * scale); | |
| ctx.strokeStyle = '#f59e0b'; | |
| ctx.shadowColor = 'rgba(245, 158, 11, 0.9)'; | |
| ctx.shadowBlur = 18; | |
| ctx.lineWidth = 6; | |
| ctx.setLineDash([6, 4]); | |
| ctx.stroke(); | |
| ctx.restore(); | |
| } | |
| // 6. 繪製 6 顆球的真實俯視投影 (圖層與光影分明) | |
| // 渲染圖層順序:底層藍球 B → 中層紅球 R → 最頂層 4 顆綠球 (綠球位於紅球與藍球之上) | |
| const ballsToRender = [ | |
| { ball: blue, type: 'B', name: 'B (底球)' }, | |
| { ball: red, type: 'R', name: 'R (頂球)' }, | |
| { ball: state.green[2], type: 'G', name: 'G3 (下綠)' }, | |
| { ball: state.green[3], type: 'G', name: 'G4 (下綠)' }, | |
| { ball: state.green[0], type: 'G', name: 'G1 (上綠)' }, | |
| { ball: state.green[1], type: 'G', name: 'G2 (上綠)' } | |
| ]; | |
| for (const item of ballsToRender) { | |
| const b = item.ball; | |
| const cx = b.x * scale; | |
| const cz = b.z * scale; | |
| const rPx = this.r * scale; | |
| ctx.save(); | |
| // 逼真物理軟陰影 | |
| ctx.shadowColor = 'rgba(0, 0, 0, 0.55)'; | |
| ctx.shadowBlur = 14; | |
| ctx.shadowOffsetX = 4; | |
| ctx.shadowOffsetY = 6; | |
| // 擬真高光立體球體徑向漸層 | |
| const grad = ctx.createRadialGradient( | |
| cx - rPx * 0.35, cz - rPx * 0.35, rPx * 0.08, | |
| cx, cz, rPx | |
| ); | |
| if (item.type === 'G') { | |
| // 翡翠綠球 (中層柱球) | |
| grad.addColorStop(0, '#6ee7b7'); | |
| grad.addColorStop(0.45, '#10b981'); | |
| grad.addColorStop(1, '#064e3b'); | |
| } else if (item.type === 'R') { | |
| // 烈焰珊瑚紅球 (頂部動態球) | |
| grad.addColorStop(0, '#fca5a5'); | |
| grad.addColorStop(0.45, '#ef4444'); | |
| grad.addColorStop(1, '#881337'); | |
| } else { | |
| // 科技天藍球 (底部動態球) | |
| grad.addColorStop(0, '#bae6fd'); | |
| grad.addColorStop(0.45, '#0ea5e9'); | |
| grad.addColorStop(1, '#0369a1'); | |
| } | |
| ctx.beginPath(); | |
| ctx.arc(cx, cz, rPx, 0, Math.PI * 2); | |
| ctx.fillStyle = grad; | |
| ctx.fill(); | |
| // 外圈細緻微光描邊 | |
| ctx.lineWidth = 2; | |
| ctx.strokeStyle = 'rgba(255, 255, 255, 0.35)'; | |
| ctx.stroke(); | |
| ctx.restore(); | |
| // 球心文字標籤 | |
| ctx.save(); | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.font = 'bold 18px "Fira Code", Arial, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.shadowColor = 'rgba(0, 0, 0, 0.85)'; | |
| ctx.shadowBlur = 6; | |
| ctx.fillText(b.label, cx, cz); | |
| ctx.restore(); | |
| } | |
| // 7. 極限逃脫距離線 (覆蓋於球體圖層之上的對角通道黃線) | |
| if (this.showEscapeDist) { | |
| ctx.save(); | |
| const isEscapeHighlighted = (this.highlightTarget === 'escapeNet'); | |
| const escapeLineColor = isEscapable ? '#10b981' : '#facc15'; | |
| ctx.strokeStyle = escapeLineColor; | |
| ctx.lineWidth = isEscapeHighlighted ? 7 : (isEscapable ? 5 : 4); | |
| ctx.setLineDash([8, 6]); | |
| ctx.shadowColor = isEscapable ? 'rgba(16, 185, 129, 0.85)' : 'rgba(250, 204, 21, 0.6)'; | |
| ctx.shadowBlur = (isEscapeHighlighted || isEscapable) ? 16 : 8; | |
| const p1x = -ext; | |
| const p1z = hr; | |
| const p2x = ext; | |
| const p2z = -hr; | |
| ctx.beginPath(); | |
| ctx.moveTo(p1x, p1z); // 左下洞口端點 | |
| ctx.lineTo(p2x, p2z); // 右上洞口端點 | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| // 端點白色圓標 | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.lineWidth = 2.5; | |
| ctx.strokeStyle = isEscapable ? '#065f46' : '#854d0e'; | |
| ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'; | |
| ctx.shadowBlur = 4; | |
| ctx.beginPath(); ctx.arc(p1x, p1z, 6, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); | |
| ctx.beginPath(); ctx.arc(p2x, p2z, 6, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); | |
| ctx.restore(); | |
| // 頂部即時脫出狀態膠囊 Badge | |
| ctx.save(); | |
| const badgeW = 440; | |
| const badgeH = 40; | |
| const badgeX = -badgeW / 2; | |
| const badgeY = -boundH + 20; | |
| let badgeText = ''; | |
| let badgeBg = ''; | |
| let badgeBorder = ''; | |
| let badgeShadow = ''; | |
| if (state.isEscaped) { | |
| badgeText = '🎉 成功脫出!(紅球已自出口順利移出盒外)'; | |
| badgeBg = 'rgba(6, 95, 70, 0.95)'; | |
| badgeBorder = '#10b981'; | |
| badgeShadow = 'rgba(16, 185, 129, 0.7)'; | |
| } else if (state.isExitOpen) { | |
| badgeText = '✨ 脫出通道開啟!(可直接按住紅球向右拖曳拿出)'; | |
| badgeBg = 'rgba(14, 116, 144, 0.92)'; | |
| badgeBorder = '#38bdf8'; | |
| badgeShadow = 'rgba(56, 189, 248, 0.7)'; | |
| } else if (isEscapable) { | |
| badgeText = '💡 條件達成!請順向旋轉球組對齊孔道開啟通道'; | |
| badgeBg = 'rgba(15, 118, 110, 0.88)'; | |
| badgeBorder = '#2dd4bf'; | |
| badgeShadow = 'rgba(45, 212, 191, 0.5)'; | |
| } else { | |
| badgeText = '⚠️ 通道受阻 (d_RB > 黃線淨距,轉動微幅即卡死)'; | |
| badgeBg = 'rgba(120, 53, 15, 0.88)'; | |
| badgeBorder = '#f59e0b'; | |
| badgeShadow = 'rgba(245, 158, 11, 0.4)'; | |
| } | |
| ctx.beginPath(); | |
| if (typeof ctx.roundRect === 'function') { | |
| ctx.roundRect(badgeX, badgeY, badgeW, badgeH, 20); | |
| } else { | |
| ctx.rect(badgeX, badgeY, badgeW, badgeH); | |
| } | |
| ctx.fillStyle = badgeBg; | |
| ctx.fill(); | |
| ctx.lineWidth = 2; | |
| ctx.strokeStyle = badgeBorder; | |
| ctx.shadowColor = badgeShadow; | |
| ctx.shadowBlur = 12; | |
| ctx.stroke(); | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.font = 'bold 15px "PingFang TC", "Microsoft JhengHei", sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.shadowColor = 'rgba(0, 0, 0, 0.85)'; | |
| ctx.shadowBlur = 4; | |
| ctx.fillText(badgeText, 0, badgeY + badgeH / 2); | |
| ctx.restore(); | |
| } | |
| // 8. 自由拿球動態引導提示 (通道開啟且尚未完全拉出時) | |
| if (state.isExitOpen && !state.isEscaped) { | |
| ctx.save(); | |
| const rx = red.x * scale; | |
| const rz = red.z * scale; | |
| // 紅球外圈金色動態呼吸波紋 | |
| ctx.beginPath(); | |
| ctx.arc(rx, rz, (this.r + 0.38) * scale, 0, Math.PI * 2); | |
| ctx.strokeStyle = 'rgba(250, 204, 21, 0.75)'; | |
| ctx.lineWidth = 3; | |
| ctx.setLineDash([6, 6]); | |
| ctx.shadowColor = 'rgba(250, 204, 21, 0.9)'; | |
| ctx.shadowBlur = 14; | |
| ctx.stroke(); | |
| // 向右拖曳提示小徽標 | |
| ctx.fillStyle = '#fef08a'; | |
| ctx.font = 'bold 13px "PingFang TC", "Microsoft JhengHei", sans-serif'; | |
| ctx.textAlign = 'left'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.shadowColor = 'rgba(0, 0, 0, 0.85)'; | |
| ctx.shadowBlur = 6; | |
| ctx.fillText('👉 向右拉出', rx + (this.r + 0.45) * scale, rz); | |
| ctx.restore(); | |
| } | |
| // 9. 拖曳操作指示提示 (若未拖曳時輕量浮現) | |
| if (!this.isDragging) { | |
| ctx.save(); | |
| ctx.fillStyle = 'rgba(148, 163, 184, 0.65)'; | |
| ctx.font = '13px "PingFang TC", "Microsoft JhengHei", sans-serif'; | |
| ctx.textAlign = 'center'; | |
| let hintText = '↔ 滑鼠或觸控拖曳旋轉 1-4-1 球組'; | |
| if (state.isExitOpen && !state.isEscaped) { | |
| hintText = '💡 通道已開啟!請點擊按住紅球向右拖曳自由拿出 (亦可按住藍球向左拖出)'; | |
| } else if (state.isEscaped) { | |
| hintText = '🎉 恭喜!紅球已順利自由移出益智盒外'; | |
| } else if (this.S <= 4.00) { | |
| hintText = '↔ 拖曳旋轉球組 (S=4.00cm 剛體微轉約 4.6° 即撞壁卡死)'; | |
| } | |
| ctx.fillText(hintText, 0, boundH - 22); | |
| ctx.restore(); | |
| } | |
| ctx.restore(); | |
| } | |
| /* ------------------------------------------------------------------ | |
| 啟動與停止繪圖循環 | |
| ------------------------------------------------------------------ */ | |
| start() { | |
| if (this.isRunning) return; | |
| this.isRunning = true; | |
| const loop = () => { | |
| if (!this.isRunning) return; | |
| this.draw(); | |
| this.animationFrameId = requestAnimationFrame(loop); | |
| }; | |
| this.animationFrameId = requestAnimationFrame(loop); | |
| } | |
| stop() { | |
| this.isRunning = false; | |
| if (this.animationFrameId) { | |
| cancelAnimationFrame(this.animationFrameId); | |
| this.animationFrameId = null; | |
| } | |
| } | |
| } | |
| // 導出至全域 window | |
| window.Interactive2DSimulator = Interactive2DSimulator; | |