Enforce on-screen rule for combat engagement and hits

- Add inView() pure geometry helper to test if a craft's visual circle is at least partly within the camera's visible rect
- Gate enemy fire, auto-fire target selection, currentTarget lock, and bullet collision checks behind craftVisible() so off-screen hostiles hold lances, can't be engaged, and are immune to tracers until they fly into view
- Extend pickTarget() with an optional qualifies predicate to narrow the candidate pool (used for the on-screen rule)
- Document the rule in data/combat.json and CombatSystem class comments
This commit is contained in:
Brian Fertig 2026-09-17 18:12:58 -06:00
parent d61f2d6934
commit 5890e93cde
4 changed files with 186 additions and 18 deletions

View File

@ -3,7 +3,7 @@
"enabled": true,
"game": {
"_comment": "COMBAT IN THE MAIN GAME (the ?combat boot). spawnCount = hostiles that appear around the ship when the player starts. SPAWNING: hostiles materialize OFF-SCREEN — the furthest of the (spawnRadius band, the camera's visible edge + spawnLead) — and FLY IN on their own chase AI (standoff ring, then lances): nothing pops into view mid-fight. spawnRadius px = the minimum materialization band around the ship; spawnLead px = how far PAST the camera's visible edge they materialize (the fly-in distance — bigger = a longer stream across the screen). autoFire = the ship's guns — while a hostile is within range the ship fires at the NEAREST one (the mouse steers, the cannon tracks; the cannon's own numbers are player.weapon below).",
"_comment": "COMBAT IN THE MAIN GAME (the ?combat boot). spawnCount = hostiles that appear around the ship when the player starts. SPAWNING: hostiles materialize OFF-SCREEN — the furthest of the (spawnRadius band, the camera's visible edge + spawnLead) — and FLY IN on their own chase AI (standoff ring, then lances): nothing pops into view mid-fight. spawnRadius px = the minimum materialization band around the ship; spawnLead px = how far PAST the camera's visible edge they materialize (the fly-in distance — bigger = a longer stream across the screen). autoFire = the ship's guns — while a hostile is within range the ship fires at the NEAREST one (the mouse steers, the cannon tracks; the cannon's own numbers are player.weapon below). ON-SCREEN RULE (js/combat/CombatSystem.js): a hostile must be VISUALLY ON SCREEN (at least partly in the camera's view) to fire or be fired upon — off-screen craft hold their lances, the cannon won't engage them, and their tracers pass straight through; they join the fight the moment they fly into view.",
"spawnCount": 3,
"spawnRadius": 340,
"spawnLead": 140,

View File

@ -10,8 +10,8 @@
* standoff-ring AI, the lance decision), the tracer (Bullet.js:
* velocity from angle/speed, world radius, one-shot consume, life
* expiry), and the system (CombatSystem.js: the pack's lifecycle,
* the auto-fire, swept hits, rams, the player's armor + respawn,
* the energy) — then asserts the behavior the range
* the auto-fire, the on-screen rule, swept hits, rams, the player's
* armor + respawn, the energy) — then asserts the behavior the range
* (js/combat/CombatScene.js) and the main game (GameScene) are built on.
*
* All numbers come from the real data/*.json (data/combat.json included),
@ -166,7 +166,7 @@ for (const f of fs.readdirSync(dataDir)) {
config.init(configData);
console.log('config loaded from data/:', Object.keys(configData).join(', '));
const { applyDamage, headingOf, orbitStep, handleFeel, pickTarget, feedPush, offscreenArrow } = await import(
const { applyDamage, headingOf, orbitStep, handleFeel, pickTarget, feedPush, offscreenArrow, inView } = await import(
pathToFileURL(join(__dirname, '../js/combat/Combat.js')).href
);
const { EnemyShip } = await import(pathToFileURL(join(__dirname, '../js/combat/EnemyShip.js')).href);
@ -529,6 +529,72 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d);
check('spawn: destroy still clears everything', combat.enemies.length === 0);
}
// ------------------------------------------------- the on-screen rule
{
// ON-SCREEN RULE (the main game): a hostile must be at least PARTLY
// in the camera's view to fire or be fired upon — off-screen craft
// hold their lances, the cannon won't engage them, and their tracers
// pass through (they re-enter the fight as they fly in).
const VW = 1280, VH = 720;
const scene = makeScene({ withSheet: true });
scene.cameras = { main: { worldView: { x: 0, y: 0, width: VW, height: VH } } };
const ship = scene.physics.add.existing(new PhaserStub.Physics.Arcade.Sprite(scene, 640, 360, '__ship', 0));
ship.radius = 12;
ship.stop = () => {};
const combat = new CombatSystem(scene, { ship, onKill: () => {} });
// One on-screen (below the player), one off-screen (above the top
// edge) — both within lance range (560), the off-screen one NEARER,
// so "nearest" alone would pick the wrong one.
const on = combat.spawnEnemy(640, 600, 0);
const off = combat.spawnEnemy(640, -50, 1);
const aim = (e) => {
e.rotation = Math.atan2(ship.y - e.y, ship.x - e.x) + e.artOffset;
e.nextFireAt = 0; // lift the spawn grace — armed for this frame
};
aim(on);
aim(off);
check('view: the on-screen craft is visible, the off-screen one is not',
combat.craftVisible(on) === true && combat.craftVisible(off) === false);
// ENEMY FIRE: the on-screen one lances; the off-screen one holds.
combat.update(1000, 16);
const eShots = combat.bullets.filter((b) => b.owner === 'enemy');
check('view: an off-screen craft holds its fire (no lance born off-screen)',
eShots.length >= 1 && eShots.every((b) => b.y > 0));
check('view: the on-screen craft lances (its shot is born in view)',
eShots.some((b) => b.y > 0 && b.y < 600));
// AUTO-FIRE + LOCK: the on-screen hostile is engaged even though the
// off-screen one is NEARER (the gun and the brackets track the same).
const pShots = combat.bullets.filter((b) => b.owner === 'player');
check('view: auto-fire targets the on-screen hostile (not the nearer off-screen one)',
pShots.length >= 1 && pShots.every((b) => b.y > 360));
check('view: currentTarget = the on-screen hostile (the lock follows the guns)',
combat.currentTarget() === on);
// BE HIT: a tracer on the off-screen hostile's path passes straight
// through (it cannot be shot while off-screen) …
const clearAll = () => { for (const b of combat.bullets) b.consume(); combat.bullets.length = 0; };
clearAll();
const offArmor = { hull: off.hull, shields: off.shields };
combat.spawnBullet(off.x - 18, off.y, 0, { bulletSpeed: 500, damage: 999, life: 2, color: '#fff', size: 16 }, 'player');
combat.update(1016, 16);
check('view: an off-screen hostile takes no hits (the tracer passes through)',
off.dead === false && off.hull === offArmor.hull && off.shields === offArmor.shields);
// …and the same path on the on-screen one connects (killing over the hull).
clearAll();
combat.spawnBullet(on.x - 18, on.y, 0, { bulletSpeed: 500, damage: 999, life: 2, color: '#fff', size: 16 }, 'player');
combat.update(1032, 16);
check('view: an on-screen hostile takes the hit (and goes down)', on.dead === true && combat.kills === 1);
combat.destroy();
check('view: destroy still clears everything', combat.enemies.length === 0 && combat.bullets.length === 0);
}
// ------------------------------------------------------------------ SFX
{
@ -627,6 +693,24 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d);
check('read: pickTarget empty / all-dead → null',
pickTarget([], 0, 0) === null && pickTarget([B], 0, 0) === null);
check('read: pickTarget tolerates missing entries', pickTarget([null, undefined, A], 0, 0) === A);
check('read: pickTarget honors the qualifies predicate (the on-screen seam)',
pickTarget([A, C], 0, 0, Infinity, (e) => e.x < 50) === A &&
pickTarget([C], 0, 0, Infinity, (e) => e.x < 50) === null);
// inView — the ON-SCREEN RULE's pure geometry (a craft's visual
// circle × the camera's visible rect): a craft is in view while ANY
// part of it is; it is off-screen only once fully out. A null/zero
// view (headless) = no constraint.
const view = { x: 0, y: 0, width: 1280, height: 720 };
check('view: fully inside → in view', inView(640, 360, 20, view) === true);
check('view: half off the edge → still in view (still visible)',
inView(1270, 360, 20, view) === true && inView(640, -10, 20, view) === true);
check('view: fully off the edge → out of view',
inView(1310, 360, 20, view) === false && inView(640, -100, 20, view) === false);
check('view: corner contact counts (the circle touches the rect)',
inView(1290, 710, 20, view) === true && inView(1305, 730, 10, view) === false);
check('view: no view (headless) → everything is in view',
inView(5000, 5000, 20, null) === true && inView(5000, 5000, 20, { width: 0, height: 0 }) === true);
// feedPush — the kill feed's pure state step.
const f1 = feedPush([], 'KILL A', 1000, 5, 3000);

View File

@ -132,16 +132,21 @@ export function orbitStep(px, py, orbitAngle, orbitDir, orbitSpeed, dt, orbitRad
* The CURRENT TARGET (the one your guns are on): the nearest LIVE hostile
* to (x, y) within `range` px, or null. The range's lock indicator and the
* main game's auto-fire both run this rule, so "what's locked" never
* drifts between the two. Dead hostiles never qualify.
* drifts between the two. Dead hostiles never qualify; a `qualifies`
* predicate (when given) narrows the pool further — the main game's
* auto-fire passes its on-screen rule (CombatSystem.craftVisible), so
* the guns only ever engage a hostile that's actually in view.
*
* @param {function(object):boolean} [qualifies] extra eligibility test (null = all live hostiles)
* @returns {object|null} the target (anything with .x/.y/.dead)
*/
export function pickTarget(enemies, x, y, range = Infinity) {
export function pickTarget(enemies, x, y, range = Infinity, qualifies = null) {
let best = null;
let bestD = Infinity;
const r2 = range * range;
for (const e of enemies || []) {
if (!e || e.dead) continue;
if (qualifies && !qualifies(e)) continue;
const dx = e.x - x;
const dy = e.y - y;
const d2 = dx * dx + dy * dy;
@ -153,6 +158,37 @@ export function pickTarget(enemies, x, y, range = Infinity) {
return best;
}
/**
* The ON-SCREEN RULE (pure): is a craft with a visual circle ((x, y),
* radius r) at least PARTIALLY inside the camera's visible world rect
* (`view` — {x, y, width, height})? A ship sliding off the edge is still
* visible — and still in the fight — while any part of it is on screen;
* it is "off-screen" only once fully out. A null/zero view (a scene
* without a camera — the headless tests) = no constraint: everything
* counts as on screen.
*
* This is the combat rule "a hostile must be VISUALLY ON SCREEN to fire
* or be fired upon" (js/combat/CombatSystem.js): off-screen hostiles
* hold their lances, the cannon won't engage them, and their tracers
* pass through. The scene-side craftVisible() feeds its camera's
* worldView in here.
*
* @param {number} x the craft's x (world px)
* @param {number} y the craft's y (world px)
* @param {number} r the craft's visual radius (world px)
* @param {object|null} view the camera's visible world rect (or null)
* @returns {boolean} true when the craft is (partly) on screen
*/
export function inView(x, y, r, view) {
if (!view || !(Number(view.width) > 0) || !(Number(view.height) > 0)) return true;
const rr = Math.max(0, Number(r) || 0);
const L = Number(view.x) || 0;
const T = Number(view.y) || 0;
const R = L + Number(view.width);
const B = T + Number(view.height);
return x + rr >= L && x - rr <= R && y + rr >= T && y - rr <= B;
}
/**
* One kill-feed entry lands (pure state step): unshift {label, t: now},
* drop anything older than `life` ms, cap at `max` lines. Returns a NEW

View File

@ -3,7 +3,7 @@ import { EnemyShip } from './EnemyShip.js';
import { enemyTypeConfig } from './EnemyTypes.js';
import { Bullet } from './Bullet.js';
import { Fx, FX_COLORS, settleCraftTint } from './Fx.js';
import { applyDamage, pickTarget } from './Combat.js';
import { applyDamage, inView, pickTarget } from './Combat.js';
import { combatSfx, combatSfxEnemyHit } from './CombatSfx.js';
import { combatShake } from './CamShake.js';
import { normalizeWaves, WaveRunner } from './Waves.js';
@ -24,6 +24,11 @@ const TAU = Math.PI * 2;
* - the AUTO-FIRE (→ game.autoFire: while a hostile is in range, the
* ship fires at the NEAREST one — the mouse steers,
* the cannon tracks; the cannon's numbers are → player.weapon)
* - the ON-SCREEN RULE (a hostile must be VISUALLY ON SCREEN to fire or
* be fired upon: an off-screen craft holds its lance,
* the cannon won't engage it, and its tracers pass
* through — it enters the fight only once it has
* flown into view; pure geometry in Combat.js → inView)
* - the collision pass (swept bullet hits + rams — the main game's
* hand-rolled circle-check style, no arcade colliders)
* - the WAVE RUNS (Waves.js state machine — quest-step combat: an
@ -161,6 +166,32 @@ export class CombatSystem {
return this.spawnRadius;
}
/**
* The camera's visible world rect (world px, zoom included) — the
* surface the ON-SCREEN RULE tests against. Null when the scene has no
* camera (the headless tests): then nothing is off-screen and the rule
* is a no-op.
*/
visibleView() {
const cam = this.scene?.cameras?.main;
const v = cam && cam.worldView ? cam.worldView : null;
return v && v.width > 0 && v.height > 0 ? v : null;
}
/**
* The ON-SCREEN RULE (the pure geometry is Combat.js → inView): a
* hostile is "on screen" while ANY part of its visual disc (its
* hitRadius, or half its hull length — whichever is wider) still
* touches the camera's visible rect. Off-screen hostiles are OUT of
* the fight: they hold their lances, the cannon won't engage them, and
* their tracers pass through (they re-enter the moment they fly in).
*/
craftVisible(e) {
if (!e) return false;
const r = Math.max(Number(e.hitRadius) || 0, (Number(e.size) || 0) * 0.5);
return inView(e.x, e.y, r, this.visibleView());
}
/**
* Spawn the starting pack in a ring around (cx, cy) — the ?combat boot
* calls this with the ship's position "when the player starts". The ring
@ -365,27 +396,36 @@ export class CombatSystem {
const player = this.playerDown ? null : this.ship;
// 1) The pack: orbit AI + lances (a dead one is its reform clock).
// The ON-SCREEN RULE: the AI may WANT to fire, but the lance only
// leaves the barrel while the craft is (at least partly) on screen —
// an off-screen hostile holds its fire until it flies into view.
for (const e of this.enemies) {
if (e.dead) {
if (e._respawnPending && time >= e.respawnAt) this.reviveEnemy(e, time);
continue;
}
const wantsFire = e.update(time, delta, player);
if (wantsFire && player) this.fireEnemy(e, player, time);
if (wantsFire && player && this.craftVisible(e)) this.fireEnemy(e, player, time);
}
// 2) AUTO-FIRE: the nearest live hostile in range (game.autoFire).
// The mouse steers the ship; the cannon tracks on its own.
// 2) AUTO-FIRE: the nearest VISIBLE hostile in range (game.autoFire).
// The mouse steers the ship; the cannon tracks on its own — but
// only at what's on screen (off-screen hostiles can't be fired
// upon; the NEAREST counts only if it's in view).
if (
player &&
this.autoFire.enabled &&
this.autoFire.range > 0 &&
time >= this.nextFireAt
) {
const target = this.nearestEnemy(player.x, player.y);
if (target && Math.hypot(target.x - player.x, target.y - player.y) <= this.autoFire.range) {
this.firePlayerAt(player, target, time);
}
const target = pickTarget(
this.enemies,
player.x,
player.y,
this.autoFire.range,
(e) => this.craftVisible(e),
);
if (target) this.firePlayerAt(player, target, time);
}
// 3) Tracers: age + cull (no walls here — the arena is the tether range).
@ -432,13 +472,20 @@ export class CombatSystem {
/**
* The CURRENT TARGET (the readability layer's lock, js/combat/CombatHud.js):
* the hostile the auto-fire is on — the nearest one WITHIN auto-fire
* range (the same pickTarget rule, ranged by game.autoFire.range — so
* the brackets sit on exactly the ship the cannon is tracking).
* the hostile the auto-fire is on — the nearest VISIBLE one WITHIN
* auto-fire range (the same pickTarget rule, ranged by game.autoFire.range
* and narrowed by the on-screen rule — so the brackets sit on exactly the
* ship the cannon can engage, never an off-screen one).
*/
currentTarget() {
if (!this.ship) return null;
return pickTarget(this.enemies, this.ship.x, this.ship.y, this.autoFire.range);
return pickTarget(
this.enemies,
this.ship.x,
this.ship.y,
this.autoFire.range,
(e) => this.craftVisible(e),
);
}
/**
@ -557,6 +604,7 @@ export class CombatSystem {
if (b.dead || b.owner !== 'player') continue;
for (const e of this.enemies) {
if (e.dead) continue;
if (!this.craftVisible(e)) continue; // off-screen: can't be hit
if (this.hitsSwept(b, e, e.hitRadius, delta)) {
const preShields = e.shields;
const killed = e.damage(b.damage);