Merge pull request #11 from 101island/dev

Merge dev branch into main
This commit is contained in:
2025-12-18 11:39:12 +08:00
committed by GitHub
10 changed files with 1071 additions and 348 deletions

View File

@@ -1,241 +0,0 @@
# 弹珠系统重构指南
## 项目结构
```
src/
├── config/
│ └── marbleConfig.ts # 配置文件:尺寸、速度、物理参数等
├── data/
│ └── users.ts # 数据层:用户数据 API 获取
├── utils/
│ ├── mouseInteraction.ts # 鼠标交互系统
│ ├── marblePhysics.ts # 物理引擎
│ ├── marbleFactory.ts # 弹珠工厂
│ ├── animationLoop.ts # 动画循环管理器
│ ├── marbleSystem.ts # 核心系统管理器
│ └── index.ts # 统一导出
└── components/
└── MainView.astro # 使用封装后的系统
```
## 1. 系统模块化
将原本集中在 `MainView.astro` 中的 ~260 行内联逻辑拆分为多个独立的、可复用的 TypeScript 模块:
### 核心模块
- **`marbleSystem.ts`**: 核心管理器,整合所有子系统,提供统一的 API
- 管理弹珠集合
- 协调各子系统工作
- 提供添加/删除弹珠、启动/停止动画等方法
- 处理窗口大小变化和缩放功能
- **`marbleFactory.ts`**: 弹珠工厂负责弹珠的创建、DOM 生成和异步加载
- 异步加载头像图片
- 创建弹珠 DOM 节点wrapper + anchor + label
- 生成随机初始位置和速度
- 支持批量创建(使用 `Promise.allSettled`
- 响应式计算弹珠大小
- 支持缩放级别调整
- **`marblePhysics.ts`**: 物理引擎,处理弹珠的运动、碰撞和边界检测
- 更新弹珠位置(应用速度、阻尼)
- 弹珠间的碰撞检测和响应
- 边界碰撞处理
- 速度限制(最小/最大速度)
- DOM 渲染transform
- **`mouseInteraction.ts`**: 鼠标交互系统,实现对弹珠的吸引和排斥力场
- 追踪鼠标位置和 Shift 键状态
- 判断是否应用力场(距离、移动状态)
- 应用吸引/排斥力(对数衰减曲线)
- 悬停在弹珠上时禁用力场(允许点击链接)
- **`animationLoop.ts`**: 独立的动画循环,采用固定时间步长以保证物理模拟的稳定性
- 固定时间步长Fixed Timestep+ 累加器
- 防止长时间暂停导致的时间跳跃
- 提供启动/停止/暂停/恢复方法
## 2. 配置与数据分离
### 配置层 (`src/config/marbleConfig.ts`)
集中管理所有弹珠系统配置:
```typescript
export const MARBLE_CONFIG = {
size: {
base: 192, // 基础大小
min: 96, // 最小大小
maxScreenRatio: 0.25, // 最大屏幕占比
},
speed: {
min: 60, // 最小初始速度
max: 150, // 最大初始速度
},
physics: {
massScale: 0.01, // 质量计算比例
massOffset: 1, // 质量偏移
damping: 0.9985, // 空气阻力系数
restitution: 0.92, // 碰撞恢复系数
wallBounce: 0.85, // 墙壁反弹系数
minSpeed: 50, // 最小速度阈值
maxSpeed: 800, // 最大速度限制
},
animation: {
fadeInDelay: 100, // 淡入延迟
fixedDeltaTime: 1 / 60, // 固定时间步长
maxFrameTime: 0.1, // 最大帧时间
},
mouseInteraction: {
attractRadius: 500, // 吸引范围
repelRadius: 300, // 排斥范围
repelForce: 400, // 排斥力度
attractForce: 600, // 吸引力度
},
};
```
### 数据层 (`src/data/users.ts`)
将用户数据获取逻辑从组件中分离:
```typescript
export async function fetchUsers(): Promise<UserEntry[]> {
// 从 API 获取用户数据
// 包含错误处理
}
```
## 3. 功能保持与优化
### 保持的原有功能
- ✅ 弹珠碰撞检测和响应
- ✅ 边界反弹
- ✅ 从 API 加载用户数据
- ✅ 缩放功能zoom in/out
- ✅ 动画循环
- ✅ 响应式弹珠大小
### 优化改进
**鼠标交互**
- 按住 `Shift` 键切换为吸引模式(默认排斥)
- 力场仅在鼠标移动时激活300ms 超时)
- 悬停在弹珠上时禁用力场(方便点击链接)
- 力场强度采用对数衰减曲线(更平滑)
- 窗口失焦/页面隐藏时自动重置状态
**物理引擎**
- 引入阻尼damping模拟空气阻力
- 恢复系数restitution控制碰撞弹性
- 墙壁反弹系数wallBounce独立控制
- 最小速度限制(防止完全静止)
- 最大速度限制(防止速度过快)
- 帧率独立的物理计算
**弹珠创建**
- 使用 `Promise.allSettled` 批量创建
- 部分头像加载失败不影响其他弹珠
- 添加淡入动画效果
- 支持动态缩放
**动画循环**
- 固定时间步长Fixed Timestep确保物理模拟一致性
- 累加器处理帧率波动
- 最大帧时间限制(防止标签页后台长时间挂起导致的卡顿)
## 4. 代码简化
### 重构前MainView.astro
- ~260 行内联脚本代码
- 所有逻辑混在一起
- 难以维护和测试
### 重构后MainView.astro
- ~60 行简洁代码
- 只负责初始化和连接
- 清晰的职责划分
```typescript
// 重构后的使用示例
import { MarbleSystem } from "../utils/marbleSystem";
import { fetchUsers } from "../data/users";
const field = document.getElementById("marble-field");
const marbleSystem = new MarbleSystem({
container: field,
fieldWidth: window.innerWidth,
fieldHeight: window.innerHeight,
});
marbleSystem.start();
fetchUsers()
.then((users) => marbleSystem.addMarbles(users))
.catch(console.error);
```
## 5. 类型定义
所有模块都有完整的 TypeScript 类型定义:
```typescript
export interface Marble {
id: string;
node: HTMLElement;
x: number;
y: number;
vx: number;
vy: number;
radius: number;
mass: number;
}
export interface UserEntry {
name: string;
id: string;
link?: string;
}
// 更多类型定义...
```
## 6. 统一导出 (`src/utils/index.ts`)
```typescript
export { MarbleSystem } from "./marbleSystem";
export { MarblePhysics } from "./marblePhysics";
export { MouseInteraction } from "./mouseInteraction";
export { MarbleFactory } from "./marbleFactory";
export { AnimationLoop } from "./animationLoop";
export type { Marble } from "./mouseInteraction";
export type { PhysicsConfig } from "./marblePhysics";
export type { MouseInteractionConfig } from "./mouseInteraction";
export type { UpdateCallback } from "./animationLoop";
export type { MarbleSystemConfig } from "./marbleSystem";
```
## 7. 优势总结
**模块化**:清晰的职责分离,每个模块专注一个功能
**可维护性**:代码结构清晰,易于理解和修改
**可复用性**:封装后的系统可在其他项目中使用
**可测试性**:独立的模块便于单元测试
**类型安全**:完整的 TypeScript 类型定义
**配置集中**:所有配置参数统一管理
**功能完整**:保持所有原有功能的同时进行了优化
**代码精简**:组件代码减少 ~75%

