diff --git a/src/environment-physics.js b/src/environment-physics.js new file mode 100644 index 0000000..ac4e59c --- /dev/null +++ b/src/environment-physics.js @@ -0,0 +1,68 @@ +export const ENVIRONMENT_ISLANDS = [ + { + id: 'west-island', + x: 430, + y: 480, + radius: 82, + turbulenceRadius: 220, + }, + { + id: 'south-east-island', + x: 1940, + y: 1840, + radius: 96, + turbulenceRadius: 250, + }, +]; + +export const environmentWindAt = ({ + x = 0, + y = 0, + timeMs = 0, +} = {}) => { + const time = timeMs / 1000; + const angle = 0.62 + + Math.sin(time / 29) * 0.2 + + Math.sin((x + y) / 880) * 0.06; + const strength = 14 + + Math.sin(time / 8.5) * 3.2 + + Math.sin(time / 2.9) * 1.4; + + let windX = Math.cos(angle) * strength; + let windY = Math.sin(angle) * strength; + + for (const island of ENVIRONMENT_ISLANDS) { + const dx = x - island.x; + const dy = y - island.y; + const distance = Math.hypot(dx, dy); + if (distance <= 1 || distance >= island.turbulenceRadius) continue; + + const influence = 1 - (distance / island.turbulenceRadius); + const normalX = dx / distance; + const normalY = dy / distance; + const tangentX = -normalY; + const tangentY = normalX; + const updraft = 11 * influence * influence; + const swirl = Math.sin(time * 1.35 + distance * 0.018) * 5 * influence; + + windX += normalX * updraft + tangentX * swirl; + windY += normalY * updraft + tangentY * swirl; + } + + return { + x: windX, + y: windY, + strength: Math.hypot(windX, windY), + heading: ((Math.atan2(windY, windX) * 180 / Math.PI) + 450) % 360, + }; +}; + +export const velocityWithEnvironment = ( + velocityX, + velocityY, + wind, + exposure = 1, +) => ({ + x: velocityX + (wind?.x || 0) * exposure, + y: velocityY + (wind?.y || 0) * exposure, +}); diff --git a/src/game-v4.js b/src/game-v4.js index 6c956b4..8f25a6e 100644 --- a/src/game-v4.js +++ b/src/game-v4.js @@ -18,6 +18,11 @@ import { pirateSpeedForRange, separationVector, } from './pirate-ai.js'; +import { + ENVIRONMENT_ISLANDS, + environmentWindAt, + velocityWithEnvironment, +} from './environment-physics.js'; const WORLD_SIZE = 2400; const PLAYER_MAX_SPEED = 180; @@ -65,6 +70,7 @@ class GameScene extends Phaser.Scene { this.pendingPlayerHits = []; this.playerHitSequence = 0; this.playerSmokeAccumulatorMs = 0; + this.environmentWind = { x: 0, y: 0, strength: 0, heading: 0 }; this.lastDiagnosticsAt = -Infinity; this.lastHudPublishAt = -Infinity; this.wave = 1; @@ -108,6 +114,7 @@ class GameScene extends Phaser.Scene { this.playerShots = this.physics.add.group(); this.pirateShots = this.physics.add.group(); this.arcGraphics = this.add.graphics().setDepth(4); + this.createEnvironmentPhysics(); this.physics.add.overlap(this.playerShots, this.pirates, this.hitPirate, null, this); this.physics.add.overlap(this.pirateShots, this.player, this.hitPlayer, null, this); @@ -122,6 +129,7 @@ class GameScene extends Phaser.Scene { pirateShots: this.pirateShots?.countActive?.(true) || 0, wave: this.wave, defeated: this.defeated, + environmentWind: this.environmentWind, now: this.time?.now ?? null, player: this.player ? { x: Math.round(this.player.x), @@ -175,6 +183,60 @@ class GameScene extends Phaser.Scene { } + + createEnvironmentPhysics() { + if (!this.textures.exists('environment-collider')) { + const colliderTexture = this.make.graphics({ x: 0, y: 0, add: false }); + colliderTexture.fillStyle(0xffffff, 1); + colliderTexture.fillRect(0, 0, 4, 4); + colliderTexture.generateTexture('environment-collider', 4, 4); + colliderTexture.destroy(); + } + + this.environmentObstacles = ENVIRONMENT_ISLANDS.map((island) => { + const obstacle = this.physics.add.staticImage( + island.x, + island.y, + 'environment-collider', + ); + obstacle + .setDisplaySize(island.radius * 2, island.radius * 2) + .setVisible(false) + .refreshBody(); + obstacle.body.setCircle(island.radius); + + this.physics.add.collider(this.player, obstacle, () => { + this.playerSpeed *= 0.7; + }); + this.physics.add.collider(this.pirates, obstacle); + this.physics.add.overlap( + this.playerShots, + obstacle, + this.hitEnvironmentProjectile, + null, + this, + ); + this.physics.add.overlap( + this.pirateShots, + obstacle, + this.hitEnvironmentProjectile, + null, + this, + ); + + return obstacle; + }); + } + + hitEnvironmentProjectile(first, second) { + const shot = first?.getData?.('projectileOwner') ? first : second; + if (!shot?.active || !shot.getData?.('projectileOwner')) return; + + const { x, y } = shot; + shot.destroy(); + this.spawnExplosion(x, y, 0.2); + } + createPlayerSmoke() { if (!this.textures.exists('player-smoke')) { const smokeTexture = this.make.graphics({ x: 0, y: 0, add: false }); @@ -224,8 +286,13 @@ class GameScene extends Phaser.Scene { const spread = Phaser.Math.FloatBetween(-0.16, 0.16); const trailHeading = heading + Math.PI + spread; const trailSpeed = Phaser.Math.Linear(16, 46, forwardThrust) * Phaser.Math.FloatBetween(0.82, 1.16); - const velocityX = Math.cos(trailHeading) * trailSpeed; - const velocityY = Math.sin(trailHeading) * trailSpeed; + const localWind = environmentWindAt({ + x: sternX, + y: sternY, + timeMs: this.time.now, + }); + const velocityX = Math.cos(trailHeading) * trailSpeed + localWind.x * 1.4; + const velocityY = Math.sin(trailHeading) * trailSpeed + localWind.y * 1.4; for (const engineSide of [-1, 1]) { const jitter = Phaser.Math.FloatBetween(-1.5, 1.5); @@ -535,7 +602,18 @@ class GameScene extends Phaser.Scene { } const h = headingRad(this.player); - this.player.setVelocity(Math.cos(h) * this.playerSpeed, Math.sin(h) * this.playerSpeed); + this.environmentWind = environmentWindAt({ + x: this.player.x, + y: this.player.y, + timeMs: this.time.now, + }); + const playerVelocity = velocityWithEnvironment( + Math.cos(h) * this.playerSpeed, + Math.sin(h) * this.playerSpeed, + this.environmentWind, + 1, + ); + this.player.setVelocity(playerVelocity.x, playerVelocity.y); if (Phaser.Input.Keyboard.JustDown(this.keys.firePort)) this.playerBroadside(-1); if (Phaser.Input.Keyboard.JustDown(this.keys.fireStarboard)) this.playerBroadside(1); @@ -582,7 +660,18 @@ class GameScene extends Phaser.Scene { baseSpeed, }); const h = headingRad(pirate); - pirate.setVelocity(Math.cos(h) * speed, Math.sin(h) * speed); + const pirateWind = environmentWindAt({ + x: pirate.x, + y: pirate.y, + timeMs: this.time.now, + }); + const pirateVelocity = velocityWithEnvironment( + Math.cos(h) * speed, + Math.sin(h) * speed, + pirateWind, + 0.9, + ); + pirate.setVelocity(pirateVelocity.x, pirateVelocity.y); const relative = signedAngleDelta(bearing, pirate.angle); const broadsideError = Math.abs(Math.abs(relative) - 90); @@ -633,10 +722,18 @@ class GameScene extends Phaser.Scene { shot.setData('damage', projectileDamage); shot.setData('projectileOwner', actor); if (actor === 'pirate') shot.setData('playerHitQueued', false); - shot.setVelocity( + const shotWind = environmentWindAt({ + x, + y, + timeMs: this.time.now, + }); + const shotVelocity = velocityWithEnvironment( Math.cos(angle) * SHOT_SPEED * velocityScale + ship.body.velocity.x * 0.25, Math.sin(angle) * SHOT_SPEED * velocityScale + ship.body.velocity.y * 0.25, + shotWind, + 0.22, ); + shot.setVelocity(shotVelocity.x, shotVelocity.y); } } @@ -877,6 +974,10 @@ class GameScene extends Phaser.Scene { port: reloadReady('port'), starboard: reloadReady('starboard'), }, + environment: { + windSpeed: this.environmentWind?.strength || 0, + windHeading: this.environmentWind?.heading || 0, + }, radar: activePirates.slice(0, 10).map((pirate) => ({ dx: (pirate.x - player.x) / 900, dy: (pirate.y - player.y) / 900, diff --git a/tests/environment-physics.test.mjs b/tests/environment-physics.test.mjs new file mode 100644 index 0000000..a375f45 --- /dev/null +++ b/tests/environment-physics.test.mjs @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + ENVIRONMENT_ISLANDS, + environmentWindAt, + velocityWithEnvironment, +} from '../src/environment-physics.js'; + +test('environment exposes collidable island geometry', () => { + assert.equal(ENVIRONMENT_ISLANDS.length, 2); + for (const island of ENVIRONMENT_ISLANDS) { + assert.ok(island.radius > 0); + assert.ok(island.turbulenceRadius > island.radius); + } +}); + +test('ambient wind changes over time and remains bounded', () => { + const first = environmentWindAt({ x: 1200, y: 1200, timeMs: 0 }); + const later = environmentWindAt({ x: 1200, y: 1200, timeMs: 18000 }); + + assert.ok(first.strength > 5 && first.strength < 30); + assert.ok(later.strength > 5 && later.strength < 30); + assert.notDeepEqual( + [Math.round(first.x * 100), Math.round(first.y * 100)], + [Math.round(later.x * 100), Math.round(later.y * 100)], + ); +}); + +test('islands produce a local turbulence contribution', () => { + const island = ENVIRONMENT_ISLANDS[0]; + const near = environmentWindAt({ + x: island.x + island.radius + 20, + y: island.y, + timeMs: 6000, + }); + const far = environmentWindAt({ + x: island.x + island.turbulenceRadius + 300, + y: island.y, + timeMs: 6000, + }); + + assert.notEqual( + Math.round(near.strength * 100), + Math.round(far.strength * 100), + ); +}); + +test('environment velocity adds wind according to exposure', () => { + const result = velocityWithEnvironment(100, -20, { x: 12, y: 8 }, 0.5); + + assert.equal(result.x, 106); + assert.equal(result.y, -16); +}); diff --git a/tests/wiring.test.mjs b/tests/wiring.test.mjs index 1d87f5f..469c706 100644 --- a/tests/wiring.test.mjs +++ b/tests/wiring.test.mjs @@ -230,6 +230,25 @@ test('player propulsion smoke emits at world engine positions with per-particle assert.doesNotMatch(source, /this\.playerSmoke\.setParticleSpeed\(/); }); +test('environment physics adds wind, island collision, turbulence, and projectile interaction', async () => { + const source = await read('src/game-v4.js'); + + assert.match(source, /ENVIRONMENT_ISLANDS/); + assert.match(source, /environmentWindAt/); + assert.match(source, /velocityWithEnvironment/); + assert.match(source, /this\.createEnvironmentPhysics\(\)/); + assert.match(source, /this\.physics\.add\.staticImage/); + assert.match(source, /obstacle\.body\.setCircle\(island\.radius\)/); + assert.match(source, /this\.physics\.add\.collider\(this\.player, obstacle/); + assert.match(source, /this\.physics\.add\.collider\(this\.pirates, obstacle\)/); + assert.match(source, /this\.physics\.add\.overlap\(\s*this\.playerShots,[\s\S]*?this\.hitEnvironmentProjectile/); + assert.match(source, /this\.environmentWind = environmentWindAt\(\{/); + assert.match(source, /const pirateWind = environmentWindAt\(\{/); + assert.match(source, /const shotWind = environmentWindAt\(\{/); + assert.match(source, /const localWind = environmentWindAt\(\{/); + assert.match(source, /windSpeed: this\.environmentWind\?\.strength/); +}); + test('mobile HUD skips hidden Phaser rendering and throttles DOM state publishing', async () => { const source = await read('src/game-v4.js');