Add thrust-driven player smoke particles

This commit is contained in:
Admin 2026-09-19 04:16:36 +00:00
parent 781b4cbd34
commit 979485e762
2 changed files with 88 additions and 0 deletions

View File

@ -34,6 +34,8 @@ const SHOT_LIFETIME = 900;
const PIRATE_FALLBACK_MAX_HULL = 120;
const MAX_PIRATES = 6;
const HUD_PUBLISH_INTERVAL_MS = 50;
const PLAYER_SMOKE_REAR_OFFSET = 48;
const PLAYER_SMOKE_ENGINE_OFFSET = 17;
const AUTO_RESET_MS = 2200;
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
@ -62,6 +64,7 @@ class GameScene extends Phaser.Scene {
this.helmVector = { x: 0, y: 0 };
this.pendingPlayerHits = [];
this.playerHitSequence = 0;
this.playerSmokeAccumulatorMs = 0;
this.lastDiagnosticsAt = -Infinity;
this.lastHudPublishAt = -Infinity;
this.wave = 1;
@ -99,6 +102,7 @@ class GameScene extends Phaser.Scene {
this.player.body.setAllowGravity(false);
this.player.body.setCircle(44, 20, 84);
this.player.setFrame(0);
this.createPlayerSmoke();
this.pirates = this.physics.add.group();
this.playerShots = this.physics.add.group();
@ -170,6 +174,71 @@ class GameScene extends Phaser.Scene {
this.spawnWave();
}
createPlayerSmoke() {
if (!this.textures.exists('player-smoke')) {
const smokeTexture = this.make.graphics({ x: 0, y: 0, add: false });
smokeTexture.fillStyle(0xe3ecee, 0.18);
smokeTexture.fillCircle(8, 8, 7);
smokeTexture.fillStyle(0xf4f7f7, 0.28);
smokeTexture.fillCircle(7, 7, 4.5);
smokeTexture.generateTexture('player-smoke', 16, 16);
smokeTexture.destroy();
}
this.playerSmoke = this.add.particles(this.player.x, this.player.y, 'player-smoke', {
lifespan: { min: 520, max: 920 },
scale: { start: 0.34, end: 1.15 },
alpha: { start: 0.24, end: 0 },
rotate: { min: 0, max: 360 },
frequency: -1,
quantity: 1,
});
this.playerSmoke.setDepth(5);
}
updatePlayerSmoke(deltaMs) {
if (!this.playerSmoke || !this.player?.active || !this.player.visible) return;
const forwardThrust = clamp(this.playerSpeed / PLAYER_MAX_SPEED, 0, 1);
if (this.gameState !== 'playing' || forwardThrust <= 0.04) {
this.playerSmokeAccumulatorMs = 0;
return;
}
const heading = headingRad(this.player);
const forwardX = Math.cos(heading);
const forwardY = Math.sin(heading);
const sideX = -forwardY;
const sideY = forwardX;
const sternX = this.player.x - forwardX * PLAYER_SMOKE_REAR_OFFSET;
const sternY = this.player.y - forwardY * PLAYER_SMOKE_REAR_OFFSET;
this.playerSmoke.setPosition(sternX, sternY);
const emissionIntervalMs = Phaser.Math.Linear(175, 58, forwardThrust);
this.playerSmokeAccumulatorMs += deltaMs;
while (this.playerSmokeAccumulatorMs >= emissionIntervalMs) {
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);
this.playerSmoke.setParticleSpeed(
Math.cos(trailHeading) * trailSpeed,
Math.sin(trailHeading) * trailSpeed,
);
for (const engineSide of [-1, 1]) {
const jitter = Phaser.Math.FloatBetween(-1.5, 1.5);
const engineX = sternX + sideX * (PLAYER_SMOKE_ENGINE_OFFSET * engineSide + jitter);
const engineY = sternY + sideY * (PLAYER_SMOKE_ENGINE_OFFSET * engineSide + jitter);
this.playerSmoke.emitParticleAt(engineX, engineY, 1);
}
this.playerSmokeAccumulatorMs -= emissionIntervalMs;
}
}
createHud() {
this.mobileHudActive = !window.matchMedia('(hover: hover) and (pointer: fine)').matches;
@ -401,6 +470,7 @@ class GameScene extends Phaser.Scene {
}
this.updatePlayer(dt, deltaMs);
this.updatePlayerSmoke(deltaMs);
this.updatePirates(dt);
this.updateProjectiles();
this.drawWeaponArcs();
@ -927,6 +997,7 @@ class GameScene extends Phaser.Scene {
}, 'warn');
this.gameState = 'lost';
this.playerSmokeAccumulatorMs = 0;
this.player.setVelocity(0, 0);
this.playerShots.clear(true, true);
this.pirateShots.clear(true, true);

View File

@ -210,6 +210,23 @@ test('player uses the four-frame north-facing sheet as a forward propulsion anim
assert.doesNotMatch(source, /key: 'player-idle'/);
});
test('player propulsion smoke is a separate world-space Phaser particle effect', async () => {
const source = await read('src/game-v4.js');
assert.match(source, /this\.createPlayerSmoke\(\)/);
assert.match(source, /this\.add\.particles\(this\.player\.x, this\.player\.y, 'player-smoke'/);
assert.match(source, /frequency: -1/);
assert.match(source, /this\.updatePlayerSmoke\(deltaMs\)/);
assert.match(source, /const forwardThrust = clamp\(this\.playerSpeed \/ PLAYER_MAX_SPEED, 0, 1\)/);
assert.match(source, /if \(this\.gameState !== 'playing' \|\| forwardThrust <= 0\.04\)/);
assert.match(source, /const sternX = this\.player\.x - forwardX \* PLAYER_SMOKE_REAR_OFFSET/);
assert.match(source, /const sternY = this\.player\.y - forwardY \* PLAYER_SMOKE_REAR_OFFSET/);
assert.match(source, /PLAYER_SMOKE_ENGINE_OFFSET \* engineSide/);
assert.match(source, /this\.playerSmoke\.setParticleSpeed\(/);
assert.match(source, /this\.playerSmoke\.emitParticleAt\(engineX, engineY, 1\)/);
assert.match(source, /Phaser\.Math\.Linear\(175, 58, forwardThrust\)/);
});
test('mobile HUD skips hidden Phaser rendering and throttles DOM state publishing', async () => {
const source = await read('src/game-v4.js');