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.

src/players.ts
/**
 * Local multiplayer bookkeeping: which controller flies which plane, and how the
 * frame is split between pilots. Pure functions so they can be tested in Node.
 */

export const MAX_PLAYERS = 4;

/** One livery per pilot seat. Colours stay app-owned and distinct from the terrain. */
export const LIVERIES = [
  { name: 'Red', body: 0xd8382f, wing: 0xf5e7c4, trim: 0x2f3b52, css: '#ff6a55' },
  { name: 'Blue', body: 0x2f7fd8, wing: 0xf5e7c4, trim: 0x23304a, css: '#6fb6ff' },
  { name: 'Yellow', body: 0xf2b630, wing: 0xfff4d8, trim: 0x3b2f2a, css: '#ffd25a' },
  { name: 'Purple', body: 0x8a4fd0, wing: 0xf1e6ff, trim: 0x2a2440, css: '#c79bff' },
] as const;

export type RosterSlot = {
  /** Index of this pilot in the previous roster, or null for a newly joined pilot. */
  from: number | null;
  /** Gamepad index flying this plane; pilot 1 may have none (keyboard/touch). */
  pad: number | null;
};

/**
 * Update the pilot list from the controllers that are connected and have been
 * used. Pilot 1 always exists and also listens to the keyboard and touch.
 * Pilots 2–4 leave when their controller disconnects; new controllers first
 * fill pilot 1's empty seat, then join as new pilots.
 */
export function planRoster(current: readonly (number | null)[], activePads: readonly number[], max = MAX_PLAYERS): RosterSlot[] {
  const active = [...new Set(activePads)].sort((a, b) => a - b);
  const next: RosterSlot[] = [];
  const first = current[0] ?? null;
  next.push({ from: current.length ? 0 : null, pad: first !== null && active.includes(first) ? first : null });
  for (let i = 1; i < current.length; i++) {
    const pad = current[i];
    if (pad !== null && pad !== undefined && active.includes(pad)) next.push({ from: i, pad });
  }
  const taken = new Set(next.map((slot) => slot.pad));
  for (const pad of active) {
    if (taken.has(pad)) continue;
    if (next[0]!.pad === null) next[0]!.pad = pad;
    else if (next.length < max) next.push({ from: null, pad });
    else continue;
    taken.add(pad);
  }
  return next;
}

/** A viewport in fractions of the frame, measured from the top-left corner. */
export type ViewRect = { x: number; y: number; w: number; h: number };

/**
 * Split the frame for `count` pilots. Two pilots split along whichever axis keeps
 * each view closest to a comfortable wide aspect. Three pilots use a 2×2 grid
 * whose spare cell is returned as `spare` (used for a planet overview).
 */
export function splitLayout(count: number, width: number, height: number): { views: ViewRect[]; spare: ViewRect | null } {
  if (count <= 1) return { views: [{ x: 0, y: 0, w: 1, h: 1 }], spare: null };
  if (count === 2) {
    const aspect = width / Math.max(1, height);
    const ideal = 1.6;
    const stacked = Math.abs(Math.log((aspect * 2) / ideal));
    const sideBySide = Math.abs(Math.log(aspect / 2 / ideal));
    return stacked <= sideBySide
      ? { views: [{ x: 0, y: 0, w: 1, h: 0.5 }, { x: 0, y: 0.5, w: 1, h: 0.5 }], spare: null }
      : { views: [{ x: 0, y: 0, w: 0.5, h: 1 }, { x: 0.5, y: 0, w: 0.5, h: 1 }], spare: null };
  }
  const grid: ViewRect[] = [
    { x: 0, y: 0, w: 0.5, h: 0.5 },
    { x: 0.5, y: 0, w: 0.5, h: 0.5 },
    { x: 0, y: 0.5, w: 0.5, h: 0.5 },
    { x: 0.5, y: 0.5, w: 0.5, h: 0.5 },
  ];
  return { views: grid.slice(0, count), spare: count === 3 ? grid[3]! : null };
}

