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,
|
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,
|
||||||
|
|||||||
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\(/);
|
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');
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user