mirror of
https://github.com/101island/lolisland.us.git
synced 2026-03-01 03:49:42 +08:00
refactor: translate all Chinese comments to English and convert block comments to single-line comments
This commit is contained in:
@@ -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/";
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
/**
|
||||
* 统一导出
|
||||
*/
|
||||
// Unified export
|
||||
|
||||
export { MarbleSystem } from "./marbleSystem";
|
||||
export { MarblePhysics } from "./marblePhysics";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user