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:
parent
d61f2d6934
commit
5890e93cde
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue