diff --git a/src/game-v4.js b/src/game-v4.js index 5a6ea48..9870ee9 100644 --- a/src/game-v4.js +++ b/src/game-v4.js @@ -43,6 +43,9 @@ const PLAYER_SMOKE_REAR_OFFSET = 48; const PLAYER_SMOKE_ENGINE_OFFSET = 17; const ROLLING_CLOUD_COUNT = 34; const ROLLING_CLOUD_WRAP_MARGIN = 180; +const DENSE_CLOUD_MIN_COUNT = 8; +const CLOUD_FIRE_SUPPRESSION_CONCEALMENT = 0.55; +const CLOUD_CLOSE_ENGAGE_RANGE = 180; const AUTO_RESET_MS = 2200; const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); @@ -204,14 +207,21 @@ class GameScene extends Phaser.Scene { this.rollingClouds = []; for (let i = 0; i < ROLLING_CLOUD_COUNT; i++) { - const foreground = Phaser.Math.Between(0, 4) === 0; - const baseScale = Phaser.Math.FloatBetween(0.85, 2.35); - const alphaMin = foreground - ? Phaser.Math.FloatBetween(0.08, 0.13) - : Phaser.Math.FloatBetween(0.025, 0.07); - const alphaMax = foreground - ? Phaser.Math.FloatBetween(0.18, 0.3) - : Phaser.Math.FloatBetween(0.08, 0.16); + const denseCloud = i < DENSE_CLOUD_MIN_COUNT || Phaser.Math.Between(0, 5) === 0; + const foreground = denseCloud || Phaser.Math.Between(0, 4) === 0; + const baseScale = denseCloud + ? Phaser.Math.FloatBetween(1.55, 2.85) + : Phaser.Math.FloatBetween(0.85, 2.35); + const alphaMin = denseCloud + ? Phaser.Math.FloatBetween(0.34, 0.46) + : foreground + ? Phaser.Math.FloatBetween(0.08, 0.13) + : Phaser.Math.FloatBetween(0.025, 0.07); + const alphaMax = denseCloud + ? Phaser.Math.FloatBetween(0.64, 0.82) + : foreground + ? Phaser.Math.FloatBetween(0.18, 0.3) + : Phaser.Math.FloatBetween(0.08, 0.16); const cloud = this.add.image( Phaser.Math.Between(-ROLLING_CLOUD_WRAP_MARGIN, WORLD_SIZE + ROLLING_CLOUD_WRAP_MARGIN), Phaser.Math.Between(-ROLLING_CLOUD_WRAP_MARGIN, WORLD_SIZE + ROLLING_CLOUD_WRAP_MARGIN), @@ -219,11 +229,12 @@ class GameScene extends Phaser.Scene { ); cloud - .setDepth(foreground ? 7 : 2) + .setDepth(denseCloud ? 8 : foreground ? 7 : 2) .setScale(baseScale) .setAlpha(alphaMin) - .setBlendMode(Phaser.BlendModes.SCREEN); + .setBlendMode(denseCloud ? Phaser.BlendModes.NORMAL : Phaser.BlendModes.SCREEN); + cloud.setData('denseCloud', denseCloud); cloud.setData('foreground', foreground); cloud.setData('baseScale', baseScale); cloud.setData('alphaMin', alphaMin); @@ -232,6 +243,11 @@ class GameScene extends Phaser.Scene { cloud.setData('alphaRate', Phaser.Math.FloatBetween(0.00018, 0.00048)); cloud.setData('rollPhase', Phaser.Math.FloatBetween(0, Math.PI * 2)); cloud.setData('driftScale', Phaser.Math.FloatBetween(0.55, 1.35)); + cloud.setData('concealmentRadius', denseCloud ? baseScale * 58 : 0); + cloud.setData( + 'concealmentStrength', + denseCloud ? Phaser.Math.FloatBetween(0.82, 0.98) : 0, + ); this.rollingClouds.push(cloud); } @@ -272,6 +288,43 @@ class GameScene extends Phaser.Scene { } } + + cloudConcealmentAt(x, y) { + if (!this.rollingClouds?.length) return 0; + + let concealment = 0; + for (const cloud of this.rollingClouds) { + if (!cloud.getData('denseCloud')) continue; + + const radius = cloud.getData('concealmentRadius') || 0; + if (radius <= 0) continue; + + const distance = Phaser.Math.Distance.Between(x, y, cloud.x, cloud.y); + if (distance >= radius) continue; + + const radialStrength = 1 - (distance / radius); + const strength = cloud.getData('concealmentStrength') || 0; + concealment = Math.max(concealment, radialStrength * strength); + } + + return clamp(concealment, 0, 1); + } + + updateCloudConcealment() { + if (!this.player?.active) return; + + const playerConcealment = this.cloudConcealmentAt(this.player.x, this.player.y); + this.player.setAlpha(Phaser.Math.Linear(1, 0.3, playerConcealment)); + this.player.setData('cloudConcealment', playerConcealment); + + for (const pirate of this.pirates?.getChildren?.() || []) { + if (!pirate?.active) continue; + const concealment = this.cloudConcealmentAt(pirate.x, pirate.y); + pirate.setAlpha(Phaser.Math.Linear(1, 0.36, concealment)); + pirate.setData('cloudConcealment', concealment); + } + } + createEnvironmentPhysics() { if (!this.textures.exists('environment-collider')) { const colliderTexture = this.make.graphics({ x: 0, y: 0, add: false }); @@ -619,6 +672,7 @@ class GameScene extends Phaser.Scene { this.updatePlayer(dt, deltaMs); this.updatePlayerSmoke(deltaMs); this.updateRollingClouds(deltaMs); + this.updateCloudConcealment(); this.updatePirates(dt); this.updateProjectiles(); this.drawWeaponArcs(); @@ -758,8 +812,11 @@ class GameScene extends Phaser.Scene { const broadsideTolerance = pirate.getData('broadsideError') || 38; const reloadMs = pirate.getData('reloadMs') || 1650; const projectileDamage = pirate.getData('projectileDamage') || DEFAULT_PIRATE_SHOT_DAMAGE; + const playerConcealment = this.player.getData('cloudConcealment') || 0; + const targetObscured = playerConcealment >= CLOUD_FIRE_SUPPRESSION_CONCEALMENT + && distance > CLOUD_CLOSE_ENGAGE_RANGE; - if (distance < broadsideRange && broadsideError < broadsideTolerance && this.time.now - lastShot > reloadMs) { + if (!targetObscured && distance < broadsideRange && broadsideError < broadsideTolerance && this.time.now - lastShot > reloadMs) { const side = relative >= 0 ? 1 : -1; this.fireBroadside(pirate, side, this.pirateShots, 'cannonball', projectileDamage, 0.9); pirate.setData('lastShot', this.time.now); @@ -1064,6 +1121,7 @@ class GameScene extends Phaser.Scene { environment: { windSpeed: this.environmentWind?.strength || 0, windHeading: this.environmentWind?.heading || 0, + concealment: this.player?.getData?.('cloudConcealment') || 0, }, radar: activePirates.slice(0, 10).map((pirate) => ({ dx: (pirate.x - player.x) / 900, diff --git a/tests/wiring.test.mjs b/tests/wiring.test.mjs index 62f4d77..717299c 100644 --- a/tests/wiring.test.mjs +++ b/tests/wiring.test.mjs @@ -260,23 +260,40 @@ test('environment physics adds wind, soft island collision, turbulence, and proj assert.match(source, /windSpeed: this\.environmentWind\?\.strength/); }); -test('rolling clouds drift with wind and vary visibility over time', async () => { +test('rolling clouds include dense concealment banks that drift with wind', async () => { const source = await read('src/game-v4.js'); assert.match(source, /const ROLLING_CLOUD_COUNT = 34/); + assert.match(source, /const DENSE_CLOUD_MIN_COUNT = 8/); assert.match(source, /this\.createRollingClouds\(\)/); + assert.match(source, /const denseCloud = i < DENSE_CLOUD_MIN_COUNT/); + assert.match(source, /setDepth\(denseCloud \? 8 : foreground \? 7 : 2\)/); + assert.match(source, /Phaser\.BlendModes\.NORMAL : Phaser\.BlendModes\.SCREEN/); + assert.match(source, /cloud\.setData\('concealmentRadius', denseCloud \? baseScale \* 58 : 0\)/); + assert.match(source, /cloud\.setData\(\s*'concealmentStrength'/); assert.match(source, /this\.updateRollingClouds\(deltaMs\)/); - assert.match(source, /this\.add\.image\([\s\S]*?'rolling-cloud'/); - assert.match(source, /setDepth\(foreground \? 7 : 2\)/); - assert.match(source, /setBlendMode\(Phaser\.BlendModes\.SCREEN\)/); assert.match(source, /const wind = environmentWindAt\(\{/); assert.match(source, /cloud\.x \+= wind\.x \* driftScale \* dt/); - assert.match(source, /const visibility = 0\.5 \+ 0\.5 \* Math\.sin/); assert.match(source, /cloud\.setAlpha\(Phaser\.Math\.Linear\(alphaMin, alphaMax, visibility\)\)/); - assert.match(source, /cloud\.setScale\(baseScale \* roll, baseScale \/ roll\)/); assert.doesNotMatch(source, /const clouds = this\.add\.graphics\(\)/); }); +test('dense clouds conceal ships and suppress long-range pirate fire', async () => { + const source = await read('src/game-v4.js'); + + assert.match(source, /cloudConcealmentAt\(x, y\)/); + assert.match(source, /cloud\.getData\('denseCloud'\)/); + assert.match(source, /Phaser\.Math\.Distance\.Between\(x, y, cloud\.x, cloud\.y\)/); + assert.match(source, /this\.updateCloudConcealment\(\)/); + assert.match(source, /this\.player\.setAlpha\(Phaser\.Math\.Linear\(1, 0\.3, playerConcealment\)\)/); + assert.match(source, /pirate\.setAlpha\(Phaser\.Math\.Linear\(1, 0\.36, concealment\)\)/); + assert.match(source, /this\.player\.setData\('cloudConcealment', playerConcealment\)/); + assert.match(source, /const targetObscured = playerConcealment >= CLOUD_FIRE_SUPPRESSION_CONCEALMENT/); + assert.match(source, /distance > CLOUD_CLOSE_ENGAGE_RANGE/); + assert.match(source, /if \(!targetObscured && distance < broadsideRange/); + assert.match(source, /concealment: this\.player\?\.getData\?\.\('cloudConcealment'\) \|\| 0/); +}); + test('mobile HUD skips hidden Phaser rendering and throttles DOM state publishing', async () => { const source = await read('src/game-v4.js');