1072 lines
39 KiB
JavaScript
1072 lines
39 KiB
JavaScript
// Weapon, special-ability and passive behavior registries for Star Control
|
|
// Super Melee. No Phaser dependency. Ship JSON entries reference these by
|
|
// string type; all numbers come from the JSON so the functions here are pure
|
|
// mechanics. Adding a new ship usually means JSON only — a new function lands
|
|
// here only for a genuinely new mechanic.
|
|
//
|
|
// Contracts:
|
|
// PRIMARIES[type] = { fire(state, ship, def), canFire?, init?, tick? }
|
|
// SPECIALS[type] = { activate(state, ship, def) -> false aborts (no cost),
|
|
// canFire?, init?, tick?, preprocess?,
|
|
// absorbDamage?(state, ship, dmg, kind) -> remaining dmg,
|
|
// ramDamage?(state, attacker) -> crew dmg dealt by contact }
|
|
// PASSIVES[name] = { init?, preprocess?, onDeath? -> true cancels death,
|
|
// onAsteroidKill? }
|
|
// Distances/speeds are UQM world units (1 display px = 4 world units); waits
|
|
// and lifetimes are 24 fps frame counts.
|
|
|
|
import {
|
|
wrap, tdelta, tdist, tdist2, angleDelta, interceptAngle,
|
|
quantizeFacing, facingToRad, spawnProjectile, muzzle, damageShip, gainCrew,
|
|
gainEnergy, killShip, enemyOf, emit, destroyAsteroid, shipStats,
|
|
ARENA_W, ARENA_H, FACING_STEP,
|
|
} from './StarControlLogic.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared helpers.
|
|
|
|
// Steers a projectile toward a target angle at a limited turn rate and keeps
|
|
// its velocity aligned with its facing.
|
|
function steerProjectile(p, targetAngle, turnPerFrame, speed) {
|
|
const d = angleDelta(p.facing, targetAngle);
|
|
const step = Math.max(-turnPerFrame, Math.min(turnPerFrame, d));
|
|
p.facing += step;
|
|
p.vx = Math.cos(p.facing) * speed;
|
|
p.vy = Math.sin(p.facing) * speed;
|
|
}
|
|
|
|
function bearingTo(from, to) {
|
|
return Math.atan2(tdelta(from.y, to.y, ARENA_H), tdelta(from.x, to.x, ARENA_W));
|
|
}
|
|
|
|
// Ray-vs-circle: distance along the ray (from x,y toward angle) where it first
|
|
// hits the circle, or Infinity. Toroidal-aware over the short delta.
|
|
function rayCircle(x, y, angle, cx, cy, radius) {
|
|
const dx = tdelta(x, cx, ARENA_W);
|
|
const dy = tdelta(y, cy, ARENA_H);
|
|
const ux = Math.cos(angle);
|
|
const uy = Math.sin(angle);
|
|
const proj = dx * ux + dy * uy;
|
|
if (proj < 0) return Infinity;
|
|
const perp2 = dx * dx + dy * dy - proj * proj;
|
|
const r2 = radius * radius;
|
|
if (perp2 > r2) return Infinity;
|
|
return proj - Math.sqrt(r2 - perp2);
|
|
}
|
|
|
|
// Everything a beam/point-defense zap can hit that belongs to the enemy.
|
|
function beamTargets(state, ship) {
|
|
const targets = [];
|
|
const enemy = enemyOf(state, ship);
|
|
if (enemy.alive && !enemy.fx.phasing) targets.push({ obj: enemy, ship: true });
|
|
for (const p of state.projectiles) {
|
|
if (p.alive && p.owner !== ship.side && p.collidesShots) targets.push({ obj: p });
|
|
}
|
|
for (const ast of state.asteroids) {
|
|
if (ast.alive) targets.push({ obj: ast, asteroid: true });
|
|
}
|
|
return targets;
|
|
}
|
|
|
|
function applyBeamHit(state, ship, hit, damage) {
|
|
if (hit.ship) {
|
|
damageShip(state, hit.obj, damage, 'beam');
|
|
} else if (hit.asteroid) {
|
|
destroyAsteroid(state, hit.obj, ship.side);
|
|
} else {
|
|
hit.obj.hp -= damage;
|
|
if (hit.obj.hp <= 0) hit.obj.alive = false;
|
|
emit(state, { t: 'shotHit', x: hit.obj.x, y: hit.obj.y, kind: 'zapped' });
|
|
}
|
|
}
|
|
|
|
// Instant hit-scan along `angle` up to `range`; damages the first thing hit.
|
|
// Returns the hit (or null) and emits the beam event.
|
|
function hitscan(state, ship, angle, range, damage, color) {
|
|
const targets = beamTargets(state, ship);
|
|
let best = null;
|
|
let bestDist = range;
|
|
for (const t of targets) {
|
|
const r = t.obj.radius ?? t.obj.def?.radius ?? 24;
|
|
const d = rayCircle(ship.x, ship.y, angle, t.obj.x, t.obj.y, r);
|
|
if (d < bestDist) { bestDist = d; best = { ...t, dist: d }; }
|
|
}
|
|
emit(state, {
|
|
t: 'beam', side: ship.side,
|
|
x1: ship.x, y1: ship.y,
|
|
x2: wrap(ship.x + Math.cos(angle) * bestDist, ARENA_W),
|
|
y2: wrap(ship.y + Math.sin(angle) * bestDist, ARENA_H),
|
|
color: color ?? 'laser', hit: !!best,
|
|
});
|
|
if (best) applyBeamHit(state, ship, best, damage);
|
|
return best;
|
|
}
|
|
|
|
// Spawned combat unit (fighter / marine / DOGI / zapsat family): a projectile
|
|
// with hp and a per-frame brain.
|
|
function spawnUnit(state, ship, opts) {
|
|
return spawnProjectile(state, ship, {
|
|
kind: 'unit',
|
|
collidesShips: false,
|
|
collidesShots: true,
|
|
gravity: false,
|
|
...opts,
|
|
});
|
|
}
|
|
|
|
function unitsOf(state, side, type) {
|
|
return state.projectiles.filter((p) => p.alive && p.owner === side && p.type === type);
|
|
}
|
|
|
|
function launchHomingMissile(state, ship, def, x, y, angle) {
|
|
// UQM tracking: every (trackWait+1) frames the missile turns one full 1/16
|
|
// facing toward the intercept course.
|
|
const trackWait = def.trackWait ?? 3;
|
|
return spawnProjectile(state, ship, {
|
|
type: def.projectileType ?? 'missile',
|
|
x, y, angle,
|
|
speed: def.speed,
|
|
damage: def.damage, hp: def.hp ?? 1, life: def.lifeFrames,
|
|
radius: def.radius ?? 28,
|
|
data: def.data ?? {},
|
|
behavior(st, p) {
|
|
if (st.frame % (trackWait + 1) !== 0) return;
|
|
const enemy = st.ships[1 - p.owner];
|
|
if (!enemy.alive || enemy.fx.cloaked || enemy.fx.phasing) return;
|
|
steerProjectile(p, interceptAngle(p.x, p.y, enemy, def.speed), FACING_STEP, def.speed);
|
|
},
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PRIMARY weapon types.
|
|
|
|
export const PRIMARIES = {
|
|
|
|
// Straight-line shot(s). Params: damage, speed, lifeFrames, hp, radius?,
|
|
// count?, spreadDeg? (fan around facing), anglesDeg? (absolute per-shot
|
|
// offsets, e.g. Pkunk [0, 120, -120]), jitterDeg? (random per shot),
|
|
// offsets? ([[fwd, side], ...] display px), addShipVelocity?, recoil?
|
|
// (units/frame kick opposite the shot), muzzleDist? (display px),
|
|
// turretArcDeg? (auto-aim at the enemy within this cone — Orz turret).
|
|
projectile: {
|
|
fire(state, ship, def) {
|
|
const count = def.count ?? (def.anglesDeg ? def.anglesDeg.length : 1);
|
|
const offsets = def.offsets ?? [[def.muzzleDist ?? 12, 0]];
|
|
const spread = ((def.spreadDeg ?? 0) * Math.PI) / 180;
|
|
for (let i = 0; i < count; i += 1) {
|
|
const [fwd, side] = offsets[i % offsets.length];
|
|
const m = muzzle(ship, fwd * 4, side * 4);
|
|
let ang = m.angle;
|
|
if (def.anglesDeg) ang += (def.anglesDeg[i] * Math.PI) / 180;
|
|
else if (count > 1 && spread > 0) ang += spread * (i / (count - 1) - 0.5);
|
|
if (def.jitterDeg) ang += ((state.rng() - 0.5) * 2 * def.jitterDeg * Math.PI) / 180;
|
|
if (def.turretArcDeg) {
|
|
const enemy = enemyOf(state, ship);
|
|
if (enemy.alive && !enemy.fx.cloaked) {
|
|
const want = interceptAngle(m.x, m.y, enemy, def.speed);
|
|
const off = angleDelta(m.angle, want);
|
|
const arc = (def.turretArcDeg * Math.PI) / 180;
|
|
if (Math.abs(off) <= arc) ang = want;
|
|
}
|
|
}
|
|
spawnProjectile(state, ship, {
|
|
type: def.projectileType ?? 'shot',
|
|
x: m.x, y: m.y, angle: ang,
|
|
vx: Math.cos(ang) * def.speed + (def.addShipVelocity ? ship.vx : 0),
|
|
vy: Math.sin(ang) * def.speed + (def.addShipVelocity ? ship.vy : 0),
|
|
damage: def.damage, hp: def.hp ?? 1, life: def.lifeFrames,
|
|
radius: def.radius ?? 24,
|
|
});
|
|
}
|
|
if (def.recoil) {
|
|
const f = facingToRad(quantizeFacing(ship.facing));
|
|
ship.vx -= Math.cos(f) * def.recoil;
|
|
ship.vy -= Math.sin(f) * def.recoil;
|
|
}
|
|
},
|
|
},
|
|
|
|
// Seeking shot. Extra params: trackWait (frames between course corrections).
|
|
homing: {
|
|
fire(state, ship, def) {
|
|
const offsets = def.offsets ?? [[def.muzzleDist ?? 12, 0]];
|
|
for (const [fwd, side] of offsets) {
|
|
const m = muzzle(ship, fwd * 4, side * 4);
|
|
launchHomingMissile(state, ship, def, m.x, m.y, m.angle);
|
|
}
|
|
},
|
|
},
|
|
|
|
// Instant hit-scan. Params: range (display px), damage, autoAim? (nearest
|
|
// target in range regardless of facing), color?.
|
|
beam: {
|
|
fire(state, ship, def) {
|
|
const range = def.range * 4;
|
|
if (def.autoAim) {
|
|
const enemy = enemyOf(state, ship);
|
|
let best = null;
|
|
let bestD2 = range * range;
|
|
const candidates = [];
|
|
if (enemy.alive && !enemy.fx.phasing) candidates.push(enemy);
|
|
for (const p of state.projectiles) {
|
|
if (p.alive && p.owner !== ship.side && p.collidesShots) candidates.push(p);
|
|
}
|
|
for (const obj of candidates) {
|
|
const d2 = tdist2(ship.x, ship.y, obj.x, obj.y);
|
|
if (d2 < bestD2) { bestD2 = d2; best = obj; }
|
|
}
|
|
if (!best) {
|
|
const f = facingToRad(quantizeFacing(ship.facing));
|
|
hitscan(state, ship, f, range, def.damage, def.color);
|
|
return;
|
|
}
|
|
hitscan(state, ship, bearingTo(ship, best), range, def.damage, def.color);
|
|
return;
|
|
}
|
|
hitscan(state, ship, facingToRad(quantizeFacing(ship.facing)), range, def.damage, def.color);
|
|
},
|
|
},
|
|
|
|
// Umgah antimatter cone: a stationary damage zone held in front of the ship,
|
|
// respawned every frame while firing (CONE_LIFE 1 in UQM). Destroys shots.
|
|
// Params: damage, radius, muzzleDist (display px).
|
|
cone: {
|
|
fire(state, ship, def) {
|
|
const m = muzzle(ship, (def.muzzleDist ?? 32) * 4, 0);
|
|
spawnProjectile(state, ship, {
|
|
type: 'cone',
|
|
x: m.x, y: m.y, angle: m.angle,
|
|
vx: ship.vx, vy: ship.vy,
|
|
damage: def.damage ?? 1, hp: def.hp ?? 100, life: def.lifeFrames ?? 2,
|
|
radius: def.radius ?? 120,
|
|
});
|
|
},
|
|
},
|
|
|
|
// Melnorme pump-up blaster: first press starts a charge; holding pumps it a
|
|
// level every levelFrames (spending energyCost again); releasing fires.
|
|
// Params: speed, lifeFrames, damagePerLevel, maxLevel, levelFrames.
|
|
chargeShot: {
|
|
canFire(state, ship) { return !ship.fx.pump; },
|
|
fire(state, ship, def) {
|
|
ship.fx.pump = { level: 0, frames: 0 };
|
|
},
|
|
tick(state, ship, def) {
|
|
const pump = ship.fx.pump;
|
|
if (!pump) return;
|
|
if (ship.input.fire) {
|
|
pump.frames += 1;
|
|
if (pump.frames >= (def.levelFrames ?? 72) && pump.level < (def.maxLevel ?? 4)
|
|
&& ship.energy >= def.energyCost) {
|
|
ship.energy -= def.energyCost;
|
|
pump.level += 1;
|
|
pump.frames = 0;
|
|
emit(state, { t: 'pump', side: ship.side, level: pump.level });
|
|
}
|
|
return;
|
|
}
|
|
// Released: let it fly.
|
|
const m = muzzle(ship, (def.muzzleDist ?? 24) * 4, 0);
|
|
spawnProjectile(state, ship, {
|
|
type: 'pumpshot',
|
|
x: m.x, y: m.y, angle: m.angle,
|
|
speed: def.speed,
|
|
damage: (def.damagePerLevel ?? 2) * (pump.level + 1),
|
|
hp: pump.level + 1,
|
|
life: def.lifeFrames ?? 10,
|
|
radius: 20 + pump.level * 14,
|
|
});
|
|
emit(state, { t: 'fire', side: ship.side, weapon: 'chargeShot', sfx: def.sfxClass });
|
|
ship.fx.pump = null;
|
|
},
|
|
},
|
|
|
|
// Chenjesu photon crystal: flies while the button is held; releasing the
|
|
// button shatters every live shard into a ring of fragments.
|
|
// Params: speed, lifeFrames, damage, hp, fragment{count,speed,lifeFrames,damage}.
|
|
crystalShard: {
|
|
fire(state, ship, def) {
|
|
const m = muzzle(ship, (def.muzzleDist ?? 12) * 4, 0);
|
|
spawnProjectile(state, ship, {
|
|
type: 'shard',
|
|
x: m.x, y: m.y, angle: m.angle,
|
|
speed: def.speed,
|
|
damage: def.damage, hp: def.hp ?? 10, life: def.lifeFrames,
|
|
radius: def.radius ?? 30,
|
|
});
|
|
},
|
|
tick(state, ship, def) {
|
|
if (ship.input.fire) return;
|
|
const frag = def.fragment ?? {};
|
|
for (const p of state.projectiles) {
|
|
if (!p.alive || p.owner !== ship.side || p.type !== 'shard') continue;
|
|
p.alive = false;
|
|
const n = frag.count ?? 8;
|
|
for (let i = 0; i < n; i += 1) {
|
|
const a = (i / n) * Math.PI * 2;
|
|
spawnProjectile(state, ship, {
|
|
type: 'fragment',
|
|
x: p.x, y: p.y, angle: a,
|
|
speed: frag.speed ?? 64,
|
|
damage: frag.damage ?? 2, hp: 1, life: frag.lifeFrames ?? 10,
|
|
radius: 16,
|
|
});
|
|
}
|
|
emit(state, { t: 'shotHit', x: p.x, y: p.y, kind: 'shatter' });
|
|
}
|
|
},
|
|
},
|
|
|
|
// Kohr-Ah spinning blades: launched forward, then steered toward wherever
|
|
// the Marauder points while the button is held.
|
|
guidedBlades: {
|
|
fire(state, ship, def) {
|
|
const m = muzzle(ship, (def.muzzleDist ?? 9) * 4, 0);
|
|
spawnProjectile(state, ship, {
|
|
type: 'blade',
|
|
x: m.x, y: m.y, angle: m.angle,
|
|
speed: def.speed,
|
|
damage: def.damage, hp: def.hp ?? 10, life: def.lifeFrames,
|
|
radius: def.radius ?? 30,
|
|
behavior(st, p) {
|
|
const owner = st.ships[p.owner];
|
|
if (!owner.alive || !owner.input.fire) return;
|
|
steerProjectile(p, facingToRad(quantizeFacing(owner.facing)), FACING_STEP / 2, def.speed);
|
|
},
|
|
});
|
|
},
|
|
},
|
|
|
|
// Slylandro lightning: a hit-scan bolt of random length.
|
|
// Params: minRange/maxRange (display px), damage.
|
|
lightning: {
|
|
fire(state, ship, def) {
|
|
const range = (def.minRange ?? 32) + state.rng() * ((def.maxRange ?? 160) - (def.minRange ?? 32));
|
|
hitscan(state, ship, facingToRad(quantizeFacing(ship.facing)), range * 4, def.damage, 'lightning');
|
|
},
|
|
},
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SPECIAL ability types.
|
|
|
|
export const SPECIALS = {
|
|
|
|
// Earthling Cruiser: point-defense laser. Zaps up to params.maxTargets
|
|
// enemy projectiles in range; hits the enemy ship for the same damage if it
|
|
// is the only thing in reach. Params: range (display px), damage, maxTargets.
|
|
pointDefense: {
|
|
activate(state, ship, def) {
|
|
const range = (def.params?.range ?? 100) * 4;
|
|
const damage = def.params?.damage ?? 1;
|
|
const maxTargets = def.params?.maxTargets ?? 4;
|
|
const inRange = [];
|
|
for (const p of state.projectiles) {
|
|
if (!p.alive || p.owner === ship.side || !p.collidesShots) continue;
|
|
const d = tdist(ship.x, ship.y, p.x, p.y);
|
|
if (d <= range) inRange.push({ obj: p, dist: d });
|
|
}
|
|
inRange.sort((a, b) => a.dist - b.dist);
|
|
let zapped = 0;
|
|
for (const t of inRange.slice(0, maxTargets)) {
|
|
applyBeamHit(state, ship, t, damage);
|
|
emit(state, { t: 'pdZap', x1: ship.x, y1: ship.y, x2: t.obj.x, y2: t.obj.y, side: ship.side });
|
|
zapped += 1;
|
|
}
|
|
if (zapped === 0) {
|
|
const enemy = enemyOf(state, ship);
|
|
if (enemy.alive && !enemy.fx.phasing
|
|
&& tdist(ship.x, ship.y, enemy.x, enemy.y) <= range + enemy.def.radius) {
|
|
damageShip(state, enemy, damage, 'beam');
|
|
emit(state, { t: 'pdZap', x1: ship.x, y1: ship.y, x2: enemy.x, y2: enemy.y, side: ship.side });
|
|
zapped += 1;
|
|
}
|
|
}
|
|
return zapped > 0 ? true : false; // no cost when nothing in reach
|
|
},
|
|
},
|
|
|
|
// Shofixti Scout: Glory Device. Self-destruct with radius falloff damage
|
|
// (UQM: MAX_DESTRUCTION * (range - d) / range + 1 within range).
|
|
// Params: range (display px), maxDamage.
|
|
glory: {
|
|
activate(state, ship, def) {
|
|
const range = (def.params?.range ?? 180) * 4;
|
|
const maxDamage = def.params?.maxDamage ?? 18;
|
|
emit(state, { t: 'glory', x: ship.x, y: ship.y, range });
|
|
const enemy = enemyOf(state, ship);
|
|
if (enemy.alive) {
|
|
const d = tdist(ship.x, ship.y, enemy.x, enemy.y);
|
|
if (d < range) {
|
|
const dmg = Math.floor((maxDamage * (range - d)) / range) + 1;
|
|
damageShip(state, enemy, dmg, 'glory');
|
|
}
|
|
}
|
|
for (const p of state.projectiles) {
|
|
if (p.alive && tdist(ship.x, ship.y, p.x, p.y) < range) p.alive = false;
|
|
}
|
|
for (const ast of state.asteroids) {
|
|
if (ast.alive && tdist(ship.x, ship.y, ast.x, ast.y) < range) {
|
|
destroyAsteroid(state, ast, ship.side);
|
|
}
|
|
}
|
|
killShip(state, ship, 'glory');
|
|
return true;
|
|
},
|
|
},
|
|
|
|
// Ur-Quan Dreadnought: autonomous fighters. Each launch costs 1 crew (plus
|
|
// energy); fighters fly out, harass the enemy with a short-range laser, then
|
|
// return to dock (restoring the crew member). Params: speed, lifeFrames,
|
|
// outFrames, laserRange (display px), laserDamage, weaponWait, hp.
|
|
fighters: {
|
|
canFire(state, ship) {
|
|
return ship.crew > 1; // never launch the last crew member
|
|
},
|
|
activate(state, ship, def) {
|
|
const p = def.params ?? {};
|
|
ship.crew -= 1;
|
|
const m = muzzle(ship, -ship.def.radius, 0); // launch from the stern
|
|
const speed = p.speed ?? 32;
|
|
spawnUnit(state, ship, {
|
|
type: 'fighter',
|
|
x: m.x, y: m.y,
|
|
angle: m.angle + Math.PI,
|
|
speed,
|
|
damage: 0, hp: p.hp ?? 1,
|
|
life: p.lifeFrames ?? 400,
|
|
radius: p.radius ?? 36,
|
|
data: {
|
|
speed,
|
|
outFrames: p.outFrames ?? 125,
|
|
laserRange: (p.laserRange ?? 72) * 4,
|
|
laserDamage: p.laserDamage ?? 1,
|
|
weaponWait: p.weaponWait ?? 8,
|
|
cooldown: 0,
|
|
age: 0,
|
|
},
|
|
behavior: fighterBrain,
|
|
});
|
|
return true;
|
|
},
|
|
},
|
|
|
|
// Ilwrath Avenger: cloaking device. Toggles on; firing the primary (or
|
|
// re-triggering the special) drops it.
|
|
cloak: {
|
|
activate(state, ship) {
|
|
ship.fx.cloaked = !ship.fx.cloaked;
|
|
emit(state, { t: 'cloak', side: ship.side, on: ship.fx.cloaked });
|
|
return true;
|
|
},
|
|
tick(state, ship) {
|
|
if (ship.fx.cloaked && ship.input.fire) {
|
|
ship.fx.cloaked = false;
|
|
emit(state, { t: 'cloak', side: ship.side, on: false });
|
|
}
|
|
},
|
|
},
|
|
|
|
// Spathi Eluder: B.U.T.T. — a homing missile launched backwards. Params
|
|
// mirror the homing primary (speed, damage, lifeFrames, trackWait, hp).
|
|
rearHoming: {
|
|
activate(state, ship, def) {
|
|
const p = def.params ?? {};
|
|
const f = facingToRad(quantizeFacing(ship.facing)) + Math.PI;
|
|
const dist = (p.muzzleDist ?? 20) * 4;
|
|
launchHomingMissile(
|
|
state, ship,
|
|
{
|
|
speed: p.speed ?? 32, damage: p.damage ?? 2, lifeFrames: p.lifeFrames ?? 30,
|
|
trackWait: p.trackWait ?? 1, hp: p.hp ?? 1, projectileType: 'butt',
|
|
},
|
|
wrap(ship.x + Math.cos(f) * dist, ARENA_W),
|
|
wrap(ship.y + Math.sin(f) * dist, ARENA_H),
|
|
f,
|
|
);
|
|
return true;
|
|
},
|
|
},
|
|
|
|
// Androsynth Guardian: Blazer form. Toggles into a ramming comet — fast,
|
|
// constant speed, can't shoot, drains energy (blazer degeneration); reverts
|
|
// when energy empties. Params: thrust, ramDamage.
|
|
blazer: {
|
|
activate(state, ship) {
|
|
ship.fx.blazer = !ship.fx.blazer;
|
|
ship.fx.energyRegenOverride = ship.fx.blazer ? -1 : undefined;
|
|
emit(state, { t: 'blazer', side: ship.side, on: ship.fx.blazer });
|
|
return true;
|
|
},
|
|
preprocess(state, ship) {
|
|
if (!ship.fx.blazer) return;
|
|
if (ship.energy <= 0) {
|
|
ship.fx.blazer = false;
|
|
ship.fx.energyRegenOverride = undefined;
|
|
emit(state, { t: 'blazer', side: ship.side, on: false });
|
|
return;
|
|
}
|
|
const p = ship.def.special.params ?? {};
|
|
ship.moveOverride = {
|
|
...(ship.moveOverride ?? {}),
|
|
autoThrust: true, setVelocity: true, noFire: true,
|
|
maxThrust: p.thrust ?? 60, thrustWait: 0,
|
|
};
|
|
},
|
|
ramDamage(state, attacker) {
|
|
return attacker.fx.blazer ? (attacker.def.special.params?.ramDamage ?? 3) : 1;
|
|
},
|
|
},
|
|
|
|
// Arilou Skiff: hyperspace shunt — random relocation, keeps velocity.
|
|
teleport: {
|
|
activate(state, ship) {
|
|
for (let tries = 0; tries < 12; tries += 1) {
|
|
const x = state.rng() * ARENA_W;
|
|
const y = state.rng() * ARENA_H;
|
|
if (tdist(x, y, state.planet.x, state.planet.y) < state.planet.radius + 500) continue;
|
|
emit(state, { t: 'teleport', side: ship.side, fromX: ship.x, fromY: ship.y, toX: x, toY: y });
|
|
ship.x = x; ship.y = y; ship.px = x; ship.py = y;
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
},
|
|
|
|
// Chenjesu D.O.G.I.: a spawned drone that chases the enemy and bites off
|
|
// energy on contact. Params: speed, hp, drain, maxUnits, radius.
|
|
dogi: {
|
|
canFire(state, ship, def) {
|
|
return unitsOf(state, ship.side, 'dogi').length < (ship.def.special.params?.maxUnits ?? 4);
|
|
},
|
|
activate(state, ship, def) {
|
|
const p = def.params ?? {};
|
|
const m = muzzle(ship, -ship.def.radius, 0);
|
|
const speed = p.speed ?? 32;
|
|
spawnUnit(state, ship, {
|
|
type: 'dogi',
|
|
x: m.x, y: m.y, angle: m.angle + Math.PI,
|
|
speed,
|
|
damage: 0, hp: p.hp ?? 3, life: 100000,
|
|
radius: p.radius ?? 48,
|
|
data: { speed, drain: p.drain ?? 10, cooldown: 0 },
|
|
behavior(st, u) {
|
|
const enemy = st.ships[1 - u.owner];
|
|
if (!enemy.alive) return;
|
|
if (u.data.cooldown > 0) u.data.cooldown -= 1;
|
|
steerProjectile(u, bearingTo(u, enemy), FACING_STEP / 2, u.data.speed);
|
|
if (u.data.cooldown === 0 && !enemy.fx.phasing
|
|
&& tdist(u.x, u.y, enemy.x, enemy.y) < enemy.def.radius + u.radius) {
|
|
enemy.energy = Math.max(0, enemy.energy - u.data.drain);
|
|
u.data.cooldown = 24;
|
|
// Bounce off after the bite.
|
|
u.facing += Math.PI;
|
|
u.vx = Math.cos(u.facing) * u.data.speed;
|
|
u.vy = Math.sin(u.facing) * u.data.speed;
|
|
emit(state, { t: 'dogiDrain', x: u.x, y: u.y, side: u.owner });
|
|
}
|
|
},
|
|
});
|
|
return true;
|
|
},
|
|
},
|
|
|
|
// Chmmr Avatar: tractor beam — hold to reel the enemy in.
|
|
// Params: pull (units/frame^2), range (display px).
|
|
tractor: {
|
|
activate(state, ship, def) {
|
|
const enemy = enemyOf(state, ship);
|
|
if (!enemy.alive) return false;
|
|
const p = def.params ?? {};
|
|
const range = (p.range ?? 1000) * 4;
|
|
if (tdist(ship.x, ship.y, enemy.x, enemy.y) > range) return false;
|
|
const b = bearingTo(enemy, ship);
|
|
const pull = p.pull ?? 6;
|
|
enemy.vx += Math.cos(b) * pull;
|
|
enemy.vy += Math.sin(b) * pull;
|
|
emit(state, {
|
|
t: 'pdZap', side: ship.side, x1: ship.x, y1: ship.y, x2: enemy.x, y2: enemy.y,
|
|
});
|
|
return true;
|
|
},
|
|
},
|
|
|
|
// Druuge Mauler: crew furnace — shove a crewman in the engine, refill the
|
|
// battery completely.
|
|
furnace: {
|
|
activate(state, ship) {
|
|
if (ship.crew <= 1) return false;
|
|
if (ship.energy >= ship.def.stats.maxEnergy) return false;
|
|
ship.crew -= 1;
|
|
ship.energy = ship.def.stats.maxEnergy;
|
|
emit(state, { t: 'furnace', side: ship.side, x: ship.x, y: ship.y });
|
|
return true;
|
|
},
|
|
},
|
|
|
|
// Kohr-Ah F.R.I.E.D.: a ring of fire that burns off incoming ordnance and
|
|
// scorches a too-close enemy. Params: range (world), frames, damage,
|
|
// damageEvery.
|
|
fireRing: {
|
|
activate(state, ship, def) {
|
|
ship.fx.friedFrames = def.params?.frames ?? 32;
|
|
emit(state, { t: 'fried', side: ship.side, x: ship.x, y: ship.y, range: def.params?.range ?? 520 });
|
|
return true;
|
|
},
|
|
tick(state, ship, def) {
|
|
if (!(ship.fx.friedFrames > 0)) return;
|
|
ship.fx.friedFrames -= 1;
|
|
const p = def.params ?? {};
|
|
const range = p.range ?? 520;
|
|
for (const pr of state.projectiles) {
|
|
if (pr.alive && pr.owner !== ship.side && pr.collidesShots
|
|
&& tdist(ship.x, ship.y, pr.x, pr.y) < range) {
|
|
pr.alive = false;
|
|
emit(state, { t: 'shotHit', x: pr.x, y: pr.y, kind: 'fried' });
|
|
}
|
|
}
|
|
const enemy = enemyOf(state, ship);
|
|
if (enemy.alive && ship.fx.friedFrames % (p.damageEvery ?? 8) === 0
|
|
&& tdist(ship.x, ship.y, enemy.x, enemy.y) < range + enemy.def.radius) {
|
|
damageShip(state, enemy, p.damage ?? 1, 'fried');
|
|
}
|
|
},
|
|
},
|
|
|
|
// Melnorme confusion pulse: a fat, harmless missile that scrambles the
|
|
// enemy's steering on impact. Params: speed, lifeFrames, confuseFrames.
|
|
confuse: {
|
|
activate(state, ship, def) {
|
|
const p = def.params ?? {};
|
|
const m = muzzle(ship, (p.muzzleDist ?? 16) * 4, 0);
|
|
spawnProjectile(state, ship, {
|
|
type: 'confuse',
|
|
x: m.x, y: m.y, angle: m.angle,
|
|
speed: p.speed ?? 120,
|
|
damage: 0, hp: p.hp ?? 200, life: p.lifeFrames ?? 20,
|
|
radius: 30,
|
|
data: {
|
|
onHitShip(st, pr, target) {
|
|
target.fx.confusedFrames = p.confuseFrames ?? 96;
|
|
emit(st, { t: 'confused', side: target.side });
|
|
},
|
|
},
|
|
});
|
|
return true;
|
|
},
|
|
},
|
|
|
|
// Mmrnmhrm Transform: swap between the ship's JSON `forms` (stat + primary
|
|
// overrides). Costs the battery, as in UQM.
|
|
transform: {
|
|
activate(state, ship) {
|
|
const forms = ship.def.forms ?? [];
|
|
if (forms.length === 0) return false;
|
|
const next = ship.fx.formIndex ? 0 : 1;
|
|
ship.fx.formIndex = next;
|
|
const form = next === 0 ? null : forms[next - 1];
|
|
ship.fx.formStats = form?.stats ?? null;
|
|
ship.fx.formPrimary = form?.primary ?? null;
|
|
ship.fx.pump = null;
|
|
emit(state, { t: 'transform', side: ship.side, form: next });
|
|
return true;
|
|
},
|
|
},
|
|
|
|
// Mycon Podship: regenerate crew from the whole battery.
|
|
// Params: crew (regeneration amount).
|
|
regenerate: {
|
|
canFire(state, ship) { return ship.crew < ship.def.stats.maxCrew; },
|
|
activate(state, ship, def) {
|
|
gainCrew(state, ship, def.params?.crew ?? 4);
|
|
emit(state, { t: 'regenerate', side: ship.side, x: ship.x, y: ship.y });
|
|
return true;
|
|
},
|
|
},
|
|
|
|
// Orz space marines: crew sent to chew through the enemy hull.
|
|
// Params: speed, hp, maxUnits, killEvery (frames), maxKills.
|
|
marines: {
|
|
canFire(state, ship, def) {
|
|
return ship.crew > 1
|
|
&& unitsOf(state, ship.side, 'marine').length < (ship.def.special.params?.maxUnits ?? 8);
|
|
},
|
|
activate(state, ship, def) {
|
|
const p = def.params ?? {};
|
|
ship.crew -= 1;
|
|
const m = muzzle(ship, -ship.def.radius, 0);
|
|
const speed = p.speed ?? 24;
|
|
spawnUnit(state, ship, {
|
|
type: 'marine',
|
|
x: m.x, y: m.y, angle: m.angle + Math.PI,
|
|
speed,
|
|
damage: 0, hp: p.hp ?? 3, life: 100000,
|
|
radius: p.radius ?? 30,
|
|
data: { speed, attached: false, timer: 0, kills: 0 },
|
|
behavior(st, u) {
|
|
const enemy = st.ships[1 - u.owner];
|
|
if (!enemy.alive) { u.alive = false; return; }
|
|
if (u.data.attached) {
|
|
u.x = enemy.x; u.y = enemy.y; u.px = enemy.px; u.py = enemy.py;
|
|
u.vx = enemy.vx; u.vy = enemy.vy;
|
|
u.collidesShots = false; // inside the hull now
|
|
u.data.timer += 1;
|
|
if (u.data.timer >= (ship.def.special.params?.killEvery ?? 36)) {
|
|
u.data.timer = 0;
|
|
damageShip(st, enemy, 1, 'marine');
|
|
u.data.kills += 1;
|
|
if (u.data.kills >= (ship.def.special.params?.maxKills ?? 4)) u.alive = false;
|
|
}
|
|
return;
|
|
}
|
|
steerProjectile(u, bearingTo(u, enemy), FACING_STEP / 2, u.data.speed);
|
|
if (!enemy.fx.phasing && tdist(u.x, u.y, enemy.x, enemy.y) < enemy.def.radius + u.radius) {
|
|
u.data.attached = true;
|
|
emit(st, { t: 'marineBoard', side: u.owner, x: u.x, y: u.y });
|
|
}
|
|
},
|
|
});
|
|
return true;
|
|
},
|
|
},
|
|
|
|
// Pkunk Fury: cathartic insults restore a little energy.
|
|
// Params: energy.
|
|
taunt: {
|
|
canFire(state, ship) { return ship.energy < ship.def.stats.maxEnergy; },
|
|
activate(state, ship, def) {
|
|
gainEnergy(state, ship, def.params?.energy ?? 2);
|
|
emit(state, { t: 'taunt', side: ship.side });
|
|
return true;
|
|
},
|
|
},
|
|
|
|
// Slylandro Probe: consume a nearby asteroid for energy.
|
|
// Params: range (display px), energy.
|
|
harvest: {
|
|
activate(state, ship, def) {
|
|
const p = def.params ?? {};
|
|
const range = (p.range ?? 78) * 4;
|
|
for (const ast of state.asteroids) {
|
|
if (ast.alive && tdist(ship.x, ship.y, ast.x, ast.y) < range + ast.radius) {
|
|
// bySide null: the passive contact-harvest bonus must not also fire.
|
|
destroyAsteroid(state, ast, null);
|
|
gainEnergy(state, ship, p.energy ?? 8);
|
|
emit(state, { t: 'harvest', side: ship.side, x: ast.x, y: ast.y });
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
},
|
|
|
|
// Supox Blade: omni-thrust — while held, the engine burns backwards.
|
|
omniThrust: {
|
|
activate(state, ship) {
|
|
if (!ship.input.thrust) return false; // only costs energy while burning
|
|
ship.moveOverride = { ...(ship.moveOverride ?? {}), reverseThrust: true };
|
|
return true;
|
|
},
|
|
},
|
|
|
|
// Syreen Song: lure a slice of the enemy crew overboard as floating pods
|
|
// either ship can scoop up. Params: range (display px), maxCrew, fraction.
|
|
song: {
|
|
activate(state, ship, def) {
|
|
const enemy = enemyOf(state, ship);
|
|
const p = def.params ?? {};
|
|
if (!enemy.alive || enemy.crew <= 1) return false;
|
|
if (tdist(ship.x, ship.y, enemy.x, enemy.y) > (p.range ?? 208) * 4) return false;
|
|
const n = Math.min(
|
|
p.maxCrew ?? 8,
|
|
Math.max(1, Math.floor(enemy.crew * (p.fraction ?? 0.25))),
|
|
enemy.crew - 1,
|
|
);
|
|
enemy.crew -= n;
|
|
emit(state, { t: 'song', side: ship.side, count: n, x: enemy.x, y: enemy.y });
|
|
for (let i = 0; i < n; i += 1) {
|
|
const a = state.rng() * Math.PI * 2;
|
|
spawnUnit(state, ship, {
|
|
type: 'crew',
|
|
x: wrap(enemy.x + Math.cos(a) * (enemy.def.radius + 140), ARENA_W),
|
|
y: wrap(enemy.y + Math.sin(a) * (enemy.def.radius + 140), ARENA_H),
|
|
angle: a,
|
|
speed: 6 + state.rng() * 8,
|
|
damage: 0, hp: 1, life: 24 * 30,
|
|
radius: 22,
|
|
data: { age: 0 },
|
|
behavior(st, u) {
|
|
// A short grace period so the victim can't instantly re-scoop them.
|
|
u.data.age += 1;
|
|
if (u.data.age < 24) return;
|
|
for (const s of st.ships) {
|
|
if (!s.alive || s.fx.phasing) continue;
|
|
if (tdist(u.x, u.y, s.x, s.y) < s.def.radius + u.radius + 20) {
|
|
gainCrew(st, s, 1);
|
|
u.alive = false;
|
|
emit(st, { t: 'crewRescued', side: s.side, x: u.x, y: u.y });
|
|
return;
|
|
}
|
|
}
|
|
},
|
|
});
|
|
}
|
|
return true;
|
|
},
|
|
},
|
|
|
|
// Thraddash Torch: afterburner — rockets forward leaving burning napalm.
|
|
// Params: thrust, thrustIncrement, napalm{lifeFrames,damage,radius}.
|
|
afterburner: {
|
|
activate(state, ship, def) {
|
|
const p = def.params ?? {};
|
|
ship.moveOverride = {
|
|
...(ship.moveOverride ?? {}),
|
|
autoThrust: true, thrustWait: 0,
|
|
maxThrust: p.thrust ?? 64, thrustIncrement: p.thrustIncrement ?? 12,
|
|
};
|
|
ship.fx.burnerTick = (ship.fx.burnerTick ?? 0) + 1;
|
|
if (ship.fx.burnerTick % 2 === 0) {
|
|
const n = p.napalm ?? {};
|
|
const m = muzzle(ship, -ship.def.radius - 20, 0);
|
|
spawnProjectile(state, ship, {
|
|
type: 'napalm',
|
|
x: m.x, y: m.y, angle: m.angle,
|
|
vx: 0, vy: 0,
|
|
damage: n.damage ?? 2, hp: 1, life: n.lifeFrames ?? 48,
|
|
radius: n.radius ?? 60,
|
|
collidesShots: false,
|
|
});
|
|
}
|
|
return true;
|
|
},
|
|
},
|
|
|
|
// Umgah Drone: retro-propulsion — instant backward hop.
|
|
// Params: dist (world units per hop).
|
|
retroHop: {
|
|
activate(state, ship, def) {
|
|
const f = facingToRad(quantizeFacing(ship.facing));
|
|
const d = def.params?.dist ?? 160;
|
|
ship.x = wrap(ship.x - Math.cos(f) * d, ARENA_W);
|
|
ship.y = wrap(ship.y - Math.sin(f) * d, ARENA_H);
|
|
emit(state, { t: 'retro', side: ship.side, x: ship.x, y: ship.y });
|
|
return true;
|
|
},
|
|
},
|
|
|
|
// Utwig Jugger: absorption shield — while raised, damage feeds the battery.
|
|
// Params: frames (shield life per press).
|
|
absorbShield: {
|
|
activate(state, ship, def) {
|
|
ship.fx.absorbFrames = def.params?.frames ?? 12;
|
|
emit(state, { t: 'shield', side: ship.side, on: true });
|
|
return true;
|
|
},
|
|
tick(state, ship) {
|
|
if (ship.fx.absorbFrames > 0) ship.fx.absorbFrames -= 1;
|
|
},
|
|
absorbDamage(state, ship, dmg) {
|
|
if (!(ship.fx.absorbFrames > 0)) return dmg;
|
|
gainEnergy(state, ship, dmg);
|
|
emit(state, { t: 'absorbed', side: ship.side, amount: dmg });
|
|
return 0;
|
|
},
|
|
},
|
|
|
|
// VUX limpets: slow homing suckers that gum up the enemy's helm (each one
|
|
// permanently worsens turning/thrust). Params: speed, lifeFrames, trackWait.
|
|
limpets: {
|
|
activate(state, ship, def) {
|
|
const p = def.params ?? {};
|
|
const f = facingToRad(quantizeFacing(ship.facing)) + Math.PI;
|
|
launchHomingMissile(
|
|
state, ship,
|
|
{
|
|
speed: p.speed ?? 25, damage: 0, lifeFrames: p.lifeFrames ?? 80,
|
|
trackWait: p.trackWait ?? 0, hp: 1, projectileType: 'limpet',
|
|
data: {
|
|
onHitShip(st, pr, target) {
|
|
target.fx.limpets = (target.fx.limpets ?? 0) + 1;
|
|
emit(st, { t: 'limpet', side: target.side, count: target.fx.limpets });
|
|
},
|
|
},
|
|
},
|
|
wrap(ship.x + Math.cos(f) * 40, ARENA_W),
|
|
wrap(ship.y + Math.sin(f) * 40, ARENA_H),
|
|
f,
|
|
);
|
|
return true;
|
|
},
|
|
},
|
|
|
|
// Yehat Terminator: force shield — total immunity while charged.
|
|
// Params: frames (shield life per press).
|
|
forceShield: {
|
|
activate(state, ship, def) {
|
|
ship.fx.shieldFrames = def.params?.frames ?? 10;
|
|
emit(state, { t: 'shield', side: ship.side, on: true });
|
|
return true;
|
|
},
|
|
tick(state, ship) {
|
|
if (ship.fx.shieldFrames > 0) ship.fx.shieldFrames -= 1;
|
|
},
|
|
absorbDamage(state, ship, dmg) {
|
|
if (!(ship.fx.shieldFrames > 0)) return dmg;
|
|
emit(state, { t: 'absorbed', side: ship.side, amount: dmg });
|
|
return 0;
|
|
},
|
|
},
|
|
|
|
// Zoq-Fot-Pik tongue: a very short-range, very heavy lick.
|
|
// Params: range (display px), damage.
|
|
tongue: {
|
|
activate(state, ship, def) {
|
|
const p = def.params ?? {};
|
|
const hit = hitscan(
|
|
state, ship,
|
|
facingToRad(quantizeFacing(ship.facing)),
|
|
(p.range ?? 35) * 4 + ship.def.radius,
|
|
p.damage ?? 12,
|
|
'tongue',
|
|
);
|
|
return hit ? true : false; // free lick when it misses
|
|
},
|
|
},
|
|
};
|
|
|
|
function fighterBrain(state, p) {
|
|
const d = p.data;
|
|
d.age += 1;
|
|
if (d.cooldown > 0) d.cooldown -= 1;
|
|
const mother = state.ships[p.owner];
|
|
const enemy = state.ships[1 - p.owner];
|
|
const returning = d.age > d.outFrames || !enemy.alive || p.life < d.outFrames;
|
|
const target = returning ? mother : enemy;
|
|
if (!target || !target.alive) return;
|
|
steerProjectile(p, bearingTo(p, target), FACING_STEP / 2, d.speed);
|
|
if (returning) {
|
|
if (mother.alive && tdist(p.x, p.y, mother.x, mother.y) < mother.def.radius + 80) {
|
|
p.alive = false;
|
|
gainCrew(state, mother, 1);
|
|
emit(state, { t: 'fighterDock', side: p.owner, x: p.x, y: p.y });
|
|
}
|
|
return;
|
|
}
|
|
if (!enemy.fx.cloaked && !enemy.fx.phasing && d.cooldown === 0
|
|
&& tdist(p.x, p.y, enemy.x, enemy.y) < d.laserRange + enemy.def.radius) {
|
|
d.cooldown = d.weaponWait + 1;
|
|
damageShip(state, enemy, d.laserDamage, 'beam');
|
|
emit(state, { t: 'pdZap', x1: p.x, y1: p.y, x2: enemy.x, y2: enemy.y, side: p.owner });
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PASSIVE abilities (always-on ship traits).
|
|
|
|
export const PASSIVES = {
|
|
|
|
// Pkunk Fury: fifty-fifty phoenix resurrection with full crew and battery.
|
|
resurrect: {
|
|
onDeath(state, ship) {
|
|
if (state.rng() >= 0.5) return false;
|
|
ship.crew = ship.def.stats.maxCrew;
|
|
ship.energy = ship.def.stats.maxEnergy;
|
|
return true;
|
|
},
|
|
},
|
|
|
|
// Arilou Skiff: inertialess drive — stops dead without thrust, ignores
|
|
// gravity.
|
|
inertialess: {
|
|
init(state, ship) { ship.fx.noGravity = true; },
|
|
preprocess(state, ship) {
|
|
if (!ship.input.thrust) { ship.vx = 0; ship.vy = 0; }
|
|
},
|
|
},
|
|
|
|
// Slylandro Probe: gravity-immune constant-velocity drive — always moving
|
|
// at full speed along its facing.
|
|
probeDrive: {
|
|
init(state, ship) { ship.fx.noGravity = true; },
|
|
preprocess(state, ship) {
|
|
const f = facingToRad(quantizeFacing(ship.facing));
|
|
const v = shipStats(ship).maxThrust;
|
|
ship.vx = Math.cos(f) * v;
|
|
ship.vy = Math.sin(f) * v;
|
|
ship.moveOverride = { ...(ship.moveOverride ?? {}), noThrust: true };
|
|
},
|
|
onAsteroidKill(state, ship) {
|
|
gainEnergy(state, ship, ship.def.special?.params?.energy ?? 8);
|
|
},
|
|
},
|
|
|
|
// Chmmr Avatar ZapSats: three orbiting satellites that zap whatever strays
|
|
// close (enemy shots first, then the enemy ship).
|
|
zapsats: {
|
|
init(state, ship) {
|
|
const cfg = ship.def.zapsats ?? {};
|
|
const n = cfg.count ?? 3;
|
|
for (let i = 0; i < n; i += 1) {
|
|
spawnUnit(state, ship, {
|
|
type: 'zapsat',
|
|
x: ship.x, y: ship.y, angle: 0, speed: 0,
|
|
damage: 0, hp: cfg.hp ?? 10, life: 100000,
|
|
radius: 26,
|
|
data: {
|
|
orbit: (i / n) * Math.PI * 2,
|
|
orbitR: cfg.orbitR ?? ship.def.radius + 190,
|
|
zapRange: (cfg.zapRange ?? 64) * 4,
|
|
zapWait: cfg.zapWait ?? 2,
|
|
cooldown: 0,
|
|
},
|
|
behavior(st, u) {
|
|
const mother = st.ships[u.owner];
|
|
if (!mother.alive) { u.alive = false; return; }
|
|
u.data.orbit += 0.045;
|
|
const nx = wrap(mother.x + Math.cos(u.data.orbit) * u.data.orbitR, ARENA_W);
|
|
const ny = wrap(mother.y + Math.sin(u.data.orbit) * u.data.orbitR, ARENA_H);
|
|
u.px = u.x; u.py = u.y;
|
|
u.x = nx; u.y = ny; u.vx = 0; u.vy = 0;
|
|
if (u.data.cooldown > 0) { u.data.cooldown -= 1; return; }
|
|
for (const pr of st.projectiles) {
|
|
if (pr.alive && pr.owner !== u.owner && pr.collidesShots && pr.kind !== 'unit'
|
|
&& tdist(u.x, u.y, pr.x, pr.y) < u.data.zapRange) {
|
|
pr.hp -= 1;
|
|
if (pr.hp <= 0) pr.alive = false;
|
|
u.data.cooldown = u.data.zapWait + 1;
|
|
emit(st, { t: 'pdZap', x1: u.x, y1: u.y, x2: pr.x, y2: pr.y, side: u.owner });
|
|
return;
|
|
}
|
|
}
|
|
const enemy = st.ships[1 - u.owner];
|
|
if (enemy.alive && !enemy.fx.phasing
|
|
&& tdist(u.x, u.y, enemy.x, enemy.y) < u.data.zapRange + enemy.def.radius) {
|
|
damageShip(st, enemy, 1, 'beam');
|
|
u.data.cooldown = u.data.zapWait + 1;
|
|
emit(st, { t: 'pdZap', x1: u.x, y1: u.y, x2: enemy.x, y2: enemy.y, side: u.owner });
|
|
}
|
|
},
|
|
});
|
|
}
|
|
},
|
|
},
|
|
|
|
// VUX Intruder: warps in right on top of its prey at the start of a battle.
|
|
warpIn: {
|
|
preprocess(state, ship) {
|
|
if (ship.fx.warped || state.frame > 2) { ship.fx.warped = true; return; }
|
|
ship.fx.warped = true;
|
|
const enemy = enemyOf(state, ship);
|
|
const a = state.rng() * Math.PI * 2;
|
|
const d = 900;
|
|
ship.x = wrap(enemy.x + Math.cos(a) * d, ARENA_W);
|
|
ship.y = wrap(enemy.y + Math.sin(a) * d, ARENA_H);
|
|
ship.px = ship.x; ship.py = ship.y;
|
|
ship.facing = bearingTo(ship, enemy);
|
|
emit(state, { t: 'teleport', side: ship.side, toX: ship.x, toY: ship.y });
|
|
},
|
|
},
|
|
};
|