From 5890e93cde022d42c18fec32c4bfb43191345732 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Thu, 17 Sep 2026 18:12:58 -0600 Subject: [PATCH] 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 --- data/combat.json | 2 +- dev/combat.test.mjs | 90 +++++++++++++++++++++++++++++++++++++-- js/combat/Combat.js | 40 ++++++++++++++++- js/combat/CombatSystem.js | 72 +++++++++++++++++++++++++------ 4 files changed, 186 insertions(+), 18 deletions(-) diff --git a/data/combat.json b/data/combat.json index 2621ecd..85c6198 100644 --- a/data/combat.json +++ b/data/combat.json @@ -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, diff --git a/dev/combat.test.mjs b/dev/combat.test.mjs index aceaa9b..0a479f1 100644 --- a/dev/combat.test.mjs +++ b/dev/combat.test.mjs @@ -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); diff --git a/js/combat/Combat.js b/js/combat/Combat.js index 48f5c98..a1ac815 100644 --- a/js/combat/Combat.js +++ b/js/combat/Combat.js @@ -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 diff --git a/js/combat/CombatSystem.js b/js/combat/CombatSystem.js index 24b804b..7bec301 100644 --- a/js/combat/CombatSystem.js +++ b/js/combat/CombatSystem.js @@ -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);