Add environmental wind and island physics
This commit is contained in:
parent
fa34292a2c
commit
2a3627fed0
68
src/environment-physics.js
Normal file
68
src/environment-physics.js
Normal 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,
|
||||
});
|
||||
111
src/game-v4.js
111
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,
|
||||
|
||||
54
tests/environment-physics.test.mjs
Normal file
54
tests/environment-physics.test.mjs
Normal 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);
|
||||
});
|
||||
@ -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');
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user