Back to Poly Dogfight
SOURCE / PINNED RELEASE

Made of little things.

Poly Dogfight

Release
b8511ee374e3…
Author-recorded commit
5579434b9d8b…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-44f63e5422b

Archive hash verified: bcadd2430d8ea2bc…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

tests/combat.test.mjs
// Guns, hit zones and battle damage.
import assert from 'node:assert/strict';
import test from 'node:test';
import { Vector3 } from 'three';
import { PLANET_RADIUS } from '../src/sim/constants.ts';
import { createFlightState, neutralInput, spawnAt, stepFlight } from '../src/sim/flight.ts';
import { createGuns, segmentBoxEntry, stepGuns } from '../src/sim/guns.ts';

const DT = 1 / 120;
const sky = { surfaceHeight: () => 0, terrainHeight: () => -60, hitsObstacle: () => false };

function plane(altitude = 200, angle = 0) {
  const state = createFlightState();
  spawnAt(state, sky, new Vector3(0, Math.cos(angle), -Math.sin(angle)), new Vector3(0, 0, -1));
  state.position.setLength(PLANET_RADIUS + altitude);
  return state;
}

function fly(state, seconds, input = {}) {
  for (let i = 0; i < seconds * 120 && state.alive; i++) stepFlight(state, { ...neutralInput(), ...input }, sky, DT);
  return state;
}

/** Bank angle: positive when the right wing is up (rolled left). */
const bank = (state) => {
  const right = new Vector3(1, 0, 0).applyQuaternion(state.orientation);
  return Math.asin(right.dot(state.position.clone().normalize()));
};

test('segment-box sweep finds the entry point and misses cleanly', () => {
  const min = [-1, -1, -1];
  const max = [1, 1, 1];
  assert.equal(segmentBoxEntry(new Vector3(-3, 0, 0), new Vector3(1, 0, 0), min, max), 0.5);
  assert.equal(segmentBoxEntry(new Vector3(-3, 2, 0), new Vector3(3, 2, 0), min, max), null);
  assert.equal(segmentBoxEntry(new Vector3(-3, 0, 0), new Vector3(-2, 0, 0), min, max), null);
});

/** A shooter 60 m behind the target, offset sideways so the guns line up on one part. */
function chase(offsetX) {
  const target = plane(200);
  const shooter = createFlightState();
  shooter.position.copy(target.position).add(new Vector3(offsetX, 0, 60));
  shooter.orientation.copy(target.orientation);
  shooter.velocity.copy(target.velocity);
  shooter.alive = true;
  return { target, shooter };
}

function shoot(shooter, target, seconds) {
  const guns = createGuns();
  const hits = [];
  for (let i = 0; i < seconds * 120; i++) {
    const events = stepGuns(guns, [shooter, target], [true, false], sky, DT);
    hits.push(...events.hits);
  }
  return hits;
}

test('rounds into the left wing damage only the left wing', () => {
  const { target, shooter } = chase(-2.2);
  const hits = shoot(shooter, target, 0.5);
  assert.ok(hits.length >= 3, `only ${hits.length} hits`);
  assert.ok(hits.every((hit) => hit.part === 'left' && hit.target === 1));
  assert.ok(target.damage.left > 0.2);
  assert.equal(target.damage.right, 0);
  assert.equal(target.damage.tail, 0);
  assert.equal(shooter.damage.left + shooter.damage.right + shooter.damage.tail, 0);
});

test('rounds from dead astern hit the tail; the engine block is armoured', () => {
  const { target, shooter } = chase(0);
  shooter.position.y += 0.2; // guns sit high on the cowling; aim along the tail
  const hits = shoot(shooter, target, 0.5);
  assert.ok(hits.length > 0 && hits.every((hit) => hit.part === 'tail'));
  assert.ok(target.damage.tail > 0);
});

test('a holed wing rolls the plane toward it; an intact plane stays level', () => {
  const healthy = fly(plane(), 2.5, { throttleSet: 1 });
  const left = plane();
  left.damage.left = 0.6;
  fly(left, 2.5, { throttleSet: 1 });
  const right = plane();
  right.damage.right = 0.6;
  fly(right, 2.5, { throttleSet: 1 });
  assert.ok(Math.abs(bank(healthy)) < 0.1);
  assert.ok(bank(left) > 0.5, `left-damaged bank ${bank(left)}`);
  assert.ok(bank(right) < -0.5, `right-damaged bank ${bank(right)}`);
  assert.ok(left.alive && right.alive);
});

test('a damaged wing can still be held level, but only with steady opposite stick', () => {
  const state = plane();
  state.damage.left = 0.6;
  let stick = 0;
  const steps = 6 * 120;
  for (let i = 0; i < steps; i++) {
    // A simple pilot: roll right in proportion to the left bank.
    const roll = Math.max(-1, Math.min(1, bank(state) * 3));
    stick += roll;
    stepFlight(state, { ...neutralInput(), throttleSet: 1, roll }, sky, DT);
  }
  assert.ok(state.alive);
  assert.ok(Math.abs(bank(state)) < 0.3, `bank ${bank(state)}`);
  assert.ok(stick / steps > 0.1, `average stick ${(stick / steps).toFixed(2)}`);
});

test('a shot-up tail slowly pitches the nose down', () => {
  const healthy = fly(plane(), 8, { throttleSet: 1 });
  const hurt = plane();
  hurt.damage.tail = 0.8;
  fly(hurt, 8, { throttleSet: 1 });
  const lost = healthy.altitude - hurt.altitude;
  assert.ok(hurt.alive);
  assert.ok(lost > 15, `only ${lost.toFixed(1)} m lower`);
  assert.ok(lost < 150, `dropped too fast: ${lost.toFixed(1)} m`);
});

for (const part of ['left', 'right', 'tail']) {
  test(`losing the ${part} ${part === 'tail' ? '' : 'wing '}sends the plane down without exploding`, () => {
    const state = plane(200);
    state.damage[part] = 1;
    state.crippled = true;
    // Still flying (not exploded) a couple of seconds later, even with full back stick.
    fly(state, 2, { throttleSet: 1, pitch: 1 });
    assert.ok(state.alive);
    fly(state, 40, { throttleSet: 1, pitch: 1, roll: part === 'left' ? 1 : part === 'right' ? -1 : 0 });
    assert.equal(state.alive, false, `still flying at ${state.altitude.toFixed(0)} m`);
    assert.equal(state.crashCause, 'shotdown');
  });
}

test('respawning repairs the plane', () => {
  const state = plane();
  state.damage.left = 1;
  state.damage.tail = 0.5;
  state.crippled = true;
  spawnAt(state, sky, new Vector3(1, 0, 0), new Vector3(0, 1, 0));
  assert.deepEqual(state.damage, { left: 0, right: 0, tail: 0 });
  assert.equal(state.crippled, false);
});