TowerofLondon / js /param22 /interactive2d.js
Lashtw's picture
Upload 11 files
557638f verified
Raw History Blame Contribute Delete
25.2 kB
/* ==========================================================================
倫敦塔益智盒 - 二維俯視幾何投影模擬器 (Interactive 2D Simulator)
支援剛性碰撞約束物理、滑鼠/觸控拖曳、幾何動態運算與 KaTeX 公式連動
========================================================================== */
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; // 繪圖縮放基準 (px/cm)
this.r = 1.10; // 球半徑 1.10cm (直徑 2.20cm)
this.holeR = 1.10; // 出口孔半徑 1.10cm (直徑 2.20cm)
this.S = options.initialS || 4.40; // 盒子內徑預設 4.40cm (死鎖初始)
this.showEscapeDist = false; // 是否顯示對角逃脫線
// 四顆關鍵粒子 (投影在 X-Z 水平俯視平面)
// G1, G3: 綠球 (柱球); R: 紅球 (頂/底動態球); B: 藍球 (底/頂動態球)
this.particles = [
{ x: 0, z: 0, color: '#10b981', label: 'G1', baseColor: '#10b981' },
{ x: 0, z: 0, color: '#10b981', label: 'G3', baseColor: '#10b981' },
{ x: 0, z: 0, color: '#ef4444', label: 'R', baseColor: '#ef4444' },
{ x: 0, z: 0, color: '#38bdf8', label: 'B', baseColor: '#38bdf8' }
];
// 互動狀態
this.mouseX = 0;
this.mouseZ = 0;
this.draggedParticle = null;
this.isRunning = false;
this.animationFrameId = null;
// 外部懸停聯動高亮目標 ('dg' | 'drb' | 'drg' | 'escapeNet' | null)
this.highlightTarget = null;
// 回呼函式 (供外部 UI 更新數值與公式)
this.onStatsUpdate = options.onStatsUpdate || null;
// 物理穩定收斂計數器 (避免未操作時數值微幅抖動)
this.settleFrames = 60;
// 初始化
this.setupEvents();
this.resetToInitialState();
}
/**
* 重置球體至對角極限角落初始狀態
*/
resetToInitialState() {
const S = this.S;
const limit = S / 2 - this.r;
const c = Math.min(limit, this.r);
// 綠球 G1, G3 置於對角
this.particles[0].x = c;
this.particles[0].z = c;
this.particles[1].x = -c;
this.particles[1].z = -c;
// 紅藍球 R, B 放置於另一對角線 (3*(2r)^2 = 3 * 4.84 = 14.52)
const safeUnderSqrt = Math.max(0, 14.52 - 8 * c * c);
const v = Math.sqrt(safeUnderSqrt) / 2;
const v_proj = v / Math.SQRT2;
this.particles[2].x = v_proj;
this.particles[2].z = -v_proj;
this.particles[3].x = -v_proj;
this.particles[3].z = v_proj;
// 預熱物理步驟以穩定接觸面
for (let i = 0; i < 60; i++) {
this.stepPhysics(S);
}
this.settleFrames = 15;
}
/**
* 設定盒子內徑 S
*/
setBoxSize(S) {
this.S = S;
this.settleFrames = 20;
this.stepPhysics(this.S);
}
/**
* 設定是否顯示對角逃脫線
*/
setEscapeDistVisible(visible) {
this.showEscapeDist = !!visible;
}
/**
* 設定側邊欄 hover 聯動高亮目標
*/
setHighlightTarget(target) {
this.highlightTarget = target || null;
}
/**
* 綁定指標與觸控事件
*/
setupEvents() {
const getPos = (clientX, clientY) => {
const rect = this.canvas.getBoundingClientRect();
// 計算 Canvas 實際渲染縮放比 (處理 CSS 響應式尺寸)
const scaleX = this.canvas.width / rect.width;
const scaleY = this.canvas.height / rect.height;
const px = (clientX - rect.left) * scaleX;
const py = (clientY - rect.top) * scaleY;
return {
x: (px - this.canvas.width / 2) / this.scale,
z: (py - this.canvas.height / 2) / this.scale
};
};
const handleDown = (clientX, clientY) => {
const pos = getPos(clientX, clientY);
let closest = null;
let minDist = 1.3; // 抓取半徑門檻 (cm)
for (const p of this.particles) {
const d = Math.hypot(pos.x - p.x, pos.z - p.z);
if (d < minDist) {
minDist = d;
closest = p;
}
}
if (closest) {
this.draggedParticle = closest;
this.mouseX = pos.x;
this.mouseZ = pos.z;
this.settleFrames = 30;
}
};
const handleMove = (clientX, clientY) => {
if (this.draggedParticle) {
const pos = getPos(clientX, clientY);
this.mouseX = pos.x;
this.mouseZ = pos.z;
this.settleFrames = 15;
}
};
const handleUp = () => {
if (this.draggedParticle) {
this.draggedParticle = null;
this.settleFrames = 15; // 放開手後收斂15幀以穩定固定
}
};
// 滑鼠事件
this.canvas.addEventListener('mousedown', (e) => {
handleDown(e.clientX, e.clientY);
});
window.addEventListener('mousemove', (e) => {
handleMove(e.clientX, e.clientY);
});
window.addEventListener('mouseup', handleUp);
// 觸控事件 (支援行動平板防誤觸與手勢鎖定)
this.canvas.addEventListener('touchstart', (e) => {
e.preventDefault(); // 嚴格阻擋原生手勢與縮放
if (e.touches.length > 0) {
// 使用主觸控點進行球體拖曳
handleDown(e.touches[0].clientX, e.touches[0].clientY);
}
}, { passive: false });
this.canvas.addEventListener('touchmove', (e) => {
e.preventDefault(); // 防止在畫布上滑動時觸發頁面彈跳
}, { passive: false });
window.addEventListener('touchmove', (e) => {
if (this.draggedParticle && e.touches.length > 0) {
e.preventDefault();
handleMove(e.touches[0].clientX, e.touches[0].clientY);
}
}, { passive: false });
window.addEventListener('touchend', handleUp);
window.addEventListener('touchcancel', handleUp);
}
/**
* 剛性物理求解:限制條件、邊界約束與剛性排斥
*/
stepPhysics(S) {
// 若未拖曳球體且系統已收斂平衡,直接進入靜態休眠,徹底杜絕數值微幅抖動
if (!this.draggedParticle && this.settleFrames <= 0) {
return;
}
if (!this.draggedParticle && this.settleFrames > 0) {
this.settleFrames--;
}
// 1. 滑鼠彈簧牽引力
if (this.draggedParticle) {
const dx = this.mouseX - this.draggedParticle.x;
const dz = this.mouseZ - this.draggedParticle.z;
const distToMouse = Math.hypot(dx, dz);
if (distToMouse > 0.001) {
const move = Math.min(distToMouse * 0.12, 0.06);
this.draggedParticle.x += (dx / distToMouse) * move;
this.draggedParticle.z += (dz / distToMouse) * move;
}
}
// 2. 真實 3D 天花板位移限制
// 綠球被紅藍擠壓時沿 Y 軸往頂壁貼齊,最大位移為 Y = S/2 - r
const max_Y = Math.max(0, S / 2 - this.r);
const safe_Y = Math.min(max_Y, 2.19);
const true_L = Math.sqrt(Math.max(0.01, 4.84 - safe_Y * safe_Y));
const limit = S / 2 - this.r;
const constraints = [
{ i: 0, j: 2, minD: true_L }, // G1-R
{ i: 1, j: 2, minD: true_L }, // G3-R
{ i: 0, j: 3, minD: true_L }, // G1-B
{ i: 1, j: 3, minD: true_L }, // G3-B
{ i: 0, j: 1, minD: 2.20 }, // G1-G3
{ i: 2, j: 3, minD: 2.20 } // R-B 不可穿透
];
// 100 次迭代求解以達到絕對剛性無穿模效果
for (let iter = 0; iter < 100; iter++) {
for (const c of constraints) {
const p1 = this.particles[c.i];
const p2 = this.particles[c.j];
const dx = p2.x - p1.x;
const dz = p2.z - p1.z;
const dist = Math.hypot(dx, dz);
if (dist < c.minD && dist > 0.0001) {
const diff = (dist - c.minD) / dist; // 負值代表穿模擠壓
p1.x += dx * diff * 0.5;
p1.z += dz * diff * 0.5;
p2.x -= dx * diff * 0.5;
p2.z -= dz * diff * 0.5;
}
}
// 四面盒壁與左右兩端圓形開口
const hr = this.holeR; // 1.10
for (const p of this.particles) {
// 若已經徹底逃出盒子,則不限制
if (p.x > S / 2 + 1 || p.x < -S / 2 - 1 || p.z > S / 2 + 1 || p.z < -S / 2 - 1) continue;
let px = p.x;
let pz = p.z;
// 右側邊壁 (含中段孔洞)
if (px > limit && px <= S / 2 + 1) {
if (pz >= hr || pz <= -hr) {
px = limit;
} else {
const d1 = Math.hypot(px - S / 2, pz - hr);
if (d1 < hr && d1 > 0.001) {
px += ((px - S / 2) / d1) * (hr - d1);
pz += ((pz - hr) / d1) * (hr - d1);
}
const d2 = Math.hypot(px - S / 2, pz + hr);
if (d2 < hr && d2 > 0.001) {
px += ((px - S / 2) / d2) * (hr - d2);
pz += ((pz + hr) / d2) * (hr - d2);
}
}
}
// 左側邊壁 (含中段孔洞)
if (px < -limit && px >= -S / 2 - 1) {
if (pz >= hr || pz <= -hr) {
px = -limit;
} else {
const d1 = Math.hypot(px + S / 2, pz - hr);
if (d1 < hr && d1 > 0.001) {
px += ((px + S / 2) / d1) * (hr - d1);
pz += ((pz - hr) / d1) * (hr - d1);
}
const d2 = Math.hypot(px + S / 2, pz + hr);
if (d2 < hr && d2 > 0.001) {
px += ((px + S / 2) / d2) * (hr - d2);
pz += ((pz + hr) / d2) * (hr - d2);
}
}
}
// 下實體牆壁
if (pz > limit) pz = limit;
// 上實體牆壁
if (pz < -limit) pz = -limit;
p.x = px;
p.z = pz;
}
}
}
/**
* 計算幾何數據並觸發 UI 回呼
*/
calculateStats() {
const pG1 = this.particles[0];
const pG3 = this.particles[1];
const pR = this.particles[2];
const pB = this.particles[3];
const dg = Math.hypot(pG1.x - pG3.x, pG1.z - pG3.z);
const drb = Math.hypot(pR.x - pB.x, pR.z - pB.z);
const drg = Math.hypot(pR.x - pG1.x, pR.z - pG1.z);
const S = this.S;
const max_Y = Math.max(0, S / 2 - this.r);
const safe_Y = Math.min(max_Y, 2.19);
const true_L = Math.sqrt(Math.max(0.01, 4.84 - safe_Y * safe_Y));
const escapeTotal = Math.sqrt(S * S + 4.84);
const escapeNet = escapeTotal - 2.20;
const statsData = {
S,
dg,
drb,
drg,
safe_Y,
true_L,
escapeTotal,
escapeNet,
pG1: { x: pG1.x, z: pG1.z },
pG3: { x: pG3.x, z: pG3.z },
pR: { x: pR.x, z: pR.z },
pB: { x: pB.x, z: pB.z }
};
if (this.onStatsUpdate) {
this.onStatsUpdate(statsData);
}
return statsData;
}
/**
* 高階暗色科技風格畫布渲染
*/
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;
this.stepPhysics(S);
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.4);
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);
// 2. 幾何座標網格線 (精緻微光)
ctx.strokeStyle = 'rgba(56, 189, 248, 0.08)';
ctx.lineWidth = 1;
const gridSize = scale;
const boundW = w / 2;
const boundH = h / 2;
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. 左右兩側出口區域半透明光暈
const hr = this.holeR * scale;
ctx.fillStyle = 'rgba(16, 185, 129, 0.10)';
ctx.fillRect((S / 2) * scale, -hr, 2 * scale, 2 * hr); // 右出口
ctx.fillRect((-S / 2 - 2) * scale, -hr, 2 * scale, 2 * hr); // 左出口
// 4. 盒子邊壁 (發光科技外框)
const ext = (S / 2) * scale;
// 壁身主線
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();
// 出孔綠色虛線指標
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([]);
// 6. 滑鼠牽引彈簧虛線
if (this.draggedParticle) {
ctx.save();
ctx.beginPath();
ctx.moveTo(this.draggedParticle.x * scale, this.draggedParticle.z * scale);
ctx.lineTo(this.mouseX * scale, this.mouseZ * scale);
ctx.strokeStyle = '#f59e0b';
ctx.setLineDash([4, 4]);
ctx.lineWidth = 3;
ctx.stroke();
// 指標點
ctx.beginPath();
ctx.arc(this.mouseX * scale, this.mouseZ * scale, 5, 0, Math.PI * 2);
ctx.fillStyle = '#f59e0b';
ctx.fill();
ctx.restore();
}
// 7. 碰撞接觸約束剛性線視覺化
const max_Y_d = Math.max(0, S / 2 - this.r);
const safe_Y_d = Math.min(max_Y_d, 2.19);
const true_L_draw = Math.sqrt(Math.max(0.01, 4.84 - safe_Y_d * safe_Y_d));
const drawConstraints = [
{ i: 0, j: 2, minD: true_L_draw }, { i: 1, j: 2, minD: true_L_draw },
{ i: 0, j: 3, minD: true_L_draw }, { i: 1, j: 3, minD: true_L_draw },
{ i: 0, j: 1, minD: 2.20 }, { i: 2, j: 3, minD: 2.20 }
];
for (const c of drawConstraints) {
const p1 = this.particles[c.i];
const p2 = this.particles[c.j];
const dist = Math.hypot(p2.x - p1.x, p2.z - p1.z);
if (dist < c.minD + 0.08) {
ctx.save();
ctx.beginPath();
ctx.moveTo(p1.x * scale, p1.z * scale);
ctx.lineTo(p2.x * scale, p2.z * scale);
const isDynamic = (c.minD === true_L_draw);
ctx.lineWidth = isDynamic ? 3.5 : 2;
ctx.strokeStyle = isDynamic ? 'rgba(56, 189, 248, 0.9)' : 'rgba(255, 255, 255, 0.4)';
if (!isDynamic) ctx.setLineDash([4, 4]);
ctx.stroke();
ctx.restore();
}
}
// 8. 繪製球體 (帶光影立體質感與光暈)
for (const p of this.particles) {
const cx = p.x * scale;
const cz = p.z * scale;
const rPx = this.r * scale;
ctx.save();
// 陰影
ctx.shadowColor = 'rgba(0, 0, 0, 0.4)';
ctx.shadowBlur = 12;
ctx.shadowOffsetX = 3;
ctx.shadowOffsetY = 5;
// 漸層球體
const grad = ctx.createRadialGradient(
cx - rPx * 0.35, cz - rPx * 0.35, rPx * 0.1,
cx, cz, rPx
);
if (p.label.startsWith('G')) {
// 翡翠綠球
grad.addColorStop(0, '#6ee7b7');
grad.addColorStop(0.5, '#10b981');
grad.addColorStop(1, '#065f46');
} else if (p.label === 'R') {
// 烈焰紅球
grad.addColorStop(0, '#fca5a5');
grad.addColorStop(0.5, '#ef4444');
grad.addColorStop(1, '#991b1b');
} else {
// 科技天藍球
grad.addColorStop(0, '#bae6fd');
grad.addColorStop(0.5, '#0ea5e9');
grad.addColorStop(1, '#075985');
}
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.3)';
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.8)';
ctx.shadowBlur = 6;
ctx.fillText(p.label, cx, cz);
ctx.restore();
}
// 8.5 懸停與動態高亮幾何連線 (G1-G3, R-B, R-G1)
const pG1 = this.particles[0];
const pG3 = this.particles[1];
const pR = this.particles[2];
const pB = this.particles[3];
const isEscapable = (stats.drb <= stats.escapeNet + 0.001);
// (A) 紅藍球心連線 (d_RB) - 平時微光,脫出時轉為暢通綠光,hover 時加粗
const isRbHighlighted = (this.highlightTarget === 'drb');
ctx.save();
ctx.beginPath();
ctx.moveTo(pR.x * scale, pR.z * scale);
ctx.lineTo(pB.x * scale, pB.z * scale);
if (this.showEscapeDist && isEscapable) {
ctx.strokeStyle = '#10b981';
ctx.shadowColor = 'rgba(16, 185, 129, 0.8)';
ctx.shadowBlur = isRbHighlighted ? 20 : 12;
ctx.lineWidth = isRbHighlighted ? 6 : 4;
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(pG1.x * scale, pG1.z * scale);
ctx.lineTo(pG3.x * scale, pG3.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(pR.x * scale, pR.z * scale);
ctx.lineTo(pG1.x * scale, pG1.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();
}
// 9. 極限逃脫距離線 (覆蓋於球體圖層之上的對角通道虛線)
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();
// 10. 頂部即時脫出狀態膠囊 Badge
ctx.save();
const badgeW = 380;
const badgeH = 38;
const badgeX = -badgeW / 2;
const badgeY = -boundH + 20;
// 膠囊圓角背景
ctx.beginPath();
ctx.roundRect(badgeX, badgeY, badgeW, badgeH, 19);
ctx.fillStyle = isEscapable ? 'rgba(6, 95, 70, 0.88)' : 'rgba(120, 53, 15, 0.85)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = isEscapable ? '#10b981' : '#f59e0b';
ctx.shadowColor = isEscapable ? 'rgba(16, 185, 129, 0.6)' : 'rgba(245, 158, 11, 0.4)';
ctx.shadowBlur = 10;
ctx.stroke();
// 膠囊文字
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 16px "PingFang TC", "Microsoft JhengHei", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.shadowColor = 'rgba(0, 0, 0, 0.8)';
ctx.shadowBlur = 4;
const badgeText = isEscapable
? '✨ 脫出通道已開啟!(d_RB ≤ 黃線淨距)'
: '⚠️ 幾何通道受阻 (d_RB > 黃線淨距,卡死)';
ctx.fillText(badgeText, 0, badgeY + badgeH / 2);
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;