refactor: translate all Chinese comments to English and convert block comments to single-line comments

This commit is contained in:
2025-12-12 23:28:08 +08:00
parent 19a50ae89d
commit 675b51da17
8 changed files with 643 additions and 785 deletions

View File

@@ -1,48 +1,48 @@
export interface UserEntry {
name: string;
id: string;
link?: string;
name: string;
id: string;
link?: string;
}
export const MARBLE_CONFIG = {
// 弹珠大小配置
size: {
base: 192,
min: 96,
maxScreenRatio: 0.25,
},
// Marble size configuration
size: {
base: 192,
min: 96,
maxScreenRatio: 0.25,
},
// 弹珠初始速度配置
speed: {
min: 60,
max: 150,
},
// Marble initial speed configuration
speed: {
min: 60,
max: 150,
},
// 物理参数
physics: {
massScale: 0.01,
massOffset: 1,
damping: 0.9985, // 空气阻力/阻尼系数 (0-1)
restitution: 0.92, // 弹珠碰撞恢复系数 (0-1)
wallBounce: 0.85, // 墙壁反弹系数 (0-1)
minSpeed: 50, // 最小速度阈值,低于此值会加速到该值,方式为 CurrentSpeed *= scale = minSpeed / speed
maxSpeed: 800, // 全局最大速度限制,限制方式如上
},
// Physics parameters
physics: {
massScale: 0.01,
massOffset: 1,
damping: 0.9985, // Air resistance/damping coefficient (0-1)
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
},
// 动画配置
animation: {
fadeInDelay: 100,
fixedDeltaTime: 1 / 60, // 60 FPS
maxFrameTime: 0.1, // 100ms
},
// Animation configuration
animation: {
fadeInDelay: 100,
fixedDeltaTime: 1 / 60, // 60 FPS
maxFrameTime: 0.1, // 100ms
},
// 鼠标交互配置
mouseInteraction: {
attractRadius: 500, // 吸引范围
repelRadius: 300, // 排斥范围
repelForce: 400, // 排斥力
attractForce: 600, // 吸引力
},
// Mouse interaction configuration
mouseInteraction: {
attractRadius: 500, // Attraction radius
repelRadius: 300, // Repulsion radius
repelForce: 400, // Repulsion force
attractForce: 600, // Attraction force
},
} as const;
export const AVATAR_BASE_URL = "https://avatar.awfufu.com/qq/";

View File

