diff --git a/src/game-v4.js b/src/game-v4.js index bbd8b4c..8a979c6 100644 --- a/src/game-v4.js +++ b/src/game-v4.js @@ -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); diff --git a/tests/wiring.test.mjs b/tests/wiring.test.mjs index 2299bda..9327dab 100644 --- a/tests/wiring.test.mjs +++ b/tests/wiring.test.mjs @@ -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');