bubbles / files / src / physics / World.ts
World.ts
dcc4757f 2026-04-09 Pavel Safronov | 1 | import { Vector2 } from './Vector2.js'; |
| 2 | import { Water } from './Water.js'; | |
| 3 | import { RigidBody } from './RigidBody.js'; | |
| 4 | ||
| 5 | const WATER_DENSITY = 1.0; | |
775e32f9 2026-04-09 Pavel Safronov | 6 | const RESTITUTION = 0.35; |
| 7 | const FRICTION = 0.72; | |
dcc4757f 2026-04-09 Pavel Safronov | 8 | const BODY_RESTITUTION = 0.4; |
| 9 | const ANGULAR_DAMPING = 0.96; | |
| 10 | ||
| 11 | export class World { | |
| 12 | bodies: RigidBody[] = []; | |
| 13 | gravity: Vector2 = new Vector2(0, 980); | |
775e32f9 2026-04-09 Pavel Safronov | 14 | readonly maxBodies: number; |
dcc4757f 2026-04-09 Pavel Safronov | 15 | |
| 16 | constructor( | |
| 17 | public water: Water, | |
| 18 | public glassX: number, | |
| 19 | public glassY: number, | |
775e32f9 2026-04-09 Pavel Safronov | 20 | maxBodies = 12, |
| 21 | ) { | |
| 22 | this.maxBodies = maxBodies; | |
| 23 | } | |
dcc4757f 2026-04-09 Pavel Safronov | 24 | |
| 25 | get glassW(): number { return this.water.glassW; } | |
| 26 | get glassH(): number { return this.water.glassH; } | |
| 27 | ||
| 28 | addBody(body: RigidBody): void { | |
| 29 | this.bodies.push(body); | |
775e32f9 2026-04-09 Pavel Safronov | 30 | if (this.bodies.length > this.maxBodies) { |
fc6e1fbf 2026-04-09 Pavel Safronov | 31 | const idx = this.bodies.findIndex(b => !b.permanent); |
| 32 | if (idx !== -1) this.bodies.splice(idx, 1); | |
dcc4757f 2026-04-09 Pavel Safronov | 33 | } |
| 34 | } | |
| 35 | ||
| 36 | step(dt: number): void { | |
| 37 | const g = this.gravity; | |
| 38 | ||
775e32f9 2026-04-09 Pavel Safronov | 39 | // Advance water simulation (signed verticalG so Water can detect flips) |
| 40 | this.water.step(dt, g.x, g.y); | |
dcc4757f 2026-04-09 Pavel Safronov | 41 | |
| 42 | for (const body of this.bodies) { | |
| 43 | // --- Buoyancy --- | |
| 44 | const submergedFrac = this.submergedFraction(body); | |
| 45 | const bodyArea = Math.PI * body.radius * body.radius; | |
775e32f9 2026-04-09 Pavel Safronov | 46 | // Buoyancy magnitude uses |g| so it works at any orientation |
| 47 | const buoyancyMag = WATER_DENSITY * g.magnitude() * bodyArea * submergedFrac; | |
dcc4757f 2026-04-09 Pavel Safronov | 48 | |
| 49 | // Net force = gravity - buoyancy (both along Y axis primarily) | |
| 50 | const gravityForce = g.scale(body.mass); | |
| 51 | // Buoyancy always opposes gravity direction | |
| 52 | const gDir = g.magnitude() > 0 ? g.normalize() : new Vector2(0, 1); | |
| 53 | const buoyancyForce = gDir.scale(-buoyancyMag); | |
| 54 | ||
| 55 | // Drag when submerged — opposes velocity | |
| 56 | const dragCoeff = submergedFrac * 3.5; | |
| 57 | const drag = body.velocity.scale(-dragCoeff * body.mass); | |
| 58 | ||
| 59 | const netForce = gravityForce.add(buoyancyForce).add(drag); | |
| 60 | const acceleration = netForce.scale(1 / body.mass); | |
| 61 | ||
| 62 | body.velocity.addMut(acceleration.scale(dt)); | |
| 63 | ||
| 64 | // Integrate position | |
| 65 | body.position.addMut(body.velocity.scale(dt)); | |
| 66 | ||
fc6e1fbf 2026-04-09 Pavel Safronov | 67 | // --- Disturb water surface based on body movement --- |
| 68 | this.disturbWater(body); | |
| 69 | ||
dcc4757f 2026-04-09 Pavel Safronov | 70 | // Angular |
| 71 | body.angularVelocity *= ANGULAR_DAMPING; | |
| 72 | body.angle += body.angularVelocity * dt; | |
| 73 | ||
| 74 | // --- Glass wall / floor constraints (in local glass space) --- | |
| 75 | this.constrainToGlass(body); | |
| 76 | } | |
| 77 | ||
775e32f9 2026-04-09 Pavel Safronov | 78 | // Body-body collisions |
dcc4757f 2026-04-09 Pavel Safronov | 79 | for (let i = 0; i < this.bodies.length; i++) { |
| 80 | for (let j = i + 1; j < this.bodies.length; j++) { | |
| 81 | this.resolveBodyCollision(this.bodies[i]!, this.bodies[j]!); | |
| 82 | } | |
| 83 | } | |
775e32f9 2026-04-09 Pavel Safronov | 84 | // Sealed container — no escape culling. |
dcc4757f 2026-04-09 Pavel Safronov | 85 | } |
| 86 | ||
| 87 | private submergedFraction(body: RigidBody): number { | |
| 88 | const localX = body.position.x - this.glassX; | |
| 89 | const localY = body.position.y - this.glassY; | |
| 90 | ||
| 91 | const r = body.radius; | |
| 92 | const surfaceY = this.water.getSurfaceY(Math.max(0, Math.min(this.glassW, localX))); | |
| 93 | ||
775e32f9 2026-04-09 Pavel Safronov | 94 | // d > 0 → body center is on the water side of the surface → submerged |
| 95 | // d < 0 → body center is on the air side | |
| 96 | // | |
| 97 | // Normal: water is BELOW surfaceY → submerged when localY > surfaceY | |
| 98 | // Flipped: water is ABOVE surfaceY → submerged when localY < surfaceY | |
| 99 | const d = this.water.isFlipped | |
| 100 | ? surfaceY - localY // positive when center is above surface (in water) | |
| 101 | : localY - surfaceY; // positive when center is below surface (in water) | |
dcc4757f 2026-04-09 Pavel Safronov | 102 | |
775e32f9 2026-04-09 Pavel Safronov | 103 | if (d >= r) return 1.0; |
| 104 | if (d <= -r) return 0.0; | |
dcc4757f 2026-04-09 Pavel Safronov | 105 | |
775e32f9 2026-04-09 Pavel Safronov | 106 | const h = d + r; |
dcc4757f 2026-04-09 Pavel Safronov | 107 | const segmentArea = r * r * Math.acos((r - h) / r) - (r - h) * Math.sqrt(2 * r * h - h * h); |
| 108 | return segmentArea / (Math.PI * r * r); | |
| 109 | } | |
| 110 | ||
| 111 | private constrainToGlass(body: RigidBody): void { | |
| 112 | const r = body.radius; | |
| 113 | let lx = body.position.x - this.glassX; | |
| 114 | let ly = body.position.y - this.glassY; | |
| 115 | ||
775e32f9 2026-04-09 Pavel Safronov | 116 | // Left wall |
| 117 | if (lx - r < 0) { | |
| 118 | lx = r; | |
| 119 | body.velocity.x = Math.abs(body.velocity.x) * RESTITUTION; | |
| 120 | body.velocity.y *= FRICTION; | |
| 121 | body.angularVelocity += body.velocity.y * 0.01; | |
| 122 | } | |
| 123 | // Right wall | |
| 124 | if (lx + r > this.glassW) { | |
| 125 | lx = this.glassW - r; | |
| 126 | body.velocity.x = -Math.abs(body.velocity.x) * RESTITUTION; | |
| 127 | body.velocity.y *= FRICTION; | |
| 128 | body.angularVelocity -= body.velocity.y * 0.01; | |
| 129 | } | |
| 130 | // Ceiling (sealed top) | |
| 131 | if (ly - r < 0) { | |
| 132 | ly = r; | |
| 133 | body.velocity.y = Math.abs(body.velocity.y) * RESTITUTION; | |
| 134 | body.velocity.x *= FRICTION; | |
dcc4757f 2026-04-09 Pavel Safronov | 135 | } |
| 136 | // Floor | |
| 137 | if (ly + r > this.glassH) { | |
| 138 | ly = this.glassH - r; | |
| 139 | body.velocity.y = -Math.abs(body.velocity.y) * RESTITUTION; | |
| 140 | body.velocity.x *= FRICTION; | |
| 141 | } | |
| 142 | ||
| 143 | body.position.x = lx + this.glassX; | |
| 144 | body.position.y = ly + this.glassY; | |
| 145 | } | |
| 146 | ||
fc6e1fbf 2026-04-09 Pavel Safronov | 147 | private disturbWater(body: RigidBody): void { |
| 148 | const localX = body.position.x - this.glassX; | |
| 149 | const localY = body.position.y - this.glassY; | |
| 150 | const surfaceY = this.water.getSurfaceY(Math.max(0, Math.min(this.glassW, localX))); | |
| 151 | ||
| 152 | const distToSurface = Math.abs(localY - surfaceY); | |
| 153 | if (distToSurface > body.radius * 1.5) return; | |
| 154 | ||
775e32f9 2026-04-09 Pavel Safronov | 155 | // Positive velY means moving toward gravity wall; negate when flipped so |
| 156 | // the disturbance always represents an impact on the water surface. | |
| 157 | const velY = this.water.isFlipped ? -body.velocity.y : body.velocity.y; | |
| 158 | if (Math.abs(velY) < 10) return; | |
fc6e1fbf 2026-04-09 Pavel Safronov | 159 | |
| 160 | this.water.disturb(localX, body.radius, velY); | |
| 161 | } | |
| 162 | ||
dcc4757f 2026-04-09 Pavel Safronov | 163 | private resolveBodyCollision(a: RigidBody, b: RigidBody): void { |
| 164 | const delta = b.position.sub(a.position); | |
| 165 | const dist = delta.magnitude(); | |
| 166 | const minDist = a.radius + b.radius; | |
| 167 | ||
| 168 | if (dist >= minDist || dist === 0) return; | |
| 169 | ||
| 170 | const normal = delta.scale(1 / dist); | |
| 171 | const overlap = minDist - dist; | |
| 172 | ||
| 173 | // Positional correction — split by mass | |
| 174 | const totalMass = a.mass + b.mass; | |
| 175 | a.position.addMut(normal.scale(-overlap * (b.mass / totalMass))); | |
| 176 | b.position.addMut(normal.scale(overlap * (a.mass / totalMass))); | |
| 177 | ||
| 178 | // Velocity resolution | |
| 179 | const relVel = b.velocity.sub(a.velocity); | |
| 180 | const velAlongNormal = relVel.dot(normal); | |
| 181 | if (velAlongNormal > 0) return; // already separating | |
| 182 | ||
| 183 | const e = BODY_RESTITUTION; | |
| 184 | const j = -(1 + e) * velAlongNormal / (1 / a.mass + 1 / b.mass); | |
| 185 | ||
| 186 | a.velocity.addMut(normal.scale(-j / a.mass)); | |
| 187 | b.velocity.addMut(normal.scale(j / b.mass)); | |
| 188 | ||
| 189 | // Small angular kick on collision | |
| 190 | a.angularVelocity += (Math.random() - 0.5) * 0.8; | |
| 191 | b.angularVelocity += (Math.random() - 0.5) * 0.8; | |
| 192 | } | |
| 193 | } |