TowerofLondon / js /interactive2d.js
Lashtw's picture
Upload 2 files
50f7125 verified
Raw History Blame Contribute Delete
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;