Kattints a fekete dobozra golyók hozzáadásához
Pár éve írtam egy bolondos fizikai kísérletet a nagyszerű Matter.js motorral. Ma megpróbálunk egy hasonlót létrehozni (bevallottan egyszerűsítve), külső könyvtárak nélkül.
Mit csinál: gravitáció alatt mozgó golyókat (köröket) szimulál, észleli a kör-kör és kör-vonal ütközéseket, szétválasztja az átfedéseket, és egyszerű rugalmas impulzusokat alkalmaz a sebesség megváltoztatásához.
Mit NEM csinál: folyamatos ütközésérzékelést (gyors mozgásnál "alagút-effektus" léphet fel), forgási dinamikát és széles fázisú optimalizálást. Ezek komoly korlátok, de a megoldás még így is szórakoztató, ha belegondolunk, hogy csak kb. 100 sor kód.
Alapötletek: vektormatek, Euler-integráció, legközelebbi pont projekció a vonalütközésekhez, és impulzusfeloldás a sebességváltozásokhoz. Lásd az algoritmus-referenciákat a kör-vonal vetítéshez és az ütközési matematikához.
integrate()Cél: Az integrate() függvény előreviszi az objektumokat az időben a sebességük és pozíciójuk frissítésével.
A fizikai motorok kis időlépésekben frissítik a mozgást. Minden képkockában:
Ezt a módszert Euler-integrációnak hívják, ami a legegyszerűbb numerikus integrációs technika.
integrate(dt) {
let gravity = new Vec(0, 300);
this.vel = this.vel.add(gravity.mul(dt));
this.pos = this.pos.add(this.vel.mul(dt));
}
gravity.mul(dt) kiszámítja, mennyit változik a sebesség ebben a képkockában.this.vel.add(...) alkalmazza ezt a változást a sebességre.this.pos.add(this.vel.mul(dt)) elmozdítja az objektumot a frissített sebesség alapján.Ez Newton törvényeit követi: a gyorsulás hat a sebességre, a sebesség pedig a pozícióra.
A motorod kétféle ütközést old fel:
Mindkettő ugyanazt a háromlépéses mintát követi.
A normális egy egységvektor, amely a felülettől az objektum felé mutat.
Az objektumokat széttoljuk, hogy ne fedjék át egymást. Ez megakadályozza a remegést vagy az elsüllyedést.
let penetration = a.r + b.r - d;
a.pos = a.pos.add(n.mul(-penetration * (a.invM / totalInv)));
b.pos = b.pos.add(n.mul(penetration * (b.invM / totalInv)));
A tömeg határozza meg, mennyit mozog az egyes objektum — a könnyebb objektumok többet mozdulnak.
Az impulzusok az ütközések okozta pillanatnyi sebességváltozások. Ezek nem erők; azonnal hatnak.
impulse = -(1 + restitution)
* relativeVelocityAlongNormal
/ totalInverseMass
let rel = b.vel.sub(a.vel);
let vn = rel.dot(n);
if (vn > 0) return;
let e = 0.8;
let j = -(1 + e) * vn / totalInv;
let impulse = n.mul(j);
a.applyImpulse(impulse.mul(-1));
b.applyImpulse(impulse);
Az eredmény valósághű pattogás és csúszás.
A szimuláció minden képkockája:
Ez a ciklus hozza létre a sima, hihető mozgást és interakciókat.
További JS útmutatók:
Goldmine - idle játék (~200 sor)
Teljes kód:
// Vector utilities
class Vec {
constructor(x=0, y=0) {
this.x = x;
this.y = y;
}
add(v) {
return new Vec(this.x + v.x,this.y + v.y);
}
sub(v) {
return new Vec(this.x - v.x,this.y - v.y);
}
mul(s) {
return new Vec(this.x * s,this.y * s);
}
dot(v) {
return this.x * v.x + this.y * v.y;
}
len() {
return Math.hypot(this.x, this.y);
}
norm() {
let l = this.len() || 1;
return this.mul(1 / l);
}
}
// Circle body
class Circle {
constructor(x, y, r, m=1) {
this.pos = new Vec(x,y);
this.vel = new Vec(0,0);
this.r = r;
this.m = m;
this.invM = 1 / m;
}
applyImpulse(j) {
this.vel = this.vel.add(j.mul(this.invM));
}
integrate(dt) {
let gravity = new Vec(0,300);
this.vel = this.vel.add(gravity.mul(dt));
this.pos = this.pos.add(this.vel.mul(dt));
}
}
// Line segment
class Line {
constructor(x1, y1, x2, y2) {
this.a = new Vec(x1,y1);
this.b = new Vec(x2,y2);
}
closestPoint(p) {
let ab = this.b.sub(this.a);
let t = p.sub(this.a).dot(ab) / ab.dot(ab);
t = Math.max(0, Math.min(1, t));
return this.a.add(ab.mul(t));
}
}
// Collision and scene
function resolveCircleLine(circle, line) {
let cp = line.closestPoint(circle.pos);
let diff = circle.pos.sub(cp);
let dist = diff.len();
if (dist < circle.r) {
let n = diff.norm();
let penetration = circle.r - dist;
circle.pos = circle.pos.add(n.mul(penetration + 0.01));
let vn = circle.vel.dot(n);
if (vn < 0) {
let e = 0.8;
circle.vel = circle.vel.sub(n.mul((1 + e) * vn));
}
}
}
function resolveCircleCircle(a, b) {
let diff = b.pos.sub(a.pos);
let d = diff.len();
if (d === 0) {
return;
}
if (d < a.r + b.r) {
let n = diff.mul(1 / d);
let penetration = a.r + b.r - d;
let totalInv = a.invM + b.invM;
a.pos = a.pos.add(n.mul(-penetration * (a.invM / totalInv)));
b.pos = b.pos.add(n.mul(penetration * (b.invM / totalInv)));
let rel = b.vel.sub(a.vel);
let vn = rel.dot(n);
if (vn > 0) {
return;
}
let e = 0.8;
let j = -(1 + e) * vn / totalInv;
let impulse = n.mul(j);
a.applyImpulse(impulse.mul(-1));
b.applyImpulse(impulse);
}
}
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let counter = 0;
let circles = [new Circle(250,150,20,1), new Circle(510,120,16,1), new Circle(170,120,10,1)];
let lines = [];
const lineCount = 16;
for (let i = 0; i < lineCount; i++) {
lines.push(new Line(100,100,200,200));
}
function step(dt) {
for (let c of circles) {
c.integrate(dt);
}
for (let i = 0; i < circles.length; i++) {
for (let j = i + 1; j < circles.length; j++) {
resolveCircleCircle(circles[i], circles[j]);
}
}
for (let c of circles) {
for (let l of lines) {
resolveCircleLine(c, l);
}
}
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let l of lines) {
ctx.strokeStyle = '#888';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(l.a.x, l.a.y);
ctx.lineTo(l.b.x, l.b.y);
ctx.stroke();
}
for (let c of circles) {
ctx.fillStyle = '#4af';
ctx.beginPath();
ctx.arc(c.pos.x, c.pos.y, c.r, 0, Math.PI * 2);
ctx.fill();
}
}
const armLength = 70;
const speed = 0.009;
let last = performance.now();
function loop(t) {
let dt = Math.min(0.033, (t - last) / 1000);
step(dt);
draw();
last = t;
// Spinning crosses
for (let i = 0; i < lineCount / 2; i++) {
lines[i * 2].a.x = 100 * i + armLength * Math.sin(counter);
lines[i * 2].a.y = 350 + armLength * Math.cos(counter);
lines[i * 2].b.x = 100 * i - armLength * Math.sin(counter);
lines[i * 2].b.y = 350 - armLength * Math.cos(counter);
lines[i * 2 + 1].a.x = 100 * i - armLength * Math.cos(counter);
lines[i * 2 + 1].a.y = 350 + armLength * Math.sin(counter);
lines[i * 2 + 1].b.x = 100 * i + armLength * Math.cos(counter);
lines[i * 2 + 1].b.y = 350 - armLength * Math.sin(counter);
}
counter = counter + speed;
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
window.onpointerdown = function(e) {
circles.push(new Circle(e.offsetX,e.offsetY,20,1));
}