233 lines
9.8 KiB
JavaScript
233 lines
9.8 KiB
JavaScript
/**
|
|
* Combat rules test (dev tool, run with Node — no browser needed):
|
|
*
|
|
* node dev/combat.test.mjs
|
|
*
|
|
* Stubs just enough of Phaser + a scene to run the REAL combat modules
|
|
* from js/combat/ — the pure rules (Combat.js: armor damage, render
|
|
* heading, orbit-ring step), the hostile craft (EnemyShip.js: config
|
|
* scaling + north-facing art offset, shields→hull damage, the
|
|
* standoff-ring AI, the lance decision), and the tracer (Bullet.js:
|
|
* velocity from angle/speed, world radius, one-shot consume, life
|
|
* expiry) — then asserts the behavior the sandbox (js/combat/CombatScene.js)
|
|
* is built on.
|
|
*
|
|
* All numbers come from the real data/*.json (data/combat.json included),
|
|
* so the assertions track the shipped config, not a private copy.
|
|
*/
|
|
import { pathToFileURL } from 'node:url';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const TAU = Math.PI * 2;
|
|
|
|
// Mirror of Phaser.Math.Angle.Wrap — the vendored build's Angle.Wrap is
|
|
// Wrap(value, -π, π): ((v - min) % range + range) % range + min.
|
|
const wrap = (a) => {
|
|
const min = -Math.PI;
|
|
const range = Math.PI * 2;
|
|
return (((a - min) % range) + range) % range + min;
|
|
};
|
|
|
|
class V2 {
|
|
constructor(x = 0, y = 0) { this.x = x; this.y = y; }
|
|
set(x, y) { this.x = x; this.y = y; return this; }
|
|
length() { return Math.hypot(this.x, this.y); }
|
|
scale(s) { this.x *= s; this.y *= s; return this; }
|
|
}
|
|
|
|
class Sprite {
|
|
constructor(scene, x, y, key, frame) {
|
|
this.scene = scene; this.x = x; this.y = y; this.key = key; this.frame = frame;
|
|
this.rotation = 0; this.scale = 1; this.alpha = 1; this.visible = true;
|
|
this.active = true; this.body = null;
|
|
}
|
|
setScale(s) { this.scale = s; return this; }
|
|
setTint() { return this; }
|
|
setBlendMode() { return this; }
|
|
setDepth() { return this; }
|
|
setOrigin() { return this; }
|
|
setAlpha(a) { this.alpha = a; return this; }
|
|
setVisible(v) { this.visible = v; return this; }
|
|
destroy() { this.active = false; this.destroyed = true; }
|
|
}
|
|
|
|
function makeBody() {
|
|
return {
|
|
velocity: new V2(),
|
|
acceleration: new V2(),
|
|
enable: true,
|
|
setCircle() { return this; },
|
|
setAllowGravity() { return this; },
|
|
setVelocity(x, y) { this.velocity.set(x, y); return this; },
|
|
};
|
|
}
|
|
|
|
const PhaserStub = {
|
|
Physics: { Arcade: { Sprite } },
|
|
Math: {
|
|
Angle: {
|
|
Wrap: wrap,
|
|
RotateTo: (cur, tgt, step) => (Math.abs(wrap(tgt - cur)) <= step ? tgt : cur + Math.sign(wrap(tgt - cur)) * step),
|
|
},
|
|
},
|
|
Display: { Color: { ValueToColor: (v) => ({ color: parseInt(String(v).replace('#', ''), 16) }) } },
|
|
BlendModes: { ADD: 'ADD' },
|
|
};
|
|
globalThis.window = { Phaser: PhaserStub }; // js/vendor/phaser.js reads this
|
|
|
|
function makeScene({ withSheet = true } = {}) {
|
|
return {
|
|
textures: { exists: () => withSheet },
|
|
make: { graphics: () => ({ fillStyle() {}, beginPath() {}, moveTo() {}, lineTo() {}, closePath() {}, fillPath() {}, fillCircle() {}, generateTexture() {}, destroy() {} }) },
|
|
add: { existing: (o) => o },
|
|
physics: { add: { existing: (o) => { o.body = makeBody(); return o; } } },
|
|
scale: { width: 1280, height: 720 },
|
|
time: { now: 0 },
|
|
};
|
|
}
|
|
|
|
// The real data/*.json (combat numbers come from data/combat.json).
|
|
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
|
|
const fs = await import('node:fs');
|
|
const dataDir = join(__dirname, '../data');
|
|
const configData = {};
|
|
for (const f of fs.readdirSync(dataDir)) {
|
|
if (!f.endsWith('.json') || f === 'manifest.json') continue;
|
|
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
|
|
}
|
|
config.init(configData);
|
|
console.log('config loaded from data/:', Object.keys(configData).join(', '));
|
|
|
|
const { applyDamage, headingOf, orbitStep } = await import(
|
|
pathToFileURL(join(__dirname, '../js/combat/Combat.js')).href
|
|
);
|
|
const { EnemyShip } = await import(pathToFileURL(join(__dirname, '../js/combat/EnemyShip.js')).href);
|
|
const { Bullet } = await import(pathToFileURL(join(__dirname, '../js/combat/Bullet.js')).href);
|
|
|
|
let failures = 0;
|
|
const check = (label, cond) => {
|
|
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
|
|
if (!cond) failures++;
|
|
};
|
|
const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
|
|
const E = (k, d) => config.get(`combat.enemy.${k}`, d);
|
|
|
|
// ------------------------------------------------------------------ armor
|
|
|
|
{
|
|
const armor = { shields: 40, hull: 100 };
|
|
const r1 = applyDamage(armor, 30);
|
|
check('damage: 30 hits shields only (40→10, hull 100)', armor.shields === 10 && armor.hull === 100 && !r1.dead && r1.shieldHit === 30);
|
|
const r2 = applyDamage(armor, 50);
|
|
check('damage: 50 drains shields then hull (0/60)', armor.shields === 0 && armor.hull === 60 && !r2.dead);
|
|
const r3 = applyDamage(armor, 999);
|
|
check('damage: overkill kills (hull 0, dead)', armor.hull === 0 && r3.dead);
|
|
applyDamage(armor, 5);
|
|
check('damage: dead armor takes no more', armor.hull === 0 && armor.shields === 0);
|
|
const a2 = { shields: 0, hull: 25 };
|
|
applyDamage(a2, 9);
|
|
check('damage: zero shields → hull straight', a2.hull === 16 && !a2.dead);
|
|
const a3 = { shields: 10, hull: 10 };
|
|
const r4 = applyDamage(a3, 'nope');
|
|
check('damage: non-numeric = 0', a3.shields === 10 && a3.hull === 10 && !r4.dead);
|
|
}
|
|
|
|
{
|
|
check('headingOf: rotation minus artOffset', approx(headingOf({ rotation: Math.PI / 2, artOffset: Math.PI / 2 }), 0));
|
|
check('headingOf: no offset', approx(headingOf({ rotation: 1.2 }), 1.2));
|
|
}
|
|
|
|
{
|
|
// player at origin, orbiting at radius 100
|
|
const t = orbitStep(0, 0, 0, 1, 1, 1, 100);
|
|
check('orbitStep: angle advances dir*speed*dt', approx(t.angle, 1));
|
|
check('orbitStep: target on the ring', approx(t.x, Math.cos(1) * 100) && approx(t.y, Math.sin(1) * 100));
|
|
const t2 = orbitStep(0, 0, 0, -1, 2, 0.5, 100);
|
|
check('orbitStep: counter-direction', approx(t2.angle, -1));
|
|
}
|
|
|
|
// ---------------------------------------------------------------- enemy
|
|
|
|
{
|
|
const scene = makeScene({ withSheet: true });
|
|
const e = new EnemyShip(scene, 200, 360, 0);
|
|
|
|
const fw = E('frameWidth', 128);
|
|
check('enemy: scale = size / frameWidth', approx(e.scale, E('size', 54) / fw));
|
|
check('enemy: north-facing art → offset +π/2', approx(e.artOffset, Math.PI / 2));
|
|
check('enemy: hitRadius from config', e.hitRadius === E('hitRadius', 20));
|
|
check('enemy: armor from config', e.maxHull === E('hull', 60) && e.maxShields === E('shields', 0));
|
|
|
|
// Damage model (shields → hull → dead).
|
|
e.shields = 30;
|
|
check('enemy.damage: shields first', e.damage(40) === false && e.shields === 0 && e.hull === E('hull', 60) - 10);
|
|
check('enemy.damage: killing blow', e.damage(999) === true && e.dead && e.hull === 0);
|
|
check('enemy.damage: dead is a wall', e.damage(10) === false);
|
|
e.revive();
|
|
check('enemy: revive restores armor + liveness', !e.dead && e.hull === e.maxHull && e.shields === e.maxShields);
|
|
|
|
// The AI steers toward its point on the standoff ring around the player.
|
|
const player = { x: 640, y: 360 };
|
|
e.x = 200; e.y = 360;
|
|
e.body.velocity.set(0, 0);
|
|
e.orbitAngle = Math.PI; // "behind" the enemy relative to the player (west side)
|
|
e.update(0, 16, player);
|
|
check('enemy AI: velocity heads for the ring (toward the player side)', e.body.velocity.x > 0 && Math.abs(e.body.velocity.y) < Math.abs(e.body.velocity.x));
|
|
|
|
// The lance: in range + aimed + off cooldown → fires once.
|
|
e.nextFireAt = 0;
|
|
e.x = 840; e.y = 360; // 200 px EAST of the player (range is 560)
|
|
const toPlayer = Math.PI; // facing west, straight at the player
|
|
e.rotation = toPlayer + e.artOffset;
|
|
const fired = e.update(0, 16, player);
|
|
check('enemy AI: fires when in range + aimed + armed', fired === true);
|
|
check('enemy AI: cooldown set after firing', e.nextFireAt > 0);
|
|
const firedAgain = e.update(0, 16, player);
|
|
check('enemy AI: no instant re-fire', firedAgain === false);
|
|
|
|
// Out of range → no fire (even aimed + armed).
|
|
e.nextFireAt = 0;
|
|
e.x = 1500; e.y = 360;
|
|
e.rotation = Math.atan2(360 - 360, 640 - 1500) + e.artOffset;
|
|
check('enemy AI: out of range, no fire', e.update(0, 16, player) === false);
|
|
|
|
// Player down → no fire, keeps orbiting the last position.
|
|
e.nextFireAt = 0;
|
|
e.x = 840; e.y = 360;
|
|
e.rotation = toPlayer + e.artOffset;
|
|
check('enemy AI: player down, no fire', e.update(0, 16, null) === false);
|
|
}
|
|
|
|
{
|
|
// No spritesheet → the procedural wedge (east-facing, offset 0).
|
|
const scene = makeScene({ withSheet: false });
|
|
const e = new EnemyShip(scene, 100, 100, 1);
|
|
check('enemy fallback: east-facing wedge, offset 0', e.artOffset === 0);
|
|
check('enemy fallback: scale from the wedge width', approx(e.scale, E('size', 54) / 76));
|
|
}
|
|
|
|
// ---------------------------------------------------------------- bullet
|
|
|
|
{
|
|
const scene = makeScene({ withSheet: true });
|
|
const b = new Bullet(scene, { x: 100, y: 100, angle: 0, speed: 680, damage: 10, life: 1.2, color: '#00e5ff', size: 16, owner: 'player' });
|
|
check('bullet: velocity = speed along angle', approx(b.body.velocity.x, 680) && approx(b.body.velocity.y, 0));
|
|
check('bullet: world radius > 0', b.radius > 0 && b.radius < 32);
|
|
check('bullet: owner + damage carried', b.owner === 'player' && b.damage === 10);
|
|
|
|
check('bullet: consume is one-shot', b.consume() === true && b.consume() === false);
|
|
check('bullet: consume destroys', b.active === false);
|
|
|
|
const b2 = new Bullet(scene, { x: 0, y: 0, angle: Math.PI / 2, speed: 330, damage: 8, life: 1.0, color: '#ff2d6f', size: 18, owner: 'enemy' });
|
|
check('bullet: down-angle velocity', approx(b2.body.velocity.x, 0) && approx(b2.body.velocity.y, 330));
|
|
b2.update(1000);
|
|
check('bullet: life expiry consumes', b2.dead === true);
|
|
}
|
|
|
|
// ------------------------------------------------------------------ done
|
|
|
|
console.log(failures === 0 ? '\ncombat rules: all checks passed' : `\n${failures} check(s) FAILED`);
|
|
process.exit(failures === 0 ? 0 : 1);
|