Resolve projectile collisions by explicit owner tag

This commit is contained in:
Admin 2026-09-18 07:08:13 +00:00
parent f39b13dcac
commit 618a881268
4 changed files with 82 additions and 10 deletions

View File

@ -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;
};

View File

@ -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) {

View File

@ -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);
});

View File

@ -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\(\)/);
});