@@ -1,24 +1,19 @@
/**
* 用户数据
* 从 API 获取用户列表并存储
*/
// User data: Fetch user list from API and store
import type { UserEntry } from "../config/marbleConfig";
export const USER_DATA_API = "https://avatar.awfufu.com/users";
/**
* 从 API 获取用户数据
*/
// Fetch user data from API
export async function fetchUsers(): Promise<UserEntry[]> {
try {
const response = await fetch(USER_DATA_API);
if (!response.ok) {
throw new Error(`Failed to fetch users: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error("Failed to fetch users:", error);
return [];
}
try {
const response = await fetch(USER_DATA_API);
if (!response.ok) {
throw new Error(`Failed to fetch users: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error("Failed to fetch users:", error);
return [];
}
}

View File

@@ -1,101 +1,86 @@
/**
* 动画循环管理器
* 使用固定时间步长的半固定时间步进法
*/
// Animation loop manager: Semi-fixed time stepping method using fixed time step
export type UpdateCallback = (dt: number) => void;
export class AnimationLoop {
private updateCallback: UpdateCallback;
private isRunning: boolean = false;
private lastTime: number = 0;
private accumulator: number = 0;
private animationFrameId: number | null = null;
private updateCallback: UpdateCallback;
private isRunning: boolean = false;
private lastTime: number = 0;
private accumulator: number = 0;
private animationFrameId: number | null = null;
private fixedDeltaTime: number;
private maxFrameTime: number;
private fixedDeltaTime: number;
private maxFrameTime: number;
constructor(
updateCallback: UpdateCallback,
fixedDeltaTime: number = 1 / 60,
maxFrameTime: number = 0.1
) {
this.updateCallback = updateCallback;
this.fixedDeltaTime = fixedDeltaTime;
this.maxFrameTime = maxFrameTime;
}
constructor(
updateCallback: UpdateCallback,
fixedDeltaTime: number = 1 / 60,
maxFrameTime: number = 0.1,
) {
this.updateCallback = updateCallback;
this.fixedDeltaTime = fixedDeltaTime;
this.maxFrameTime = maxFrameTime;
}
/**
* 动画循环逻辑
*/
private loop = (nowMs: number): void => {
if (!this.isRunning) return;
// Animation loop logic
private loop = (nowMs: number): void => {
if (!this.isRunning) return;
const now = nowMs / 1000;
let frameTime = now - this.lastTime;
const now = nowMs / 1000;
let frameTime = now - this.lastTime;
// 防止长时间暂停后的巨大时间跳跃(闲置切换标签页时几率触发)
if (frameTime > this.maxFrameTime) {
frameTime = this.maxFrameTime;
}
// Prevent huge time jumps after long pauses (triggered when switching tabs while idle)
if (frameTime > this.maxFrameTime) {
frameTime = this.maxFrameTime;
}
this.lastTime = now;
this.accumulator += frameTime;
this.lastTime = now;
this.accumulator += frameTime;
// 使用固定时间步长更新
while (this.accumulator >= this.fixedDeltaTime) {
this.updateCallback(this.fixedDeltaTime);
this.accumulator -= this.fixedDeltaTime;
}
// Update using fixed time step
while (this.accumulator >= this.fixedDeltaTime) {
this.updateCallback(this.fixedDeltaTime);
this.accumulator -= this.fixedDeltaTime;
}
this.animationFrameId = requestAnimationFrame(this.loop);
};
this.animationFrameId = requestAnimationFrame(this.loop);
};
/**
* 启动动画循环
*/
public start(): void {
if (this.isRunning) return;
// Start animation loop
public start(): void {
if (this.isRunning) return;
this.isRunning = true;
this.lastTime = performance.now() / 1000;
this.accumulator = 0;
this.animationFrameId = requestAnimationFrame(this.loop);
}
this.isRunning = true;
this.lastTime = performance.now() / 1000;
this.accumulator = 0;
this.animationFrameId = requestAnimationFrame(this.loop);
}
/**
* 停止动画循环
*/
public stop(): void {
this.isRunning = false;
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
}
// Stop animation loop
public stop(): void {
this.isRunning = false;
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
}
/**
* 暂停动画循环
*/
public pause(): void {
this.isRunning = false;
}
// Pause animation loop
public pause(): void {
this.isRunning = false;
}
/**
* 恢复动画循环
*/
public resume(): void {
if (this.isRunning) return;
// Resume animation loop
public resume(): void {
if (this.isRunning) return;
this.isRunning = true;
this.lastTime = performance.now() / 1000;
this.animationFrameId = requestAnimationFrame(this.loop);
}
this.isRunning = true;
this.lastTime = performance.now() / 1000;
this.animationFrameId = requestAnimationFrame(this.loop);
}
/**
* 检查是否正在运行
*/
public isActive(): boolean {
return this.isRunning;
}
// Check if running
public isActive(): boolean {
return this.isRunning;
}
}

View File

@@ -1,6 +1,4 @@
/**
* 统一导出
*/
// Unified export
export { MarbleSystem } from "./marbleSystem";
export { MarblePhysics } from "./marblePhysics";

View File

@@ -1,189 +1,158 @@
/**
* 弹珠工厂
* 负责创建和初始化弹珠实例
*/
// Marble factory: Responsible for creating and initializing marble instances
import type { Marble } from "./mouseInteraction";
import type { UserEntry } from "../config/marbleConfig";
import { MARBLE_CONFIG, AVATAR_BASE_URL } from "../config/marbleConfig";
export class MarbleFactory {
private container: HTMLElement;
private fieldWidth: number;
private fieldHeight: number;
private zoomLevel: number = 1.0;
private container: HTMLElement;
private fieldWidth: number;
private fieldHeight: number;
private zoomLevel: number = 1.0;
constructor(container: HTMLElement, fieldWidth: number, fieldHeight: number) {
this.container = container;
this.fieldWidth = fieldWidth;
this.fieldHeight = fieldHeight;
}
constructor(container: HTMLElement, fieldWidth: number, fieldHeight: number) {
this.container = container;
this.fieldWidth = fieldWidth;
this.fieldHeight = fieldHeight;
}
/**
* 计算弹珠大小(响应式)
*/
private calculateMarbleSize(): 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)) * this.zoomLevel;
}
// Calculate marble size (responsive)
private calculateMarbleSize(): 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)) * this.zoomLevel;
}
/**
* 生成头像 URL
*/
private getAvatarUrl(id: string): string {
return `${AVATAR_BASE_URL}${id}`;
}
// Generate avatar URL
private getAvatarUrl(id: string): string {
return `${AVATAR_BASE_URL}${id}`;
}
/**
* 创建弹珠 DOM 节点包装器
*/
private createMarbleWrapper(
entry: UserEntry,
size: number,
url: string
): HTMLElement {
const wrapper = document.createElement("div");
wrapper.className = "marble-wrapper";
wrapper.style.width = `${size}px`;
wrapper.style.height = `${size}px`;
wrapper.style.opacity = "0";
// Create marble DOM node wrapper
private createMarbleWrapper(entry: UserEntry, size: number, url: string): HTMLElement {
const wrapper = document.createElement("div");
wrapper.className = "marble-wrapper";
wrapper.style.width = `${size}px`;
wrapper.style.height = `${size}px`;
wrapper.style.opacity = "0";
const node = document.createElement("a");
node.className = "marble";
const node = document.createElement("a");
node.className = "marble";
if (entry.link) {
node.href = entry.link;
node.target = "_blank";
} else {
node.removeAttribute("href");
node.style.cursor = "default";
}
if (entry.link) {
node.href = entry.link;
node.target = "_blank";
} else {
node.removeAttribute("href");
node.style.cursor = "default";
}
node.style.backgroundImage = `url("${url}")`;
node.style.backgroundImage = `url("${url}")`;
const label = document.createElement("div");
label.className = "marble-label";
label.textContent = entry.name || entry.id;
node.appendChild(label);
const label = document.createElement("div");
label.className = "marble-label";
label.textContent = entry.name || entry.id;
node.appendChild(label);
wrapper.appendChild(node);
wrapper.appendChild(node);
return wrapper;
}
return wrapper;
}
/**
* 生成随机位置和速度
*/
private generateRandomPhysics(radius: number) {
const { min, max } = MARBLE_CONFIG.speed;
const startX = Math.random() * (this.fieldWidth - radius * 2) + radius;
const startY = Math.random() * (this.fieldHeight - radius * 2) + radius;
const speed = min + Math.random() * (max - min);
const angle = Math.random() * Math.PI * 2;
// Generate random position and velocity
private generateRandomPhysics(radius: number) {
const { min, max } = MARBLE_CONFIG.speed;
const startX = Math.random() * (this.fieldWidth - radius * 2) + radius;
const startY = Math.random() * (this.fieldHeight - radius * 2) + radius;
const speed = min + Math.random() * (max - min);
const angle = Math.random() * Math.PI * 2;
return {
x: startX,
y: startY,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
};
}
return {
x: startX,
y: startY,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
};
}
/**
* 创建弹珠(异步加载图片)
*/
public async createMarble(entry: UserEntry): Promise<Marble> {
return new Promise((resolve, reject) => {
if (!entry?.id) {
reject(new Error("Invalid user entry"));
return;
}
// Create marble (async image loading)
public async createMarble(entry: UserEntry): Promise<Marble> {
return new Promise((resolve, reject) => {
if (!entry?.id) {
reject(new Error("Invalid user entry"));
return;
}
const url = this.getAvatarUrl(entry.id);
const img = new Image();
const url = this.getAvatarUrl(entry.id);
const img = new Image();
img.onload = () => {
const size = this.calculateMarbleSize();
const radius = size / 2;
const wrapper = this.createMarbleWrapper(entry, size, url);
const physics = this.generateRandomPhysics(radius);
img.onload = () => {
const size = this.calculateMarbleSize();
const radius = size / 2;
const wrapper = this.createMarbleWrapper(entry, size, url);
const physics = this.generateRandomPhysics(radius);
const { massScale, massOffset } = MARBLE_CONFIG.physics;
const marble: Marble = {
id: entry.id,
node: wrapper,
x: physics.x,
y: physics.y,
vx: physics.vx,
vy: physics.vy,
radius,
mass: radius * radius * massScale + massOffset,
};
const { massScale, massOffset } = MARBLE_CONFIG.physics;
const marble: Marble = {
id: entry.id,
node: wrapper,
x: physics.x,
y: physics.y,
vx: physics.vx,
vy: physics.vy,
radius,
mass: radius * radius * massScale + massOffset,
};
this.container.appendChild(wrapper);
this.container.appendChild(wrapper);
// 淡入动画
setTimeout(() => {
wrapper.style.opacity = "1";
}, MARBLE_CONFIG.animation.fadeInDelay);
// Fade in animation
setTimeout(() => {
wrapper.style.opacity = "1";
}, MARBLE_CONFIG.animation.fadeInDelay);
resolve(marble);
};
resolve(marble);
};
img.onerror = () => {
reject(new Error(`Failed to load image: ${url}`));
};
img.onerror = () => {
reject(new Error(`Failed to load image: ${url}`));
};
img.src = url;
});
}
img.src = url;
});
}
/**
* 批量创建弹珠
*/
public async createMarbles(entries: UserEntry[]): Promise<Marble[]> {
const results = await Promise.allSettled(
entries.map((entry) => this.createMarble(entry))
);
// Batch create marbles
public async createMarbles(entries: UserEntry[]): Promise<Marble[]> {
const results = await Promise.allSettled(entries.map((entry) => this.createMarble(entry)));
const marbles: Marble[] = [];
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.status === "fulfilled") {
marbles.push(result.value);
} else {
console.warn(
`Failed to create marble for ${entries[i].name}:`,
result.reason
);
}
}
const marbles: Marble[] = [];
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.status === "fulfilled") {
marbles.push(result.value);
} else {
console.warn(`Failed to create marble for ${entries[i].name}:`, result.reason);
}
}
return marbles;
}
return marbles;
}
/**
* 更新场地尺寸
*/
public updateFieldSize(width: number, height: number): void {
this.fieldWidth = width;
this.fieldHeight = height;
}
// Update field size
public updateFieldSize(width: number, height: number): void {
this.fieldWidth = width;
this.fieldHeight = height;
}
/**
* 设置缩放级别
*/
public setZoomLevel(zoom: number): void {
this.zoomLevel = zoom;
}
// Set zoom level
public setZoomLevel(zoom: number): void {
this.zoomLevel = zoom;
}
/**
* 获取当前缩放级别
*/
public getZoomLevel(): number {
return this.zoomLevel;
}
// Get current zoom level
public getZoomLevel(): number {
return this.zoomLevel;
}
}

