/* ========================================================================== 倫敦塔益智盒 - 二維俯視幾何模擬器 (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;