View File

@@ -6,7 +6,9 @@ import Particles from "./Particles.astro";
---
<Navbar />
<Particles />
<div class="particles-container">
<Particles />
</div>
<div class="halo"></div>
<div class="grain"></div>
<div id="marble-field"></div>
@@ -61,6 +63,54 @@ import Particles from "./Particles.astro";
})
.catch((err) => console.error("Failed to fetch users:", err));
// Debug Info Loop
// Debug Mode Check
const urlParams = new URLSearchParams(window.location.search);
const isDebug = urlParams.get("debug") === "true";
if (isDebug) {
// 1. Create and inject Debug Canvas
const debugCanvas = document.createElement("canvas");
debugCanvas.id = "debug-velocity-canvas";
document.body.appendChild(debugCanvas);
// 2. Create and inject Debug Info Panel
const debugEl = document.createElement("div");
debugEl.id = "debug-info";
debugEl.innerHTML = "Initializing Gyro...";
document.body.appendChild(debugEl);
// 3. Enable Debug Mode in System
// We must set debug mode BEFORE starting the loop if we want it to pick up the canvas immediately,
// or set it now. The marbleSystem.setDebugMode will find the canvas by ID.
marbleSystem.setDebugMode(true);
// 4. Start Debug Info Loop
const updateDebug = () => {
const info = marbleSystem.getAllDebugInfo();
debugEl.innerHTML = `
<div>MActive: ${info.motionActive}</div>
<div>MSupported: ${info.motionSupported}</div>
<div>MAX: ${info.motionAx}</div>
<div>MAY: ${info.motionAy}</div>
<div>MAForce: ${Math.hypot(parseFloat(info.motionAx), parseFloat(info.motionAy)).toFixed(2)}</div>
<div>Active: ${info.active}</div>
<div>Supported: ${info.supported}</div>
<div>AX: ${info.ax}</div>
<div>AY: ${info.ay}</div>
<div>Alpha: ${info.alpha}</div>
<div>Beta: ${info.beta}</div>
<div>Gamma: ${info.gamma}</div>
<div>Force: ${Math.hypot(parseFloat(info.ax), parseFloat(info.ay)).toFixed(2)}</div>
<div>SubSteps: ${info.subSteps}</div>
<div>T: ${info.kineticEnergy.toFixed(2)}</div>
<div>MinSpeed: ${(info.minSpeed ?? 0).toFixed(2)}</div>
`;
requestAnimationFrame(updateDebug);
};
updateDebug();
}
// 缩放功能
const zoomInBtn = document.getElementById("zoom-in");
const zoomOutBtn = document.getElementById("zoom-out");
@@ -83,6 +133,100 @@ import Particles from "./Particles.astro";
window.addEventListener("toggle-collision", ((e: CustomEvent) => {
marbleSystem.setCollisions(e.detail.enabled);
}) as EventListener);
// Toggle Device Motion
window.addEventListener("toggle-device-motion", ((e: CustomEvent) => {
marbleSystem.setDeviceMotion(e.detail.enabled);
}) as EventListener);
// Toggle Device Orientation
window.addEventListener("toggle-device-orientation", ((e: CustomEvent) => {
marbleSystem.setDeviceOrientation(e.detail.enabled);
}) as EventListener);
// Toggle Title
let titleTimeout: number;
window.addEventListener("toggle-title", ((e: CustomEvent) => {
const titleCard = document.querySelector(".title-card") as HTMLElement;
if (titleCard) {
clearTimeout(titleTimeout);
if (e.detail.enabled) {
titleCard.style.display = "inline-block";
// Force Reflow
void titleCard.offsetWidth;
titleCard.style.opacity = "1";
titleCard.style.pointerEvents = "auto";
} else {
titleCard.style.opacity = "0";
titleCard.style.pointerEvents = "none";
titleTimeout = setTimeout(() => {
titleCard.style.display = "none";
}, 500) as unknown as number;
}
}
}) as EventListener);
// Toggle Marbles
let marbleTimeout: number;
window.addEventListener("toggle-marbles", ((e: CustomEvent) => {
const marbleField = document.getElementById("marble-field");
if (marbleField) {
clearTimeout(marbleTimeout);
if (e.detail.enabled) {
// 1. Resume physics immediately (so they move while fading in)
marbleSystem.start();
// 2. Show element
marbleField.style.display = "block";
// 3. Force reflow for transition
void marbleField.offsetWidth;
// 4. Fade in
marbleField.style.opacity = "1";
} else {
// 1. Fade out
marbleField.style.opacity = "0";
// 2. Wait for transition, then stop physics & hide
marbleTimeout = setTimeout(() => {
marbleSystem.stop();
marbleField.style.display = "none";
}, 500) as unknown as number;
}
}
}) as EventListener);
// Toggle Background
let bgTimeout: number;
window.addEventListener("toggle-background", ((e: CustomEvent) => {
const bgElements = [
document.querySelector(".halo"),
document.querySelector(".grain"),
document.querySelector(".particles-container"),
] as HTMLElement[];
bgElements.forEach((el) => {
if (el) {
// Ensure transition is active (robustness against CSS issues)
el.style.transition = "opacity 0.5s ease";
if (e.detail.enabled) {
el.style.display = "block";
// Force Reflow
void el.offsetWidth;
el.style.opacity = "1";
} else {
el.style.opacity = "0";
}
}
});
if (!e.detail.enabled) {
bgTimeout = setTimeout(() => {
bgElements.forEach((el) => {
if (el) el.style.display = "none";
});
}, 500) as unknown as number;
}
}) as EventListener);
}
</script>
@@ -95,6 +239,11 @@ import Particles from "./Particles.astro";
pointer-events: none;
animation: drift 20s linear infinite;
z-index: 1;
transition: opacity 0.5s ease;
}
.particles-container {
transition: opacity 0.5s ease;
}
.halo {
@@ -102,6 +251,7 @@ import Particles from "./Particles.astro";
inset: 0;
pointer-events: none;
z-index: 0;
transition: opacity 0.5s ease;
}
.halo::before,
@@ -160,7 +310,7 @@ import Particles from "./Particles.astro";
pointer-events: auto;
z-index: 6;
opacity: 0;
transition: opacity 1s ease;
transition: opacity 0.5s ease;
}
.navbar {
@@ -219,6 +369,33 @@ import Particles from "./Particles.astro";
inset: 0;
overflow: hidden;
z-index: 2;
opacity: 1; /* Default visible */
transition: opacity 0.5s ease;
}
:global(#debug-velocity-canvas) {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9999;
}
:global(#debug-info) {
position: fixed;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.7);
color: #0f0;
font-family: monospace;
padding: 8px;
border-radius: 4px;
z-index: 9999;
font-size: 12px;
pointer-events: none;
display: block; /* Visible when added */
}
:global(.marble-wrapper) {

View File

@@ -73,18 +73,27 @@
particles.push(new Particle());
}
const animate = () => {
let animationId: number;
let isPaused = false;
const startAnimation = () => {
// Clear canvas
ctx.clearRect(0, 0, width, height);
// Update and draw particles
particles.forEach((p) => {
p.update();
p.draw();
});
requestAnimationFrame(animate);
// Continue loop if not paused
if (!isPaused) {
animationId = requestAnimationFrame(startAnimation);
}
};
animate();
// Start initial animation
startAnimation();
window.addEventListener("resize", () => {
width = window.innerWidth;
@@ -92,5 +101,18 @@
canvas.width = width;
canvas.height = height;
});
// Pause/Resume Logic
window.addEventListener("toggle-background", ((e: CustomEvent) => {
if (e.detail.enabled) {
if (isPaused) {
isPaused = false;
startAnimation();
}
} else {
isPaused = true;
cancelAnimationFrame(animationId);
}
}) as EventListener);
}
</script>

