diff --git a/src/game-logic.js b/src/game-logic.js index 4fd262f..c5c7767 100644 --- a/src/game-logic.js +++ b/src/game-logic.js @@ -132,3 +132,12 @@ export const reducePlayerProjectileHits = ({ gameOver: nextHull <= 0, }; }; + + +export const resolveTaggedProjectile = (first, second, owner) => { + for (const candidate of [first, second]) { + if (!candidate || typeof candidate.getData !== 'function') continue; + if (candidate.getData('projectileOwner') === owner) return candidate; + } + return null; +}; diff --git a/src/game-v4.js b/src/game-v4.js index 58d9547..ff0db2f 100644 --- a/src/game-v4.js +++ b/src/game-v4.js @@ -3,7 +3,7 @@ import './style.css'; import airshipSheetUrl from './assets/airships.svg?url'; import explosionSheetUrl from './assets/explosions.svg?url'; import { airshipLog, registerDebugSnapshot } from './diagnostics.js'; -import { normalizePlayerDamage, reducePlayerProjectileHits } from './game-logic.js'; +import { normalizePlayerDamage, reducePlayerProjectileHits, resolveTaggedProjectile } from './game-logic.js'; import { pirateClassForWave, separationVector } from './pirate-ai.js'; const WORLD_SIZE = 2400; @@ -536,7 +536,8 @@ class GameScene extends Phaser.Scene { shot.body.setAllowGravity(false); shot.setData('bornAt', this.time.now); shot.setData('damage', damage); - if (group === this.pirateShots) shot.setData('playerHitQueued', false); + shot.setData('projectileOwner', actor); + if (actor === 'pirate') shot.setData('playerHitQueued', false); shot.setVelocity( Math.cos(angle) * SHOT_SPEED * velocityScale + ship.body.velocity.x * 0.25, Math.sin(angle) * SHOT_SPEED * velocityScale + ship.body.velocity.y * 0.25, @@ -544,8 +545,13 @@ class GameScene extends Phaser.Scene { } } - hitPirate(shot, pirate) { - if (!shot?.active || !pirate?.active || this.gameState !== 'playing') return; + hitPirate(first, second) { + if (this.gameState !== 'playing') return; + + const shot = resolveTaggedProjectile(first, second, 'player'); + const pirate = shot === first ? second : first; + if (!shot?.active || !pirate?.active || pirate === this.player) return; + const damage = shot.getData('damage') || 10; const { x, y } = shot; shot.destroy(); @@ -580,15 +586,27 @@ class GameScene extends Phaser.Scene { this.flashShip(pirate, 0xffcccc); } - hitPlayer(shot) { - if (!shot?.active || this.gameState !== 'playing') return; + hitPlayer(first, second) { + if (this.gameState !== 'playing') return; + + const shot = resolveTaggedProjectile(first, second, 'pirate'); + if (!shot?.active || shot === this.player) { + airshipLog('combat:player-hit-rejected', { + firstOwner: first?.getData?.('projectileOwner') ?? null, + secondOwner: second?.getData?.('projectileOwner') ?? null, + firstIsPlayer: first === this.player, + secondIsPlayer: second === this.player, + }, 'warn'); + return; + } + if (shot.getData('playerHitQueued')) return; const damage = normalizePlayerDamage(shot.getData('damage'), PIRATE_SHOT_DAMAGE); - // The Arcade overlap callback only records the collision. Hull mutation - // happens from the scene update after the physics step; projectile cleanup - // remains on the normal lifetime path to avoid collision-time lifecycle changes. + // The overlap callback only records a verified pirate projectile. + // Hull mutation and projectile destruction happen on the next scene update, + // outside the Arcade Physics callback. shot.setData('playerHitQueued', true); this.pendingPlayerHits.push({ shot, @@ -632,7 +650,14 @@ class GameScene extends Phaser.Scene { const { totalDamage, appliedHits, gameOver } = result; for (const impact of hits) { - if (impact.shot?.active) impact.shot.destroy(); + const shot = impact.shot; + if ( + shot !== this.player + && shot?.active + && shot?.getData?.('projectileOwner') === 'pirate' + ) { + shot.destroy(); + } } if (appliedHits > 0) { diff --git a/tests/game-logic.test.mjs b/tests/game-logic.test.mjs index a445c6d..750ebc2 100644 --- a/tests/game-logic.test.mjs +++ b/tests/game-logic.test.mjs @@ -10,6 +10,7 @@ import { reducePlayerHitQueue, reducePlayerProjectileHits, reducePlayerVolleyHits, + resolveTaggedProjectile, } from '../src/game-logic.js'; test('first pirate hit removes exactly the default damage and does not end the game', () => { @@ -240,3 +241,29 @@ test('multiple distinct pirate projectiles in the same frame all apply damage', assert.equal(result.totalDamage, 18); assert.equal(result.appliedHits, 3); }); + + +test('projectile resolver is independent of Phaser overlap argument order', () => { + const player = { + getData() { return undefined; }, + }; + const pirateShot = { + getData(key) { + return key === 'projectileOwner' ? 'pirate' : undefined; + }, + }; + + assert.equal(resolveTaggedProjectile(pirateShot, player, 'pirate'), pirateShot); + assert.equal(resolveTaggedProjectile(player, pirateShot, 'pirate'), pirateShot); +}); + +test('projectile resolver never mistakes the player for an untagged shot', () => { + const player = { + getData() { return undefined; }, + }; + const untagged = { + getData() { return undefined; }, + }; + + assert.equal(resolveTaggedProjectile(player, untagged, 'pirate'), null); +}); diff --git a/tests/wiring.test.mjs b/tests/wiring.test.mjs index cf40f73..a2e2fcf 100644 --- a/tests/wiring.test.mjs +++ b/tests/wiring.test.mjs @@ -236,3 +236,14 @@ test('non-lethal damage preserves the live player sprite and body', async () => assert.match(source, /if \(this\.player\.alpha !== 1\) this\.player\.setAlpha\(1\)/); assert.match(source, /if \(this\.player\.body && !this\.player\.body\.enable\)/); }); + + +test('player overlap resolves an explicitly tagged pirate projectile before queueing or destroying it', async () => { + const source = await read('src/game-v4.js'); + + assert.match(source, /shot\.setData\('projectileOwner', actor\)/); + assert.match(source, /hitPlayer\(first, second\)/); + assert.match(source, /resolveTaggedProjectile\(first, second, 'pirate'\)/); + assert.match(source, /if \(!shot\?\.active \|\| shot === this\.player\)/); + assert.match(source, /shot !== this\.player[\s\S]*?projectileOwner'\) === 'pirate'[\s\S]*?shot\.destroy\(\)/); +});