View File

@@ -1,172 +1,148 @@
/**
* 弹珠物理系统
* 负责弹珠的运动、碰撞检测和边界处理
*/
// Marble physics system: Handles marble movement, collision detection, and boundary processing
import type { Marble } from "./mouseInteraction";
export interface PhysicsConfig {
fieldWidth: number;
fieldHeight: number;
damping?: number; // 空气阻力系数
restitution?: number; // 碰撞恢复系数
wallBounce?: number; // 墙壁反弹系数
minSpeed?: number; // 最小速度
maxSpeed?: number; // 最大速度
fieldWidth: number;
fieldHeight: number;
damping?: number; // Air resistance coefficient
restitution?: number; // Collision restitution coefficient
wallBounce?: number; // Wall bounce coefficient
minSpeed?: number; // Minimum speed
maxSpeed?: number; // Maximum speed
}
export class MarblePhysics {
private config: PhysicsConfig;
private config: PhysicsConfig;
constructor(config: PhysicsConfig) {
this.config = {
fieldWidth: config.fieldWidth,
fieldHeight: config.fieldHeight,
damping: config.damping ?? 0.9985,
restitution: config.restitution ?? 0.92,
wallBounce: config.wallBounce ?? 0.85,
minSpeed: config.minSpeed ?? 30,
maxSpeed: config.maxSpeed ?? 800,
};
}
constructor(config: PhysicsConfig) {
this.config = {
fieldWidth: config.fieldWidth,
fieldHeight: config.fieldHeight,
damping: config.damping ?? 0.9985,
restitution: config.restitution ?? 0.92,
wallBounce: config.wallBounce ?? 0.85,
minSpeed: config.minSpeed ?? 30,
maxSpeed: config.maxSpeed ?? 800,
};
}
/**
* 更新弹珠位置
* @param marbles 弹珠数组
* @param dt 时间增量
*/
public updatePositions(marbles: Marble[], dt: number): void {
const { damping, minSpeed, maxSpeed } = this.config;
// Update marble positions
public updatePositions(marbles: Marble[], dt: number): void {
const { damping, minSpeed, maxSpeed } = this.config;
for (const m of marbles) {
// 应用空气阻力
if (damping !== undefined && damping < 1) {
const dampingFactor = Math.pow(damping, dt * 60); // 帧率独立
m.vx *= dampingFactor;
m.vy *= dampingFactor;
}
for (const m of marbles) {
// Apply air resistance
if (damping !== undefined && damping < 1) {
const dampingFactor = Math.pow(damping, dt * 60); // Frame rate independent
m.vx *= dampingFactor;
m.vy *= dampingFactor;
}
// 计算当前速度
const speed = Math.hypot(m.vx, m.vy);
// Calculate current speed
const speed = Math.hypot(m.vx, m.vy);
// 维持最小速度(防止完全静止)
if (minSpeed !== undefined && speed > 0 && speed < minSpeed) {
const scale = minSpeed / speed;
m.vx *= scale;
m.vy *= scale;
}
// Maintain minimum speed (prevent complete stop)
if (minSpeed !== undefined && speed > 0 && speed < minSpeed) {
const scale = minSpeed / speed;
m.vx *= scale;
m.vy *= scale;
}
// 限制最大速度
if (maxSpeed !== undefined && speed > maxSpeed) {
const scale = maxSpeed / speed;
m.vx *= scale;
m.vy *= scale;
}
// Limit maximum speed
if (maxSpeed !== undefined && speed > maxSpeed) {
const scale = maxSpeed / speed;
m.vx *= scale;
m.vy *= scale;
}
// 更新位置
m.x += m.vx * dt;
m.y += m.vy * dt;
}
}
// Update position
m.x += m.vx * dt;
m.y += m.vy * dt;
}
}
/**
* 处理弹珠之间的碰撞
* @param marbles 弹珠数组
*/
public handleCollisions(marbles: Marble[]): void {
const restitution = this.config.restitution ?? 1;
// Handle collisions between marbles
public handleCollisions(marbles: Marble[]): void {
const restitution = this.config.restitution ?? 1;
for (let i = 0; i < marbles.length; i++) {
for (let j = i + 1; j < marbles.length; j++) {
const a = marbles[i];
const b = marbles[j];
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.hypot(dx, dy) || 0.001;
const minDist = a.radius + b.radius;
for (let i = 0; i < marbles.length; i++) {
for (let j = i + 1; j < marbles.length; j++) {
const a = marbles[i];
const b = marbles[j];
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.hypot(dx, dy) || 0.001;
const minDist = a.radius + b.radius;
if (dist < minDist) {
const nx = dx / dist;
const ny = dy / dist;
const relativeVelocity = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
if (dist < minDist) {
const nx = dx / dist;
const ny = dy / dist;
const relativeVelocity = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
if (relativeVelocity < 0) {
// 应用恢复系数计算冲量
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;
}
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;
}
// 位置校正,防止重叠
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;
}
}
}
}
// 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;
}
}
}
}
/**
* 处理边界碰撞
* @param marbles 弹珠数组
*/
public handleBoundaries(marbles: Marble[]): void {
const { fieldWidth, fieldHeight, wallBounce } = this.config;
const bounce = wallBounce ?? 1;
// Handle boundary collisions
public handleBoundaries(marbles: Marble[]): void {
const { fieldWidth, fieldHeight, wallBounce } = this.config;
const bounce = wallBounce ?? 1;
for (const m of marbles) {
// 左边界
if (m.x - m.radius < 0) {
m.x = m.radius;
if (m.vx < 0) m.vx *= -bounce;
}
// 右边界
if (m.x + m.radius > fieldWidth) {
m.x = fieldWidth - m.radius;
if (m.vx > 0) m.vx *= -bounce;
}
// 上边界
if (m.y - m.radius < 0) {
m.y = m.radius;
if (m.vy < 0) m.vy *= -bounce;
}
// 下边界
if (m.y + m.radius > fieldHeight) {
m.y = fieldHeight - m.radius;
if (m.vy > 0) m.vy *= -bounce;
}
}
}
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;
}
}
}
/**
* 渲染弹珠到 DOM
* @param marbles 弹珠数组
*/
public render(marbles: Marble[]): void {
for (const m of marbles) {
m.node.style.transform = `translate(${m.x - m.radius}px, ${
m.y - m.radius
}px)`;
}
}
// Render marbles to DOM
public render(marbles: Marble[]): void {
for (const m of marbles) {
m.node.style.transform = `translate(${m.x - m.radius}px, ${m.y - m.radius}px)`;
}
}
/**
* 更新配置
* @param config 新的配置
*/
public updateConfig(config: Partial<PhysicsConfig>): void {
this.config = { ...this.config, ...config };
}
// Update configuration
public updateConfig(config: Partial<PhysicsConfig>): void {
this.config = { ...this.config, ...config };
}
/**
* 获取当前配置
*/
public getConfig(): PhysicsConfig {
return { ...this.config };
}
// Get current configuration
public getConfig(): PhysicsConfig {
return { ...this.config };
}
}

