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.
/**
* 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 };
}
