Add faction crossfire: rivals shoot each other in war-zones
- Bullets and seekers now carry their shooter's factionId so a faction's fire drops rival-faction craft but never its own wing (classic pack bolts/seekers are unaffected) - NPC kills via crossfire are silent: no kill count, XP, defeat comm, or screen shake — the war is the world's, not the player's ledger - Survivors of a neutral-to-player side peel off once their rivals are spent, clearing the sky for the director's cadence - Bump faction cooldown to 40–60 s and update data/encounters tests
This commit is contained in:
parent
89fac9ef78
commit
1a581af211
|
|
@ -1679,11 +1679,11 @@
|
|||
],
|
||||
"_tiers": "THE LEVEL-6 JUMP (design): 1–2 → 2–3 → 5–7 (a ~2× step in the pack) → 7–9 → 9–12 (the cap — past level 13 the count holds and the roster's heavier types are the pressure). roster = the band's weighted pirate MIX (js/combat/Encounters.js → compose draws it into `pirateTypes`; a band without one leaves the scene's random pick standing — tiers 0–5 predate the rosters): raiders stay the anchor (the filler), spitter/bomber land at the jump, stinger/gunner at 9+, hunter (missiles) and barrage (rings) at 13+ — the heavy hitters, never the early pack.",
|
||||
"faction": {
|
||||
"_comment": "FACTION SPACE (the war-zones, js/combat/Encounters.js's compose context + GameScene._spawnFactionEncounter): in a faction-controlled system the ambient encounter is the LOCAL faction and one OPPOSING faction (js/combat/Encounters.js picks one of the other roster factions seeded; BOTH groups are sized off the `bands`' pirate counts, each drawn from its OWN combat.factions roster — no pirates/neutrals). DISPOSITION is per-faction, off the player's standing WITH each (js/reputation/Reputation.js → factionStanding, data/reputation.json): standing below `hostileBelow` → that faction targets the player and the player may return fire (the guns/skills' pools include it); at/above `hostileBelow` → it fights only the other faction, never the player (its bolts/seekers/rams can't touch the ship), and once its rival is spent it peels off (CombatSystem._peelOff) so the encounter ends on its own — the player observes. Factions ALWAYS fight each other while they're both up (CombatSystem.pickEnemyTarget). TIMERS: faction space rolls more often than neutral wild space (rollIntervalMin..Max, the director's roll schedule) with a shorter quiet after each encounter (cooldownMin..Max). The ships are the factions' OWN roster crafts (no new art).",
|
||||
"_comment": "FACTION SPACE (the war-zones, js/combat/Encounters.js's compose context + GameScene._spawnFactionEncounter): in a faction-controlled system the ambient encounter is the LOCAL faction and one OPPOSING faction (js/combat/Encounters.js picks one of the other roster factions seeded; BOTH groups are sized off the `bands`' pirate counts, each drawn from its OWN combat.factions roster — no pirates/neutrals). DISPOSITION is per-faction, off the player's standing WITH each (js/reputation/Reputation.js → factionStanding, data/reputation.json): standing below `hostileBelow` → that faction targets the player and the player may return fire (the guns/skills' pools include it); at/above `hostileBelow` → it fights only the other faction, never the player (its bolts/seekers/rams can't touch the ship), and once its rival is spent it peels off (CombatSystem._peelOff) so the encounter ends on its own — the player observes. Factions ALWAYS fight each other while they're both up (CombatSystem.pickEnemyTarget). TIMERS: faction space rolls more often than neutral wild space (rollIntervalMin..Max, the director's roll schedule) with a longer quiet after each encounter (cooldownMin..Max, 40–60 s). The ships are the factions' OWN roster crafts (no new art).",
|
||||
"rollIntervalMin": 20,
|
||||
"rollIntervalMax": 40,
|
||||
"cooldownMin": 20,
|
||||
"cooldownMax": 40,
|
||||
"cooldownMin": 40,
|
||||
"cooldownMax": 60,
|
||||
"hostileBelow": -5
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1802,5 +1802,105 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d);
|
|||
|
||||
// ------------------------------------------------------------------ done
|
||||
|
||||
// THE FACTION CROSSFIRE (the war-zones — both fleets shoot and kill each
|
||||
// other, and the player's disposition is the only thing that decides who
|
||||
// may fight the PLAYER): the crossfire collide passes (a faction's bolts /
|
||||
// seekers drop a hostile-faction craft — never their own wing, never the
|
||||
// classic pack), the SILENT npc kill (no kill count — the bounty is the
|
||||
// player's, the war isn't), and the peel-off (the last survivor of a
|
||||
// neutral-to-the-player side flies away once its rivals are spent, and the
|
||||
// cull clears the sky for the director's cadence).
|
||||
{
|
||||
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: () => {} });
|
||||
const tag = (e, fid, hostileToPlayer) => {
|
||||
e.factionId = fid;
|
||||
e.hostileToPlayer = hostileToPlayer;
|
||||
e.respawn = false;
|
||||
e.nextFireAt = 999999999; // hold their own lances — the test drives the volleys
|
||||
return e;
|
||||
};
|
||||
const zen = tag(combat.spawnEnemy(500, 360, 0), 'zenith', false);
|
||||
const apx = tag(combat.spawnEnemy(780, 360, 1), 'apex', false);
|
||||
const zen2 = tag(combat.spawnEnemy(500, 300, 2), 'zenith', false);
|
||||
const apx2 = tag(combat.spawnEnemy(780, 420, 3), 'apex', false);
|
||||
|
||||
// TARGETING: each ship's target is the RIVAL faction — the player is not
|
||||
// in the fight (both sides stand neutral to it).
|
||||
check('crossfire: a faction ship targets the rival faction',
|
||||
combat.pickEnemyTarget(zen, ship) === apx && combat.pickEnemyTarget(apx, ship) === zen);
|
||||
check('crossfire: neutral-to-the-player — the player pool is empty (no auto-fire)',
|
||||
combat._playerPool().length === 0 && combat.playerTargetable(zen) === false &&
|
||||
combat.playerTargetable(apx) === false);
|
||||
|
||||
// CROSSFIRE SEEKERS: a faction's missile drops a hostile-faction craft
|
||||
// (the same one-kill read; the seeker's own faction is the friendly-fire
|
||||
// rule — and its neutral disposition keeps it off the player's hull).
|
||||
// A minimal seeker stub, held still on the victim's line — the pass
|
||||
// reads exactly this shape.
|
||||
const seeker = {
|
||||
x: 780 - 5, y: 360, radius: 12, dead: false, owner: 'enemy',
|
||||
factionId: 'zenith', hostileToPlayer: false, targetRef: apx,
|
||||
damage: 999, heading: 0, body: { velocity: { x: 0, y: 0 } },
|
||||
update: () => {}, // held on the line — the hit pass settles it
|
||||
consume: () => { seeker.dead = true; return true; },
|
||||
};
|
||||
combat.missiles.push(seeker);
|
||||
combat.update(1000, 16);
|
||||
check('crossfire: a faction seeker drops the rival (and is spent)',
|
||||
seeker.dead === true && apx.dead === true);
|
||||
check('crossfire: the npc kill banks no kill count (not a player kill)', combat.kills === 0);
|
||||
check('crossfire: the neutral seeker fizzles past the ship (no player threat)', ship.dead !== true);
|
||||
|
||||
// CROSSFIRE BOLTS: zenith's lance drops the other apex hull.
|
||||
combat.spawnBullet(780 - 18, 420, 0, { bulletSpeed: 500, damage: 999, life: 2, color: '#fff', size: 16, factionId: 'zenith' }, 'enemy');
|
||||
combat.update(1016, 16);
|
||||
check('crossfire: the rival hull takes the bolt and goes down', apx2.dead === true);
|
||||
|
||||
// FRIENDLY FIRE: the same volley passes through the shooter's own wing.
|
||||
combat.spawnBullet(500 - 18, 300, 0, { bulletSpeed: 500, damage: 999, life: 2, color: '#fff', size: 16, factionId: 'zenith' }, 'enemy');
|
||||
combat.update(1032, 16);
|
||||
check('crossfire: a faction volley never touches its own wing', zen2.dead === false);
|
||||
|
||||
// CLASSIC PACK: a bolt with no faction stamp never shoots the pack
|
||||
// (pirates don't fire on pirates — the seam holds).
|
||||
combat.spawnBullet(500 - 18, 300, 0, { bulletSpeed: 500, damage: 999, life: 2, color: '#fff', size: 16 }, 'enemy');
|
||||
combat.update(1048, 16);
|
||||
check('crossfire: a classic bolt passes through the pack', zen2.dead === false);
|
||||
|
||||
// PEEL-OFF: the apex side is all spent — the neutral zenith survivors
|
||||
// disengage (they steer AWAY from the ship, then the cull collects them
|
||||
// off-screen — the sky clears for the director's cadence).
|
||||
combat.update(1064, 16);
|
||||
check("peel: the survivors' disengage arms (peeling)", zen.peeling === true && zen2.peeling === true);
|
||||
check('peel: the velocity runs away from the player',
|
||||
zen.body && zen.body.velocity.x < 0 && zen2.body && zen2.body.velocity.x < 0);
|
||||
let frames = 0;
|
||||
while ((combat.enemies.includes(zen) || combat.enemies.includes(zen2)) && frames < 240) {
|
||||
if (zen.body) { zen.x += zen.body.velocity.x * 0.016; zen.y += zen.body.velocity.y * 0.016; }
|
||||
if (zen2.body) { zen2.x += zen2.body.velocity.x * 0.016; zen2.y += zen2.body.velocity.y * 0.016; }
|
||||
combat.update(1064 + (frames + 1) * 16, 16);
|
||||
frames++;
|
||||
}
|
||||
check('peel: off-screen, the survivors are culled (the sky clears)',
|
||||
frames < 240 && !combat.enemies.includes(zen) && !combat.enemies.includes(zen2) &&
|
||||
zen._peelGone === true && zen2._peelGone === true);
|
||||
|
||||
// HOSTILE DISPOSITION: a faction ship the player HAS heat on keeps the
|
||||
// classic fight — with no rival up, the player is its target (and the
|
||||
// player's guns may engage it in return).
|
||||
const hot = tag(combat.spawnEnemy(500, 360, 3), 'apex', true);
|
||||
check('disposition: hostile-to-the-player — the player is the fallback target',
|
||||
combat.pickEnemyTarget(hot, ship) === ship);
|
||||
check('disposition: the player guns may engage a hostile faction (pool + lock)',
|
||||
combat._playerPool().includes(hot) && combat.nearestEnemy(640, 360) === hot);
|
||||
combat.destroy();
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? '\ncombat rules: all checks passed' : `\n${failures} check(s) FAILED`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
|
|
|
|||
|
|
@ -78,8 +78,8 @@ const fixed = {
|
|||
);
|
||||
check('data: the post-encounter quiet is the ~30–60 s band', e.cooldownMin === 30 && e.cooldownMax === 60);
|
||||
check(
|
||||
'data: the FACTION schedule ships (tighter: 20–40 s rolls, 20–40 s quiet, the hostile floor)',
|
||||
!!e.faction && e.faction.rollIntervalMin === 20 && e.faction.rollIntervalMax === 40 && e.faction.cooldownMin === 20 && e.faction.cooldownMax === 40 && e.faction.hostileBelow === -5,
|
||||
'data: the FACTION schedule ships (tighter rolls: 20–40 s, a 40–60 s quiet, the hostile floor)',
|
||||
!!e.faction && e.faction.rollIntervalMin === 20 && e.faction.rollIntervalMax === 40 && e.faction.cooldownMin === 40 && e.faction.cooldownMax === 60 && e.faction.hostileBelow === -5,
|
||||
JSON.stringify(e.faction),
|
||||
);
|
||||
check(
|
||||
|
|
|
|||
|
|
@ -1316,8 +1316,14 @@ export class CombatSystem {
|
|||
// The WAR-ZONE disposition (e.hostileToPlayer, see the spawner) rides
|
||||
// the volley — the bolts/seekers it spawns carry it, so a
|
||||
// neutral-to-the-player faction's fire fizzles past the ship (the
|
||||
// collide passes + the missile's home rule).
|
||||
return firePattern(this, e, player, { ...(e.wpn ?? {}), hostileToPlayer: e.hostileToPlayer !== false });
|
||||
// collide passes + the missile's home rule) — and the shooter's
|
||||
// FACTION rides them too (the crossfire passes: a faction's fire
|
||||
// hits the rival faction's craft, never its own wing).
|
||||
return firePattern(this, e, player, {
|
||||
...(e.wpn ?? {}),
|
||||
hostileToPlayer: e.hostileToPlayer !== false,
|
||||
factionId: typeof e.factionId === 'string' ? e.factionId : null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1360,6 +1366,10 @@ export class CombatSystem {
|
|||
// neutral-to-the-player faction fizzles past the ship (collide's
|
||||
// lances×player pass).
|
||||
b.hostileToPlayer = typeof wpn.hostileToPlayer === 'boolean' ? wpn.hostileToPlayer : true;
|
||||
// The shooter's FACTION (the crossfire passes' friendly-fire rule —
|
||||
// a faction's bolt drops the rival faction's hulls, never its own
|
||||
// wing; a classic bolt has none and never shoots itself).
|
||||
b.factionId = typeof wpn.factionId === 'string' ? wpn.factionId : null;
|
||||
b.setDepth(20);
|
||||
this.bullets.push(b);
|
||||
return b;
|
||||
|
|
@ -1461,6 +1471,36 @@ export class CombatSystem {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Their lances × the RIVAL FACTION's craft (the war-zones' crossfire
|
||||
// — a faction's bolt drops a hostile-faction hull, exactly the way a
|
||||
// pirate's can drop a neutral escort in the mixed fight). The bolt
|
||||
// carries its shooter's faction (fireEnemy's stamp — spawnBullet): a
|
||||
// faction's fire never touches its own wing (no friendly fire), and a
|
||||
// classic pack (no faction stamp) never shoots itself. The kill is
|
||||
// SILENT (killEnemy's npc flag — no XP, no defeat line, no shake: the
|
||||
// bounty is the player's, the war isn't), and the downed side's
|
||||
// survivors peel off (the update loop's gate).
|
||||
for (let i = this.bullets.length - 1; i >= 0; i--) {
|
||||
const b = this.bullets[i];
|
||||
if (b.dead || b.owner !== 'enemy') continue;
|
||||
if (!b.factionId) continue; // a classic bolt: the pack never shoots itself
|
||||
for (const e of this.enemies) {
|
||||
if (e.dead) continue;
|
||||
if (!e.factionId || e.factionId === b.factionId) continue; // own wing stands
|
||||
if (!this.craftVisible(e)) continue;
|
||||
if (this.hitsSwept(b, e, e.hitRadius, delta)) {
|
||||
const preShields = e.shields;
|
||||
const killed = e.damage(b.damage);
|
||||
const kind = preShields > 0 ? 'shield' : 'hull';
|
||||
this.fx.enemyHit(e, b.x, b.y, b.angle0, kind, b.damage);
|
||||
this.fx.damage(b.x, b.y, b.damage, kind);
|
||||
combatSfxEnemyHit(this.scene, preShields, b.damage);
|
||||
if (killed) this.killEnemy(e, this._now(), true);
|
||||
if (b.consume()) this.bullets.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Their lances × the WRAITH'S DECOYS (data/skills.json → 'blink'): the
|
||||
// decoy is what drew the fire — a bolt that reaches it fizzles on the
|
||||
// shell (the hologram has no hull to break, only the pop).
|
||||
|
|
@ -1521,6 +1561,9 @@ export class CombatSystem {
|
|||
// Their heat-seekers × your escorts (mixed fight: a pirate seeker
|
||||
// can drop a neutral ship).
|
||||
this.missilesHitAllies(delta);
|
||||
// FACTION SEEKERS × the rival faction's craft (the war-zones'
|
||||
// crossfire — a faction's seeker drops a hostile-faction hull).
|
||||
this.missilesHitRivals(delta);
|
||||
// Your escorts' heat-seekers × the pack (the escorts' missile
|
||||
// patterns — one kill path, the XP still lands).
|
||||
this.missilesHitEnemies(delta);
|
||||
|
|
@ -1569,6 +1612,43 @@ export class CombatSystem {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FACTION SEEKERS × the RIVAL FACTION's craft (the war-zones' crossfire
|
||||
* — a faction's seeker drops a hostile-faction hull, exactly the way a
|
||||
* pirate's can drop a neutral escort in the mixed fight; the seeker
|
||||
* carries its shooter's faction — launchMissiles' stamp — so a
|
||||
* faction's seekers never take their own wing, and a classic seeker
|
||||
* (no faction) never shoots the pack). The kill is SILENT (killEnemy's
|
||||
* npc flag — no XP, no defeat line: the bounty is the player's, the
|
||||
* war isn't).
|
||||
*/
|
||||
missilesHitRivals(delta) {
|
||||
const ms = this.missiles;
|
||||
if (!ms || ms.length === 0 || this.enemies.length === 0) return;
|
||||
for (let i = ms.length - 1; i >= 0; i--) {
|
||||
const m = ms[i];
|
||||
if (m.dead) { ms.splice(i, 1); continue; }
|
||||
if (m.owner !== 'enemy') continue;
|
||||
if (!m.factionId) continue; // a classic seeker: the pack never shoots itself
|
||||
for (const e of this.enemies) {
|
||||
if (e.dead) continue;
|
||||
if (!e.factionId || e.factionId === m.factionId) continue; // own wing stands
|
||||
if (!this.craftVisible(e)) continue;
|
||||
if (this.hitsSwept(m, e, e.hitRadius, delta)) {
|
||||
const preShields = e.shields;
|
||||
const killed = e.damage(m.damage);
|
||||
const kind = preShields > 0 ? 'shield' : 'hull';
|
||||
this.fx.enemyHit(e, m.x, m.y, m.heading, kind, m.damage);
|
||||
this.fx.damage(m.x, m.y, m.damage, kind);
|
||||
combatSfxEnemyHit(this.scene, preShields, m.damage);
|
||||
if (killed) this.killEnemy(e, this._now(), true);
|
||||
if (m.consume()) ms.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Escort seekers × the pack — the same hit read as the player's. */
|
||||
missilesHitEnemies(delta) {
|
||||
const ms = this.missiles;
|
||||
|
|
@ -1777,7 +1857,7 @@ export class CombatSystem {
|
|||
* / the type's override). Idempotent via _killFx — damage() flips
|
||||
* e.dead first, so the dead flag can't be the "already counted" guard.
|
||||
*/
|
||||
killEnemy(e, time) {
|
||||
killEnemy(e, time, npc = false) {
|
||||
if (e._killFx) return;
|
||||
e._killFx = true;
|
||||
// Settle any pending white hit-flash NOW — the craft dies in its
|
||||
|
|
@ -1786,23 +1866,28 @@ export class CombatSystem {
|
|||
// AND settle any strung read (the kill supersedes the stun's dim and
|
||||
// discharge — no dimmed corpse, no orphaned bolts).
|
||||
this._stunRelease(e, false);
|
||||
this.kills += 1;
|
||||
// THE NPC KILL (the war-zones' crossfire — a faction dropped the
|
||||
// craft, not the player): the read stands (the wreck, the beat),
|
||||
// but the kill's BOUNTY doesn't — no XP, no kill count, no defeat
|
||||
// line, no screen kick (the war is the world's, the player's ledger
|
||||
// is the player's).
|
||||
if (!npc) this.kills += 1;
|
||||
// COMBAT XP — the kill's reward (the hostile's own type's value —
|
||||
// combat.enemy.xp / the type's override; the curve is
|
||||
// data/progression.json). The +N floater lands on the wreckage, and a
|
||||
// crossed level's beat rides the manager's 'levelup' event (the armor
|
||||
// bump, the burst, the feed's line) — the kill path stays one grant.
|
||||
if (this.xp) {
|
||||
if (!npc && this.xp) {
|
||||
const gained = this.xp.grant(Number(e.xp) || 0, 'kill');
|
||||
if (gained.gained > 0) this.fx.xp(e.x, e.y, gained.gained);
|
||||
}
|
||||
this.fx.explode(e.x, e.y, '#ff5d6f');
|
||||
combatShake(this.scene, 'kill'); // the world kick (the bar stays still)
|
||||
if (!npc) combatShake(this.scene, 'kill'); // the world kick (the bar stays still)
|
||||
combatSfx(this.scene, 'kill');
|
||||
// THE DEFEAT COMM (data/combat.json → comms.defeat): a downed pirate
|
||||
// rolls its defeat line (20%) — the comm's beat rides the same kill
|
||||
// (once per death — killEnemy is idempotent via _killFx).
|
||||
this._commDefeatRoll(e);
|
||||
if (!npc) this._commDefeatRoll(e);
|
||||
e.setVisible(false);
|
||||
if (e.body) {
|
||||
e.body.velocity.x = 0;
|
||||
|
|
|
|||
|
|
@ -538,6 +538,10 @@ const HANDLERS = {
|
|||
// neutral-to-the-player faction's wing seekers never re-home
|
||||
// onto the ship when their victim falls (Weapons.js's rule).
|
||||
m.hostileToPlayer = e.hostileToPlayer === true;
|
||||
// The shooter's FACTION (the crossfire pass's friendly-fire
|
||||
// rule — a faction's wing seekers drop the rival faction's
|
||||
// craft, never its own wing).
|
||||
m.factionId = typeof e.factionId === 'string' ? e.factionId : null;
|
||||
ctx.missiles.push(m);
|
||||
} catch { /* stub scene — the volley's beat still plays */ }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -241,6 +241,10 @@ export function launchMissiles(ctx, ship, player, wpn) {
|
|||
// onto the weapon): the seeker's fizzle rule + the no-rehome rule
|
||||
// above read it off the missile.
|
||||
m.hostileToPlayer = typeof wpn.hostileToPlayer === 'boolean' ? wpn.hostileToPlayer : true;
|
||||
// The shooter's FACTION (the crossfire pass's friendly-fire rule —
|
||||
// a faction's seeker drops the rival faction's craft, never its own
|
||||
// wing; a classic seeker has none and the pass stands it off).
|
||||
m.factionId = typeof wpn.factionId === 'string' ? wpn.factionId : null;
|
||||
ctx.missiles.push(m);
|
||||
}
|
||||
combatSfx(scene, 'missile_fire'); // the rocket voice (distinct from the laser)
|
||||
|
|
|
|||
Loading…
Reference in New Issue