View File

@@ -1,7 +1,4 @@
/**
* 弹珠系统管理器
* 整合所有子系统,提供统一的 API
*/
// Marble system manager: Integrates all subsystems and provides a unified API
import type { Marble, MouseInteractionConfig } from "./mouseInteraction";
import type { UserEntry } from "../config/marbleConfig";
@@ -12,233 +9,195 @@ import { AnimationLoop } from "./animationLoop";
import { MARBLE_CONFIG } from "../config/marbleConfig";
export interface MarbleSystemConfig {
container: HTMLElement;
fieldWidth: number;
fieldHeight: number;
mouseInteractionConfig?: {
attractRadius?: number;
repelRadius?: number;
repelForce?: number;
attractForce?: number;
};
container: HTMLElement;
fieldWidth: number;
fieldHeight: number;
mouseInteractionConfig?: {
attractRadius?: number;
repelRadius?: number;
repelForce?: number;
attractForce?: number;
};
}
export class MarbleSystem {
private container: HTMLElement;
private marbles: Marble[] = [];
private container: HTMLElement;
private marbles: Marble[] = [];
// 子系统
private mouseInteraction: MouseInteraction;
private physics: MarblePhysics;
private factory: MarbleFactory;
private animationLoop: AnimationLoop;
// Subsystems
private mouseInteraction: MouseInteraction;
private physics: MarblePhysics;
private factory: MarbleFactory;
private animationLoop: AnimationLoop;
// 场地尺寸
private fieldWidth: number;
private fieldHeight: number;
// Field dimensions
private fieldWidth: number;
private fieldHeight: number;
constructor(config: MarbleSystemConfig) {
this.container = config.container;
this.fieldWidth = config.fieldWidth;
this.fieldHeight = config.fieldHeight;
constructor(config: MarbleSystemConfig) {
this.container = config.container;
this.fieldWidth = config.fieldWidth;
this.fieldHeight = config.fieldHeight;
// MouseInteraction Init
const mouseConfig: MouseInteractionConfig = {
attractRadius:
config.mouseInteractionConfig?.attractRadius ??
MARBLE_CONFIG.mouseInteraction.attractRadius,
repelRadius:
config.mouseInteractionConfig?.repelRadius ??
MARBLE_CONFIG.mouseInteraction.repelRadius,
repelForce:
config.mouseInteractionConfig?.repelForce ??
MARBLE_CONFIG.mouseInteraction.repelForce,
attractForce:
config.mouseInteractionConfig?.attractForce ??
MARBLE_CONFIG.mouseInteraction.attractForce,
};
this.mouseInteraction = new MouseInteraction(mouseConfig);
this.mouseInteraction.init();
// MouseInteraction Init
const mouseConfig: MouseInteractionConfig = {
attractRadius:
config.mouseInteractionConfig?.attractRadius ??
MARBLE_CONFIG.mouseInteraction.attractRadius,
repelRadius:
config.mouseInteractionConfig?.repelRadius ?? MARBLE_CONFIG.mouseInteraction.repelRadius,
repelForce:
config.mouseInteractionConfig?.repelForce ?? MARBLE_CONFIG.mouseInteraction.repelForce,
attractForce:
config.mouseInteractionConfig?.attractForce ?? MARBLE_CONFIG.mouseInteraction.attractForce,
};
this.mouseInteraction = new MouseInteraction(mouseConfig);
this.mouseInteraction.init();
// MarblePhysics Init
this.physics = new MarblePhysics({
fieldWidth: this.fieldWidth,
fieldHeight: this.fieldHeight,
damping: MARBLE_CONFIG.physics.damping,
restitution: MARBLE_CONFIG.physics.restitution,
wallBounce: MARBLE_CONFIG.physics.wallBounce,
minSpeed: MARBLE_CONFIG.physics.minSpeed,
maxSpeed: MARBLE_CONFIG.physics.maxSpeed,
});
// MarblePhysics Init
this.physics = new MarblePhysics({
fieldWidth: this.fieldWidth,
fieldHeight: this.fieldHeight,
damping: MARBLE_CONFIG.physics.damping,
restitution: MARBLE_CONFIG.physics.restitution,
wallBounce: MARBLE_CONFIG.physics.wallBounce,
minSpeed: MARBLE_CONFIG.physics.minSpeed,
maxSpeed: MARBLE_CONFIG.physics.maxSpeed,
});
// MarbleFactory Init
this.factory = new MarbleFactory(
this.container,
this.fieldWidth,
this.fieldHeight
);
// MarbleFactory Init
this.factory = new MarbleFactory(this.container, this.fieldWidth, this.fieldHeight);
// AnimationLoop Init
this.animationLoop = new AnimationLoop(
this.update.bind(this),
MARBLE_CONFIG.animation.fixedDeltaTime,
MARBLE_CONFIG.animation.maxFrameTime
);
// AnimationLoop Init
this.animationLoop = new AnimationLoop(
this.update.bind(this),
MARBLE_CONFIG.animation.fixedDeltaTime,
MARBLE_CONFIG.animation.maxFrameTime,
);
// 监听窗口大小变化
this.setupResizeHandler();
}
// Setup window resize handler
this.setupResizeHandler();
}
/**
* 每帧更新逻辑
*/
private update(dt: number): void {
// 应用鼠标力场
for (const marble of this.marbles) {
if (this.mouseInteraction.shouldApplyForce(marble)) {
this.mouseInteraction.applyForce(marble, dt);
}
}
// 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);
}
}
// 更新物理
this.physics.updatePositions(this.marbles, dt);
this.physics.handleCollisions(this.marbles);
this.physics.handleBoundaries(this.marbles);
this.physics.render(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);
}
/**
* 设置窗口大小变化监听
*/
private setupResizeHandler(): void {
window.addEventListener("resize", () => {
this.fieldWidth = window.innerWidth;
this.fieldHeight = window.innerHeight;
this.physics.updateConfig({
fieldWidth: this.fieldWidth,
fieldHeight: this.fieldHeight,
});
this.factory.updateFieldSize(this.fieldWidth, this.fieldHeight);
});
}
// Set up window resize listener
private setupResizeHandler(): void {
window.addEventListener("resize", () => {
this.fieldWidth = window.innerWidth;
this.fieldHeight = window.innerHeight;
this.physics.updateConfig({
fieldWidth: this.fieldWidth,
fieldHeight: this.fieldHeight,
});
this.factory.updateFieldSize(this.fieldWidth, this.fieldHeight);
});
}
/**
* 添加单个弹珠
*/
public async addMarble(entry: UserEntry): Promise<Marble> {
const marble = await this.factory.createMarble(entry);
this.marbles.push(marble);
return marble;
}
// Add a single marble
public async addMarble(entry: UserEntry): Promise<Marble> {
const marble = await this.factory.createMarble(entry);
this.marbles.push(marble);
return marble;
}
/**
* 批量添加弹珠
*/
public async addMarbles(entries: UserEntry[]): Promise<Marble[]> {
const newMarbles = await this.factory.createMarbles(entries);
this.marbles.push(...newMarbles);
return newMarbles;
}
// Batch add marbles
public async addMarbles(entries: UserEntry[]): Promise<Marble[]> {
const newMarbles = await this.factory.createMarbles(entries);
this.marbles.push(...newMarbles);
return newMarbles;
}
/**
* 移除弹珠
*/
public removeMarble(marbleId: string): boolean {
const index = this.marbles.findIndex((m) => m.id === marbleId);
if (index === -1) return false;
// Remove marble
public removeMarble(marbleId: string): boolean {
const index = this.marbles.findIndex((m) => m.id === marbleId);
if (index === -1) return false;
const marble = this.marbles[index];
marble.node.remove();
this.marbles.splice(index, 1);
return true;
}
const marble = this.marbles[index];
marble.node.remove();
this.marbles.splice(index, 1);
return true;
}
/**
* 清空所有弹珠
*/
public clear(): void {
for (const marble of this.marbles) {
marble.node.remove();
}
this.marbles = [];
}
// Clear all marbles
public clear(): void {
for (const marble of this.marbles) {
marble.node.remove();
}
this.marbles = [];
}
/**
* 启动动画
*/
public start(): void {
this.animationLoop.start();
}
// Start animation
public start(): void {
this.animationLoop.start();
}
/**
* 停止动画
*/
public stop(): void {
this.animationLoop.stop();
}
// Stop animation
public stop(): void {
this.animationLoop.stop();
}
/**
* 暂停动画
*/
public pause(): void {
this.animationLoop.pause();
}
// Pause animation
public pause(): void {
this.animationLoop.pause();
}
/**
* 恢复动画
*/
public resume(): void {
this.animationLoop.resume();
}
// Resume animation
public resume(): void {
this.animationLoop.resume();
}
/**
* 获取所有弹珠
*/
public getMarbles(): ReadonlyArray<Marble> {
return this.marbles;
}
// Get all marbles
public getMarbles(): ReadonlyArray<Marble> {
return this.marbles;
}
/**
* 获取弹珠数量
*/
public getMarbleCount(): number {
return this.marbles.length;
}
// Get marble count
public getMarbleCount(): number {
return this.marbles.length;
}
/**
* 更新弹珠大小(缩放)
*/
public updateMarbleSize(zoomLevel: number): void {
this.factory.setZoomLevel(zoomLevel);
const size = this.calculateMarbleSize(zoomLevel);
const radius = size / 2;
// Update marble size (zoom)
public updateMarbleSize(zoomLevel: number): void {
this.factory.setZoomLevel(zoomLevel);
const size = this.calculateMarbleSize(zoomLevel);
const radius = size / 2;
for (const m of this.marbles) {
m.radius = radius;
m.node.style.width = `${size}px`;
m.node.style.height = `${size}px`;
// 重新计算质量
const { massScale, massOffset } = MARBLE_CONFIG.physics;
m.mass = radius * radius * massScale + massOffset;
}
}
for (const m of this.marbles) {
m.radius = radius;
m.node.style.width = `${size}px`;
m.node.style.height = `${size}px`;
// Recalculate mass
const { massScale, massOffset } = MARBLE_CONFIG.physics;
m.mass = radius * radius * massScale + massOffset;
}
}
/**
* 计算弹珠大小
*/
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;
}
// 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;
}
/**
* 销毁系统
*/
public destroy(): void {
this.stop();
this.clear();
}
// Destroy system
public destroy(): void {
this.stop();
this.clear();
}
}

View File

@@ -1,171 +1,147 @@
/**
* 鼠标交互系统
* 负责处理鼠标对弹珠的力场效果(推开/吸引)
*/
// Mouse interaction system: Handles force field effects of mouse on marbles (repel/attract)
export interface Marble {
id: string;
node: HTMLElement;
x: number;
y: number;
vx: number;
vy: number;
radius: number;
mass: number;
id: string;
node: HTMLElement;
x: number;
y: number;
vx: number;
vy: number;
radius: number;
mass: number;
}
export interface MouseInteractionConfig {
attractRadius: number; // 吸引半径
repelRadius: number; // 排斥半径
repelForce: number; // 排斥力度
attractForce: number; // 吸引力度
attractRadius: number; // Attraction radius
repelRadius: number; // Repulsion radius
repelForce: number; // Repulsion force
attractForce: number; // Attraction force
}
export class MouseInteraction {
private mouseX: number = -1145;
private mouseY: number = -1145;
private isShiftPressed: boolean = false;
private lastMoveTime: number = 0;
private isMoving: boolean = false;
private config: MouseInteractionConfig;
private mouseX: number = -1145;
private mouseY: number = -1145;
private isShiftPressed: boolean = false;
private lastMoveTime: number = 0;
private isMoving: boolean = false;
private config: MouseInteractionConfig;
constructor(config: MouseInteractionConfig) {
this.config = config;
}
constructor(config: MouseInteractionConfig) {
this.config = config;
}
/**
* 初始化鼠标交互监听器
*/
public init(): void {
window.addEventListener("mousemove", (e) => {
this.mouseX = e.clientX;
this.mouseY = e.clientY;
// 实时同步 Shift 键状态,防止状态不一致
this.isShiftPressed = e.shiftKey;
// 标记鼠标正在移动
this.isMoving = true;
this.lastMoveTime = performance.now();
});
// Initialize mouse interaction listeners
public init(): void {
window.addEventListener("mousemove", (e) => {
this.mouseX = e.clientX;
this.mouseY = e.clientY;
// Sync Shift key state in real-time to prevent state inconsistency
this.isShiftPressed = e.shiftKey;
// Mark mouse as moving
this.isMoving = true;
this.lastMoveTime = performance.now();
});
window.addEventListener("keydown", (e) => {
if (e.key === "Shift") this.isShiftPressed = true;
});
window.addEventListener("keydown", (e) => {
if (e.key === "Shift") this.isShiftPressed = true;
});
window.addEventListener("keyup", (e) => {
if (e.key === "Shift") this.isShiftPressed = false;
});
window.addEventListener("keyup", (e) => {
if (e.key === "Shift") this.isShiftPressed = false;
});
// 窗口失焦时重置状态,防止状态卡住
window.addEventListener("blur", () => {
this.isShiftPressed = false;
});
// Reset state when window loses focus to prevent stuck state
window.addEventListener("blur", () => {
this.isShiftPressed = false;
});
// 页面隐藏时重置状态
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
this.isShiftPressed = false;
}
});
}
// Reset state when page is hidden
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
this.isShiftPressed = false;
}
});
}
/**
* 判断是否应该对弹珠应用力场
* @param marble 弹珠对象
* @returns 是否应用力场
*/
public shouldApplyForce(marble: Marble): boolean {
// 检查鼠标是否在最近移动过300ms 内)
const now = performance.now();
if (now - this.lastMoveTime > 300) {
this.isMoving = false;
}
// Determine if force field should be applied to marble
public shouldApplyForce(marble: Marble): boolean {
// Check if mouse moved recently (within 300ms)
const now = performance.now();
if (now - this.lastMoveTime > 300) {
this.isMoving = false;
}
// 只有鼠标移动时才应用力场
if (!this.isMoving) {
return false;
}
// Only apply force field when mouse is moving
if (!this.isMoving) {
return false;
}
const dx = marble.x - this.mouseX;
const dy = marble.y - this.mouseY;
const dist = Math.hypot(dx, dy);
const dx = marble.x - this.mouseX;
const dy = marble.y - this.mouseY;
const dist = Math.hypot(dx, dy);
// 如果鼠标悬停在弹珠上,不应用力场(允许点击链接)
if (dist <= marble.radius) {
return false;
}
// If mouse hovers over marble, do not apply force field (allow clicking links)
if (dist <= marble.radius) {
return false;
}
// 检查是否在力场影响半径内
const { attractRadius, repelRadius } = this.config;
const interactRadius = this.isShiftPressed ? attractRadius : repelRadius;
// Check if within force field influence radius
const { attractRadius, repelRadius } = this.config;
const interactRadius = this.isShiftPressed ? attractRadius : repelRadius;
// 只有在影响半径内才应用力场
return dist < interactRadius;
}
// Only apply force field if within influence radius
return dist < interactRadius;
}
/**
* 获取力场是否激活
*/
public isForceFieldActive(): boolean {
const now = performance.now();
return this.isMoving && now - this.lastMoveTime <= 300;
}
// Get whether force field is active
public isForceFieldActive(): boolean {
const now = performance.now();
return this.isMoving && now - this.lastMoveTime <= 300;
}
/**
* 应用鼠标力场效果
* 注意:此函数应该只在 shouldApplyForce 返回 true 时调用
* @param marble 弹珠对象
* @param dt 时间增量
*/
public applyForce(marble: Marble, dt: number): void {
// 在函数开始时固定状态,避免执行过程中状态变化
const isAttractMode = this.isShiftPressed;
const { attractRadius, repelRadius, repelForce, attractForce } =
this.config;
// Apply mouse force field effect (Note: This function should only be called when shouldApplyForce returns true)
public applyForce(marble: Marble, dt: number): void {
// Fix state at the start of function to avoid state change during execution
const isAttractMode = this.isShiftPressed;
const { attractRadius, repelRadius, repelForce, attractForce } = this.config;
const dx = marble.x - this.mouseX;
const dy = marble.y - this.mouseY;
const dist = Math.hypot(dx, dy) || 0.001; // 防止除零
const dx = marble.x - this.mouseX;
const dy = marble.y - this.mouseY;
const dist = Math.hypot(dx, dy) || 0.001; // Prevent division by zero
// 根据模式选择不同的半径和力度
const interactRadius = isAttractMode ? attractRadius : repelRadius;
const force = isAttractMode ? attractForce : repelForce;
// Select different radius and force based on mode
const interactRadius = isAttractMode ? attractRadius : repelRadius;
const force = isAttractMode ? attractForce : repelForce;
// 使用对数衰减曲线:-log(t + 0.1),非常柔和的过渡
const t = dist / interactRadius;
const strength = -Math.log(t * 0.9 + 0.1) * force * dt;
const angle = Math.atan2(dy, dx);
// Use logarithmic decay curve: -log(t + 0.1), very smooth transition
const t = dist / interactRadius;
const strength = -Math.log(t * 0.9 + 0.1) * force * dt;
const angle = Math.atan2(dy, dx);
if (isAttractMode) {
// Shift 键:吸引模式(拉向鼠标)
marble.vx -= Math.cos(angle) * strength;
marble.vy -= Math.sin(angle) * strength;
} else {
// 默认:排斥模式(推开)
marble.vx += Math.cos(angle) * strength;
marble.vy += Math.sin(angle) * strength;
}
// 注意:速度限制已在物理系统中统一处理
}
if (isAttractMode) {
// Shift key: Attraction mode (pull towards mouse)
marble.vx -= Math.cos(angle) * strength;
marble.vy -= Math.sin(angle) * strength;
} else {
// Default: Repulsion mode (push away)
marble.vx += Math.cos(angle) * strength;
marble.vy += Math.sin(angle) * strength;
}
// Note: Speed limits are handled uniformly in the physics system
}
/**
* 更新配置
* @param config 新的配置
*/
public updateConfig(config: Partial<MouseInteractionConfig>): void {
this.config = { ...this.config, ...config };
}
// Update configuration
public updateConfig(config: Partial<MouseInteractionConfig>): void {
this.config = { ...this.config, ...config };
}
/**
* 获取当前鼠标位置
*/
public getMousePosition(): { x: number; y: number } {
return { x: this.mouseX, y: this.mouseY };
}
// Get current mouse position
public getMousePosition(): { x: number; y: number } {
return { x: this.mouseX, y: this.mouseY };
}
/**
* 获取是否按住 Shift 键
*/
public isAttractMode(): boolean {
return this.isShiftPressed;
}
// Get if Shift key is pressed
public isAttractMode(): boolean {
return this.isShiftPressed;
}
}