
每次点击“添加球”按钮时,旧球被随机重置位置,是因为 balls 数组和物理逻辑被错误地重复初始化;正确做法是将球数组、边界线等全局状态提取到函数外部,仅在 addBall() 中执行新增逻辑。
每次点击“添加球”按钮时,旧球被随机重置位置,是因为 `balls` 数组和物理逻辑被错误地重复初始化;正确做法是将球数组、边界线等全局状态提取到函数外部,仅在 `addball()` 中执行新增逻辑。
问题根源在于原始代码中将核心数据结构(如 balls = []、lines = [])和初始化逻辑(如 create() 调用、mainLoop() 启动)全部封装在 addBall() 函数内部。这意味着每次调用 addBall() 都会重新声明空的 balls 数组、重建 lines 边界、并重跑整个初始化流程——导致已有球对象被丢弃,新球生成时旧球“消失”,视觉上表现为位置随机重置。
✅ 正确的结构组织方式
应严格分离「状态声明」、「初始化」和「增量操作」:
-
全局状态(只声明一次):
balls、lines、canvas、ctx等应在最外层作用域定义; -
一次性初始化(只执行一次):边界线
lines的创建、Canvas 尺寸设置、mainLoop()启动等,应放在脚本加载后立即执行; -
纯增量逻辑(每次调用仅新增):
addBall()函数内只负责生成一个新球、校验不重叠、推入balls数组,不触碰已有球或重置任何状态。
以下是重构后的关键代码结构(ES6 风格,更清晰、更安全):
// === 全局状态声明(顶层作用域)===
const canvas = document.querySelector('#canvas');
const ctx = canvas.getContext('2d');
canvas.width = 1000;
canvas.height = 550;
const balls = []; // ✅ 所有球实例持久存在
const lines = []; // ✅ 边界线只需创建一次
// 物理常量
const TAU = Math.PI * 2;
const gravity = 0;
const wallLoss = 1;
const minBallSize = 13;
const maxBallSize = 20;
const velMin = 1;
const velMax = 5;
const maxResolutionCycles = 100;
// 工具函数(避免污染 Math)
const rand = (min, max) => Math.random() * (max - min) + min;
const randInt = (min, max) => Math.floor(Math.random() * (max - min)) + min;
// === 初始化:仅执行一次 ===
function initWalls() {
const w = canvas.width;
const h = canvas.height;
lines.push(new Line(-10, 10, w + 10, 5)); // top
lines.push(new Line(-10, h - 2, w + 10, h - 10)); // bottom
lines.push(new Line(10, -10, 4, h + 10)); // left
lines.push(new Line(w - 3, -10, w - 10, h + 10)); // right
}
initWalls();
// === 核心类定义(ES6 class,语义清晰)===
class Ball {
constructor(x, y, vx, vy, r = rand(minBallSize, maxBallSize)) {
this.r = r;
this.m = (4 / 3) * Math.PI * r ** 3;
this.x = x;
this.y = y;
this.vx = vx;
this.vy = vy;
}
update() {
this.x += this.vx;
this.y += this.vy;
this.vy += gravity;
}
draw() {
ctx.beginPath();
ctx.arc(this.x, this.y, this.r, 0, TAU);
ctx.fill();
}
doesOverlap(other) {
const dx = this.x - other.x;
const dy = this.y - other.y;
return Math.sqrt(dx * dx + dy * dy) < this.r + other.r;
}
}
class Line {
constructor(x1, y1, x2, y2) {
this.x1 = x1; this.y1 = y1;
this.x2 = x2; this.y2 = y2;
}
draw() {
ctx.beginPath();
ctx.moveTo(this.x1, this.y1);
ctx.lineTo(this.x2, this.y2);
}
}
// === 碰撞检测与解析(略去具体实现,保持逻辑独立)===
function resolveCollisions() { /* ... */ }
// === 增量操作:仅新增一个球 ===
function addBall() {
const w = canvas.width;
const h = canvas.height;
let tries = 100;
while (tries--) {
const x = rand(maxBallSize + 10, w - maxBallSize - 10);
const y = rand(maxBallSize + 10, h - maxBallSize - 10);
const dir = rand(0, TAU);
const vel = rand(velMin, velMax);
const ball = new Ball(
x, y,
Math.cos(dir) * vel,
Math.sin(dir) * vel
);
// 检查是否与现有球重叠
if (!balls.some(b => ball.doesOverlap(b))) {
balls.push(ball);
document.querySelector('#ball-count').textContent = balls.length;
break;
}
}
}
// === 主循环:驱动所有球持续更新 ===
function mainLoop() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
resolveCollisions(); // 处理潜在碰撞(时间步进)
balls.forEach(ball => ball.update());
// 绘制
balls.forEach(ball => ball.draw());
lines.forEach(line => line.draw());
requestAnimationFrame(mainLoop);
}
// ✅ 启动主循环(全局仅调用一次!)
mainLoop();⚠️ 关键注意事项
-
禁止在
addBall()内重新声明balls = []或调用initWalls():这是导致旧球丢失的直接原因; -
移除对
Math的扩展(如Math.rand):污染原生对象易引发兼容性问题和调试困难,应使用独立工具函数; -
用
balls.length替代numBalls计数器:避免状态冗余和不同步风险; -
边界线
lines只需初始化一次:重复添加会导致多层冗余线条,影响性能与碰撞逻辑; -
碰撞检测前务必确保
balls是稳定引用:resolveCollisions()必须遍历同一个balls数组实例。
通过以上重构,addBall() 真正成为“无副作用”的纯添加操作,旧球状态完全保留,新球自然融入现有物理系统——既解决了位置重置问题,也为后续扩展(如删除球、调整参数)打下坚实基础。
立即学习“前端免费学习笔记(深入)”;



















