mirror of
https://github.com/101island/lolisland.us.git
synced 2026-03-01 03:49:42 +08:00
Merge pull request #9 from 101island/feature/deviceMotion
feat: add device motion interaction
This commit is contained in:
@@ -63,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");
|
||||
@@ -86,6 +134,16 @@ import Particles from "./Particles.astro";
|
||||
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) => {
|
||||
@@ -315,6 +373,31 @@ import Particles from "./Particles.astro";
|
||||
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) {
|
||||
position: absolute;
|
||||
will-change: transform, width, height;
|
||||
|
||||
@@ -54,6 +54,18 @@
|
||||
<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>
|
||||
@@ -94,6 +106,12 @@
|
||||
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
|
||||
@@ -107,6 +125,16 @@
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ 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)
|
||||
}
|
||||
|
||||
export class MarblePhysics {
|
||||
@@ -26,6 +28,8 @@ export class MarblePhysics {
|
||||
minSpeed: config.minSpeed ?? 30,
|
||||
maxSpeed: config.maxSpeed ?? 800,
|
||||
enableCollisions: config.enableCollisions ?? true,
|
||||
debugCanvas: config.debugCanvas ?? null,
|
||||
debugVectorScale: config.debugVectorScale ?? 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,7 +162,7 @@ export class MarblePhysics {
|
||||
const ny = dy / dist;
|
||||
const relativeVelocity = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
|
||||
|
||||
if (relativeVelocity < 0) {
|
||||
if (relativeVelocity > 0) {
|
||||
// Calculate impulse using restitution coefficient
|
||||
const impulse =
|
||||
((1 + restitution) * relativeVelocity) / (a.mass + b.mass);
|
||||
@@ -226,4 +230,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 = 2;
|
||||
} else {
|
||||
subSteps = 4;
|
||||
}
|
||||
|
||||
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.handleBoundaries(this.marbles);
|
||||
}
|
||||
|
||||
// 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