View File

@@ -37,10 +37,40 @@
<div class="separator"></div>
<label class="toggle-switch">
<input type="checkbox" id="title-toggle" checked />
<span class="slider"></span>
<span class="label-text">Title</span>
</label>
<label class="toggle-switch">
<input type="checkbox" id="marbles-toggle" checked />
<span class="slider"></span>
<span class="label-text">Marbles</span>
</label>
<label class="toggle-switch" id="collision-container">
<input type="checkbox" id="collision-toggle" checked />
<span class="slider"></span>
<span class="label-text">Collisions</span>
</label>
<label class="toggle-switch" id="device-motion-container">
<input type="checkbox" id="device-motion-toggle" checked />
<span class="slider"></span>
<span class="label-text">Motion</span>
</label>
<label class="toggle-switch" id="device-orientation-container">
<input type="checkbox" id="device-orientation-toggle" checked />
<span class="slider"></span>
<span class="label-text">Orientation</span>
</label>
<label class="toggle-switch">
<input type="checkbox" id="background-toggle" checked />
<span class="slider"></span>
<span class="label-text">Background</span>
</label>
</div>
<!-- Toggle Button -->
@@ -67,16 +97,101 @@
<script>
const toggleBtn = document.getElementById("settings-toggle");
const menu = document.getElementById("settings-menu");
const collisionToggle = document.getElementById(
"collision-toggle",
) as HTMLInputElement;
if (collisionToggle) {
collisionToggle.addEventListener("change", (e) => {
const enabled = (e.target as HTMLInputElement).checked;
window.dispatchEvent(
new CustomEvent("toggle-collision", { detail: { enabled } }),
);
// Toggle Elements
const toggles = {
collision: document.getElementById("collision-toggle") as HTMLInputElement,
title: document.getElementById("title-toggle") as HTMLInputElement,
marbles: document.getElementById("marbles-toggle") as HTMLInputElement,
background: document.getElementById(
"background-toggle",
) as HTMLInputElement,
deviceMotion: document.getElementById(
"device-motion-toggle",
) as HTMLInputElement,
deviceOrientation: document.getElementById(
"device-orientation-toggle",
) as HTMLInputElement,
};
// Helper to dispatch event
const emit = (name: string, enabled: boolean) => {
window.dispatchEvent(new CustomEvent(name, { detail: { enabled } }));
};
// Bind events
if (toggles.collision) {
toggles.collision.addEventListener("change", (e) =>
emit("toggle-collision", (e.target as HTMLInputElement).checked),
);
}
if (toggles.deviceMotion) {
toggles.deviceMotion.addEventListener("change", (e) =>
emit("toggle-device-motion", (e.target as HTMLInputElement).checked),
);
}
if (toggles.deviceOrientation) {
toggles.deviceOrientation.addEventListener("change", (e) =>
emit("toggle-device-orientation", (e.target as HTMLInputElement).checked),
);
}
if (toggles.title) {
toggles.title.addEventListener("change", (e) => {
const target = e.target as HTMLInputElement;
emit("toggle-title", target.checked);
// Debounce: Disable for 1s
target.disabled = true;
const parent = target.parentElement;
if (parent) parent.classList.add("disabled");
setTimeout(() => {
target.disabled = false;
if (parent) parent.classList.remove("disabled");
}, 500);
});
}
if (toggles.background) {
toggles.background.addEventListener("change", (e) => {
const target = e.target as HTMLInputElement;
emit("toggle-background", target.checked);
// Debounce: Disable for 1s
target.disabled = true;
const parent = target.parentElement;
if (parent) parent.classList.add("disabled");
setTimeout(() => {
target.disabled = false;
if (parent) parent.classList.remove("disabled");
}, 500);
});
}
// Special handling for Marbles toggle (controls collision visibility)
if (toggles.marbles) {
toggles.marbles.addEventListener("change", (e) => {
const target = e.target as HTMLInputElement;
emit("toggle-marbles", target.checked);
// Debounce: Disable for 0.5s
target.disabled = true;
const parent = target.parentElement;
if (parent) parent.classList.add("disabled");
setTimeout(() => {
target.disabled = false;
if (parent) parent.classList.remove("disabled");
}, 500);
// logic to disable/enable collision toggle
if (toggles.collision) {
toggles.collision.disabled = !target.checked;
const container = toggles.collision.parentElement;
if (container) {
container.classList.toggle("disabled", !target.checked);
}
}
});
}
@@ -116,12 +231,9 @@
flex-direction: column; /* Vertical stack */
gap: 0.8rem;
padding: 0.6rem; /* Reduced padding */
background: linear-gradient(
145deg,
rgba(255, 255, 255, 0.12),
rgba(255, 255, 255, 0.04)
); /* Translucent gradient */
backdrop-filter: blur(6px); /* Glass blur effect */
background: rgba(15, 15, 15, 0.75); /* Dark semi-transparent */
backdrop-filter: blur(16px); /* Frosted glass effect */
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
@@ -207,6 +319,12 @@
user-select: none;
padding: 0 0.5rem; /* Add padding */
height: 48px; /* Match button height */
transition: opacity 0.3s;
}
.toggle-switch.disabled {
opacity: 0.5;
pointer-events: none;
}
.toggle-switch input {

View File

@@ -9,7 +9,8 @@ export const MARBLE_CONFIG = {
size: {
base: 192,
min: 96,
maxScreenRatio: 0.25,
marbleCount: 19,
marbleArea: 0.5,
},
// Marble initial speed configuration
@@ -26,7 +27,7 @@ export const MARBLE_CONFIG = {
restitution: 0.92, // Marble collision restitution coefficient (0-1)
wallBounce: 0.85, // Wall bounce coefficient (0-1)
minSpeed: 50, // Minimum speed threshold. Below this value, speed increases to this value: CurrentSpeed *= scale = minSpeed / speed
maxSpeed: 800, // Global maximum speed limit, limited in the same way as above
maxSpeed: 3200, // Global maximum speed limit, limited in the same way as above
},
// Animation configuration
@@ -43,6 +44,17 @@ export const MARBLE_CONFIG = {
repelForce: 400, // Repulsion force
attractForce: 600, // Attraction force
},
// Device motion interaction configuration
deviceOrientation: {
sensitivity: 600, // Sensitivity
maxForce: 6000, // Maximum force limit
enable: true,
},
deviceMotion: {
sensitivity: 600, // Sensitivity
maxForce: 6000, // Maximum force limit
enable: true,
},
} as const;
export const AVATAR_BASE_URL = "https://avatar.awfufu.com/qq/";

View File

@@ -0,0 +1,135 @@
/**
* Device motion interaction system
* Handles the effect of device tilt (acceleration) on marbles
*/
import type { Marble } from "./mouseInteraction";
export interface DeviceMotionConfig {
sensitivity: number;
maxForce: number;
enable: boolean;
}
export class DeviceMotionInteraction {
private ax: number = 0;
private ay: number = 0;
private isActive: boolean = false;
private config: DeviceMotionConfig;
constructor(config: DeviceMotionConfig) {
this.config = config;
}
/**
* Initialize device motion listener
*/
public init(): void {
if (typeof window === "undefined") return;
// Check if DeviceMotionEvent is supported
if (window.DeviceMotionEvent) {
window.addEventListener("devicemotion", this.handleMotion.bind(this));
this.isActive = true;
}
}
/**
* Handle device motion event
*/
private handleMotion(event: DeviceMotionEvent): void {
// x axis acceleration
// y axis acceleration
const accel = event.acceleration;
if (accel) {
this.ax = -(accel.x || 0);
this.ay = accel.y || 0;
}
}
/**
* Get Debug Info
*/
public getDebugInfo(): {
motionSupported: boolean;
motionActive: boolean;
motionAx: string;
motionAy: string;
} {
return {
motionActive: this.isActive,
motionSupported:
typeof window !== "undefined" && !!window.DeviceMotionEvent,
motionAx: this.ax.toFixed(2),
motionAy: this.ay.toFixed(2),
};
}
/**
* Get whether supported and active
*/
public isActivated(): boolean {
return this.isActive;
}
/**
* Request PermissioniOS 13+
*/
public async requestPermission(): Promise<boolean> {
if (typeof (DeviceMotionEvent as any).requestPermission === "function") {
try {
const response = await (DeviceMotionEvent as any).requestPermission();
if (response === "granted") {
this.init();
return true;
}
return false;
} catch (e) {
console.error("DeviceMotion permission error:", e);
return false;
}
}
return true; // Non iOS 13+ devices do not require a request
}
/**
* Apply Force
*/
public applyForce(marbles: Marble[], dt: number): void {
if (!this.isActive || !this.config.enable) return;
// Threshold filtering to prevent jitter
if (Math.abs(this.ax) < 0.5 && Math.abs(this.ay) < 0.5) return;
const { sensitivity, maxForce } = this.config;
// Calculate force
// ax, ay unit is m/s^2
// times sensitivity
let fx = this.ax * sensitivity;
let fy = this.ay * sensitivity;
// Limit max force
const force = Math.hypot(fx, fy);
if (force > maxForce) {
const scale = maxForce / force;
fx *= scale;
fy *= scale;
}
// Apply to all marbles
for (const m of marbles) {
// m.vx += fx;
// m.vy += fy;
m.vx += fx * dt;
m.vy += fy * dt;
// console.log("fx", fx, "fy", fy, "dt", dt);
}
}
/**
* Update Config
*/
public updateConfig(config: Partial<DeviceMotionConfig>): void {
this.config = { ...this.config, ...config };
}
}

View File

@@ -0,0 +1,161 @@
/**
* Device orientation interaction system
* Handles the effect of device tilt (gravity) on marbles
*/
import type { Marble } from "./mouseInteraction";
export interface DeviceOrientationConfig {
sensitivity: number; // Can be used to scale gravity effect slightly, default 1
maxForce: number; // Not strictly needed for gravity, but can limit if physics goes crazy
enable: boolean;
}
export class DeviceOrientationInteraction {
private ax: number = 0; // Acceleration on X (m/s^2)
private ay: number = 0; // Acceleration on Y (m/s^2)
private alpha: number | null = 0;
private beta: number | null = 0;
private gamma: number | null = 0;
private isActive: boolean = false;
private config: DeviceOrientationConfig;
constructor(config: DeviceOrientationConfig) {
this.config = config;
}
/**
* Initialize device orientation listener
*/
public init(): void {
if (typeof window === "undefined") return;
// Check if DeviceOrientationEvent is supported
if (window.DeviceOrientationEvent) {
window.addEventListener(
"deviceorientation",
this.handleOrientation.bind(this),
);
this.isActive = true;
}
}
/**
* Handle device orientation event
* alpha: rotation around Z (compass heading) in degrees, range [0, 360)
* beta: front-to-back tilt in degrees, range [-180, 180)
* gamma: left-to-right tilt in degrees, range [-90, 90)
*/
private handleOrientation(event: DeviceOrientationEvent): void {
const { alpha, beta, gamma } = event;
this.alpha = alpha;
this.beta = beta;
this.gamma = gamma;
if (
alpha === null ||
beta === null ||
gamma === null ||
(alpha === 0 && beta === 90 && gamma === 0)
)
return;
// Gravity constant
const g = 9.8;
const toRad = Math.PI / 180;
this.ax = g * Math.sin(gamma * toRad) * Math.cos(beta * toRad);
this.ay = g * Math.sin(beta * toRad);
}
public getDebugInfo() {
return {
active: this.isActive,
supported:
typeof window !== "undefined" && !!window.DeviceOrientationEvent,
ax: this.ax.toFixed(2),
ay: this.ay.toFixed(2),
alpha: this.alpha?.toFixed(1),
beta: this.beta?.toFixed(1),
gamma: this.gamma?.toFixed(1),
};
}
/**
* Get whether supported and active
*/
public isActivated(): boolean {
return this.isActive;
}
/**
* Request Permission (iOS 13+ need permission for DeviceOrientation too)
*/
public async requestPermission(): Promise<boolean> {
if (
typeof (DeviceOrientationEvent as any).requestPermission === "function"
) {
try {
const response = await (
DeviceOrientationEvent as any
).requestPermission();
if (response === "granted") {
this.init();
return true;
}
return false;
} catch (e) {
console.error("DeviceOrientation permission error:", e);
return false;
}
}
this.init(); // Init anyway for non-iOS 13+
return true;
}
/**
* Check if gravity is active and significant
*/
public hasActiveGravity(): boolean {
return this.isActive && (this.ax !== 0 || this.ay !== 0);
}
/**
* Get current acceleration vector
*/
public getAcceleration(): { x: number; y: number } {
return { x: this.ax, y: this.ay };
}
public getEnabled(): boolean {
return this.config.enable;
}
/**
* Apply Force (Gravity)
*/
public applyForce(marbles: Marble[], dt: number): void {
if (!this.isActive || !this.config.enable) return;
const { sensitivity } = this.config;
// Apply sensitivity scaling if desired (default 1 simulates real gravity)
const gx = this.ax * sensitivity;
const gy = this.ay * sensitivity;
// Apply to all marbles
// v = v0 + at
for (const m of marbles) {
m.vx += gx * dt;
m.vy += gy * dt;
}
}
/**
* Update Config
*/
public updateConfig(config: Partial<DeviceOrientationConfig>): void {
this.config = { ...this.config, ...config };
}
}

View File

@@ -17,10 +17,11 @@ export class MarbleFactory {
}
// Calculate marble size (responsive)
private calculateMarbleSize(): number {
const { base, min, maxScreenRatio } = MARBLE_CONFIG.size;
const quarter =
Math.min(window.innerWidth, window.innerHeight) * maxScreenRatio;
public calculateMarbleSize(): number {
const { base, min, marbleCount, marbleArea } = MARBLE_CONFIG.size;
const fieldArea = this.fieldWidth * this.fieldHeight;
const areaPerMarble = (fieldArea * marbleArea) / marbleCount;
const quarter = Math.sqrt((4 * areaPerMarble) / Math.PI);
const capped = Math.min(base, quarter || base);
return Math.max(min, Math.floor(capped)) * this.zoomLevel;
}

View File

@@ -1,7 +1,16 @@
// Marble physics system: Handles marble movement, collision detection, and boundary processing
// Refactored to use XPBD (Extended Position Based Dynamics) for better stability
import type { Marble } from "./mouseInteraction";
// Extend Marble interface to include previous positions for XPBD
interface PhysicsMarble extends Marble {
prevX?: number;
prevY?: number;
prevVx?: number;
prevVy?: number;
}
export interface PhysicsConfig {
fieldWidth: number;
fieldHeight: number;
@@ -11,10 +20,21 @@ export interface PhysicsConfig {
minSpeed?: number; // Minimum speed
maxSpeed?: number; // Maximum speed
enableCollisions?: boolean; // Enable/Disable collisions
debugCanvas?: HTMLCanvasElement | null; // Optional canvas for debug rendering
debugVectorScale?: number; // Scale factor for velocity vectors (default: 0.5)
}
interface Contact {
a: PhysicsMarble;
b: PhysicsMarble;
nx: number;
ny: number;
dist: number; // Penetration depth or distance
}
export class MarblePhysics {
private config: PhysicsConfig;
public config: PhysicsConfig;
private currentContacts: Contact[] = [];
constructor(config: PhysicsConfig) {
this.config = {
@@ -26,22 +46,33 @@ export class MarblePhysics {
minSpeed: config.minSpeed ?? 30,
maxSpeed: config.maxSpeed ?? 800,
enableCollisions: config.enableCollisions ?? true,
debugCanvas: config.debugCanvas ?? null,
debugVectorScale: config.debugVectorScale ?? 0.5,
};
}
// Update marble positions
public updatePositions(marbles: Marble[], dt: number): void {
// 1. Integration Step: Apply external forces and predict new positions
// XPBD: x' = x + v * dt
public updatePositions(marbles: PhysicsMarble[], dt: number): void {
const { damping, minSpeed, maxSpeed } = this.config;
for (const m of marbles) {
// Apply air resistance
// Store Velocity for Restitution (XPBD needs pre-solve velocity)
m.prevVx = m.vx;
m.prevVy = m.vy;
// Initialize prev positions if missing
if (m.prevX === undefined) m.prevX = m.x;
if (m.prevY === undefined) m.prevY = m.y;
// Apply external forces to velocity (e.g. Damping)
if (damping !== undefined && damping < 1) {
const dampingFactor = damping ** (dt * 60); // Frame rate independent
const dampingFactor = damping ** (dt * 60);
m.vx *= dampingFactor;
m.vy *= dampingFactor;
}
// Calculate current speed
// Limit max speed
const speed = Math.hypot(m.vx, m.vy);
// Maintain minimum speed (prevent complete stop)
@@ -58,17 +89,36 @@ export class MarblePhysics {
m.vy *= scale;
}
// Update position
// Store current position as previous
m.prevX = m.x;
m.prevY = m.y;
// Predict new position
m.x += m.vx * dt;
m.y += m.vy * dt;
}
}
// Handle collisions between marbles using Spatial Grid
public handleCollisions(marbles: Marble[]): void {
if (this.config.enableCollisions === false) return;
// 2. Constraint Solving: Correct positions to satisfy constraints
public handleCollisions(marbles: PhysicsMarble[]): void {
const { enableCollisions, fieldWidth, fieldHeight } = this.config;
this.currentContacts = []; // Clear previous contacts
const restitution = this.config.restitution ?? 1;
// --- Boundary Constraints (Position Correction) ---
for (const m of marbles) {
if (m.x < m.radius) m.x = m.radius;
if (m.x > fieldWidth - m.radius) m.x = fieldWidth - m.radius;
if (m.y < m.radius) m.y = m.radius;
if (m.y > fieldHeight - m.radius) m.y = fieldHeight - m.radius;
}
// --- Marble-Marble Collisions (Grid-based) ---
if (enableCollisions !== false) {
this.handleMarbleCollisions(marbles);
}
}
private handleMarbleCollisions(marbles: PhysicsMarble[]) {
const { fieldWidth, fieldHeight } = this.config;
// 1. Determine grid cell size
@@ -87,27 +137,22 @@ export class MarblePhysics {
// 2. Build the grid
// Map: cellIndex -> Particle[]
const grid = new Map<number, Marble[]>();
const grid = new Map<number, PhysicsMarble[]>();
const getGridIndex = (x: number, y: number) => {
const gx = Math.floor(x / cellSize);
const gy = Math.floor(y / cellSize);
// Clamp to valid range to handle out-of-bounds marbles gracefully
if (gx < 0 || gx >= gridWidth || gy < 0 || gy >= gridHeight) return -1;
return gx + gy * gridWidth;
};
for (const m of marbles) {
const index = getGridIndex(m.x, m.y);
if (index === -1) continue; // Skip out of bounds marbles (handled by boundaries)
if (!grid.has(index)) {
grid.set(index, []);
}
if (index === -1) continue;
if (!grid.has(index)) grid.set(index, []);
grid.get(index)?.push(m);
}
// 3. Check collisions (Grid-based)
// 3. Solve Collisions (Grid-based)
// We iterate through each marble, find its cell, and check that cell + neighbors
for (const i_marble of marbles) {
const gx = Math.floor(i_marble.x / cellSize);
@@ -123,14 +168,13 @@ export class MarblePhysics {
for (let ny = gy - 1; ny <= gy + 1; ny++) {
if (nx < 0 || nx >= gridWidth || ny < 0 || ny >= gridHeight) continue;
const neighborIndex = nx + ny * gridWidth;
const cellMarbles = grid.get(neighborIndex);
const cellMarbles = grid.get(nx + ny * gridWidth);
if (!cellMarbles) continue;
for (const j_marble of cellMarbles) {
// Avoid self-collision
if (i_marble === j_marble) continue;
if (i_marble.id >= j_marble.id) continue; // Check unique pair
// Avoid double checking: only check if index(i) < index(j)
// But here we rely on the object content.
@@ -141,7 +185,6 @@ export class MarblePhysics {
// Ideally: We iterate unique pairs.
// Optimization: Only check half-neighborhood?
// Or simpler: check all, but only act if i_marble.id < j_marble.id
if (i_marble.id >= j_marble.id) continue;
const a = i_marble;
const b = j_marble;
@@ -154,26 +197,37 @@ export class MarblePhysics {
if (distSq < minDistSq) {
const dist = Math.sqrt(distSq) || 0.001;
const nx = dx / dist;
const ny = dy / dist;
const relativeVelocity = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
const nX = dx / dist;
const nY = dy / dist;
const penetration = minDist - dist;
if (relativeVelocity < 0) {
// Calculate impulse using restitution coefficient
const impulse =
((1 + restitution) * relativeVelocity) / (a.mass + b.mass);
a.vx -= impulse * b.mass * nx;
a.vy -= impulse * b.mass * ny;
b.vx += impulse * a.mass * nx;
b.vy += impulse * a.mass * ny;
}
// XPBD Position Correction
const wA = 1 / a.mass;
const wB = 1 / b.mass;
const wSum = wA + wB;
// Position correction to prevent overlap
const overlap = minDist - dist + 0.5;
a.x -= nx * overlap * 0.5;
a.y -= ny * overlap * 0.5;
b.x += nx * overlap * 0.5;
b.y += ny * overlap * 0.5;
if (wSum === 0) continue;
// dx_p = (w / wSum) * penetration * n
// Compliance = 0 (hard constraint)
const lambda = penetration / wSum;
const deltaX = nX * lambda;
const deltaY = nY * lambda;
a.x -= deltaX * wA;
a.y -= deltaY * wA;
b.x += deltaX * wB;
b.y += deltaY * wB;
// Store contact for velocity resolve
this.currentContacts.push({
a,
b,
nx: nX,
ny: nY,
dist: penetration,
});
}
}
}
@@ -181,32 +235,73 @@ export class MarblePhysics {
}
}
// Handle boundary collisions
public handleBoundaries(marbles: Marble[]): void {
// 3. Velocity Update (and Resolve)
// XPBD: v = (x - prevX) / dt
// Then apply restitution to v
public resolveVelocities(marbles: PhysicsMarble[], dt: number): void {
// 3a. Update velocities from position change
for (const m of marbles) {
if (m.prevX !== undefined && m.prevY !== undefined) {
m.vx = (m.x - m.prevX) / dt;
m.vy = (m.y - m.prevY) / dt;
}
}
// 3b. Apply Wall Bounce (Velocity Reflection)
const { fieldWidth, fieldHeight, wallBounce } = this.config;
const bounce = wallBounce ?? 1;
const e = wallBounce ?? 0.85;
for (const m of marbles) {
// Left boundary
if (m.x - m.radius < 0) {
m.x = m.radius;
if (m.vx < 0) m.vx *= -bounce;
}
// Right boundary
if (m.x + m.radius > fieldWidth) {
m.x = fieldWidth - m.radius;
if (m.vx > 0) m.vx *= -bounce;
}
// Top boundary
if (m.y - m.radius < 0) {
m.y = m.radius;
if (m.vy < 0) m.vy *= -bounce;
}
// Bottom boundary
if (m.y + m.radius > fieldHeight) {
m.y = fieldHeight - m.radius;
if (m.vy > 0) m.vy *= -bounce;
}
// Left Wall
if (m.x <= m.radius + 0.5 && m.vx < 0) m.vx *= -e;
// Right Wall
if (m.x >= fieldWidth - m.radius - 0.5 && m.vx > 0) m.vx *= -e;
// Top Wall
if (m.y <= m.radius + 0.5 && m.vy < 0) m.vy *= -e;
// Bottom Wall
if (m.y >= fieldHeight - m.radius - 0.5 && m.vy > 0) m.vy *= -e;
}
// 3c. Apply Marble Restitution
this.applyRestitution();
}
private applyRestitution() {
const restitution = this.config.restitution ?? 0.92;
for (const contact of this.currentContacts) {
const { a, b, nx, ny } = contact;
// Use stored pre-velocities
const vax = a.prevVx ?? 0;
const vay = a.prevVy ?? 0;
const vbx = b.prevVx ?? 0;
const vby = b.prevVy ?? 0;
const dvx = vbx - vax;
const dvy = vby - vay;
const vn_pre = dvx * nx + dvy * ny;
// If separating already, skip
if (vn_pre > 0) continue;
// Target: vn_final = -e * vn_pre
const vn_current = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
const vn_goal = -restitution * vn_pre;
const delta_vn = vn_goal - vn_current;
// Only apply if we need to add impulse
if (delta_vn <= 0) continue;
const wA = 1 / a.mass;
const wB = 1 / b.mass;
const wSum = wA + wB;
if (wSum === 0) continue;
const impulse = delta_vn / wSum;
a.vx -= impulse * wA * nx;
a.vy -= impulse * wA * ny;
b.vx += impulse * wB * nx;
b.vy += impulse * wB * ny;
}
}
@@ -226,4 +321,69 @@ export class MarblePhysics {
public getConfig(): PhysicsConfig {
return { ...this.config };
}
// Render debug velocity vectors on canvas
public renderDebugVectors(marbles: Marble[]): void {
const canvas = this.config.debugCanvas;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const scale = this.config.debugVectorScale ?? 0.5;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const m of marbles) {
const speed = Math.hypot(m.vx, m.vy);
if (speed < 1) continue; // Skip very slow marbles
const powerFactor = speed ** -0.1;
// Calculate arrow end point
const endX = m.x + m.vx * scale * powerFactor;
const endY = m.y + m.vy * scale * powerFactor;
// Color based on speed (green -> yellow -> red)
const normalizedSpeed = Math.min(speed / 500, 1);
const hue = (1 - normalizedSpeed) * 120; // 120=green, 0=red
ctx.strokeStyle = `hsl(${hue}, 100%, 50%)`;
ctx.fillStyle = `hsl(${hue}, 100%, 50%)`;
ctx.lineWidth = 2;
// Draw line
ctx.beginPath();
ctx.moveTo(m.x, m.y);
ctx.lineTo(endX, endY);
ctx.stroke();
// Draw arrowhead
const arrowSize = 8;
const angle = Math.atan2(m.vy, m.vx);
ctx.beginPath();
ctx.moveTo(endX, endY);
ctx.lineTo(
endX - arrowSize * Math.cos(angle - Math.PI / 6),
endY - arrowSize * Math.sin(angle - Math.PI / 6),
);
ctx.lineTo(
endX - arrowSize * Math.cos(angle + Math.PI / 6),
endY - arrowSize * Math.sin(angle + Math.PI / 6),
);
ctx.closePath();
ctx.fill();
// Draw speed text
ctx.font = "10px monospace";
ctx.fillText(`${speed.toFixed(0)}`, m.x + 5, m.y - 5);
}
}
public addRandomSpeed(marbles: Marble[]): void {
for (const m of marbles) {
m.vx += Math.random() * 2 - 1;
m.vy += Math.random() * 2 - 1;
}
}
}

View File

@@ -2,6 +2,8 @@
import type { UserEntry } from "../config/marbleConfig";
import { MARBLE_CONFIG } from "../config/marbleConfig";
import { DeviceOrientationInteraction } from "./deviceOrientationInteraction";
import { DeviceMotionInteraction } from "./deviceMotionInteraction";
import { AnimationLoop } from "./animationLoop";
import { MarbleFactory } from "./marbleFactory";
import { MarblePhysics } from "./marblePhysics";
@@ -18,6 +20,16 @@ export interface MarbleSystemConfig {
repelForce?: number;
attractForce?: number;
};
deviceOrientationConfig?: {
sensitivity?: number;
maxForce?: number;
enable?: boolean;
};
deviceMotionConfig?: {
sensitivity?: number;
maxForce?: number;
enable?: boolean;
};
}
export class MarbleSystem {
@@ -26,6 +38,8 @@ export class MarbleSystem {
// Subsystems
private mouseInteraction: MouseInteraction;
private deviceOrientationInteraction: DeviceOrientationInteraction;
private deviceMotionInteraction: DeviceMotionInteraction;
private physics: MarblePhysics;
private factory: MarbleFactory;
private animationLoop: AnimationLoop;
@@ -34,6 +48,11 @@ export class MarbleSystem {
private fieldWidth: number;
private fieldHeight: number;
// Debug mode
private debugMode: boolean = false;
private debugCanvas: HTMLCanvasElement | null = null;
private debugVectorScale: number = 0.5;
constructor(config: MarbleSystemConfig) {
this.container = config.container;
this.fieldWidth = config.fieldWidth;
@@ -57,6 +76,34 @@ export class MarbleSystem {
this.mouseInteraction = new MouseInteraction(mouseConfig);
this.mouseInteraction.init();
// DeviceOrientationInteraction Init
this.deviceOrientationInteraction = new DeviceOrientationInteraction({
sensitivity:
config.deviceOrientationConfig?.sensitivity ??
MARBLE_CONFIG.deviceOrientation.sensitivity,
maxForce:
config.deviceOrientationConfig?.maxForce ??
MARBLE_CONFIG.deviceOrientation.maxForce,
enable:
config.deviceOrientationConfig?.enable ??
MARBLE_CONFIG.deviceOrientation.enable,
});
this.deviceOrientationInteraction.init();
// DeviceMotionInteraction Init
this.deviceMotionInteraction = new DeviceMotionInteraction({
sensitivity:
config.deviceMotionConfig?.sensitivity ??
MARBLE_CONFIG.deviceMotion.sensitivity,
maxForce:
config.deviceMotionConfig?.maxForce ??
MARBLE_CONFIG.deviceMotion.maxForce,
enable:
config.deviceMotionConfig?.enable ?? MARBLE_CONFIG.deviceMotion.enable,
});
this.deviceMotionInteraction.init();
// MarblePhysics Init
this.physics = new MarblePhysics({
fieldWidth: this.fieldWidth,
@@ -66,6 +113,8 @@ export class MarbleSystem {
wallBounce: MARBLE_CONFIG.physics.wallBounce,
minSpeed: MARBLE_CONFIG.physics.minSpeed,
maxSpeed: MARBLE_CONFIG.physics.maxSpeed,
debugCanvas: this.debugCanvas,
debugVectorScale: this.debugVectorScale,
});
// MarbleFactory Init
@@ -86,20 +135,72 @@ export class MarbleSystem {
this.setupResizeHandler();
}
private currentSubSteps: number = 1;
// Per-frame update logic
private update(dt: number): void {
// Apply mouse force field
for (const marble of this.marbles) {
if (this.mouseInteraction.shouldApplyForce(marble)) {
this.mouseInteraction.applyForce(marble, dt);
let subSteps = 1;
if (
this.deviceOrientationInteraction.hasActiveGravity() &&
this.deviceOrientationInteraction.getEnabled()
) {
const { x, y } = this.deviceOrientationInteraction.getAcceleration();
const magnitude = Math.hypot(x, y);
// Dynamically adjust sub-steps based on gravity intensity
// Theory: Less gravity = less force clamping marbles against walls = less tunneling risk
if (magnitude < 2.0) {
subSteps = 1;
} else if (magnitude < 5.0) {
subSteps = 3;
} else {
subSteps = 6;
}
const maxMagnitude = 7.0;
const exponent = 3;
const t = Math.min(magnitude / maxMagnitude, 1.0);
const factor = 1 - (2 * t) ** exponent;
const minSpeed = MARBLE_CONFIG.physics.minSpeed * Math.max(0, factor);
this.physics.updateConfig({ minSpeed: minSpeed });
} else {
this.physics.updateConfig({ minSpeed: MARBLE_CONFIG.physics.minSpeed });
}
this.currentSubSteps = subSteps;
const subDt = dt / subSteps;
for (let i = 0; i < subSteps; i++) {
// Apply mouse force field
for (const marble of this.marbles) {
if (this.mouseInteraction.shouldApplyForce(marble)) {
this.mouseInteraction.applyForce(marble, subDt);
}
}
// Apply device orientation force
if (this.deviceOrientationInteraction.isActivated()) {
this.deviceOrientationInteraction.applyForce(this.marbles, subDt);
}
// Apply device motion force
if (this.deviceMotionInteraction.isActivated()) {
this.deviceMotionInteraction.applyForce(this.marbles, subDt);
}
// Update physics
this.physics.updatePositions(this.marbles, subDt);
this.physics.handleCollisions(this.marbles);
this.physics.resolveVelocities(this.marbles, subDt);
}
// Update physics
this.physics.updatePositions(this.marbles, dt);
this.physics.handleCollisions(this.marbles);
this.physics.handleBoundaries(this.marbles);
this.physics.render(this.marbles);
// Render debug vectors if enabled
if (this.debugMode) {
this.physics.renderDebugVectors(this.marbles);
}
}
// Set up window resize listener
@@ -112,6 +213,11 @@ export class MarbleSystem {
fieldHeight: this.fieldHeight,
});
this.factory.updateFieldSize(this.fieldWidth, this.fieldHeight);
if (this.debugCanvas) {
this.debugCanvas.width = this.fieldWidth;
this.debugCanvas.height = this.fieldHeight;
}
});
}
@@ -178,10 +284,20 @@ export class MarbleSystem {
return this.marbles.length;
}
public getKineticEnergy(): number {
let totalKineticEnergy = 0;
for (const m of this.marbles) {
const speedSq = m.vx * m.vx + m.vy * m.vy;
const energy = 0.5 * m.mass * speedSq;
totalKineticEnergy += energy;
}
return totalKineticEnergy;
}
// Update marble size (zoom)
public updateMarbleSize(zoomLevel: number): void {
this.factory.setZoomLevel(zoomLevel);
const size = this.calculateMarbleSize(zoomLevel);
const size = this.factory.calculateMarbleSize();
const radius = size / 2;
for (const m of this.marbles) {
@@ -194,13 +310,28 @@ export class MarbleSystem {
}
}
// Calculate marble size
private calculateMarbleSize(zoomLevel: number): number {
const { base, min, maxScreenRatio } = MARBLE_CONFIG.size;
const quarter =
Math.min(window.innerWidth, window.innerHeight) * maxScreenRatio;
const capped = Math.min(base, quarter || base);
return Math.max(min, Math.floor(capped)) * zoomLevel;
/**
* Request device motion permission
*/
public async requestDeviceOrientationPermission(): Promise<boolean> {
return this.deviceOrientationInteraction.requestPermission();
}
public async requestDeviceMotionPermission(): Promise<boolean> {
return this.deviceMotionInteraction.requestPermission();
}
/**
* Get device motion debug info see also MainView.astro
*/
public getAllDebugInfo() {
return {
...this.deviceOrientationInteraction.getDebugInfo(),
...this.deviceMotionInteraction.getDebugInfo(),
subSteps: this.currentSubSteps,
kineticEnergy: this.getKineticEnergy(),
minSpeed: this.physics.getConfig().minSpeed,
};
}
// Destroy system
@@ -212,5 +343,52 @@ export class MarbleSystem {
// Toggle collision
public setCollisions(enabled: boolean): void {
this.physics.updateConfig({ enableCollisions: enabled });
this.physics.addRandomSpeed(this.marbles);
}
// Toggle device motion
public setDeviceMotion(enabled: boolean): void {
this.deviceMotionInteraction.updateConfig({ enable: enabled });
}
// Toggle device orientation
public setDeviceOrientation(enabled: boolean): void {
this.deviceOrientationInteraction.updateConfig({ enable: enabled });
}
// Toggle debug mode (show velocity vectors)
public setDebugMode(enabled: boolean): void {
this.debugMode = enabled;
if (enabled) {
// Get canvas from DOM if not already cached
if (!this.debugCanvas) {
this.debugCanvas = document.getElementById(
"debug-velocity-canvas",
) as HTMLCanvasElement | null;
}
if (this.debugCanvas) {
// Show canvas and configure physics
this.debugCanvas.style.display = "block";
this.debugCanvas.width = this.fieldWidth;
this.debugCanvas.height = this.fieldHeight;
this.physics.updateConfig({ debugCanvas: this.debugCanvas });
}
} else {
if (this.debugCanvas) {
// Hide canvas and clear
this.debugCanvas.style.display = "none";
const ctx = this.debugCanvas.getContext("2d");
if (ctx)
ctx.clearRect(0, 0, this.debugCanvas.width, this.debugCanvas.height);
}
this.physics.updateConfig({ debugCanvas: null });
}
}
// Get debug mode status
public isDebugMode(): boolean {
return this.debugMode;
}
}