Add environmental wind and island physics

This commit is contained in:
Admin 2026-09-20 07:38:29 +00:00
parent fa34292a2c
commit 2a3627fed0
4 changed files with 247 additions and 5 deletions

View File

@ -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,
});

View File

@ -18,6 +18,11 @@ import {
pirateSpeedForRange, pirateSpeedForRange,
separationVector, separationVector,
} from './pirate-ai.js'; } from './pirate-ai.js';
import {
ENVIRONMENT_ISLANDS,
environmentWindAt,
velocityWithEnvironment,
} from './environment-physics.js';
const WORLD_SIZE = 2400; const WORLD_SIZE = 2400;
const PLAYER_MAX_SPEED = 180; const PLAYER_MAX_SPEED = 180;
@ -65,6 +70,7 @@ class GameScene extends Phaser.Scene {
this.pendingPlayerHits = []; this.pendingPlayerHits = [];
this.playerHitSequence = 0; this.playerHitSequence = 0;
this.playerSmokeAccumulatorMs = 0; this.playerSmokeAccumulatorMs = 0;
this.environmentWind = { x: 0, y: 0, strength: 0, heading: 0 };
this.lastDiagnosticsAt = -Infinity; this.lastDiagnosticsAt = -Infinity;
this.lastHudPublishAt = -Infinity; this.lastHudPublishAt = -Infinity;
this.wave = 1; this.wave = 1;
@ -108,6 +114,7 @@ class GameScene extends Phaser.Scene {
this.playerShots = this.physics.add.group(); this.playerShots = this.physics.add.group();
this.pirateShots = this.physics.add.group(); this.pirateShots = this.physics.add.group();
this.arcGraphics = this.add.graphics().setDepth(4); 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.playerShots, this.pirates, this.hitPirate, null, this);
this.physics.add.overlap(this.pirateShots, this.player, this.hitPlayer, 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, pirateShots: this.pirateShots?.countActive?.(true) || 0,
wave: this.wave, wave: this.wave,
defeated: this.defeated, defeated: this.defeated,
environmentWind: this.environmentWind,
now: this.time?.now ?? null, now: this.time?.now ?? null,
player: this.player ? { player: this.player ? {
x: Math.round(this.player.x), 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() { createPlayerSmoke() {
if (!this.textures.exists('player-smoke')) { if (!this.textures.exists('player-smoke')) {
const smokeTexture = this.make.graphics({ x: 0, y: 0, add: false }); 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 spread = Phaser.Math.FloatBetween(-0.16, 0.16);
const trailHeading = heading + Math.PI + spread; const trailHeading = heading + Math.PI + spread;
const trailSpeed = Phaser.Math.Linear(16, 46, forwardThrust) * Phaser.Math.FloatBetween(0.82, 1.16); const trailSpeed = Phaser.Math.Linear(16, 46, forwardThrust) * Phaser.Math.FloatBetween(0.82, 1.16);
const velocityX = Math.cos(trailHeading) * trailSpeed; const localWind = environmentWindAt({
const velocityY = Math.sin(trailHeading) * trailSpeed; 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]) { for (const engineSide of [-1, 1]) {
const jitter = Phaser.Math.FloatBetween(-1.5, 1.5); const jitter = Phaser.Math.FloatBetween(-1.5, 1.5);
@ -535,7 +602,18 @@ class GameScene extends Phaser.Scene {
} }
const h = headingRad(this.player); 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.firePort)) this.playerBroadside(-1);
if (Phaser.Input.Keyboard.JustDown(this.keys.fireStarboard)) this.playerBroadside(1); if (Phaser.Input.Keyboard.JustDown(this.keys.fireStarboard)) this.playerBroadside(1);
@ -582,7 +660,18 @@ class GameScene extends Phaser.Scene {
baseSpeed, baseSpeed,
}); });
const h = headingRad(pirate); 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 relative = signedAngleDelta(bearing, pirate.angle);
const broadsideError = Math.abs(Math.abs(relative) - 90); const broadsideError = Math.abs(Math.abs(relative) - 90);
@ -633,10 +722,18 @@ class GameScene extends Phaser.Scene {
shot.setData('damage', projectileDamage); shot.setData('damage', projectileDamage);
shot.setData('projectileOwner', actor); shot.setData('projectileOwner', actor);
if (actor === 'pirate') shot.setData('playerHitQueued', false); 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.cos(angle) * SHOT_SPEED * velocityScale + ship.body.velocity.x * 0.25,
Math.sin(angle) * SHOT_SPEED * velocityScale + ship.body.velocity.y * 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'), port: reloadReady('port'),
starboard: reloadReady('starboard'), starboard: reloadReady('starboard'),
}, },
environment: {
windSpeed: this.environmentWind?.strength || 0,
windHeading: this.environmentWind?.heading || 0,
},
radar: activePirates.slice(0, 10).map((pirate) => ({ radar: activePirates.slice(0, 10).map((pirate) => ({
dx: (pirate.x - player.x) / 900, dx: (pirate.x - player.x) / 900,
dy: (pirate.y - player.y) / 900, dy: (pirate.y - player.y) / 900,

View File

@ -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);
});

View File

@ -230,6 +230,25 @@ test('player propulsion smoke emits at world engine positions with per-particle
assert.doesNotMatch(source, /this\.playerSmoke\.setParticleSpeed\(/); 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 () => { test('mobile HUD skips hidden Phaser rendering and throttles DOM state publishing', async () => {
const source = await read('src/game-v4.js'); const source = await read('src/game-v4.js');