mirror of
https://github.com/101island/lolisland.us.git
synced 2026-03-01 03:49:42 +08:00
@@ -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%
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/";
|
||||
|
||||
135
src/utils/deviceMotionInteraction.ts
Normal file
135
src/utils/deviceMotionInteraction.ts
Normal 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 Permission(iOS 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 };
|
||||
}
|
||||
}
|
||||
161
src/utils/deviceOrientationInteraction.ts
Normal file
161
src/utils/deviceOrientationInteraction.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user