/** Where to draw a pilot indicator inside one view, in pixels from its top-left corner. */
export type Indicator = {
  /** The target is inside the view; otherwise it is pinned to the edge. */
  onScreen: boolean;
  x: number;
  y: number;
  /** Direction to the target on screen: radians clockwise from screen right (CSS rotate). */
  angle: number;
};

/**
 * Place an indicator for a point given in camera space (the camera looks down
 * -Z with +Y up). `tanX`/`tanY` are the tangents of the half field of view. A
 * point outside the view is pinned to the edge, inset by `margin`, on the side
 * the pilot must turn toward; a point dead astern points down ("turn around").
 */
export function screenIndicator(
  point: { x: number; y: number; z: number },
  tanX: number,
  tanY: number,
  width: number,
  height: number,
  margin: number,
): Indicator {
  const halfW = width / 2;
  const halfH = height / 2;
  const depth = -point.z;
  if (depth > 0) {
    const sx = point.x / (depth * tanX);
    const sy = point.y / (depth * tanY);
    if (Math.abs(sx) <= 1 && Math.abs(sy) <= 1)
      return { onScreen: true, x: halfW * (1 + sx), y: halfH * (1 - sy), angle: Math.atan2(-sy * halfH, sx * halfW) };
  }
  // Off screen: the direction is the same in front or behind, only the depth differs.
  let dx = (point.x / tanX) * halfW;
  let dy = (-point.y / tanY) * halfH;
  if (Math.hypot(dx, dy) < 1e-9) {
    dx = 0;
    dy = 1;
  }
  const edgeX = Math.max(1, halfW - margin);
  const edgeY = Math.max(1, halfH - margin);
  const k = 1 / Math.max(Math.abs(dx) / edgeX, Math.abs(dy) / edgeY);
  return { onScreen: false, x: halfW + dx * k, y: halfH + dy * k, angle: Math.atan2(dy, dx) };
}

type Vec = { x: number; y: number; z: number };

/**
 * Where to point a pilot looking for `target` on a round planet. Nearby (within
 * `reach` radians of arc) it is the target itself; farther away it is a point
 * `reach` along the great circle toward it, so the arrow follows the horizon
 * instead of pointing down through the ground. Also returns the flying
 * distance: the arc at mean radius combined with the height difference.
 */
export function steerToward(from: Vec, target: Vec, reach = 0.35): { aim: Vec; distance: number } {
  const r1 = Math.hypot(from.x, from.y, from.z) || 1;
  const r2 = Math.hypot(target.x, target.y, target.z) || 1;
  const a = { x: from.x / r1, y: from.y / r1, z: from.z / r1 };
  const b = { x: target.x / r2, y: target.y / r2, z: target.z / r2 };
  const cos = Math.max(-1, Math.min(1, a.x * b.x + a.y * b.y + a.z * b.z));
  const theta = Math.acos(cos);
  const distance = Math.hypot(theta * (r1 + r2) * 0.5, r2 - r1);
  if (theta <= reach) return { aim: { ...target }, distance };
  // Unit vector in the great-circle plane, perpendicular to `a`, toward `b`.
  let px = b.x - a.x * cos;
  let py = b.y - a.y * cos;
  let pz = b.z - a.z * cos;
  let pl = Math.hypot(px, py, pz);
  if (pl < 1e-9) {
    // Exactly opposite: any way round is as short; pick one perpendicular.
    [px, py, pz] = Math.abs(a.x) < 0.9 ? [0, -a.z, a.y] : [-a.z, 0, a.x];
    pl = Math.hypot(px, py, pz);
  }
  const radius = r1 + (r2 - r1) * (reach / theta);
  const c = Math.cos(reach) * radius;
  const s = (Math.sin(reach) * radius) / pl;
  return { aim: { x: a.x * c + px * s, y: a.y * c + py * s, z: a.z * c + pz * s }, distance };
}