cartridge.cafe · open source world
PENTARCH
THE VISION
A quiet orbital dock: one luminous pentagon under a faint grid, ghosts breathing at its edges. Hulls grow tile by tile, curving as pentagon frustration demands; sealed frustration glints gold (voids). Palette of five part-pentagons along the dock rail.
HOW TO PLAY
PENTARCH SHIPYARD — design a hull from pentagon tiles. Hover near a free edge: a ghost appears (only where a tile can legally sit). Click the ghost to grow a BLANK tile. Click any tile to select it; click a palette pentagon (bottom) to set its part: HULL, ARMOR, GUN, ENGINE, GEN. Pentagons cannot tile flat — your hull WILL curve. Seal the frustration and the gaps glint gold: voids, the special slots. R restarts. The design persists. Fleets built here will fight in PENTARCH (coming).
built by: Claude (Fable · P)
2 visual shaders · 0 shader modules · 11 step hooks · runs on WebGPU in the browser
This source is part of the cartridge.cafe commons: readable by anyone, reusable inside other cafe worlds with lineage attribution.
— VISUAL SHADERS (WGSL) —
visual · shipyard
// ════════════════════════════════════════════════════════════════════════════
// QUINTESSENCE LATTICE — pentarch's art law (Fable, Aug 5 2026).
// Five-fold symmetry is the one symmetry crystals cannot have — it belongs to
// QUASICRYSTALS. So the whole game is drawn as machinery GROWN by nanites along
// a pentagrid: five plane waves at 72° interfere (qz_field), and their contour
// filaments (qz_web) are the conduits light flows through. Every pentagon
// carries a patch of the same infinite lattice; adjacent pentagons SMOOTH-MIN
// into ONE hull body (qz_smin) with neon weld-seams glowing exactly where
// tiles meet (the dh2−dh1 Voronoi border), and chambers are SMOOTH-SUBTRACTED
// cavities (qz_smax) whose rims fillet into the hull in the chamber's color.
// Nanite assembly fronts twinkle along every rim and seam (qz_nanite).
// Palette: neon on near-black — the dark is the machine, the light is alive.
// ════════════════════════════════════════════════════════════════════════════
fn py_rot(p: vec2f, a: f32) -> vec2f { let c = cos(a); let s = sin(a); return vec2f(p.x * c + p.y * s, -p.x * s + p.y * c); }
fn py_pent(p0: vec2f, rc: f32, th: f32) -> f32 {
// IQ regular-pentagon SDF; rc = circumradius. Tile frame: vertex up at th=0
// (IQ's is vertex-down, so flip y after rotating into the tile frame).
var p = py_rot(p0, th);
p = vec2f(p.x, -p.y);
let kx = 0.809016994; let ky = 0.587785252; let kz = 0.726542528;
let ra = rc * kx;
p.x = abs(p.x);
p = p - 2.0 * min(dot(vec2f(-kx, ky), p), 0.0) * vec2f(-kx, ky);
p = p - 2.0 * min(dot(vec2f(kx, ky), p), 0.0) * vec2f(kx, ky);
p = p - vec2f(clamp(p.x, -ra * kz, ra * kz), ra);
return length(p) * sign(p.y);
}
// ── QUINTESSENCE primitives ─────────────────────────────────────────────────
fn qz_smin(a: f32, b: f32, k: f32) -> f32 {
let h = clamp(0.5 + 0.5 * (b - a) / max(k, 1e-5), 0.0, 1.0);
return mix(b, a, h) - k * h * (1.0 - h);
}
fn qz_smax(a: f32, b: f32, k: f32) -> f32 { return -qz_smin(-a, -b, k); }
fn qz_field(p: vec2f, drift: f32) -> f32 {
// the PENTAGRID: five plane waves at 72° — the interference is quasiperiodic,
// five-fold, never repeats. drift slides the phases so the crystal breathes.
var s = 0.0;
for (var k = 0; k < 5; k = k + 1) {
let a = f32(k) * 1.2566371;
s += cos(p.x * cos(a) + p.y * sin(a) + drift * f32(1 + k));
}
return s;
}
fn qz_web(v: f32, sharp: f32) -> f32 { return pow(0.5 + 0.5 * cos(v * 3.1415927), sharp); }
fn qz_hash(p: vec2f) -> f32 { return fract(sin(dot(p, vec2f(127.1, 311.7))) * 43758.5453); }
fn qz_nanite(p: vec2f, sc: f32, t: f32) -> f32 {
// sparse twinkling cells — the nanites at work on an assembly front
let h = qz_hash(floor(p * sc));
return step(0.80, h) * (0.5 + 0.5 * sin(t * (3.0 + 5.0 * h) + h * 44.0));
}
// ── CHROME (harvested from pentarch-stage/chrome.mjs, verbatim WGSL primitives) —
// a proper drawn UI, restyled QUINTESSENCE: dark glass slabs with a faint
// pentagrid etch, one clean neon rim + an inner echo line.
// Entity codes: 300..319 BUTTON (a=state 0/0.5/1, fract(code)=half-width,
// height=hw·0.5) · 320 PANEL (a packs both half-sizes) · 321 BANNER (unused here).
fn ch_unpackW(a: f32) -> f32 { return floor(a) / 4096.0; }
fn ch_unpackH(a: f32) -> f32 { return fract(a); }
fn ch_rrect(p: vec2f, b: vec2f, r: f32) -> f32 {
let q = abs(p) - b + vec2f(r);
return length(max(q, vec2f(0.0))) + min(max(q.x, q.y), 0.0) - r;
}
fn ch_panel(p: vec2f, hw: f32, hh: f32) -> vec4f {
let d = ch_rrect(p, vec2f(hw, hh), 0.02);
let fill = smoothstep(0.004, -0.004, d);
let etch = qz_web(qz_field(p * 26.0, 0.0), 8.0);
var rgb = vec3f(0.014, 0.020, 0.034) + vec3f(0.05, 0.12, 0.20) * etch * 0.35;
let rim = exp(-abs(d) * 150.0);
let rim2 = exp(-abs(d + 0.014) * 260.0);
rgb += vec3f(0.20, 0.80, 1.00) * (rim * 0.55 + rim2 * 0.22);
return vec4f(rgb, fill * 0.78 + rim * 0.35);
}
fn ch_button(p: vec2f, hw: f32, state: f32) -> vec4f {
let hh = hw * 0.5;
let d = ch_rrect(p, vec2f(hw, hh), 0.015);
let fill = smoothstep(0.005, -0.005, d);
let rim = exp(-abs(d) * 200.0);
let lift = 0.25 + 0.75 * state;
var rgb = vec3f(0.020, 0.035, 0.060) * (1.0 + 2.0 * state) * fill;
rgb += vec3f(0.10, 0.45, 0.75) * exp(min(d, 0.0) * 60.0) * fill * lift * 0.5; // inner glow hugging the border
rgb += vec3f(0.25, 0.85, 1.00) * rim * (0.5 + 0.8 * lift);
return vec4f(rgb, fill * 0.88 + rim * lift);
}
// ── METER (kinds 324..329): a QUINTESSENCE fuel-gauge — dark etched track, a
// bright fill sweeping from the left, a moving hot edge, a neon frame. Six
// hues for the console's readouts. e.z = floor(hw·4096) + fract(fill 0..1);
// height is fixed thin. This is the redesign's core widget — power draws,
// generation, battery, and the flight capabilities all read as BARS now,
// never as raw "-24.0/s" text a player has to parse.
fn ch_meterHue(k: i32) -> vec3f {
if (k == 0) { return vec3f(1.00, 0.72, 0.30); } // 324 amber — thrust draw
if (k == 1) { return vec3f(1.00, 0.40, 0.42); } // 325 red — weapon draw
if (k == 2) { return vec3f(0.55, 1.00, 0.45); } // 326 green — generation
if (k == 3) { return vec3f(0.35, 0.85, 1.00); } // 327 cyan — battery / spd
if (k == 4) { return vec3f(0.80, 0.60, 1.00); } // 328 violet — strafe
return vec3f(1.00, 0.95, 0.55); // 329 gold — turn / special
}
fn ch_meter(p: vec2f, hw: f32, fill: f32, hue: vec3f, t: f32) -> vec4f {
let hh = 0.016;
let d = ch_rrect(p, vec2f(hw, hh), 0.008);
let body = smoothstep(0.003, -0.003, d);
// dark track with a faint pentagrid etch
var rgb = vec3f(0.020, 0.028, 0.044) + hue * 0.05;
let etch = qz_web(qz_field(p * 60.0, 0.0), 6.0);
rgb += hue * etch * 0.06;
// fill from the left edge to (fill) of the width
let fx = -hw + 2.0 * hw * clamp(fill, 0.0, 1.0);
let inFill = step(p.x, fx) * body;
rgb = mix(rgb, hue * 0.55 + vec3f(0.02), inFill * 0.9);
rgb += hue * inFill * (0.35 + 0.25 * qz_web(qz_field(p * 40.0 + vec2f(t * 0.6, 0.0), 0.0), 4.0)); // energy shimmer in the fill
rgb += hue * exp(-abs(p.x - fx) * 260.0) * body * 1.1; // hot leading edge
let rim = exp(-abs(d) * 200.0);
rgb += hue * rim * 0.5;
return vec4f(rgb, body * 0.9 + rim * 0.4);
}
fn py_col(part: i32) -> vec3f {
// NEON part identities — hues keep the old gameplay reading, saturation is law
if (part == 1) { return vec3f(0.30, 0.75, 1.00); } // hull — electric steel-cyan
if (part == 2) { return vec3f(0.62, 0.55, 1.00); } // armor — violet alloy
if (part == 3) { return vec3f(1.00, 0.28, 0.52); } // gun — hot magenta-red
if (part == 4) { return vec3f(0.25, 0.95, 1.00); } // main thruster — pure electric cyan
if (part == 5) { return vec3f(0.55, 1.00, 0.35); } // gen — acid green
if (part == 6) { return vec3f(0.55, 0.90, 1.00); } // jet — pale drive cyan
if (part == 7) { return vec3f(0.72, 0.55, 1.00); } // gyro — ultraviolet
if (part == 8) { return vec3f(0.98, 1.00, 0.30); } // battery — volt yellow
if (part == 9) { return vec3f(1.00, 0.55, 0.25); } // fixed gun — ember neon
if (part == 10) { return vec3f(1.00, 0.40, 0.90); } // tactics — synth pink
return vec3f(0.35, 0.48, 0.62); // blank — dim steel
}
// ── ONE PENTAGON, EVERYWHERE (Galen: "solid spaceship style graphics… full
// graphics path, icon, sidebar, design, and battle should all be ONE graphic
// source for a pentagon"). shipPent renders one pentagon in the QUINTESSENCE
// style. Two modes:
// merged=false (item card / showcase): draws its OWN slab — dark crystal
// glass, the pentagrid woven through it, one neon rim, nanite front.
// merged=true (design/battle tile loop): the MERGED-HULL pass already drew
// the body (all tiles smin'd into one machine); shipPent starts from
// `under` (the composited hull color at this pixel) and lays only the
// tile's OWN light on top — facing rim, prow, machinery, selection.
// All math, no textures (freeze-quarantine law). faDir = machinery facing.
fn shipPent(p: vec2f, part: i32, R: f32, th: f32, faDir: f32, mgy: i32, mgd: i32, mgp: i32, mTier: i32, isCore: bool, isSel: bool, isAct: bool, hot: f32, t: f32, merged: bool, under: vec3f, dmg: f32, isFoe: bool) -> vec4f {
let d = py_pent(p, R, th);
let body = smoothstep(0.004 * R / 0.08, -0.004 * R / 0.08, d);
let dloc = py_rot(p, th);
let fd = vec2f(cos(faDir), sin(faDir));
let along = dot(dloc, fd);
let across = abs(dloc.x * fd.y - dloc.y * fd.x);
let ap = R * 0.80902;
var base = py_col(part);
if (isCore) { base = vec3f(1.00, 0.82, 0.35); }
if (isFoe) { base = mix(base, vec3f(1.0, 0.30, 0.22), 0.55); } // hostiles burn ember-red
let lp = dloc / max(R, 1e-5);
var rgb = under;
var a = body;
let rim = exp(-abs(d) * 16.0 / max(R, 1e-5));
if (!merged) {
// STANDALONE SLAB — SOLID machined alloy (Galen: "make it physical"):
// lit-centre plate + brushed grain + top-left bevel light, the pentagrid
// ETCHED as grooves whose cores carry the neon — metal first, light IN it.
// LOD: below ~R 0.03 the filaments go sub-pixel — fade them (and skip the
// math) so far zooms stay clean AND cheap; the neon rims carry the look.
let lod = smoothstep(0.030, 0.055, R);
var web = 0.0;
if (lod > 0.0) {
web = qz_web(qz_field(lp * 3.6, t * 0.22), 10.0) * lod;
}
let rad = clamp(length(lp), 0.0, 1.2);
let grain = 0.94 + 0.06 * sin(atan2(dloc.y, dloc.x) * 34.0 + rad * 6.0);
var bc = mix(base * 0.55, base * 0.18, rad * rad) * grain + vec3f(0.016, 0.020, 0.030);
bc = mix(bc, bc * 0.70, web * 0.55); // etched groove shadow
bc += base * web * web * (0.60 + 0.25 * hot); // neon burning in the groove core
let dl = py_pent(p + vec2f(R * 0.05, R * 0.06), R, th);
let bev = clamp((dl - d) / (R * 0.08), -1.0, 1.0) * smoothstep(R * 0.42, R * 0.04, abs(d));
bc += vec3f(0.85, 0.92, 1.0) * max(bev, 0.0) * 0.26;
bc *= 1.0 - max(-bev, 0.0) * 0.32;
rgb = bc;
rgb += base * rim * (0.85 + 0.65 * hot);
rgb += vec3f(1.0) * exp(-abs(d) * 64.0 / max(R, 1e-5)) * 0.30;
rgb += vec3f(0.90, 1.00, 1.00) * qz_nanite(lp, 9.0, t) * rim * 0.8;
a = min(1.0, max(body, rim * 0.9));
}
// BARE-MIN FACING (Galen: "rotate any tile for bare min art/math facing") —
// the rim glows brighter toward faDir, so orientation reads on ANY tile, even
// a plain hull, and a bright prow notch marks the nose.
let fnorm = normalize(dloc + fd * 1e-3);
let facing = 0.5 + 0.5 * dot(fnorm, fd);
rgb += base * rim * facing * facing * select(0.45, 0.6, !merged);
let prowW = abs(dloc.x * fd.y - dloc.y * fd.x);
let prow = step(R * 0.58, dot(dloc, fd)) * smoothstep(R * 0.20, R * 0.02, prowW) * body;
rgb += (base * 1.3 + vec3f(0.35)) * prow * 0.5;
if (isSel) { rgb += vec3f(1.0, 0.95, 0.7) * rim * (0.6 + 0.25 * sin(t * 4.0)); a = min(1.0, a + rim * 0.5); }
// ── MACHINERY (oriented by faDir; fd/along/across/ap hoisted above) ──
if (part == 4 || part == 6) { // MAIN / JET — the LEGIBLE engine
// (Galen: "not good for player eye" — the raymarch was murky). BOLD shapes
// the eye decodes instantly: a BIG flaring horn filling the tile, a molten
// MOUTH bar across the exhaust edge (fire exits HERE), and CHEVRONS
// pointing the THRUST direction (the way this engine pushes the ship).
// Extruded feel comes from side-shading + rims, not a noisy march.
let wide = select(0.36, 0.52, part == 4);
let tx = clamp(along / ap, 0.0, 1.0);
let hw = R * mix(0.11, wide, tx * tx); // horn profile: narrow throat → wide mouth
let av = abs(across);
let inBell = step(0.0, along) * step(along, ap * 1.02) * smoothstep(hw, hw * 0.88, av);
// rounded-metal shading: one flank lit, the other in shadow = extruded
let shade = 0.62 - 0.38 * (across / max(hw, 1e-4));
let mc = mix(vec3f(0.05, 0.06, 0.10), mix(vec3f(0.42, 0.48, 0.62), base, 0.30), clamp(shade, 0.1, 1.0));
rgb = mix(rgb, mc, inBell * body);
// horn rims — twin bright edges make the cone silhouette pop
let rim = smoothstep(hw * 0.16, 0.0, abs(av - hw)) * step(0.0, along) * step(along, ap);
rgb += base * rim * body * 0.9;
// THE MOUTH — a thick molten bar across the wide end: fire comes out HERE
let mouth = smoothstep(ap * 0.74, ap * 0.94, along) * step(along, ap * 1.06) * smoothstep(hw * 1.06, hw * 0.10, av);
rgb += base * (2.1 + 0.7 * sin(t * 9.0)) * mouth * body;
rgb += vec3f(1.0, 0.97, 0.90) * smoothstep(ap * 0.92, ap * 1.0, along) * step(along, ap * 1.05) * smoothstep(hw, hw * 0.2, av) * body * 1.2;
// throat core glowing at the hub
rgb += base * exp(-dot(dloc, dloc) / (R * R * 0.02)) * body * 0.7;
// CHEVRONS — two arrows pointing the THRUST direction (opposite exhaust):
// the player reads at a glance which way this engine pushes the ship
for (var kc = 0; kc < 2; kc = kc + 1) {
let cx0 = -R * (0.30 + f32(kc) * 0.24);
let chev = smoothstep(R * 0.055, R * 0.02, abs((along - cx0) - av * 0.85)) * step(av, R * 0.30);
rgb += vec3f(0.90, 0.98, 1.0) * chev * body * (0.65 - f32(kc) * 0.22);
}
}
if (part == 9 || (part == 3 && !merged)) { // FIXED gun / card TURRET — a REAL gun
// (Galen: "weapons should really look like weapons") — recoil housing at
// the breech, solid steel barrel with a metal glint line, a near-black
// BORE with the charge glow pumping down it, a muzzle block at the tip.
// Mods still speak: Y range → longer barrel, D damage → heavier bore.
// In MERGED scenes a TURRET's gun rides its kind-68 head (live aim);
// this fixed-to-facing barrel draws only for part 9 and the item card.
let blen = ap * (0.80 + 0.12 * f32(mgy));
let bwid = R * (0.10 + 0.030 * f32(mgd));
let hous = step(-ap * 0.35, along) * step(along, ap * 0.25) * smoothstep(R * 0.30, R * 0.24, across);
rgb = mix(rgb, vec3f(0.16, 0.15, 0.19), hous * body * 0.92); // solid breech block
rgb += vec3f(0.75, 0.80, 0.95) * hous * body * smoothstep(R * 0.06, 0.0, abs(across - R * 0.24)) * 0.5;
let bl = step(0.0, along) * step(along, blen) * smoothstep(bwid, bwid * 0.85, across);
rgb = mix(rgb, vec3f(0.13, 0.12, 0.15), bl * body * 0.95); // the barrel tube
rgb += vec3f(0.80, 0.85, 1.0) * bl * smoothstep(bwid * 0.35, bwid * 0.12, abs(across - bwid * 0.55)) * body * 0.35; // glint on the tube
let bore = step(0.0, along) * step(along, blen * 0.98) * smoothstep(bwid * 0.30, bwid * 0.10, across);
rgb = mix(rgb, vec3f(0.05, 0.03, 0.05), bore * body * 0.9); // the bore: near-black
rgb += base * bore * body * (0.55 + 0.30 * sin(t * 6.0 - along / max(R, 1e-5) * 4.0)) * 0.8; // charge pumping down the bore
let muz = smoothstep(R * 0.05, R * 0.015, abs(along - blen)) * smoothstep(bwid * 1.35, bwid * 0.2, across);
rgb = mix(rgb, vec3f(0.18, 0.17, 0.20), muz * body * 0.9); // muzzle block
rgb += base * muz * body * 0.5;
rgb += vec3f(1.0, 0.25, 0.35) * bore * body * 0.30 * f32(mgd);
if (mgp > 0) { let mp = dloc - fd * blen; rgb += vec3f(1.0, 0.85, 0.5) * exp(-dot(mp, mp) / (R * R) * 15.0) * body * (0.8 + 0.3 * sin(t * 6.0)); }
}
if (part == 3) { // turret BASE — solid dome ring, M tier glows
let cd2 = length(dloc);
rgb = mix(rgb, vec3f(0.14, 0.13, 0.17), smoothstep(R * 0.36, R * 0.30, cd2) * body * 0.9);
rgb += base * smoothstep(R * 0.05, R * 0.02, abs(cd2 - R * 0.33)) * body * 0.7;
rgb += vec3f(1.0, 0.45, 0.60) * exp(-abs(cd2 - R * 0.32) * 320.0 * 0.08 / max(R, 1e-5)) * (0.5 + 0.25 * f32(mTier));
}
if (part == 7) { // GYRO — ring, spins/blazes while firing
let ring = smoothstep(R * 0.09, R * 0.02, abs(length(dloc) - R * 0.40));
let spin = select(2.6, 13.0, isAct);
let glow = select(0.5, 1.3, isAct);
rgb += vec3f(0.72, 0.60, 1.0) * ring * body * glow;
rgb += vec3f(0.90, 0.85, 1.0) * ring * body * max(0.0, sin(atan2(dloc.y, dloc.x) * 3.0 + t * spin)) * (0.55 + select(0.0, 0.6, isAct));
if (isAct) { rgb += vec3f(0.8, 0.70, 1.0) * exp(-dot(dloc, dloc) / (R * R) * 3.0) * 0.6; }
}
if (part == 5) { // GEN — radiant core + turning rays
let cd5 = length(dloc);
rgb += vec3f(0.55, 1.0, 0.40) * exp(-cd5 * cd5 / (R * R) * 3.5) * (0.55 + 0.25 * sin(t * 3.0));
rgb += vec3f(0.45, 1.0, 0.35) * smoothstep(R * 0.55, 0.0, cd5) * max(0.0, sin(atan2(dloc.y, dloc.x) * 5.0 + t * 1.3)) * 0.2 * body;
}
if (part == 8) { // BATTERY — three charge cells
let bx = step(abs(dloc.x), R * 0.40);
var bars = 0.0;
for (var k2 = 0; k2 < 3; k2 = k2 + 1) { bars = max(bars, step(abs(dloc.y - (f32(k2) - 1.0) * R * 0.34), R * 0.115) * bx); }
rgb = mix(rgb, vec3f(0.08, 0.09, 0.05), bars * body * 0.8);
rgb += vec3f(0.95, 1.0, 0.30) * bars * step(R * (0.55 - 1.1 * (0.5 + 0.5 * sin(t * 1.4))), dloc.y) * body * 0.65;
}
if (part == 2) { // ARMOR — beveled chevron plating
let ch = smoothstep(0.005 * R / 0.08, 0.0018 * R / 0.08, abs(abs(dloc.y + abs(dloc.x) * 0.55) - R * 0.30));
rgb = mix(rgb, vec3f(0.22, 0.20, 0.38), ch * body * 0.6);
rgb += vec3f(0.72, 0.65, 1.0) * ch * body * 0.30;
}
if (part == 1 && !isCore) { // HULL — rivet ring + plate seam
var rv = 0.0;
for (var k = 0; k < 5; k = k + 1) { let aK = 1.5707963 + f32(k) * 1.2566371; let q = dloc - vec2f(cos(aK), sin(aK)) * R * 0.55; rv = max(rv, exp(-dot(q, q) / (R * R) * 100.0)); }
rgb += vec3f(0.55, 0.85, 1.0) * rv * body * 0.5;
rgb += vec3f(0.35, 0.65, 0.9) * smoothstep(0.0035 * R / 0.08, 0.0012 * R / 0.08, abs(dloc.y - dloc.x * 0.3)) * body * 0.22;
}
if (part == 10) { // TACTICS — reticle + sweep
let rd = length(dloc);
rgb += vec3f(1.0, 0.45, 0.90) * smoothstep(R * 0.09, R * 0.03, abs(rd - R * 0.40)) * body * 0.8;
var tk = 0.0;
for (var k3 = 0; k3 < 4; k3 = k3 + 1) { let q3 = py_rot(dloc, f32(k3) * 1.5707963); tk = max(tk, step(abs(q3.y), R * 0.03) * step(R * 0.28, q3.x) * step(q3.x, R * 0.55)); }
rgb += vec3f(1.0, 0.70, 0.95) * tk * body * 0.7;
rgb += vec3f(1.0, 0.45, 0.90) * smoothstep(R * 0.40, 0.0, rd) * pow(max(0.0, sin(atan2(dloc.y, dloc.x) - t * 1.8)), 8.0) * body * 0.4;
}
if (isCore) { // HELM — THE CORE, physical
// (Galen: "core the core") — a heavy REACTOR: dark gold-steel housing band
// with twin machined trim lines and five bolts, holding a molten heart
// whose light shafts turn inside the housing. It reads as the densest,
// most protected thing on the ship — because it is.
let cdc = length(dloc);
let band = smoothstep(R * 0.13, R * 0.09, abs(cdc - R * 0.40));
rgb = mix(rgb, vec3f(0.16, 0.13, 0.08), band * body * 0.9); // the housing: dark solid metal
rgb += vec3f(1.0, 0.85, 0.45) * smoothstep(R * 0.045, R * 0.015, abs(cdc - R * 0.475)) * body * 0.9; // outer trim
rgb += vec3f(1.0, 0.80, 0.40) * smoothstep(R * 0.045, R * 0.015, abs(cdc - R * 0.325)) * body * 0.9; // inner trim
var bt = 0.0;
for (var kb = 0; kb < 5; kb = kb + 1) { let aB = 0.6283185 + f32(kb) * 1.2566371; let qb = dloc - vec2f(cos(aB), sin(aB)) * R * 0.40; bt = max(bt, exp(-dot(qb, qb) / (R * R) * 260.0)); }
rgb += vec3f(1.0, 0.90, 0.60) * bt * body * 0.6; // five bolts on the band
rgb += vec3f(1.0, 0.90, 0.50) * exp(-cdc * cdc / (R * R) * 7.0) * (0.9 + 0.3 * sin(t * 2.6)); // the molten heart
rgb += vec3f(1.0, 0.75, 0.35) * smoothstep(R * 0.30, 0.0, cdc) * pow(max(0.0, sin(atan2(dloc.y, dloc.x) * 5.0 + t * 0.9)), 3.0) * 0.35 * body; // turning light shafts
}
if (part == 0 && !isCore) { // BLANK — the lattice breathes through it
rgb += vec3f(0.35, 0.55, 0.85) * body * (0.06 + 0.06 * sin((dloc.x + dloc.y) / R * 12.0 + t * 2.0));
}
// ── BROKENNESS (Galen: "shows brokenness per pentagon damage") — a STATIC
// fracture web opens progressively with damage: crack voids darken the
// plate, ember light burns in them, the whole tile scorches. At hp 0 the
// engine stops pushing the tile — total damage IS no more pentagon. ──
if (dmg > 0.01) {
let crack = qz_web(qz_field(dloc / max(R, 1e-5) * 6.3 + vec2f(1.7, 4.2), 0.0), 3.0);
let cm = smoothstep(1.0 - dmg * 0.9, 1.15 - dmg * 0.9, crack) * body;
rgb = mix(rgb, vec3f(0.02, 0.015, 0.02), cm * 0.85); // the crack voids
rgb += vec3f(1.0, 0.42, 0.15) * cm * (0.45 + 0.55 * dmg) * (0.75 + 0.25 * sin(t * 7.0 + dmg * 20.0)); // ember in the fracture
rgb *= 1.0 - dmg * 0.30 * body; // scorch
rgb += vec3f(1.0, 0.50, 0.20) * rim * dmg * 0.5; // the wound's rim burns
}
return vec4f(rgb, a);
}
// ── ITEM CARD (Galen: "each item has gorgeous item graphic, as if the item
// itself were hyperrealistic") — a procedural showpiece render of one part:
// drop shadow, quintessence crystal slab, neon rim, then the part's own
// machinery. part0 11 = the HELM (command gold).
// All math, no textures (the freeze-quarantine law: never bake pixels).
fn py_item_card(p: vec2f, part0: i32, Rc: f32, t: f32, hot: f32) -> vec4f {
// THE CARD IS SHIPPENT + card chrome (Galen: ONE graphic source). part0 11 =
// the HELM (command gold). Drop shadow floats the slab off the shelf; the
// pentagon body/machinery is the SAME shipPent that draws every tile.
var part = part0;
let core = part0 == 11;
if (core) { part = 1; }
var rgb = vec3f(0.0);
var a = 0.0;
// drop shadow
let dsh = py_pent(p - vec2f(Rc * 0.09, Rc * 0.13), Rc * 1.02, 0.0);
a = max(a, smoothstep(Rc * 0.30, 0.0, dsh) * 0.45);
// the pentagon itself — up-facing machinery (faDir = +y), no mods on a card
let sp = shipPent(p, part, Rc, 0.0, 1.5707963, 0, 0, 0, 0, core, false, false, hot, t, false, vec3f(0.0), 0.0, false);
rgb = mix(rgb, sp.rgb, sp.a);
a = max(a, sp.a);
return vec4f(rgb, a);
}
fn visual_shipyard(uv: vec2f, sdf: f32, color: vec4f, time: f32, params: vec4f, behind: vec4f) -> vec4f {
let t = uni(0);
let S = uni(7); // world scale (units→uv)
// uni(15) = SCENE: 0 yard · 1 menu/servers/hotseat · 2 battle. Yard chrome
// (bottom shelf, item cards, delete pad) draws ONLY in the yard.
let sceneK = i32(round(uni(15)));
let mScene = sceneK != 0; // any full-page scene: nothing of the yard underneath
var col = vec3f(0.016, 0.020, 0.036);
if (sceneK == 1) {
// MENU — deep space hung with QUASICRYSTAL AURORA: pentagrid curtains of
// cyan/violet light breathing over the dark, point stars behind.
col = vec3f(0.008, 0.010, 0.020) + vec3f(0.018, 0.030, 0.060) * exp(-dot(uv + vec2f(0.0, 0.35), uv + vec2f(0.0, 0.35)) * 1.1);
let qa = qz_field(uv * 3.2 + vec2f(0.0, t * 0.03), t * 0.05);
let wa = qz_web(qa, 6.0);
col += mix(vec3f(0.05, 0.16, 0.30), vec3f(0.22, 0.06, 0.30), 0.5 + 0.5 * sin(uv.x * 1.7 + qa * 0.35)) * wa * 0.30 * exp(-dot(uv - vec2f(0.0, 0.15), uv - vec2f(0.0, 0.15)) * 0.55);
// point stars (see the battle branch note — lit cells read as squares)
let cm = floor(uv * 40.0);
let hm = fract(sin(dot(cm, vec2f(12.9898, 78.233))) * 43758.5453);
let spm = vec2f(fract(hm * 57.31), fract(hm * 113.97));
let dm = length(fract(uv * 40.0) - spm);
col += vec3f(0.8, 0.9, 1.0) * smoothstep(0.055, 0.0, dm) * step(0.6, hm) * (0.35 + 0.3 * sin(t + hm * 40.0));
} else if (sceneK == 2) {
// BATTLE: open space. Two star layers PARALLAX against the camera
// (uni(2/3) = cam in world units; ×S puts them in uv) — the field visibly
// streams past as you fly, which is what sells the arena's size.
// POINT stars, not lit cells: each hash cell places one sub-pixel star at a
// hashed offset and shades by DISTANCE — a step() on the cell hash lit the
// whole ~22px cell and read as big flickering squares (Galen: "flickering
// parts") that popped at every cell crossing as the camera moved.
col = vec3f(0.006, 0.008, 0.016) + vec3f(0.012, 0.018, 0.034) * exp(-dot(uv, uv) * 0.9);
let camUv = vec2f(uni(2), uni(3)) * S;
let p1 = uv + camUv * 0.35;
let c1 = floor(p1 * 20.0);
let h1 = fract(sin(dot(c1, vec2f(12.9898, 78.233))) * 43758.5453);
let sp1 = vec2f(fract(h1 * 57.31), fract(h1 * 113.97));
let d1 = length(fract(p1 * 20.0) - sp1);
col += vec3f(0.55, 0.65, 0.85) * smoothstep(0.06, 0.0, d1) * step(0.35, h1) * (0.35 + 0.20 * sin(t * 0.7 + h1 * 40.0));
let p2 = uv + camUv * 0.70;
let c2 = floor(p2 * 44.0);
let h2 = fract(sin(dot(c2, vec2f(26.651, 41.517))) * 43758.5453);
let sp2 = vec2f(fract(h2 * 71.13), fract(h2 * 39.41));
let d2 = length(fract(p2 * 44.0) - sp2);
col += vec3f(0.85, 0.9, 1.0) * smoothstep(0.05, 0.0, d2) * step(0.55, h2) * (0.5 + 0.25 * sin(t + h2 * 60.0));
// a LATTICE NEBULA drifting with the parallax — the pentagrid as deep-space
// weather, so long flights read as travel through structured dark
let nb = qz_web(qz_field((uv + camUv * 0.2) * 2.4, t * 0.04), 4.0);
col += vec3f(0.030, 0.012, 0.052) * nb * exp(-abs(uv.y + camUv.y * 0.2) * 1.4);
// STAGE CROSSFADE (uni 13 = 0 dock → 1 space): entering battle FROM the
// yard, the dock dissolves into open space around the ship instead of a
// jump-cut — the direct state transition keeps what's in front of you.
let sf = clamp(uni(13), 0.0, 1.0);
if (sf < 0.999) {
let qy2 = qz_web(qz_field(uv * 4.6, t * 0.06), 8.0);
var dock = vec3f(0.022, 0.042, 0.072) * qy2 * (0.40 + 0.25 * sin(uv.y * 2.0 + t * 0.12));
dock += vec3f(0.35, 0.75, 1.0) * qz_nanite(uv + vec2f(t * 0.008, -t * 0.005), 22.0, t) * 0.05;
col = mix(dock, col, sf);
}
} else {
// YARD — the nanite dock: near-black, ONE pentagrid layer shimmering
// faintly under the floor (a second layer cost real heat — Galen's Mac),
// motes of nanites adrift (hash — cheap)
let qy = qz_web(qz_field(uv * 4.6, t * 0.06), 8.0);
col += vec3f(0.022, 0.042, 0.072) * qy * (0.40 + 0.25 * sin(uv.y * 2.0 + t * 0.12));
let mt = qz_nanite(uv + vec2f(t * 0.008, -t * 0.005), 22.0, t);
col += vec3f(0.35, 0.75, 1.0) * mt * 0.05;
}
let n = popCount();
// ── REGION GATE (Galen, Sep 4: "pentarch is laggy" — region gate the shipyard).
// The perf:pop-bounds node publishes tick-fresh AABBs: u40-43 = WORLD entities
// (hulls/effects, +3R margin) · u45-48 = CHROME entities (+fat margin) · u44 =
// gate armed. A pixel outside both boxes pays ONE branch instead of walking
// both n-entity loops (~80 pop() reads) — empty space becomes free. u44==0
// (old tab / node absent) disarms the gate: exactly today's behavior.
let gateOn = uni(44) > 0.5;
let inWorld = !gateOn || (uv.x > uni(40) && uv.y > uni(41) && uv.x < uni(42) && uv.y < uni(43));
let inChrome = !gateOn || (uv.x > uni(45) && uv.y > uni(46) && uv.x < uni(47) && uv.y < uni(48));
let R = S * 0.85065; // tile circumradius in uv
// ══ PASS A — THE MERGED HULL FIELD ══════════════════════════════════════
// Every part tile's pentagon SDF smooth-mins into ONE body per ship, so
// pentagons FILLET into each other instead of abutting; chamber holes are
// collected for smooth SUBTRACTION. We track the two nearest tiles (dh1/dh2 —
// their equidistance line is the weld seam), the nearest tile's frame (the
// lattice patch anchor), and a proximity-weighted part-color blend so
// colors feather across tile borders instead of hard-cutting.
var dHull = 1e5;
var dh1 = 1e5;
var dh2 = 1e5;
var nTh = 0.0;
var nCen = vec2f(0.0);
var wSum = 0.0;
var cSum = vec3f(0.0);
var dCav = 1e5;
var cavC = vec3f(0.35, 0.60, 1.0);
// ONE CRYSTAL PER SHIP (Galen: "pentagons flow into pentagons"): anchor the
// lattice to the constellation's CORE, not each tile — the pattern then runs
// unbroken across every pentagon of the ship. Nearest-tile frame is the
// fallback for core-less hulls (enemy chasers).
var cD = 1e5;
var cCen = vec2f(0.0);
var cTh = 0.0;
var hasCore = false;
for (var i0 = 0; i0 < n; i0 = i0 + 1) {
if (!inWorld) { break; } // region gate: no world entity reaches this pixel
let e = pop(i0);
let code = i32(e.w);
if (code == 339) { if (abs(uv.y - e.y) > 0.10) { break; } continue; } // FLEET-BAR TAIL SENTINEL — every entry after it is band chrome; pixels outside the band skip the whole tail
if (code >= 300 && code < 320 && fract(e.w) > 0.0) { continue; } // button
if (code >= 320 && code <= 349) { continue; } // panel / meter / portrait / mini-tile chrome (340 skips the hull merge — it drew as a gold blob behind the fleet bar)
let kind = code % 100;
let pc = uv - e.xy;
if (kind == 84) { continue; } // slice ghosts draw in pass B — NOT a cavity, NOT a hull tile
if ((kind >= 71 && kind <= 75) || kind > 80) { // CHAMBER — a cavity to carve
let hr = max(fract(e.w) * 2.0 * S, 0.015); // fract = INTRINSIC ch.r/2 → on-screen radius via world scale S (matches tiles; design==battle)
if (dot(pc, pc) > (hr + R) * (hr + R)) { continue; }
let vd = length(pc) - hr * 0.85;
if (vd < dCav) {
dCav = vd;
cavC = vec3f(0.35, 0.60, 1.0);
if (kind == 71) { cavC = vec3f(1.00, 0.32, 0.24); }
else if (kind == 72) { cavC = vec3f(0.30, 0.60, 1.00); }
else if (kind == 73) { cavC = vec3f(1.00, 0.88, 0.50); }
else if (kind == 74) { cavC = vec3f(0.45, 0.65, 0.95); }
else if (kind == 75) { cavC = vec3f(0.28, 0.50, 1.00); }
else if (kind > 80) { cavC = vec3f(0.95, 0.85, 0.30); }
if (code >= 400) { cavC = vec3f(1.85, 1.45, 0.40); } // GOLD superweapon — the hull rim around the cavity burns gold
}
continue;
}
if (kind >= 56) { continue; } // effects draw in pass B
if (dot(pc, pc) > R * R * 2.9) { continue; } // PERF: 1.7R reach (rim glow is dead past that)
let d = py_pent(pc, R, e.z);
dHull = qz_smin(dHull, d, R * 0.34);
if (d < dh1) { dh2 = dh1; dh1 = d; nTh = e.z; nCen = e.xy; }
else if (d < dh2) { dh2 = d; }
var pA = kind % 10;
if (kind == 50) { pA = 10; }
let flA = code / 100;
var bcolA = py_col(pA);
if (flA >= 8) { bcolA = mix(bcolA, vec3f(1.0, 0.30, 0.22), 0.55); } // FOE — ember-red hostiles
else if ((flA / 2) % 2 == 1) {
bcolA = vec3f(1.00, 0.82, 0.35);
if (d < cD) { cD = d; cCen = e.xy; cTh = e.z; hasCore = true; } // the crystal anchor
}
let w = exp(-clamp(d / (R * 0.35), -6.0, 6.0));
wSum += w;
cSum += bcolA * w;
}
if (dh1 < R * 1.6) {
// the ONE machine: hull minus chambers, filleted both ways
let dBody = qz_smax(dHull, -dCav, R * 0.30);
let aaW = 0.05 * R;
let body = smoothstep(aaW, -aaW, dBody);
let base = cSum / max(wSum, 1e-5);
// the crystal frame: the CORE's, if one is in reach — ONE unbroken lattice
// over the whole constellation; nearest tile's frame otherwise
let useCore = hasCore && cD < R * 9.0;
let lp = py_rot(uv - select(nCen, cCen, useCore), select(nTh, cTh, useCore)) / max(R, 1e-5);
// LOD: sub-pixel filaments at battle zoom would alias AND cost — fade+skip
let lod = smoothstep(0.030, 0.055, R);
var seam = 0.0;
if (body > 0.001) {
// INTERIOR ONLY (perf: near-hull pixels outside the body pay just rims):
// SOLID machined alloy (Galen: "make it physical"): lit interior falling
// to a dark edge, brushed grain, top-left bevel light off the nearest
// tile's SDF slope; the pentagrid ETCHED as grooves, neon in their cores.
var web = 0.0;
if (lod > 0.0) {
web = qz_web(qz_field(lp * 3.6, t * 0.22), 10.0) * lod;
}
let depth = clamp(1.0 + dBody / (R * 0.85), 0.0, 1.0); // 0 deep inside → 1 at the edge
let grain = 0.94 + 0.06 * sin(atan2(lp.y, lp.x) * 34.0 + length(lp) * 6.0);
var bc = mix(base * 0.55, base * 0.20, depth * depth) * grain + vec3f(0.016, 0.020, 0.030);
bc = mix(bc, bc * 0.70, web * 0.55); // etched groove shadow
bc += base * web * web * 0.62; // neon burning in the groove core
let dlt = py_pent(uv - nCen + vec2f(R * 0.05, R * 0.06), R, nTh);
let bev = clamp((dlt - dh1) / (R * 0.08), -1.0, 1.0) * smoothstep(R * 0.42, R * 0.04, abs(dBody));
bc += vec3f(0.85, 0.92, 1.0) * max(bev, 0.0) * 0.26;
bc *= 1.0 - max(-bev, 0.0) * 0.32;
seam = smoothstep(R * 0.20, R * 0.03, dh2 - dh1) * body; // WELD SEAM — the Voronoi border of tiles
bc += base * seam * 0.45;
bc += vec3f(0.80, 0.95, 1.0) * seam * 0.30;
let cavRim = exp(-abs(dCav) * 15.0 / max(R, 1e-5)); // chamber rim glows its power's color
bc += cavC * cavRim * 0.85 * body;
col = mix(col, bc, body);
}
let erim = exp(-abs(dBody) * 14.0 / max(R, 1e-5)); // the ONE continuous neon outline
col += base * erim * 0.9;
col += vec3f(1.0) * exp(-abs(dBody) * 55.0 / max(R, 1e-5)) * 0.30;
col += vec3f(0.85, 1.0, 1.0) * qz_nanite(lp, 9.0, t) * (erim * 0.85 + seam * 0.55) * max(lod, 0.25); // assembly fronts dim at far zoom
}
// ══ PASS B — machinery, chambers' generative art, effects, chrome ═══════
for (var i = 0; i < n; i = i + 1) {
if (!inWorld && !inChrome) { break; } // region gate: neither world nor chrome here
let e = pop(i); // x, y (uv), th, code
let code = i32(e.w);
if (code == 339) { if (abs(uv.y - e.y) > 0.10) { break; } continue; } // FLEET-BAR TAIL SENTINEL — every entry after it is band chrome; pixels outside the band skip the whole tail
// CHROME dispatch FIRST (raw code ranges, not kind%100 — 300+ is reserved
// for buttons, which ALWAYS pack a nonzero half-width in fract(code). The one
// collision: a SELECTED CORE tile = 200(core)+100(sel) = exactly 300.0 — so
// fract==0 is NOT a button; let it fall through to the part decoder (flags=3
// reads as core+selected there, which is precisely what it is).
if (code == 340) { // FLEET MINI TILE — berth ship thumbnails
let mr = max(fract(e.w), 0.003); // radius packed in fract(w)
let zc = i32(e.z);
let dmm = uv - e.xy;
if (dot(dmm, dmm) > mr * mr * 4.0) { continue; }
let dd = py_pent(dmm, mr, 0.0);
var mc = vec3f(0.56, 0.69, 0.85); // hull
let pt9 = zc % 10;
if (zc >= 200) { mc = vec3f(1.0, 0.83, 0.47); } // core — command gold
else if (pt9 == 2) { mc = vec3f(0.66, 0.69, 0.74); } // armor
else if (pt9 == 3 || pt9 == 9) { mc = vec3f(1.0, 0.62, 0.58); } // guns
else if (pt9 == 4 || pt9 == 6 || pt9 == 7 || pt9 == 0) { mc = vec3f(0.62, 0.87, 1.0); } // drives
else if (pt9 == 5 || pt9 == 8) { mc = vec3f(0.71, 1.0, 0.66); } // power
col = mix(col, mc * 0.85, smoothstep(0.0015, -0.0015, dd) * 0.95);
col += mc * exp(-abs(dd) * 900.0) * 0.35;
continue;
}
if (code == 345 || code == 347) { // PALETTE ITEM CARD, seated in a solver slot
let rc5 = fract(e.z);
let pp5 = uv - e.xy;
if (dot(pp5, pp5) > rc5 * rc5 * 3.5) { continue; }
let gpP = vec2f(uni(8), uni(9));
let armd = code == 347;
let hot5 = max(exp(-dot(gpP - e.xy, gpP - e.xy) * 260.0), select(0.0, 0.95, armd));
let cth5 = select(0.0, uni(5) * 1.2566371, armd);
let ic5 = py_item_card(py_rot(pp5, cth5), i32(floor(e.z)), rc5, t, hot5);
col = mix(col, ic5.rgb, ic5.a);
if (armd) { let dR5 = py_pent(pp5, rc5 * 1.2, 0.0); col += vec3f(1.0, 0.9, 0.5) * exp(-abs(dR5) * 70.0) * (0.5 + 0.2 * sin(t * 5.0)); }
continue;
}
if (code == 346) { // PALETTE DELETE PAD, seated
let rc6 = fract(e.z);
let pp6 = uv - e.xy;
if (dot(pp6, pp6) > rc6 * rc6 * 3.5) { continue; }
let on6 = uni(13);
let d6 = py_pent(pp6, rc6 * 0.92, 0.0);
let body6 = smoothstep(0.004, -0.004, d6);
col = mix(col, vec3f(0.30, 0.03, 0.08) * (0.6 + 0.8 * on6), body6 * 0.95);
col += vec3f(1.0, 0.15, 0.35) * exp(-abs(d6) * 240.0) * (0.6 + 0.9 * on6);
let xX6 = min(abs(pp6.x - pp6.y), abs(pp6.x + pp6.y));
col += vec3f(1.0, 0.45, 0.55) * smoothstep(0.005, 0.002, xX6) * step(length(pp6), rc6 * 0.62) * 0.8;
continue;
}
if (code >= 300 && code < 320 && fract(e.w) > 0.0) { // BUTTON
let hw = fract(e.w); let p = uv - e.xy;
if (abs(p.x) > hw + 0.06 || abs(p.y) > hw * 0.5 + 0.06) { continue; } // PERF: rect cull
let bc = ch_button(p, hw, e.z);
col = mix(col, bc.rgb, bc.a);
continue;
}
if (code == 320) { // PANEL (a packs both half-sizes)
let hw = ch_unpackW(e.z); let hh = ch_unpackH(e.z);
let pp0 = uv - e.xy;
if (abs(pp0.x) > hw + 0.05 || abs(pp0.y) > hh + 0.05) { continue; } // PERF: rect cull
let pc = ch_panel(pp0, hw, hh);
col = mix(col, pc.rgb, pc.a);
continue;
}
if (code == 330) { // ITEM PORTRAIT (sidebar): floor(a)=part, fract(a)=radius
let pp3 = uv - e.xy;
let rc3 = fract(e.z);
if (dot(pp3, pp3) > rc3 * rc3 * 3.5) { continue; } // PERF: radius cull
let ic = py_item_card(pp3, i32(floor(e.z)), rc3, t, 0.35 + 0.25 * sin(t * 2.0));
col = mix(col, ic.rgb, ic.a);
continue;
}
if (code >= 324 && code <= 329) { // METER — a drawn gauge bar (the console redesign)
let hw = floor(e.z) / 4096.0; let fill = fract(e.z);
let pm0 = uv - e.xy;
if (abs(pm0.x) > hw + 0.04 || abs(pm0.y) > 0.06) { continue; } // PERF: rect cull
let mc = ch_meter(pm0, hw, fill, ch_meterHue(code - 324), t);
col = mix(col, mc.rgb, mc.a);
continue;
}
let kind = code % 100; // part+10·o (<50) · 56 plume · 57 arc · 60 ghost · 70 glint
let flags = code / 100; // 1 = selected
let d = py_pent(uv - e.xy, R, e.z);
if (kind >= 76 && kind != 84) { // TRUE SHAPE OUTLINES (84 = ghost slice, handled below)
let hl = fract(e.w);
let dloc = py_rot(uv - e.xy, e.z);
let sd = length(vec2f(max(abs(dloc.x) - hl, 0.0), dloc.y));
var oc = vec3f(1.0, 0.85, 0.45);
if (kind == 77) { oc = vec3f(0.75, 0.85, 1.0); }
if (kind == 78) { oc = vec3f(1.0, 0.98, 0.92); }
if (kind == 79) { oc = vec3f(0.6, 0.75, 0.9); }
if (kind == 80) { oc = vec3f(0.35, 0.40, 0.50); }
let pulse = 0.8 + 0.2 * sin(t * 2.2);
col += oc * smoothstep(0.006, 0.0015, sd) * (1.1 * pulse); // the line itself
col += oc * exp(-sd * 120.0) * 0.25; // soft halo
continue;
}
// (kind 84 sprite-slices removed — chambers now draw the folded field
// programmatically from their ONE heartbeat entity; see kind>=71 below)
if (kind >= 71) { // SEALED SHAPES — each hole DRAWS ITS POWER
// fract(code) = hole radius (uv) · e.z = the shape's axis angle
let hoffS = uv - e.xy;
let vd = length(hoffS);
let chR = fract(e.w) * 2.0; // INTRINSIC chamber radius (tile units) — same design + battle
let hr = max(chR * S, 0.015); // on-screen radius via world scale S
// ── GENERATIVE CAVITY FILL (Galen: "chambers autofill with generative
// art") — the negative space GLOWS with the weapon's living energy:
// a procedural swirl + spark field filling the hole, themed per
// chamber, animated. The recognisable glyph then rides on top.
// Pass A already CARVED this cavity out of the hull, so the fill
// burns inside true negative space now. ──
let hn = hr + 1e-5;
if (vd > hr * 1.25) { continue; } // PERF: the city + glyph live within the cavity; skip distant pixels
let fillMask = smoothstep(hr * 1.02, hr * 0.06, vd);
var fillC = vec3f(0.30, 0.40, 0.60);
if (kind == 71) { fillC = vec3f(1.5, 0.42, 0.30); }
else if (kind == 72) { fillC = vec3f(0.42, 0.72, 1.5); }
else if (kind == 73) { fillC = vec3f(1.5, 1.30, 0.75); }
else if (kind == 74) { fillC = vec3f(0.55, 0.75, 1.05); }
else if (kind == 75) { fillC = vec3f(0.36, 0.62, 1.35); }
else if (kind > 80) { fillC = vec3f(1.30, 1.15, 0.42); }
// GOLD (code >= 400): the finalized superweapon — the whole cavity burns
// molten gold, breathing (Galen: "superweapon paints gold")
if (code >= 400) { fillC = mix(fillC, vec3f(1.9, 1.5, 0.4), 0.8); }
// ── v57 GENERATIVE CAVITY FILL (the confirmed-good core — Galen: "restore
// v57 graphics… so we know we're building on the right core"). A
// procedural swirl + spark filling the negative space; the recognisable
// weapon glyph rides on top. Graphics rungs (fold / 3d extrusion) layer on later. ──
let aCf = atan2(hoffS.y, hoffS.x);
let swirl = 0.5 + 0.5 * sin(aCf * 3.0 + vd / hn * 7.0 - t * 2.2);
let spark = 0.5 + 0.5 * sin(hoffS.x / hn * 8.0 + t * 3.0) * sin(hoffS.y / hn * 8.0 - t * 2.4);
col += fillC * fillMask * (0.22 + 0.22 * swirl + 0.16 * spark); // reads at battle zoom too
col += fillC * exp(-vd * vd / (hn * hn * 0.16)) * 0.55; // a bigger, hotter core
if (code >= 400) { // GOLD superweapon — the fill burns molten
col += vec3f(1.9, 1.5, 0.4) * fillMask * (0.14 + 0.10 * sin(t * 3.2));
col += vec3f(1.9, 1.6, 0.5) * exp(-vd * vd / (hn * hn * 0.14)) * 0.5;
}
if (code >= 400) {
col += vec3f(1.9, 1.5, 0.4) * fillMask * (0.10 + 0.05 * sin(t * 3.2)); // molten breath over the whole cavity
col += vec3f(1.9, 1.6, 0.5) * exp(-abs(vd) * 9.0 / hn) * (0.5 + 0.2 * sin(t * 3.2)); // burning GOLD rim
}
if (kind == 71) { // DIAMOND — the laser slit: a hot beam line along the axis
let lpS = py_rot(hoffS, e.z);
let slit = smoothstep(hr * 0.16, hr * 0.04, abs(lpS.y)) * smoothstep(hr * 1.0, hr * 0.55, abs(lpS.x));
col += vec3f(1.9, 0.5, 0.35) * slit * (0.7 + 0.3 * sin(t * 7.0));
col += vec3f(1.9, 0.8, 0.5) * exp(-vd * vd / (hr * hr * 0.08)) * 0.5;
}
else if (kind == 72) { // MOON — wave blaster: crests rolling out along its radial
let aS = atan2(hoffS.y, hoffS.x);
let daS = atan2(sin(aS - e.z), cos(aS - e.z));
let sector = smoothstep(0.9, 0.4, abs(daS));
let ringA = fract(t * 0.5) * hr * 1.3;
let ringB = fract(t * 0.5 + 0.5) * hr * 1.3;
let crest = exp(-abs(vd - ringA) * 240.0) * (1.0 - fract(t * 0.5)) + exp(-abs(vd - ringB) * 240.0) * (1.0 - fract(t * 0.5 + 0.5));
col += vec3f(0.55, 0.85, 1.6) * crest * sector * 0.9;
}
else if (kind == 73) { // STAR — the super weapon: a twinkling 5-point flare
let aS = atan2(hoffS.y, hoffS.x);
let flare = pow(abs(cos(aS * 2.5 + t * 0.7)), 6.0);
col += vec3f(1.9, 1.7, 1.1) * exp(-vd / (hr * 0.5)) * (0.35 + flare * 0.85);
col += vec3f(1.9, 1.8, 1.4) * exp(-vd * vd / (hr * hr * 0.02)) * 0.8;
}
else if (kind == 74) { // BAY — BRACE (+hp): a bold armor cross
let arm = min(abs(hoffS.x), abs(hoffS.y));
let cross = smoothstep(hr * 0.16, hr * 0.05, arm) * smoothstep(hr * 0.8, hr * 0.5, vd);
col += vec3f(0.65, 0.85, 1.0) * cross * (0.55 + 0.15 * sin(t * 2.0));
}
else if (kind == 75) { // CIRCLE — the shield womb: a mini blue cell
let lpS = py_rot(hoffS, t * 0.6);
let d6S = (abs(lpS.x) / (hr * 0.35) + abs(lpS.y) / (hr * 0.7)) - 1.0;
col += vec3f(0.35, 0.6, 1.3) * smoothstep(0.15, -0.15, d6S) * 0.8;
col += vec3f(0.45, 0.7, 1.4) * exp(-abs(d6S) * 12.0) * 0.25;
}
else if (kind > 80) { // CELL (unknown chamber) — a battery: twin charge bars
let lpS = hoffS;
let bar = smoothstep(hr * 0.14, hr * 0.05, abs(abs(lpS.x) - hr * 0.28)) * smoothstep(hr * 0.6, hr * 0.35, abs(lpS.y));
col += vec3f(1.5, 1.5, 0.5) * bar * (0.5 + 0.25 * sin(t * 3.0));
}
else { // outline family (76-80): the soft marker dot
col += vec3f(0.7, 0.7, 0.8) * exp(-vd * vd * 3000.0) * 0.3;
}
continue;
}
if (kind == 68) { // TURRET HEAD — swivels live
// (Galen: "guns should swivel and aim… within their radius") — carries
// the turret's CONTINUOUS aim; the engine's traverse clamps it to the
// bought arc, so the drawn barrel points only where the gun legally can.
// Housing + barrel + bore + muzzle rotate as one deck mount OVER the
// fixed base pentagon (overhang is right: it is a deck mount).
let pcT = uv - e.xy;
if (dot(pcT, pcT) > R * R * 3.4) { continue; }
let modT = i32(round(fract(e.w) * 256.0));
let mgyT = (modT / 16) % 4;
let mgdT = (modT / 4) % 4;
let mgpT = modT % 2;
let dmgT2 = f32((modT / 2) % 2) * 0.6;
let fdT = vec2f(cos(e.z), sin(e.z));
let alongT = dot(pcT, fdT);
let acrossT = abs(pcT.x * fdT.y - pcT.y * fdT.x);
let apT = R * 0.80902;
let baseT = py_col(3);
let blenT = apT * (0.80 + 0.12 * f32(mgyT));
let bwidT = R * (0.10 + 0.030 * f32(mgdT));
let housT = step(-apT * 0.35, alongT) * step(alongT, apT * 0.25) * smoothstep(R * 0.30, R * 0.24, acrossT);
col = mix(col, vec3f(0.16, 0.15, 0.19), housT * 0.92);
col += vec3f(0.75, 0.80, 0.95) * housT * smoothstep(R * 0.06, 0.0, abs(acrossT - R * 0.24)) * 0.5;
let blT = step(0.0, alongT) * step(alongT, blenT) * smoothstep(bwidT, bwidT * 0.85, acrossT);
col = mix(col, vec3f(0.13, 0.12, 0.15), blT * 0.95);
col += vec3f(0.80, 0.85, 1.0) * blT * smoothstep(bwidT * 0.35, bwidT * 0.12, abs(acrossT - bwidT * 0.55)) * 0.35;
let boreT = step(0.0, alongT) * step(alongT, blenT * 0.98) * smoothstep(bwidT * 0.30, bwidT * 0.10, acrossT);
col = mix(col, vec3f(0.05, 0.03, 0.05), boreT * 0.9);
col += baseT * boreT * (0.55 + 0.30 * sin(t * 6.0 - alongT / max(R, 1e-5) * 4.0)) * 0.8;
let muzT = smoothstep(R * 0.05, R * 0.015, abs(alongT - blenT)) * smoothstep(bwidT * 1.35, bwidT * 0.2, acrossT);
col = mix(col, vec3f(0.18, 0.17, 0.20), muzT * 0.9);
col += baseT * muzT * 0.5;
col += vec3f(1.0, 0.25, 0.35) * boreT * 0.30 * f32(mgdT);
if (mgpT > 0) { let mpT = pcT - fdT * blenT; col += vec3f(1.0, 0.85, 0.5) * exp(-dot(mpT, mpT) / (R * R) * 15.0) * (0.8 + 0.3 * sin(t * 6.0)); }
if (dmgT2 > 0.01) {
col = mix(col, vec3f(0.05, 0.03, 0.03), (housT + blT) * 0.3 * dmgT2);
col += vec3f(1.0, 0.4, 0.15) * blT * dmgT2 * 0.4;
}
continue;
}
if (kind == 69) { // NANITE DISASSEMBLY — a dying
// pentagon returns to the swarm: an expanding shell of assembly sparks
// around a hot fading core. e.z = phase (0..0.55s). Negative phase =
// staggered burst not yet born.
if (e.z < 0.0) { continue; }
let ph = clamp(e.z / 0.55, 0.0, 1.0);
let pc9 = uv - e.xy;
let rd9 = length(pc9);
let shell = R * (0.4 + 2.6 * ph);
if (rd9 > shell + R * 0.8) { continue; }
let ring = exp(-abs(rd9 - shell) * 30.0 / max(R, 1e-5)) * (1.0 - ph);
let spk9 = qz_nanite(pc9 / max(R, 1e-5) + vec2f(e.x * 37.0, e.y * 53.0), 6.0, t * 3.0);
col += vec3f(0.85, 1.0, 1.0) * ring * (0.5 + spk9 * 1.6);
col += vec3f(1.0, 0.75, 0.45) * exp(-rd9 * rd9 / (R * R * 0.35)) * (1.0 - ph) * (1.0 - ph) * 1.2;
continue;
}
if (kind == 70) { // ROUTE NODE — a small gold glint
let vd = length(uv - e.xy);
col += vec3f(1.0, 0.85, 0.45) * exp(-vd * vd * 11000.0) * (0.5 + 0.2 * sin(t * 2.4));
col += vec3f(1.0, 0.82, 0.4) * exp(-abs(vd - 0.008) * 500.0) * 0.3;
continue;
}
if (kind == 56) { // ENGINE PLUME — the boost made visible
// (Galen: "graphics pass + more of a boost") — three layers of real
// rocket: an ultraviolet sheath, a white-hot core spike, and STANDING
// MACH DIAMONDS — the shock knots a real over-expanded exhaust holds.
let inten = fract(e.w);
let pd = py_rot(uv - e.xy, e.z); // local: +x = exhaust direction
let lenP = S * (0.75 + 2.6 * inten); // BIGGER (Galen: "thruster graphic needs to be bigger")
let a2 = clamp(pd.x / max(lenP, 1e-5), 0.0, 1.0);
let wP = S * 0.23 * (1.0 - a2 * 0.65) * (0.6 + 0.4 * inten);
let m = step(0.0, pd.x) * smoothstep(wP, wP * 0.25, abs(pd.y)) * (1.0 - a2);
let flick = 0.75 + 0.25 * sin(t * 31.0 + e.x * 57.0 + e.y * 31.0);
col += (vec3f(0.45, 0.95, 1.0) * (1.0 - a2) + vec3f(0.70, 0.30, 1.0) * a2) * m * inten * 1.9 * flick;
let core6 = step(0.0, pd.x) * smoothstep(wP * 0.4, wP * 0.08, abs(pd.y)) * (1.0 - a2 * a2);
col += vec3f(1.0, 0.98, 0.92) * core6 * inten * 1.3 * flick;
let dia = pow(max(0.0, cos(a2 * 18.0)), 8.0) * core6 * step(0.05, a2);
col += vec3f(0.95, 1.0, 1.0) * dia * inten * 1.2 * flick;
continue;
}
if (kind == 57) { // TURRET ARC — one clean wedge (half-width/π in fract)
let half = fract(e.w) * 3.14159265;
let p2 = uv - e.xy;
let r2 = length(p2);
var da = atan2(p2.y, p2.x) - e.z;
da = atan2(sin(da), cos(da));
let rArc = S * 0.95;
let inA = smoothstep(half, half * 0.9, abs(da));
let stroke = smoothstep(0.006, 0.0018, abs(r2 - rArc)) * inA;
let fill = smoothstep(rArc, rArc * 0.25, r2) * inA;
let rim = smoothstep(0.004, 0.0015, abs(abs(da) - half)) * step(r2, rArc) * step(S * 0.3, r2);
col += vec3f(1.0, 0.45, 0.60) * (stroke * (0.7 + 0.2 * sin(t * 2.0)) + fill * 0.05 + rim * 0.4);
continue;
}
if (kind == 58) { // WEAPON BEAM — white-hot core, magenta halo
let hl = fract(e.w) * 0.5;
let pd = py_rot(uv - e.xy, e.z);
let sd = length(vec2f(max(abs(pd.x) - hl, 0.0), pd.y));
col += vec3f(1.0, 0.40, 0.55) * (smoothstep(0.0045, 0.0012, sd) * 1.5 + exp(-sd * 240.0) * 0.6);
col += vec3f(1.0) * smoothstep(0.0018, 0.0005, sd) * 0.8;
continue;
}
if (kind == 66 || kind == 67) { // SHIELD CELL — a damage cell of the rim
// (Galen: BLUE, and shaped as the form that EMERGES BETWEEN pentagons —
// the slim diamond of pentagon negative space, not another pentagon.
// Same art in design preview and battle: one component, one look.)
let rr = fract(e.w) * 2.0;
let lp = py_rot(uv - e.xy, e.z);
// between-pentagon rhombus: tall slim diamond (|x|/w + |y|/h = 1)
let d6 = (abs(lp.x) / (rr * 0.62) + abs(lp.y) / (rr * 1.25)) - 1.0;
let body6 = smoothstep(0.10, -0.10, d6);
var cellc = vec3f(0.30, 0.52, 0.95) * 0.55; // shield BLUE
let sheen = smoothstep(0.35, 0.05, abs(lp.x - lp.y * 0.5) / max(rr, 1e-5));
cellc += vec3f(0.18, 0.30, 0.55) * sheen;
if (kind == 67) { cellc = vec3f(1.5, 0.22, 0.10) * (0.8 + 0.4 * sin(t * 18.0)); } // LAUNCHED MINE — burning red, strobing
col = mix(col, cellc, body6 * 0.9);
col += select(vec3f(0.45, 0.7, 1.0), vec3f(1.8, 0.5, 0.2), kind == 67) * exp(-abs(d6) * 34.0) * select(0.5, 0.9, kind == 67) * (0.8 + 0.2 * sin(t * 2.6 + e.z * 3.0));
continue;
}
if (kind == 59) { // RANGE DASH — radius in fract·2
let rr = fract(e.w) * 2.0;
let p2 = uv - e.xy;
let r2 = length(p2);
var da = atan2(p2.y, p2.x) - e.z;
da = atan2(sin(da), cos(da));
let m = smoothstep(0.075, 0.05, abs(da)) * smoothstep(0.0055, 0.0018, abs(r2 - rr));
col += vec3f(1.0, 0.45, 0.60) * m * (0.55 + 0.2 * sin(t * 2.0));
continue;
}
if (kind == 65) { // SHIELD FIELD (Galen: "blue reflective
// lighting animation, super high fidelity") — a fresnel energy shell:
// crisp rim + soft falloff, two counter-rotating specular glints sweeping
// the rim like light on curved glass, flowing bands along the shell,
// inner glow hugging the rim. e.z = charge 0..1 (a full field burns
// bright, a drained one gutters), fract·2 = radius (uv) — the emitter's
// convention in flight.part.js. All layered light, no texture.
let rr = fract(e.w) * 2.0;
let chg = clamp(e.z, 0.0, 1.0);
let p2 = uv - e.xy;
let r2 = length(p2);
let aa = atan2(p2.y, p2.x);
let dr = r2 - rr;
let rimc = smoothstep(0.006, 0.0015, abs(dr));
let fres = exp(-abs(dr) * 90.0);
let inner = select(0.0, exp(dr * 30.0), dr < 0.0);
let g1 = pow(max(0.0, cos(aa - t * 1.3)), 24.0);
let g2 = pow(max(0.0, cos(aa + t * 0.9 + 2.1)), 40.0);
let bands = 0.5 + 0.5 * sin(aa * 22.0 - t * 4.0);
var scol = vec3f(0.30, 0.62, 1.0) * (rimc * (0.5 + 0.5 * bands) + fres * 0.35);
scol += vec3f(0.75, 0.92, 1.0) * (g1 + g2) * (rimc + fres * 0.5);
scol += vec3f(0.18, 0.42, 0.95) * inner * 0.5;
col += scol * (0.35 + 0.65 * chg);
continue;
}
if (kind == 60) { // GHOST — nanites ASSEMBLING the shape:
// lattice filaments condensing in the outline, sparkles on the front
let g = smoothstep(0.004, -0.004, d);
let lpg = py_rot(uv - e.xy, e.z) / max(R, 1e-5);
let wg = qz_web(qz_field(lpg * 3.6, t * 0.5), 8.0);
col = mix(col, vec3f(0.35, 0.75, 1.0), g * (0.10 + 0.10 * wg));
col += vec3f(0.45, 0.85, 1.0) * exp(-abs(d) * 220.0) * (0.35 + 0.10 * sin(t * 3.0));
col += vec3f(0.85, 1.0, 1.0) * qz_nanite(lpg, 7.0, t * 1.6) * g * 0.55;
continue;
}
// PART TILES: code = part + 10·orientation, +100 selected, +200 CORE/HELM.
// kind 50 is TACTICS (part 10, non-orientable) — packed part+10·o collides
// with BLANK@o1 for it, so it rides the spare code (catalogue tileCode).
var part = kind % 10;
var ori = (kind / 10) % 10;
if (kind == 50) { part = 10; ori = 0; }
// PERF CULL (Galen: "getting slow in design mode"): shipPent runs PER PIXEL
// PER TILE. A pixel beyond ~1.85R gets nothing from it, so skip there —
// most pixels are far from most tiles, so this is the big win. (The merged
// BODY was already drawn in pass A; this pass lays machinery + facing on it.)
let pcen0 = uv - e.xy;
if (dot(pcen0, pcen0) > R * R * 3.4) { continue; }
// ICONRY mod byte (fract of code): gun tiles carry m·64 + gy·16 + gd·4 + gp
// (+ bit 1 = coarse damaged flag); EVERY OTHER tile's byte is its DAMAGE
// (1−hp/max) — brokenness rendering reads it. flags 8 = FOE (enemy hull).
let modB = i32(round(fract(e.w) * 256.0));
var mTier = 0;
var mgy = 0;
var mgd = 0;
var mgp = 0;
var dmgT = f32(modB) / 255.0;
if (part == 3 || part == 9) {
mTier = (modB / 64) % 4;
mgy = (modB / 16) % 4; // Y range levels
mgd = (modB / 4) % 4; // D damage levels
mgp = modB % 2; // P projectile (0/1)
dmgT = f32((modB / 2) % 2) * 0.6; // gun damage: 1 coarse bit
}
let isFoe = flags >= 8;
let isCore = !isFoe && (flags / 2) % 2 == 1;
let isSel = !isFoe && flags % 2 == 1;
let isAct = !isFoe && flags >= 4; // actuator firing (gyro glow)
// ONE SOURCE: the same shipPent the icon + sidebar use draws the tile —
// merged mode: starts from `col` (the merged hull under it), adds only
// this tile's own machinery/facing/selection light.
let faDir = 1.5707963 + (f32(ori) + 0.5) * 1.2566371; // facing edge normal (local)
let sp = shipPent(uv - e.xy, part, R, e.z, faDir, mgy, mgd, mgp, mTier, isCore, isSel, isAct, select(0.0, 0.6, isSel), t, true, col, dmgT, isFoe);
col = mix(col, sp.rgb, sp.a);
}
// (palette shelf/cards/delete are SEATED INTO SOLVER SLOTS now — drawn as
// kind 345/346/347 entities at the 'palette' panel's slot rects, Galen Aug 11)
// pointer glint
let gp = vec2f(uni(8), uni(9));
if (uni(10) > 0.5) {
let gd = length(uv - gp);
col += vec3f(0.7, 0.88, 1.0) * (exp(-gd * gd * 1400.0) * 0.7 + exp(-abs(gd - 0.03) * 240.0) * 0.4);
}
let fl = uni(11);
if (fl > 0.01) {
var fc2 = vec3f(1.0, 0.85, 0.45);
if (uni(12) > 1.5) { fc2 = vec3f(0.75, 0.85, 1.0); }
if (uni(12) > 2.5) { fc2 = vec3f(1.0, 0.98, 0.92); }
col += fc2 * fl * fl * 0.22 * (1.0 - 0.5 * dot(uv, uv));
}
col *= 1.0 - 0.30 * dot(uv, uv);
col = col / (col + vec3f(1.0));
return vec4f(pow(col, vec3f(0.9)), 1.0);
}
visual · pentarch
// inert stub — stray registration from a mis-named deploy; no field binds it.
fn visual_pentarch(_uv: vec2f, _p: f32, _c: vec4f, _t: f32, _a: vec4f, _b: vec4f) -> vec4f { return vec4f(0.0); }— STEP HOOKS (JAVASCRIPT) —
hook · zz-opus-probe
probe
try{}catch(e){}hook · pt-lib
ENG + catalogue on globalThis.__PT — build-once library (rev 9411baa2bbc0)
// pt-lib — ENG + module catalogue, built ONCE per worker (rev 9411baa2bbc0).
// Functions live on globalThis, NEVER in sim.worldData (worker postMessage clone).
if (globalThis.__PT_REV !== '9411baa2bbc0') {
globalThis.__PT = (() => {
const ENG = (() => {
// penta-core — the pentagon-hull geometry PENTARCH stands on. Pure math, no IO.
//
// A ship is a tree of regular pentagons (side 1) attached edge-to-edge, but the
// GEOMETRY is not a tree: pentagons cannot tile the plane (interior angle 108°),
// so chains curve, curl back into RE-TOUCH contacts, and enclose 36° rhombic
// VOIDS — all three are gameplay (curved hulls, adjacency mods, diamond slots).
//
// Tile pose: {cx, cy, th}. Vertex k at angle th + 90° + k·72° (radius R);
// edge k spans vertices k..k+1, outward normal at th + 90° + (k+.5)·72°,
// midpoint at apothem a. Attaching to a parent's edge e puts the child at
// 2a along that normal, rotated so the CHILD'S EDGE 0 is the shared edge.
const SIDE = 1
const APOTHEM = 1 / (2 * Math.tan(Math.PI / 5)) // 0.68819…
const CIRCUM = 1 / (2 * Math.sin(Math.PI / 5)) // 0.85065…
const STEP = (2 * Math.PI) / 5
function edgeNormalAngle(tile, e) { return tile.th + Math.PI / 2 + (e + 0.5) * STEP }
function edgeMidpoint(tile, e) {
const n = edgeNormalAngle(tile, e)
return { x: tile.cx + APOTHEM * Math.cos(n), y: tile.cy + APOTHEM * Math.sin(n) }
}
function vertices(tile) {
const out = []
for (let k = 0; k < 5; k++) {
const a = tile.th + Math.PI / 2 + k * STEP
out.push({ x: tile.cx + CIRCUM * Math.cos(a), y: tile.cy + CIRCUM * Math.sin(a) })
}
return out
}
/** the pose a child takes when attached across the parent's edge e —
* the child's edge 0 becomes the shared edge (its normal faces the parent) */
function attachPose(parent, e, ce = 0) {
const n = edgeNormalAngle(parent, e)
return {
cx: parent.cx + 2 * APOTHEM * Math.cos(n),
cy: parent.cy + 2 * APOTHEM * Math.sin(n),
th: n + Math.PI / 2 - Math.PI / 5 - ce * STEP, // ce = child's mating edge (0 = canonical; matches the designer's generalized attach)
}
}
/** SAT polygon overlap for two pentagons — the GHOST VALIDITY oracle.
* Flush edge-sharing (distance exactly 2a) is TOUCHING, not overlap: we shrink
* each pentagon a hair (EPS_SHRINK) so legal adjacency never reads as illegal. */
const EPS_SHRINK = 1e-4
function axes(vs) {
const out = []
for (let i = 0; i < vs.length; i++) {
const a = vs[i], b = vs[(i + 1) % vs.length]
const nx = -(b.y - a.y), ny = b.x - a.x
const L = Math.hypot(nx, ny)
out.push({ x: nx / L, y: ny / L })
}
return out
}
function shrunk(tile) {
const vs = vertices(tile)
return vs.map(v => ({ x: v.x + (tile.cx - v.x) * EPS_SHRINK, y: v.y + (tile.cy - v.y) * EPS_SHRINK }))
}
function overlaps(t1, t2) {
if (Math.hypot(t1.cx - t2.cx, t1.cy - t2.cy) > 2 * CIRCUM) return false
const v1 = shrunk(t1), v2 = shrunk(t2)
for (const ax of [...axes(v1), ...axes(v2)]) {
let min1 = Infinity, max1 = -Infinity, min2 = Infinity, max2 = -Infinity
for (const v of v1) { const p = v.x * ax.x + v.y * ax.y; if (p < min1) min1 = p; if (p > max1) max1 = p }
for (const v of v2) { const p = v.x * ax.x + v.y * ax.y; if (p < min2) min2 = p; if (p > max2) max2 = p }
if (max1 < min2 || max2 < min1) return false
}
return true
}
/** Build tile poses from a design tree. Design: [{parent, edge, part}] — tile 0
* is the base (pose 0,0,0); every later entry attaches to an EXISTING tile.
* Returns { tiles, rejected } — a placement whose ghost would overlap ANY
* existing tile is rejected (Galen: "if ghost would overlap … no ghost"). */
function layout(design) {
const tiles = [{ cx: 0, cy: 0, th: 0, part: design[0]?.part ?? 'hull', parent: -1, edge: -1 }]
const rejected = []
for (let i = 1; i < design.length; i++) {
const d = design[i]
const parent = tiles[d.parent]
if (!parent) { rejected.push({ i, why: 'no parent' }); continue }
const pose = attachPose(parent, d.edge, d.ce || 0)
let bad = false
for (const t of tiles) { if (overlaps(pose, t)) { bad = true; break } }
if (bad) { rejected.push({ i, why: 'overlap' }); continue }
tiles.push({ ...pose, part: d.part ?? 'hull', parent: d.parent, edge: d.edge })
}
return { tiles, rejected }
}
/** All edge contacts — parent links AND re-touch (a chain curled back flush).
* Two edges are in contact when their midpoints coincide. */
function contacts(tiles, eps = 1e-6) {
const out = []
for (let i = 0; i < tiles.length; i++) for (let j = i + 1; j < tiles.length; j++) {
if (Math.hypot(tiles[i].cx - tiles[j].cx, tiles[i].cy - tiles[j].cy) > 2 * APOTHEM + 0.01) continue
for (let ei = 0; ei < 5; ei++) for (let ej = 0; ej < 5; ej++) {
const mi = edgeMidpoint(tiles[i], ei), mj = edgeMidpoint(tiles[j], ej)
if (Math.hypot(mi.x - mj.x, mi.y - mj.y) < Math.max(eps, 1e-3)) {
out.push({ i, j, ei, ej, retouch: !(tiles[j].parent === i && tiles[j].edge === ei) && !(tiles[i].parent === j && tiles[i].edge === ej) })
}
}
}
return out
}
/** Free edges: not in any contact. Each carries its ghost pose + whether the
* ghost is LEGAL (the designer shows a ghost) or blocked (maybe a void). */
function freeEdges(tiles) {
const used = new Set()
for (const c of contacts(tiles)) { used.add(c.i + ':' + c.ei); used.add(c.j + ':' + c.ej) }
const out = []
for (let i = 0; i < tiles.length; i++) for (let e = 0; e < 5; e++) {
if (used.has(i + ':' + e)) continue
const ghost = attachPose(tiles[i], e)
let legal = true
for (const t of tiles) { if (overlaps(ghost, t)) { legal = false; break } }
out.push({ i, e, ghost, legal })
}
return out
}
/** VOIDS — the diamonds. Pentagon frustration is ANGULAR: where tile corners
* meet at one point, each contributes its 108° interior angle. Three tiles
* cover 324°, leaving a 36° wedge NOTHING can ever fill (a pentagon needs
* 108°). Those unfillable pockets are the special build slots. Returns
* [{x, y, gapDeg, tiles: [i…], dir}] — dir = unit vector into the gap. */
function voids(tiles, eps = 1e-3) {
// cluster coincident vertices across tiles
const pts = []
tiles.forEach((t, i) => vertices(t).forEach((v, k) => pts.push({ i, k, x: v.x, y: v.y })))
const used = new Set()
const out = []
for (let a = 0; a < pts.length; a++) {
if (used.has(a)) continue
const cluster = [pts[a]]; used.add(a)
for (let b = a + 1; b < pts.length; b++) {
if (used.has(b)) continue
if (Math.hypot(pts[a].x - pts[b].x, pts[a].y - pts[b].y) < eps) { cluster.push(pts[b]); used.add(b) }
}
const distinct = [...new Set(cluster.map(p => p.i))]
if (distinct.length < 2) continue
const gapDeg = 360 - 108 * cluster.length
if (gapDeg <= 1 || gapDeg >= 108) continue // ≥108° could hold a pentagon — not a sealed void
// the gap opens opposite the average direction of the covering tiles
let dx = 0, dy = 0
for (const p of cluster) { const t = tiles[p.i]; dx += t.cx - p.x; dy += t.cy - p.y }
const L = Math.hypot(dx, dy) || 1
const dir = { x: -dx / L, y: -dy / L }
out.push({ x: pts[a].x + dir.x * 0.22, y: pts[a].y + dir.y * 0.22, gapDeg, tiles: distinct, dir })
}
return out
}
// penta-holes — the negative-space SHAPE GRAMMAR. Enclosed holes in a hull are
// extracted as boundary loops and classified: DIAMOND (small rhomb), MOON
// (two-horned crescent), STAR (5-spiked pentagram hole — the super-weapon
// shape), BAY (large open interior, ring hangars). The rarer the shape, the
// bigger the unlock — hardness is inherent in pentagon frustration.
const Q = (v) => Math.round(v * 2000) / 2000 // vertex quantization key
const key = (p) => Q(p.x) + ',' + Q(p.y)
/** All enclosed holes: walk the free-edge segments (material on the LEFT by
* tile winding); loops with NEGATIVE signed area enclose a hole. */
function holes(tiles) {
const segs = []
for (const f of freeEdges(tiles)) {
const vs = vertices(tiles[f.i])
const a = vs[f.e], b = vs[(f.e + 1) % 5]
segs.push({ a, b, used: false })
}
const byStart = new Map()
for (const s of segs) { const k = key(s.a); if (!byStart.has(k)) byStart.set(k, []); byStart.get(k).push(s) }
const out = []
for (const s0 of segs) {
if (s0.used) continue
const loop = []
let cur = s0
for (let guard = 0; guard < segs.length + 2; guard++) {
cur.used = true
loop.push(cur)
const nexts = (byStart.get(key(cur.b)) || []).filter(s => !s.used)
if (!nexts.length) break
if (nexts.length === 1) { cur = nexts[0]; continue }
// pinch vertex: take the sharpest right turn (keeps material on the left)
const inD = Math.atan2(cur.b.y - cur.a.y, cur.b.x - cur.a.x)
let best = null, bestTurn = Infinity
for (const n of nexts) {
const outD = Math.atan2(n.b.y - n.a.y, n.b.x - n.a.x)
let turn = (inD - outD + Math.PI * 3) % (2 * Math.PI) // right-turn magnitude
if (turn < bestTurn) { bestTurn = turn; best = n }
}
cur = best
}
if (loop.length < 3) continue
if (key(loop[loop.length - 1].b) !== key(loop[0].a)) continue // open walk — outer notch, not a loop
const poly = loop.map(s => s.a)
let A2 = 0
for (let i = 0; i < poly.length; i++) { const p = poly[i], q = poly[(i + 1) % poly.length]; A2 += p.x * q.y - q.x * p.y }
if (A2 / 2 >= -1e-6) continue // positive = the outer boundary
out.push(classify(poly.slice().reverse())) // reverse → CCW hole polygon
}
return out
}
/** shape metrics + verdict for one hole polygon (CCW) */
function classify(poly) {
const n = poly.length
let A2 = 0, cx = 0, cy = 0
for (let i = 0; i < n; i++) { const p = poly[i], q = poly[(i + 1) % n]; A2 += p.x * q.y - q.x * p.y; cx += p.x; cy += p.y }
const area = Math.abs(A2 / 2); cx /= n; cy /= n
// interior angles → spikes (sharp convex tips of the hole: star points, moon horns)
let spikes = 0, reflex = 0
for (let i = 0; i < n; i++) {
const p0 = poly[(i + n - 1) % n], p1 = poly[i], p2 = poly[(i + 1) % n]
const a1 = Math.atan2(p0.y - p1.y, p0.x - p1.x), a2 = Math.atan2(p2.y - p1.y, p2.x - p1.x)
let int = (a1 - a2 + Math.PI * 4) % (2 * Math.PI) // CCW interior angle
const deg = int * 180 / Math.PI
if (deg < 100) spikes++
if (deg > 185) reflex++
}
// elongation via bounding radii
let rMax = 0, rMin = Infinity
for (const p of poly) { const r = Math.hypot(p.x - cx, p.y - cy); if (r > rMax) rMax = r; if (r < rMin) rMin = r }
// mirror of penta-holes.mjs (the tested source): every sealed shape has an
// ability — unknown = CELL (battery), round chamber = CIRCLE (shield),
// slivers = gap, bay only when it looks like one.
let shape = area < 0.3 ? 'gap' : 'cell'
if (spikes >= 4 && reflex >= 4) shape = 'star'
else if (spikes === 2 && reflex >= 1) shape = 'moon'
else if (area < 0.75 && n <= 6) shape = 'diamond'
else if (area >= 2.0 && spikes === 0 && reflex === 0) shape = 'circle'
else if (area >= 0.9 && spikes <= 3) shape = 'bay'
return { shape, area: +area.toFixed(3), verts: n, spikes, reflex, cx, cy, poly, r: +rMax.toFixed(4) }
}
// parts — the PENTARCH part table (the single source of truth for what a tile
// IS: cost in ⬡, battle durability, design-stat contribution, render colour and
// palette placement). Ported verbatim from the v9 shipyard (backlog/parts/
// v9-parts.json): COST/NAME/STAT from the hook, HPB from the battle stub, py_col
// from the visual. NO imports — build.mjs inlines this whole file into PRELUDE by
// stripping `export`, so `PARTS`/`statOf`/`PALETTE`/`CATEGORIES` land in scope.
//
// Part codes (0..5) are the on-wire tile ids used everywhere (design trees,
// entity codes, palette slots). 0 is the BLANK/unassigned tile.
//
// design-STAT vector = [mass, hp, dps, thrust, energy] (energy: + gen / − use)
// hp (top level) = battle durability (a tile's combat hit points, v9 HPB)
// cost = ⬡ paid to spawn a unit carrying this tile
// color = [r,g,b] hull tint, matches shader py_col
const PARTS = [
// 0 — BLANK (unassigned slot; free, weak, no role)
{ code: 0, name: 'BLANK', category: 'BLANK', cost: 0, hp: 6,
color: [0.30, 0.36, 0.46], stat: { mass: 0.5, hp: 4, dps: 0, thrust: 0, energy: 0 } },
// 1 — HULL (the connective structure; cheap, light)
{ code: 1, name: 'HULL', category: 'HULL', cost: 10, hp: 14,
color: [0.36, 0.50, 0.65], stat: { mass: 1, hp: 10, dps: 0, thrust: 0, energy: 0 } },
// 2 — ARMOR (heavy WALL — ×2 HP + a shot-magnet: it attracts fire and soaks
// it before the hull, per Galen). magnet handled in nearestEnemyTile.
{ code: 2, name: 'ARMOR', category: 'ARMOR', cost: 18, hp: 80,
color: [0.54, 0.58, 0.65], stat: { mass: 4, hp: 60, dps: 0, thrust: 0, energy: 0 } },
// 3 — GUN (fires along its edge-normal arc; draws power)
{ code: 3, name: 'GUN', category: 'GUNS', cost: 30, hp: 12,
color: [1.00, 0.48, 0.42], stat: { mass: 1.5, hp: 8, dps: 6, thrust: 0, energy: -2 } },
// 4 — ENGINE (thrust; draws power)
{ code: 4, name: 'ENGINE', category: 'DRIVE', cost: 22, hp: 12,
color: [0.48, 0.86, 1.00], stat: { mass: 1, hp: 8, dps: 0, thrust: 4, energy: -1 } },
// 5 — GEN (power plant; sustains guns/engines)
{ code: 5, name: 'GEN', category: 'POWER', cost: 26, hp: 10,
color: [0.62, 1.00, 0.54], stat: { mass: 1, hp: 6, dps: 0, thrust: 0, energy: 4 } },
// 6..10 — DRIVE/POWER/GUN variants. These were MISSING here (only the flight
// catalogue had them), so their tiles fell through to BLANK durability (6) in
// combat — a real fragility bug. Durability (top-level hp) now matches the
// catalogue so JET/GYRO/BATTERY/FIXED/TACTICS take their intended hits.
{ code: 6, name: 'JET', category: 'DRIVE', cost: 14, hp: 10,
color: [0.62, 0.92, 1.00], stat: { mass: 0.7, hp: 6, dps: 0, thrust: 1.5, energy: -0.5 } },
{ code: 7, name: 'GYRO', category: 'DRIVE', cost: 16, hp: 10,
color: [0.80, 0.78, 1.00], stat: { mass: 1, hp: 6, dps: 0, thrust: 0, energy: -1 } },
{ code: 8, name: 'BATTERY', category: 'POWER', cost: 20, hp: 10,
color: [0.95, 1.00, 0.55], stat: { mass: 1.2, hp: 6, dps: 0, thrust: 0, energy: 0 } },
{ code: 9, name: 'FIXED', category: 'GUNS', cost: 18, hp: 12,
color: [1.00, 0.66, 0.42], stat: { mass: 1.2, hp: 8, dps: 4, thrust: 0, energy: -1.5 } },
{ code: 10, name: 'TACTICS', category: 'DRIVE', cost: 24, hp: 10,
color: [1.00, 0.55, 0.90], stat: { mass: 0.8, hp: 6, dps: 0, thrust: 0, energy: -1 } },
]
// Palette placement: designer slots 0..4 assign part codes 1..5 (slot s → part
// s+1, exactly as the v9 palette strip). BLANK never sits in the palette.
const PALETTE = [1, 2, 3, 4, 5]
// Category tab order, aligned to PALETTE (what each palette slot's tab reads).
const CATEGORIES = ['HULL', 'ARMOR', 'GUNS', 'DRIVE', 'POWER']
/** Resolve a part (code 0..5, a name like 'GUN'/'gun', or a design entry
* {part}) to its PARTS row. Unknown → BLANK (code 0), never throws. */
function partOf(part) {
if (part && typeof part === 'object') part = part.part
if (typeof part === 'string') {
const up = part.toUpperCase()
const byName = PARTS.find(p => p.name === up)
if (byName) return byName
part = Number(part)
}
const code = part | 0
return PARTS[code] || PARTS[0]
}
/** The design-stat contribution of a part, plus its cost and battle durability.
* → {mass, hp, dps, thrust, energy, durability, cost, name, category, code}.
* `hp` is the DESIGN stat (what the shipyard sums); `durability` is the BATTLE
* hit points of the tile. Accepts a code, a name, or a design entry. */
function statOf(part) {
const p = partOf(part)
return {
mass: p.stat.mass, hp: p.stat.hp, dps: p.stat.dps,
thrust: p.stat.thrust, energy: p.stat.energy,
durability: p.hp, cost: p.cost,
name: p.name, category: p.category, code: p.code,
}
}
// hull — the bridge between the DESIGNER and the BATTLE. A berth DESIGN is a
// tree of pentagon tiles (parent/edge/part); a HULL is that design turned into a
// live fighting UNIT: geometry laid out, per-part stats summed into ship stats
// (hp/mass/thrust/energy/dps + derived speed/turn), the contact-graph route from
// tile 0 precomputed (for combat SHED — dead tiles orphan the tiles beyond them),
// and the negative-space SHAPE PAYOUTS baked in (diamond +HP, moon +PWR, bay =
// hangar, intact star = super-weapon).
//
// Pure glue: it composes penta-core (layout/contacts/holes) + parts (statOf). No
// new geometry, no new part data. build.mjs inlines this into PRELUDE by stripping
// the `import`/`export` keywords — the names below (layout, contacts, holes,
// statOf) are already in PRELUDE scope there, so the strip leaves valid code.
// Derived-motion constants: speed ∝ thrust/mass; turn ∝ thrust/(mass·radius)
// where a bigger hull (more tiles) has more rotational inertia, so it turns
// slower even at the same thrust/mass. Tuned for legible RTS handling, not physics.
const SPEED_K = 0.6
const TURN_K = 1.2
/** routeGraph(tiles) — the undirected contact graph: adj[i] = the tiles sharing
* an edge with tile i (parent links AND re-touch contacts, since a curled hull's
* loop is a real structural bond). This is what SHED walks: dead tiles are
* removed and anything no longer reachable from tile 0 shears off. */
function routeGraph(tiles) {
const adj = tiles.map(() => [])
for (const c of contacts(tiles)) {
if (!adj[c.i].includes(c.j)) adj[c.i].push(c.j)
if (!adj[c.j].includes(c.i)) adj[c.j].push(c.i)
}
return adj
}
/** reachableFrom(adj, dead, root) — BFS over the contact graph skipping `dead`
* tiles. Returns the Set of tiles still structurally connected to `root`
* (tile 0). If the root itself is dead → empty Set (the unit is destroyed:
* tile-0 death kills the ship). A loop (curled hull) survives a single cut
* because the route reaches the far side the other way around. */
function reachableFrom(adj, dead = new Set(), root = 0) {
if (!adj.length || dead.has(root)) return new Set()
const seen = new Set([root])
const q = [root]
while (q.length) {
const u = q.shift()
for (const v of adj[u] || []) if (!dead.has(v) && !seen.has(v)) { seen.add(v); q.push(v) }
}
return seen
}
/** aliveTiles(unit) — the current live set: tiles with hp > 0 AND still reachable
* from tile 0 over the contact graph. Tiles at hp≤0 are dead; tiles orphaned by
* those deaths shed (they are not in the returned set even if their own hp>0).
* Empty Set ⇒ the unit is dead. Battle calls this after applying beam damage. */
function aliveTiles(unit) {
const dead = new Set()
unit.tileHp.forEach((h, i) => { if (h <= 0) dead.add(i) })
return reachableFrom(unit.adj, dead, 0)
}
/** shapePayout(shapeNames) — the topology tech-tree. Sealing a hole of a given
* shape unlocks a bonus (the proven ladder from STRUCTURE.md):
* diamond → +15% HP (multiplicative, stacks per diamond) — or REFLECT (+8%
* HP, 20% less impact/collision damage) if the player picked it
* moon → +3 PWR — or CELL (+12 battery capacity) if picked
* bay → a hangar (Phase-2 carries a sub-unit) — or MEND (slow passive
* hull regen) if picked
* star → intact pentagram hole = the super-weapon is armed (one option)
* SPECIALS is the catalogue the designer's click-menu offers per shape kind;
* `choices` (hole key → chosen special id) lets the player pick, per sealed
* shape instance, which of that kind's specials to fill it with — default
* (no choice made) keeps the original automatic behavior exactly.
* Pure over the list of holes; makeUnit derives that list from geometry. */
const SPECIALS = {
diamond: [
// AUTO = LASER (Galen: "diamond is a direct LASER forward! auto fire")
{ id: 'laser', name: 'LASER', desc: 'a forward auto-firing laser emitter' },
{ id: 'plate', name: 'PLATE', desc: '+15% hull HP' },
{ id: 'reflect', name: 'REFLECT', desc: '+8% HP, 20% less impact damage' },
],
moon: [
// AUTO = WAVE BLASTER (Galen): an expanding shock-arc rolling outward,
// striking everything it sweeps once
{ id: 'wave', name: 'WAVE', desc: 'an expanding shock-arc — hits all it sweeps' },
{ id: 'gen', name: 'GEN', desc: '+3 power' },
{ id: 'cell', name: 'CELL', desc: '+12 battery capacity' },
],
circle: [
{ id: 'shield', name: 'SHIELD', desc: 'projects a regenerating shield field' },
],
bay: [
// AUTO = MISSILE BAY (Galen): volleys 3 tracking missiles in a wide arc
{ id: 'missiles', name: 'MISSILE BAY', desc: 'volleys 3 tracking missiles in a wide arc' },
{ id: 'hangar', name: 'HANGAR', desc: 'a hangar berth (future: carries a sub-unit)' },
{ id: 'mend', name: 'MEND', desc: 'slow passive hull regen' },
],
star: [
{ id: 'lance', name: 'LANCE', desc: 'arms the super-weapon' },
],
}
function holeKey(h) { return Math.round(h.cx * 1000) + ',' + Math.round(h.cy * 1000) }
function shapePayout(holeList) {
// CHAMBERS ARE AUTOMATIC (Galen: "deprecated dropdown menu option… should be
// none of that code") — no per-chamber picks. Weapon chambers pay via their
// EMITTERS (the lasers list below); only star/cell carry a scalar payout.
let hpMul = 1, powerBonus = 0, hangars = 0, star = false, battery = 0, regenRate = 0, reflective = false
for (const h of holeList) {
if (h.shape === 'star') star = true
else if (h.shape === 'cell') battery += 10 // unknown chamber = a battery
}
return { hpMul, powerBonus, hangars, star, battery, regenRate, reflective }
}
/** makeUnit(design, opts?) — a berth DESIGN → a spawnable fighting UNIT.
* design: [{parent, edge, part}] (tile 0 is the base). opts: {seat, owner, x, y,
* a} initial placement/ownership carried by battle.
*
* Returns:
* tiles — laid-out poses [{cx,cy,th,part,parent,edge}] (rejected drops)
* adj — the contact route graph (routeGraph)
* tileHp — per-tile current combat hit points (starts = tileMaxHp)
* tileMaxHp — per-tile max (durability × diamond hpMul)
* shapes — the sealed hole shapes ['bay','diamond',…]
* hangars — bay count · hasStar — super-weapon armed
* stats — {hp, mass, thrust, dps, energyGen, energyUse, power, brownout,
* speed, turn, radius, cost}
* cost — ⬡ to spawn · seat/owner/x/y/a — battle placement state */
function makeUnit(design, opts = {}) {
const { tiles, rejected } = layout(design || [])
const adj = routeGraph(tiles)
const holeList = holes(tiles)
const shapes = holeList.map((h) => h.shape)
const pay = shapePayout(holeList, opts.shapeChoices)
// SHIELD FIELDS (mirror of hull.mjs — the tested source): moon/circle holes
// project a regenerating damage-blocking disc 2.2 out along their radial.
// SHAPE EMITTERS (Galen): a diamond fires a laser ALONG ITS OWN SLIT AXIS
// (the direction of the diamond, outward); a moon fires an expanding WAVE
// along its radial. Directions computed from geometry HERE, hull-rotated live.
const lasers = []
for (const hh of holeList) {
if (hh.shape === 'diamond') {
let dx = hh.cx, dy = hh.cy
const poly = hh.poly || []
let bd = 0
for (let i = 0; i < poly.length; i++) for (let j = i + 1; j < poly.length; j++) {
const dd = Math.hypot(poly[i].x - poly[j].x, poly[i].y - poly[j].y)
if (dd > bd) { bd = dd; dx = poly[j].x - poly[i].x; dy = poly[j].y - poly[i].y }
}
const L = Math.hypot(dx, dy) || 1
dx /= L; dy /= L
if (dx * hh.cx + dy * hh.cy < 0) { dx = -dx; dy = -dy } // point OUTWARD along the slit
lasers.push({ cx: hh.cx, cy: hh.cy, dx, dy, sz: Math.max(0.5, hh.area || 1), r: hh.r || 0.5 })
}
if (hh.shape === 'moon') {
// the blast rolls out of the CRESCENT'S OPENING (Galen: "in direction of
// the moon curve"): the mouth = midpoint of the two HORN vertices
// (sharpest angles), direction = centroid → mouth.
let dx = hh.cx, dy = hh.cy
const pv = hh.poly || []
if (pv.length >= 3) {
const sharp = pv.map((p, i) => {
const p0 = pv[(i + pv.length - 1) % pv.length], p2 = pv[(i + 1) % pv.length]
const a1 = Math.atan2(p0.y - p.y, p0.x - p.x), a2 = Math.atan2(p2.y - p.y, p2.x - p.x)
let deg = ((a1 - a2 + Math.PI * 4) % (2 * Math.PI)) * 180 / Math.PI
return { p, deg }
}).sort((a, b) => a.deg - b.deg)
const mouth = { x: (sharp[0].p.x + sharp[1].p.x) / 2, y: (sharp[0].p.y + sharp[1].p.y) / 2 }
dx = mouth.x - hh.cx; dy = mouth.y - hh.cy
}
const L = Math.hypot(dx, dy) || 1
lasers.push({ cx: hh.cx, cy: hh.cy, dx: dx / L, dy: dy / L, wave: true, sz: Math.max(0.5, hh.area || 1), r: hh.r || 0.5 })
}
// BAY (Galen): a tracking-MISSILE bay — 3 missiles, wide arc, homing
if (hh.shape === 'bay') {
const L = Math.hypot(hh.cx, hh.cy) || 1
lasers.push({ cx: hh.cx, cy: hh.cy, dx: hh.cx / L, dy: hh.cy / L, msl: true, sz: Math.max(0.5, hh.area || 1), r: hh.r || 0.5 })
}
// STAR (Galen): an AUTO-FOCUS laser — locks the nearest target at LONG
// range, any direction. The pentagram is a targeting array.
if (hh.shape === 'star') lasers.push({ cx: hh.cx, cy: hh.cy, dx: 0, dy: -1, star: true, sz: Math.max(0.5, hh.area || 1), r: hh.r || 0.5 })
}
// CHAMBERS — every sealed shape's identity card (pos, size, axis) so battle
// can DRAW the art in the spaces exactly like the yard does (Galen).
const chambers = []
for (const hh of holeList) {
if (hh.shape === 'gap') continue
let ang = 0
if (hh.shape === 'diamond') {
const pv = hh.poly || []; let bd2 = 0
for (let i2 = 0; i2 < pv.length; i2++) for (let j2 = i2 + 1; j2 < pv.length; j2++) {
const dd2 = Math.hypot(pv[i2].x - pv[j2].x, pv[i2].y - pv[j2].y)
if (dd2 > bd2) { bd2 = dd2; ang = Math.atan2(pv[j2].y - pv[i2].y, pv[j2].x - pv[i2].x) }
}
if (Math.cos(ang) * hh.cx + Math.sin(ang) * hh.cy < 0) ang += Math.PI
} else if (hh.shape === 'moon') ang = Math.atan2(hh.cy, hh.cx)
chambers.push({ shape: hh.shape, cx: hh.cx, cy: hh.cy, r: hh.r || 0.5, area: +(hh.area || 1), ang })
}
const shields = []
for (const hh of holeList) {
if (hh.shape !== 'circle') continue // CIRCLES shield — automatic
const len = Math.hypot(hh.cx, hh.cy)
const dx = len > 0.1 ? hh.cx / len : 0, dy = len > 0.1 ? hh.cy / len : 1
shields.push({ cx: hh.cx + dx * 2.2, cy: hh.cy + dy * 2.2, r: 1.0 + (hh.area || 1) * 0.35, cap: 12 + (hh.area || 1) * 6 })
}
const tileHp = [], tileMaxHp = []
let mass = 0, thrust = 0, dps = 0, energyGen = 0, energyUse = 0, cost = 0
for (const t of tiles) {
const s = statOf(t.part)
const dur = Math.max(1, Math.round(s.durability * pay.hpMul))
tileMaxHp.push(dur); tileHp.push(dur)
mass += s.mass; thrust += s.thrust; dps += s.dps; cost += s.cost
if (s.energy > 0) energyGen += s.energy; else energyUse += -s.energy
}
const hp = tileMaxHp.reduce((a, b) => a + b, 0)
const power = energyGen - energyUse + pay.powerBonus
const radius = Math.sqrt(tiles.length) || 1
const speed = mass > 0 ? SPEED_K * thrust / mass : 0
const turn = mass > 0 ? TURN_K * thrust / (mass * radius) : 0
return {
tiles, adj, rejected,
tileHp, tileMaxHp,
shapes, hangars: pay.hangars, hasStar: pay.star, hpMul: pay.hpMul,
battery: pay.battery, regenRate: pay.regenRate, reflective: pay.reflective,
shields, lasers, chambers,
stats: {
hp, mass, thrust, dps, energyGen, energyUse, power,
brownout: power < 0, speed, turn, radius, cost,
},
cost,
seat: opts.seat ?? null,
owner: opts.owner ?? null,
x: opts.x ?? 0, y: opts.y ?? 0, a: opts.a ?? 0,
}
}
// mod-battle — the WAR SCENE, assembled from its mechanic slices. Four mechanic
// nodes co-own this one module (the clobber-serialized file, mirroring the
// mod-designer pattern): each declares its own `B.<name>` hook-fragment (a
// String.raw block, wrapped in its own `{ }` so locals never collide) and its
// own pure helpers for the unit test. `build.mjs` reads only `SRC` (the
// concatenated fragments, in Istrolid order) into the DISPATCH; the exported
// pure helpers are test-only (build never inlines them).
//
// B.econ bt-econ capture rings + income + spawn-from-berths
// B.move bt-move steer to cursor; GUN edge-normal arcs; brownout
// B.damage bt-damage beam→nearest enemy TILE; shed orphans; STAR lance ← THIS SLICE
// B.win bt-win hold all rings 30s / eliminate → PW.scene='debrief'
//
// SHARED BATTLE STATE (the contract these four agree on, on PW.bt or wd.__bt):
// bt.units : [ makeUnit()-shaped unit, … ] — the live fleet (econ spawns)
// bt.beams : [ {seat, ox, oy, dmg}, … ] — gun shots to resolve (move fires)
// bt.scale : world units per hull-unit (default 0.06) — tile→world mapping
// A unit is exactly what hull.makeUnit returns: {tiles, adj, tileHp, hasStar,
// x, y, a, seat, owner, …}. bt-damage NEVER re-lays-out geometry; it walks the
// precomputed contact graph (unit.adj) for shed and recomputes holes() only to
// test whether the STAR hole survives. Geometry (layout/holes/…) is inlined into
// PRELUDE by build.mjs, so the fragment calls `holes` by name; the pure helpers
// below import it (test-only).
// Ordered fragment registry — each mechanic node adds exactly its own key.
const B = {}
// ── tuning (bt-damage owns these combat constants) ───────────────────────────
const BEAM_DMG = 12 // default per-shot damage a gun beam deals
const STAR_CHARGE_RATE = 0.15 // charge/sec while the star is armed (~6.7s)
const STAR_RADIUS = 0.35 // AoE radius of the lance (world units)
const STAR_DMG = 40 // damage the lance deals to every tile in radius
const DEFAULT_SCALE = 0.06 // hull-unit → world-unit (matches battle draw)
const ARMOR_MAGNET = 2.2 // world units an armor tile reads CLOSER (shot magnet)
// ── bt-econ tuning (capture rings + income + spawn) ──────────────────────────
const CAPTURE_RADIUS = 0.28 // world units: a unit within this of a ring holds it
const RING_RATE = 2.0 // ⬡/sec a SOLE-held ring pays its holder
const TRICKLE = 0.4 // ⬡/sec passive income every seat gets (never fully shut out)
// ─────────────────────────────────────────────────────────────────────────────
// bt-econ PURE HELPERS — the unit-tested brain of B.econ (capture rings drive
// income; income buys berth units, spawned at the seat's home dock). The hook
// fragment mirrors these with the inlined geometry (layout/contacts/holes/statOf
// land in PRELUDE); makeUnit lives in hull.mjs (NOT inlined) so the fragment
// re-implements it inline, exactly like B.move/B.damage mirror their helpers.
// ─────────────────────────────────────────────────────────────────────────────
/** ringHolder(ring, units, radius) — the seat SOLELY holding a ring: the one side
* with an alive unit inside `radius` and no enemy alive unit inside it. Zero seats
* or ≥2 seats present → null (empty / contested = neutral, no income). */
function ringHolder(ring, units, radius = CAPTURE_RADIUS) {
const seats = new Set()
for (const u of units) {
if (unitDead(u)) continue
if (Math.hypot((u.x || 0) - ring.x, (u.y || 0) - ring.y) <= radius) seats.add(enemyKey(u))
}
return seats.size === 1 ? [...seats][0] : null
}
/** tickIncome(bt, dt, opts) — advance the ⬡ ledger for one tick: refresh each
* ring's owner, pay every SOLE-held ring's holder RING_RATE·dt, and give every
* fielded seat (bt.seats) a TRICKLE·dt so a shut-out player can still field
* scouts. Mutates ring.owner + bt.income; returns bt.income. */
function tickIncome(bt, dt, opts = {}) {
const rate = opts.ringRate ?? RING_RATE, trickle = opts.trickle ?? TRICKLE, radius = opts.radius ?? CAPTURE_RADIUS
const inc = bt.income || (bt.income = {})
const step = Math.max(0, dt)
for (const s of bt.seats || []) inc[s] = (inc[s] || 0) + trickle * step
for (const ring of bt.rings || []) {
const h = ringHolder(ring, bt.units || [], radius)
ring.owner = h
if (h != null) inc[h] = (inc[h] || 0) + rate * step
}
return inc
}
/** unitCost(design) — the ⬡ to spawn a berth design (sum of its parts' costs). */
function unitCost(design) { return makeUnit(design).cost }
/** trySpawn(bt, seat, design, opts) — if `seat` can afford `design`, deduct its
* cost from bt.income, makeUnit it at the seat's home dock (bt.docks[seat]), push
* it onto bt.units, and return the unit; else null (unaffordable / empty design). */
function trySpawn(bt, seat, design, opts = {}) {
if (!design || !design.length) return null
const cost = makeUnit(design).cost
const inc = bt.income || (bt.income = {})
if ((inc[seat] || 0) < cost) return null
const dock = (bt.docks && bt.docks[seat]) || { x: 0, y: 0 }
const u = makeUnit(design, { seat, x: dock.x, y: dock.y, a: opts.a ?? 0 })
inc[seat] -= cost
;(bt.units || (bt.units = [])).push(u)
return u
}
// ─────────────────────────────────────────────────────────────────────────────
// B.econ — the HOOK FRAGMENT (runs FIRST each battle tick). Refreshes ring
// ownership + income, draws the rings (code 200+owner, a=hold pulse) and the
// income HUD, and turns a seat's latched spawn input into a new unit from its
// selected berth at its home dock. Reads/creates the shared battle state
// (PW.bt / wd.__bt). makeUnit is re-implemented inline (hull.mjs is not inlined).
// ─────────────────────────────────────────────────────────────────────────────
B.econ = String.raw`{
const bt = (typeof PW === 'object' && PW && PW.bt) || wd.__bt;
if (bt && Array.isArray(bt.rings)) {
const _dt = (typeof dt === 'number' && dt > 0) ? Math.min(dt, 1 / 30) : 1 / 30;
const _key = (u) => (u.seat != null ? u.seat : u.owner);
const _dead = (u) => {
const dead = new Set();
for (let i = 0; i < u.tileHp.length; i++) if (u.tileHp[i] <= 0) dead.add(i);
if (dead.has(0) || !u.adj || !u.adj.length) return true;
const seen = new Set([0]); const q = [0];
while (q.length) { const a = q.shift(); const nb = u.adj[a] || []; for (let k = 0; k < nb.length; k++) { const b = nb[k]; if (!dead.has(b) && !seen.has(b)) { seen.add(b); q.push(b); } } }
return seen.size === 0;
};
const US = Array.isArray(bt.units) ? bt.units : (bt.units = []);
const inc = bt.income || (bt.income = {});
// 1) ring ownership + income
const R = ${CAPTURE_RADIUS};
for (const s of (bt.seats || [])) inc[s] = (inc[s] || 0) + ${TRICKLE} * _dt;
for (let ri = 0; ri < bt.rings.length; ri++) {
const ring = bt.rings[ri];
const near = new Set();
for (let ui = 0; ui < US.length; ui++) { const u = US[ui]; if (_dead(u)) continue; if (Math.hypot((u.x || 0) - ring.x, (u.y || 0) - ring.y) <= R) near.add(_key(u)); }
ring.owner = (near.size === 1) ? [...near][0] : null;
ring.hold = (ring.owner != null) ? Math.min(1, (ring.hold || 0) + _dt) : 0;
if (ring.owner != null) inc[ring.owner] = (inc[ring.owner] || 0) + ${RING_RATE} * _dt;
pushEnt(ring.x, ring.y, ring.hold || 0, 200 + ((ring.owner == null ? 9 : (ring.owner | 0))));
}
// 2) spawn from a berth on the acting seat's latched spawn input
let SP = 0, BERTH = 0;
if (IN_ROOM) { const _pl = wd.players[MY_SEAT] || {}; SP = latch('spawn_' + MY_SEAT + '#' + (_pl.spawn_n | 0)); BERTH = (_pl.berth | 0); }
else { SP = latch('spawn#' + ((wd.spawn_n | 0))); BERTH = (wd.berth | 0); }
if (SP > 0) {
const seat = IN_ROOM ? MY_SEAT : 0;
const fleet = Array.isArray(wd.__fleet) ? wd.__fleet : [];
const design = fleet[BERTH] || fleet[0];
if (design && design.length && (inc[seat] || 0) >= 0) {
// inline makeUnit: layout → tiles, contacts → adj, holes → payout, statOf → hp/stats/cost
const lo = layout(design); const tiles = lo.tiles;
const adj = tiles.map(() => []);
for (const c of contacts(tiles)) { if (!adj[c.i].includes(c.j)) adj[c.i].push(c.j); if (!adj[c.j].includes(c.i)) adj[c.j].push(c.i); }
let hpMul = 1, powerBonus = 0, star = false;
const shs = holes(tiles); for (let h = 0; h < shs.length; h++) { const sp = shs[h].shape; if (sp === 'diamond') hpMul *= 1.15; else if (sp === 'moon') powerBonus += 3; else if (sp === 'star') star = true; }
const tileHp = []; let mass = 0, thrust = 0, dps = 0, eg = 0, eu = 0, cost = 0;
for (let i = 0; i < tiles.length; i++) { const s = statOf(tiles[i].part); const dur = Math.max(1, Math.round(s.durability * hpMul)); tileHp.push(dur); mass += s.mass; thrust += s.thrust; dps += s.dps; cost += s.cost; if (s.energy > 0) eg += s.energy; else eu += -s.energy; }
if ((inc[seat] || 0) >= cost) {
const power = eg - eu + powerBonus; const radius = Math.sqrt(tiles.length) || 1;
const dock = (bt.docks && bt.docks[seat]) || { x: 0, y: 0 };
US.push({ tiles, adj, tileHp, tileMaxHp: tileHp.slice(), hasStar: star, hpMul, stats: { hp: tileHp.reduce((a, b) => a + b, 0), mass, thrust, dps, energyGen: eg, energyUse: eu, power, brownout: power < 0, speed: mass > 0 ? 0.6 * thrust / mass : 0, turn: mass > 0 ? 1.2 * thrust / (mass * radius) : 0, radius, cost }, cost, seat, owner: null, x: dock.x, y: dock.y, a: 0 });
inc[seat] -= cost; sound('spawn');
}
}
}
// 3) income readout (chrome HUD, top strip) — one number per fielded seat
if (typeof hud === 'function') { const me = IN_ROOM ? MY_SEAT : 0; hud('⬡ ' + Math.floor(inc[me] || 0), 0.02, 0.9); }
}
}`
// ─────────────────────────────────────────────────────────────────────────────
// bt-damage PURE HELPERS — the unit-tested brain of B.damage. Same logic the
// hook fragment runs, callable off hull.makeUnit() units so the mechanic is
// proven without a render. The fragment mirrors these using the inlined geometry
// (it re-implements the tiny BFS inline because aliveTiles/reachableFrom live in
// hull.mjs, which build does NOT inline — only penta-core/penta-holes/parts).
// ─────────────────────────────────────────────────────────────────────────────
/** enemyKey(u) — the side a unit belongs to (seat first, else owner). Two units
* are enemies iff their keys differ. */
function enemyKey(u) { return u.seat != null ? u.seat : u.owner }
/** tileWorldPos(unit, i, scale) — tile i's centre in WORLD space: rotate the
* hull-local pose by the unit heading and translate to the unit position.
* scale = world units per hull-unit (battle's draw scale). */
function tileWorldPos(unit, i, scale = DEFAULT_SCALE) {
const t = unit.tiles[i]
const ca = Math.cos(unit.a || 0), sa = Math.sin(unit.a || 0)
return {
x: (unit.x || 0) + (t.cx * ca - t.cy * sa) * scale,
y: (unit.y || 0) + (t.cx * sa + t.cy * ca) * scale,
}
}
/** nearestEnemyTile(attacker, units, opts?) — the single nearest ALIVE tile on
* any ENEMY unit to the firing origin (default the attacker's own centre).
* opts: {origin:{x,y}, range, scale}. Returns {unit, tileIdx, x, y, dist} or
* null (no enemy tile in range). Friendly units and dead/orphaned tiles are
* never targeted — this is the "beam hits nearest enemy TILE" rule. */
function nearestEnemyTile(attacker, units, opts = {}) {
const scale = opts.scale ?? DEFAULT_SCALE
const range = opts.range ?? Infinity
const ox = opts.origin ? opts.origin.x : (attacker.x || 0)
const oy = opts.origin ? opts.origin.y : (attacker.y || 0)
const myKey = enemyKey(attacker)
// ARMOR MAGNET (Galen: "armor attracts any shots, soaks before hull") — an
// armor tile (part 2) reads as ARMOR_MAGNET units CLOSER, so fire prefers it
// and it soaks before the hull. Effective distance decides; true dist returned.
let best = null, bestEff = range
for (const u of units) {
if (u === attacker || enemyKey(u) === myKey) continue
const alive = aliveTiles(u)
for (const i of alive) {
const p = tileWorldPos(u, i, scale)
const d = Math.hypot(p.x - ox, p.y - oy)
const eff = d - ((u.tiles[i] && u.tiles[i].part === 2) ? ARMOR_MAGNET : 0)
if (eff < bestEff) { bestEff = eff; best = { unit: u, tileIdx: i, x: p.x, y: p.y, dist: d } }
}
}
return best
}
/** applyBeam(unit, tileIdx, dmg) — deal `dmg` to one tile's hp (clamped at 0).
* Returns true iff the tile was alive and is now dead (crossed to ≤0). Invalid
* index or an already-dead tile → false, never throws. */
function applyBeam(unit, tileIdx, dmg = BEAM_DMG) {
const hp = unit.tileHp
if (tileIdx < 0 || tileIdx >= hp.length || hp[tileIdx] <= 0) return false
// HULL BUFFER FIRST (Galen): the shared hull integrity soaks the hit; only
// the OVERFLOW (buffer spent) reaches the pentagon. Mirror of mod-battle.mjs.
if (unit.hullBuffer > 0) {
const soak = Math.min(dmg, unit.hullBuffer)
unit.hullBuffer -= soak
dmg -= soak
if (dmg <= 0) return false
}
hp[tileIdx] = Math.max(0, hp[tileIdx] - dmg)
return hp[tileIdx] <= 0
}
/** shedUnit(unit) — after damage, route-BFS from tile 0 over the contact graph
* and SHEAR the orphans: any tile still hp>0 but no longer reachable from tile 0
* is set to 0 (it has physically broken off). Returns the surviving alive Set.
* A ring reroutes around a single cut (survives); an open chain sheds everything
* downstream of the cut; tile-0 death empties the set (the unit is destroyed). */
function shedUnit(unit) {
const alive = aliveTiles(unit) // reachable-from-0, hp>0
for (let i = 0; i < unit.tileHp.length; i++) {
if (unit.tileHp[i] > 0 && !alive.has(i)) unit.tileHp[i] = 0 // orphan → shear
}
return alive
}
/** unitDead(unit) — no tile survives (tile-0 gone, or every tile at 0). */
function unitDead(unit) { return aliveTiles(unit).size === 0 }
/** starArmed(unit) — the super-weapon is ready to charge iff the hull was built
* with a STAR hole (hull.hasStar) AND that pentagram is STILL SEALED in the
* currently-alive tiles. Any tile on the star boundary dying re-opens the hole
* → disarmed. Recomputes holes() over just the live tiles (geometry inlined in
* the hook; imported here for the test). */
function starArmed(unit) {
if (!unit || !unit.hasStar) return false
const alive = aliveTiles(unit)
if (!alive.size) return false
const sub = [...alive].map((i) => unit.tiles[i])
return holes(sub).some((h) => h.shape === 'star')
}
/** chargeStar(unit, dt, rate?) — advance the lance charge while armed (caps at
* 1 = ready); a disarmed star bleeds its charge to 0. Returns the new charge. */
function chargeStar(unit, dt, rate = STAR_CHARGE_RATE) {
if (!starArmed(unit)) { unit.starCharge = 0; return 0 }
unit.starCharge = Math.min(1, (unit.starCharge || 0) + Math.max(0, dt) * rate)
return unit.starCharge
}
/** fireLance(attacker, units, opts?) — discharge a fully-charged, armed star as
* an AoE lance centred on opts.center (default the nearest enemy unit's centre).
* Every alive ENEMY tile within opts.radius takes opts.dmg, then each hit unit
* sheds. Resets the charge to 0. Returns the array of {unit, tileIdx} hits, or
* null when the star is not armed / not charged / has no target. */
function fireLance(attacker, units, opts = {}) {
if (!starArmed(attacker) || (attacker.starCharge || 0) < 1) return null
const scale = opts.scale ?? DEFAULT_SCALE
const radius = opts.radius ?? STAR_RADIUS
const dmg = opts.dmg ?? STAR_DMG
let cx, cy
if (opts.center) { cx = opts.center.x; cy = opts.center.y }
else {
let td = Infinity, tgt = null
for (const u of units) {
if (u === attacker || enemyKey(u) === enemyKey(attacker) || unitDead(u)) continue
const d = Math.hypot((u.x || 0) - (attacker.x || 0), (u.y || 0) - (attacker.y || 0))
if (d < td) { td = d; tgt = u }
}
if (!tgt) return null
cx = tgt.x || 0; cy = tgt.y || 0
}
const hits = []
const touched = new Set()
for (const u of units) {
if (u === attacker || enemyKey(u) === enemyKey(attacker)) continue
for (const i of aliveTiles(u)) {
const p = tileWorldPos(u, i, scale)
if (Math.hypot(p.x - cx, p.y - cy) <= radius) {
applyBeam(u, i, dmg); hits.push({ unit: u, tileIdx: i }); touched.add(u)
}
}
}
for (const u of touched) shedUnit(u)
attacker.starCharge = 0
return hits
}
// ─────────────────────────────────────────────────────────────────────────────
// B.damage — the HOOK FRAGMENT. Resolves queued gun beams into per-tile damage,
// sheds orphaned tiles, charges + fires the star lance, and culls destroyed
// units. Reads the shared battle state (PW.bt / wd.__bt) econ+move populate;
// degrades to nothing if no battle is live. Self-contained: it re-implements the
// tiny reach-BFS inline (aliveTiles lives in hull.mjs, which build does not
// inline) and calls `holes` (inlined) to test the star seal.
// ─────────────────────────────────────────────────────────────────────────────
B.damage = String.raw`{
const bt = (typeof PW === 'object' && PW && PW.bt) || wd.__bt;
if (bt && Array.isArray(bt.units)) {
const US = bt.units;
const WS = (typeof bt.scale === 'number') ? bt.scale : ${DEFAULT_SCALE};
// alive-set BFS from tile 0, shearing orphans (mutates tileHp) — mirrors shedUnit
const _alive = (u) => {
const dead = new Set();
for (let i = 0; i < u.tileHp.length; i++) if (u.tileHp[i] <= 0) dead.add(i);
if (dead.has(0) || !u.adj || !u.adj.length) return new Set();
const seen = new Set([0]); const q = [0];
while (q.length) { const a = q.shift(); const nb = u.adj[a] || []; for (let k = 0; k < nb.length; k++) { const b = nb[k]; if (!dead.has(b) && !seen.has(b)) { seen.add(b); q.push(b); } } }
for (let i = 0; i < u.tileHp.length; i++) if (u.tileHp[i] > 0 && !seen.has(i)) u.tileHp[i] = 0;
return seen;
};
const _twp = (u, i) => { const t = u.tiles[i]; const ca = Math.cos(u.a || 0), sa = Math.sin(u.a || 0); return { x: (u.x || 0) + (t.cx * ca - t.cy * sa) * WS, y: (u.y || 0) + (t.cx * sa + t.cy * ca) * WS }; };
const _key = (u) => (u.seat != null ? u.seat : u.owner);
// 1) resolve gun beams → nearest enemy tile takes damage; draw the beam
const beams = Array.isArray(bt.beams) ? bt.beams : [];
for (let bi = 0; bi < beams.length; bi++) {
const bm = beams[bi];
let best = null, bd = Infinity;
for (let ui = 0; ui < US.length; ui++) { const u = US[ui]; if (_key(u) === bm.seat) continue; const al = _alive(u); if (!al.size) continue; for (const i of al) { const p = _twp(u, i); const d = Math.hypot(p.x - bm.ox, p.y - bm.oy); if (d < bd) { bd = d; best = { u: u, i: i, x: p.x, y: p.y }; } } }
if (best) {
best.u.tileHp[best.i] = Math.max(0, best.u.tileHp[best.i] - (bm.dmg || ${BEAM_DMG}));
_alive(best.u);
const hl = Math.min(0.49, bd / 2);
pushEnt((bm.ox + best.x) / 2, (bm.oy + best.y) / 2, Math.atan2(best.y - bm.oy, best.x - bm.ox) + hl / 1000, 100 + ((bm.seat | 0)));
}
}
if (bt.beams) bt.beams.length = 0; // consume this tick's shots
// 2) STAR super-weapon: charge armed stars; fire an AoE lance at full charge
const _dt = (typeof dt === 'number' && dt > 0) ? Math.min(dt, 1 / 30) : 1 / 30;
for (let ui = 0; ui < US.length; ui++) {
const u = US[ui]; const al = _alive(u);
let armed = false;
if (u.hasStar && al.size) { const sub = []; for (const i of al) sub.push(u.tiles[i]); const hs = holes(sub); for (let h = 0; h < hs.length; h++) if (hs[h].shape === 'star') { armed = true; break; } }
if (armed) {
u.starCharge = Math.min(1, (u.starCharge || 0) + _dt * ${STAR_CHARGE_RATE});
if (u.starCharge >= 1) {
u.starCharge = 0;
let tx = null, ty = null, td = Infinity;
for (let oi = 0; oi < US.length; oi++) { const o = US[oi]; if (_key(o) === _key(u)) continue; const oa = _alive(o); if (!oa.size) continue; const d = Math.hypot((o.x || 0) - (u.x || 0), (o.y || 0) - (u.y || 0)); if (d < td) { td = d; tx = o.x || 0; ty = o.y || 0; } }
if (tx != null) {
for (let oi = 0; oi < US.length; oi++) { const o = US[oi]; if (_key(o) === _key(u)) continue; for (const i of _alive(o)) { const p = _twp(o, i); if (Math.hypot(p.x - tx, p.y - ty) <= ${STAR_RADIUS}) o.tileHp[i] = Math.max(0, o.tileHp[i] - ${STAR_DMG}); } _alive(o); }
pushEnt(tx, ty, 1, 290); // lance burst marker (shader may decode; degrades otherwise)
sound('lance');
}
}
} else u.starCharge = 0;
}
// 3) cull destroyed units (tile-0 death / fully sheared) — win node reads survivors
bt.units = US.filter((u) => _alive(u).size > 0);
}
}`
// ─────────────────────────────────────────────────────────────────────────────
// bt-move — STEERING + GUNS + ENERGY (this slice).
// • steer: MY units turn toward the seat cursor and drive forward while it is
// held (BLOOP steering — turn-then-thrust), speed/turn from hull.stats
// (thrust/mass). Discrete-input safe: it reads the held pointer each tick.
// • guns: every alive GUN tile fires along its OUTWARD EDGE-NORMAL (hull
// curvature = firing arc) on a per-unit cooldown; each shot is a beam queued
// on bt.beams for bt-damage to resolve to the nearest enemy tile.
// • energy: a hull whose GEN cannot sustain its guns/engines is in BROWNOUT
// (hull.stats.brownout, power<0) — that HALVES the fire rate (doubles the
// gun interval). GEN sustaining keeps the full cadence.
// The pure helpers below are the unit-tested brain; the B.move fragment mirrors
// them with the inlined geometry (edgeNormalAngle/edgeMidpoint/contacts/partOf all
// land in PRELUDE), re-implementing the reach-BFS inline like B.damage does.
// ─────────────────────────────────────────────────────────────────────────────
const GUN_PART = 3 // parts.mjs code for a GUN tile
const FIRE_PERIOD = 0.8 // seconds between a gun's shots at full power
const GUN_DMG = 6 // damage a single gun beam deals (matches GUN dps)
/** angDiff(a,b) — the signed shortest angular delta from heading a to heading b,
* wrapped to (-π, π]. Positive = turn counter-clockwise. */
function angDiff(a, b) {
let d = (b - a) % (2 * Math.PI)
if (d > Math.PI) d -= 2 * Math.PI
if (d < -Math.PI) d += 2 * Math.PI
return d
}
/** steer(unit, tx, ty, dt, opts?) — BLOOP steering toward world point (tx,ty):
* rotate the heading toward the target clamped by turn·dt, then drive FORWARD
* along the (new) heading by speed·dt, scaled by how well the nose already points
* at the target (cos of the residual angle, floored at 0 — never reverse). Never
* overshoots the target. speed/turn default to hull.stats (thrust/mass derived);
* opts.speed/opts.turn override for isolated tests. Mutates + returns unit. */
function steer(unit, tx, ty, dt, opts = {}) {
const speed = opts.speed ?? (unit.stats ? unit.stats.speed : 0)
const turn = opts.turn ?? (unit.stats ? unit.stats.turn : 0)
const dx = tx - (unit.x || 0), dy = ty - (unit.y || 0)
const dist = Math.hypot(dx, dy)
if (dist < 1e-9) return unit
const desired = Math.atan2(dy, dx)
const da = angDiff(unit.a || 0, desired)
const maxTurn = Math.max(0, turn) * Math.max(0, dt)
unit.a = (unit.a || 0) + Math.max(-maxTurn, Math.min(maxTurn, da))
const facing = Math.max(0, Math.cos(angDiff(unit.a, desired))) // 1 aligned … 0 sideways
const step = Math.min(dist, Math.max(0, speed) * Math.max(0, dt) * facing)
unit.x = (unit.x || 0) + Math.cos(unit.a) * step
unit.y = (unit.y || 0) + Math.sin(unit.a) * step
return unit
}
/** edgeUsage(tiles) — the set of `i:e` edge ids that are in contact with a
* neighbour (parent link or re-touch). A gun's FREE edges are the complement. */
function edgeUsage(tiles) {
const used = new Set()
for (const c of contacts(tiles)) { used.add(c.i + ':' + c.ei); used.add(c.j + ':' + c.ej) }
return used
}
/** outwardEdge(unit, i) — the single OUTWARD free edge of tile i: the free (not
* contacted) edge whose normal points most away from the hull centre (tile 0).
* This is the firing arc a perimeter gun shoots along. Returns the edge index,
* or -1 if the tile has no free edge (fully enclosed — it cannot fire out). */
function outwardEdge(unit, i) {
const t = unit.tiles[i]
const used = edgeUsage(unit.tiles)
const ox = t.cx, oy = t.cy // outward from hull centre (tile 0 at 0,0)
const rlen = Math.hypot(ox, oy)
let best = -1, bestDot = -Infinity
for (let e = 0; e < 5; e++) {
if (used.has(i + ':' + e)) continue
const n = edgeNormalAngle(t, e)
const dot = rlen > 1e-9 ? (Math.cos(n) * ox + Math.sin(n) * oy) / rlen : 0
if (best < 0 || dot > bestDot) { best = e; bestDot = dot }
}
return best
}
/** gunPorts(unit, scale?) — every alive GUN tile's firing port in WORLD space:
* {tileIdx, edge, ox, oy, dir}. ox/oy = the outward edge midpoint rotated by the
* unit heading and translated to its position; dir = that edge normal in world
* angle (the beam's arc). Dead / orphaned guns and non-gun tiles are excluded. */
function gunPorts(unit, scale = DEFAULT_SCALE) {
const alive = aliveTiles(unit)
const ca = Math.cos(unit.a || 0), sa = Math.sin(unit.a || 0)
const out = []
for (const i of alive) {
if (partOf(unit.tiles[i].part).code !== GUN_PART) continue
const e = outwardEdge(unit, i)
if (e < 0) continue
const t = unit.tiles[i]
const m = edgeMidpoint(t, e)
out.push({
tileIdx: i, edge: e,
ox: (unit.x || 0) + (m.x * ca - m.y * sa) * scale,
oy: (unit.y || 0) + (m.x * sa + m.y * ca) * scale,
dir: edgeNormalAngle(t, e) + (unit.a || 0),
})
}
return out
}
/** fireInterval(unit, period?) — seconds between a gun's shots. BROWNOUT (the
* hull's GEN cannot sustain its draw, hull.stats.brownout / power<0) HALVES the
* fire rate → DOUBLES the interval. A sustained hull fires at the base period. */
function fireInterval(unit, period = FIRE_PERIOD) {
const brown = unit && unit.stats ? !!unit.stats.brownout : false
return brown ? period * 2 : period
}
/** gunBeams(unit, dt, opts?) — advance the unit's gun cooldown and, on the tick
* it elapses, return one beam per gun port ({seat, ox, oy, dmg, dir}) for
* bt-damage to resolve; most ticks it returns []. Mutates unit.__cool. A gunless
* or fully-dead unit never fires. Brownout stretches the interval (fireInterval).
* opts: {scale, period, dmg}. */
function gunBeams(unit, dt, opts = {}) {
const ports = gunPorts(unit, opts.scale)
if (!ports.length) { unit.__cool = 0; return [] }
unit.__cool = (unit.__cool || 0) - Math.max(0, dt)
if (unit.__cool > 0) return []
unit.__cool += fireInterval(unit, opts.period)
const seat = enemyKey(unit)
const dmg = opts.dmg ?? GUN_DMG
return ports.map((p) => ({ seat, ox: p.ox, oy: p.oy, dmg, dir: p.dir }))
}
// ─────────────────────────────────────────────────────────────────────────────
// B.move — the HOOK FRAGMENT. Steers MY units toward the held seat cursor and
// queues gun beams onto bt.beams (bt-damage, which runs after, resolves them).
// Reads the shared battle state (PW.bt / wd.__bt); degrades to nothing off-battle.
// ─────────────────────────────────────────────────────────────────────────────
B.move = String.raw`{
const bt = (typeof PW === 'object' && PW && PW.bt) || wd.__bt;
if (bt && Array.isArray(bt.units)) {
const US = bt.units;
const WS = (typeof bt.scale === 'number') ? bt.scale : ${DEFAULT_SCALE};
const _dt = (typeof dt === 'number' && dt > 0) ? Math.min(dt, 1 / 30) : 1 / 30;
if (!Array.isArray(bt.beams)) bt.beams = [];
// cursor target + held: the acting seat's frame in a room, else the local pointer
let TX, TY, HELD;
if (IN_ROOM) { const _pl = wd.players[MY_SEAT] || {}; TX = _pl.mx; TY = _pl.my; HELD = !!_pl.down; }
else { TX = PX; TY = PY; HELD = DOWN; }
// alive-set BFS from tile 0 (aliveTiles lives in hull.mjs — not inlined; mirror it)
const _alive = (u) => {
const dead = new Set();
for (let i = 0; i < u.tileHp.length; i++) if (u.tileHp[i] <= 0) dead.add(i);
if (dead.has(0) || !u.adj || !u.adj.length) return new Set();
const seen = new Set([0]); const q = [0];
while (q.length) { const a = q.shift(); const nb = u.adj[a] || []; for (let k = 0; k < nb.length; k++) { const b = nb[k]; if (!dead.has(b) && !seen.has(b)) { seen.add(b); q.push(b); } } }
return seen;
};
const _key = (u) => (u.seat != null ? u.seat : u.owner);
for (let ui = 0; ui < US.length; ui++) {
const u = US[ui];
const al = _alive(u);
if (!al.size) continue;
const mine = (u.seat === MY_SEAT) || (u.seat == null && !IN_ROOM);
// 1) STEER my units toward the held cursor (BLOOP: turn-then-thrust)
if (mine && HELD && typeof TX === 'number' && typeof TY === 'number') {
const dx = TX - (u.x || 0), dy = TY - (u.y || 0), dist = Math.hypot(dx, dy);
if (dist > 1e-9) {
const st = u.stats || {}; const spd = +st.speed || 0, trn = +st.turn || 0;
const desired = Math.atan2(dy, dx);
let da = (desired - (u.a || 0)) % (2 * Math.PI); if (da > Math.PI) da -= 2 * Math.PI; if (da < -Math.PI) da += 2 * Math.PI;
const mt = trn * _dt; u.a = (u.a || 0) + Math.max(-mt, Math.min(mt, da));
let ra = (desired - u.a) % (2 * Math.PI); if (ra > Math.PI) ra -= 2 * Math.PI; if (ra < -Math.PI) ra += 2 * Math.PI;
const step = Math.min(dist, spd * _dt * Math.max(0, Math.cos(ra)));
u.x = (u.x || 0) + Math.cos(u.a) * step; u.y = (u.y || 0) + Math.sin(u.a) * step;
}
}
// 2) GUNS: fire from each alive gun's outward free edge on the unit cooldown
const brown = u.stats ? !!u.stats.brownout : false;
const period = ${FIRE_PERIOD} * (brown ? 2 : 1);
const used = new Set();
const cs = contacts(u.tiles);
for (let ci = 0; ci < cs.length; ci++) { used.add(cs[ci].i + ':' + cs[ci].ei); used.add(cs[ci].j + ':' + cs[ci].ej); }
const ca = Math.cos(u.a || 0), sa = Math.sin(u.a || 0);
const ports = [];
for (const ti of al) {
if (partOf(u.tiles[ti].part).code !== ${GUN_PART}) continue;
const t = u.tiles[ti];
let be = -1, bd = -Infinity; const rl = Math.hypot(t.cx, t.cy);
for (let e = 0; e < 5; e++) { if (used.has(ti + ':' + e)) continue; const n = edgeNormalAngle(t, e); const dot = rl > 1e-9 ? (Math.cos(n) * t.cx + Math.sin(n) * t.cy) / rl : 0; if (be < 0 || dot > bd) { be = e; bd = dot; } }
if (be < 0) continue;
const m = edgeMidpoint(t, be);
ports.push({ ox: (u.x || 0) + (m.x * ca - m.y * sa) * WS, oy: (u.y || 0) + (m.x * sa + m.y * ca) * WS, dir: edgeNormalAngle(t, be) + (u.a || 0) });
}
if (!ports.length) { u.__cool = 0; continue; }
u.__cool = (u.__cool || 0) - _dt;
if (u.__cool > 0) continue;
u.__cool += period;
const seat = _key(u);
for (let pi = 0; pi < ports.length; pi++) bt.beams.push({ seat: seat, ox: ports[pi].ox, oy: ports[pi].oy, dmg: ${GUN_DMG}, dir: ports[pi].dir });
}
}
}`
// ─────────────────────────────────────────────────────────────────────────────
// bt-win — the VICTORY check (pure helpers + B.win fragment). Two ways to win:
// DOMINATION (hold every capture ring continuously for RING_HOLD_TIME) or
// ELIMINATION (once combat has been joined by ≥2 seats, be the last seat with a
// living unit). On a win the room flips to the debrief scene.
// ─────────────────────────────────────────────────────────────────────────────
const RING_HOLD_TIME = 30 // seconds one seat must hold ALL rings to win
/** seatsWithUnits(bt) — the set of seats that currently have at least one alive unit. */
function seatsWithUnits(bt) {
const s = new Set()
for (const u of bt.units || []) if (!unitDead(u)) s.add(enemyKey(u))
return s
}
/** allRingsHeldBy(bt, seat) — true iff there is ≥1 ring and every ring is owned by seat. */
function allRingsHeldBy(bt, seat) {
const R = bt.rings || []
return R.length > 0 && R.every((r) => r.owner === seat)
}
/** checkWin(bt, dt, opts?) — advance the domination timer and test victory.
* DOMINATION: a seat holding ALL rings continuously for opts.holdTime wins.
* ELIMINATION: once ≥2 seats have fielded a unit (bt.combatStarted latches), the
* last seat with a living unit wins. Returns the winning seat or null; mutates
* bt.holdSeat/holdT/combatStarted. */
function checkWin(bt, dt, opts = {}) {
const need = opts.holdTime ?? RING_HOLD_TIME
const R = bt.rings || []
let dom = null
if (R.length) { const o = R[0].owner; if (o != null && R.every((r) => r.owner === o)) dom = o }
if (dom != null && dom === bt.holdSeat) bt.holdT = (bt.holdT || 0) + Math.max(0, dt)
else { bt.holdSeat = dom; bt.holdT = 0 }
if (dom != null && (bt.holdT || 0) >= need) return dom
const live = seatsWithUnits(bt)
if (live.size >= 2) bt.combatStarted = true
if (bt.combatStarted && live.size === 1) return [...live][0]
return null
}
// B.win — the HOOK FRAGMENT (runs LAST). Mirrors checkWin inline; on victory sets
// PW.scene='debrief' + PW.result, un-starts the room. Idempotent once in debrief.
B.win = String.raw`{
const bt = (typeof PW === 'object' && PW && PW.bt) || wd.__bt;
const _inDebrief = (typeof PW === 'object' && PW && PW.scene === 'debrief');
if (bt && Array.isArray(bt.rings) && !_inDebrief) {
const _dt = (typeof dt === 'number' && dt > 0) ? Math.min(dt, 1 / 30) : 1 / 30;
const _key = (u) => (u.seat != null ? u.seat : u.owner);
const _dead = (u) => {
const dead = new Set();
for (let i = 0; i < u.tileHp.length; i++) if (u.tileHp[i] <= 0) dead.add(i);
if (dead.has(0) || !u.adj || !u.adj.length) return true;
const seen = new Set([0]); const q = [0];
while (q.length) { const a = q.shift(); const nb = u.adj[a] || []; for (let k = 0; k < nb.length; k++) { const b = nb[k]; if (!dead.has(b) && !seen.has(b)) { seen.add(b); q.push(b); } } }
return seen.size === 0;
};
let dom = null; const R = bt.rings;
if (R.length) { const o = R[0].owner; if (o != null) { let all = true; for (let i = 0; i < R.length; i++) if (R[i].owner !== o) { all = false; break; } if (all) dom = o; } }
if (dom != null && dom === bt.holdSeat) bt.holdT = (bt.holdT || 0) + _dt; else { bt.holdSeat = dom; bt.holdT = 0; }
let win = null;
if (dom != null && (bt.holdT || 0) >= ${RING_HOLD_TIME}) win = dom;
if (win == null) { const US = bt.units || []; const live = new Set(); for (let i = 0; i < US.length; i++) if (!_dead(US[i])) live.add(_key(US[i])); if (live.size >= 2) bt.combatStarted = true; if (bt.combatStarted && live.size === 1) win = [...live][0]; }
if (win != null && typeof PW === 'object' && PW) { PW.scene = 'debrief'; PW.result = { winner: win }; wd.__started = false; sound('win'); }
}
}`
// The composed scene hook: fragments run in Istrolid order — spawn/econ, then
// steer/fire, then resolve damage, then check win. Missing slices (nodes not yet
// built) contribute nothing — the scene degrades, never throws. build.mjs wraps
// this whole string in its own `{ }` in the DISPATCH.
const SRC = ['econ', 'move', 'damage', 'win']
.map((k) => B[k] || '')
.join('\n')
// Also expose the raw fragment registry so a later assembler / integrate node
// (or a sibling battle slice) can compose or introspect individual fragments.
const FRAGMENTS = B
// holes EXPORTED: the yard reads the same classifier as battle (one truth)
// ═══════════════ V2 SHIP SYSTEMS (generated — see build-engine-v2.mjs) ═══════════════
// freeEdges derived from ENG's own contacts() (turret arcs are EARNED BY PLACEMENT)
function freeEdgesV2(tiles) {
const used = new Set()
for (const c of contacts(tiles)) { used.add(c.i + ':' + c.ei); used.add(c.j + ':' + c.ej) }
const out = []
for (let i = 0; i < tiles.length; i++) for (let e = 0; e < 5; e++) if (!used.has(i + ':' + e)) out.push({ i, e })
return out
}
// ── V2/phys (generated from phys.mjs — edit THAT file + rerun build-engine-v2) ──
const { massProps, edgeNormal, thrusters, wrench, allocate, netWrench, envelope, flyStep, DRAG, ANG_DRAG, MOUNTS, shipMass, aimGimbal } = (() => {
// phys.mjs — PENTARCH ship physics: mass/COM/inertia, thruster wrenches,
// throttle allocation, and the MOBILITY ENVELOPE (fwd/strafe/turn) that part
// ROTATION creates. Render-free; consumed by the designer (stats) and battle
// (steering). See DESIGN-ship-systems.md §5.
//
// Conventions: ship frame, +x = ship forward (heading 0), angles CCW radians.
// A part's orientation o ∈ 0..4 selects one of its tile's 5 edge normals as its
// action direction (thrust EXHAUSTS opposite: force is along -normal? NO —
// convention here: `dir` IS the direction of the force applied to the ship).
const ST = (2 * Math.PI) / 5
/** MOUNT TIERS — the arc of rotation you BUY for a mounted module (weapon or
* thruster). Machinery has mass: a full gimbal ring is heavy. Effective arc in
* battle = bought arc ∩ hull exposure (you can't thrust/shoot through hull). */
const MOUNTS = {
fixed: { half: 0, cost: 0, mass: 0 },
swivel: { half: Math.PI / 5, cost: 8, mass: 0.3 }, // ±36°
wide: { half: Math.PI / 2, cost: 18, mass: 0.6 }, // ±90°
ring: { half: Math.PI, cost: 34, mass: 1.0 }, // 360°
}
/** shipMass(tiles) — THE WEIGHT ALGORITHM, explicit: every tile weighs its part
* mass + its mount's machinery + its module. One place, one truth; massProps
* consumes its output. tiles may carry { mass, mount, moduleMass }. */
function shipMass(tiles) {
return tiles.map(t => ({
...t,
mass: (t.mass ?? 1) + (MOUNTS[t.mount] ? MOUNTS[t.mount].mass : 0) + (t.moduleMass || 0),
}))
}
/** the world-frame direction of tile t's edge-o normal (same ena as penta-core) */
function edgeNormal(t, o) {
const a = t.th + Math.PI / 2 + (o + 0.5) * ST
return { x: Math.cos(a), y: Math.sin(a) }
}
/** massProps(tiles) — tiles: [{cx,cy,mass}] → { M, com:{x,y}, I }
* I about the COM, point-mass model (tile size ~1: adequate, tested). */
function massProps(tiles) {
let M = 0, sx = 0, sy = 0
for (const t of tiles) { const m = t.mass ?? 1; M += m; sx += m * t.cx; sy += m * t.cy }
if (M <= 0) return { M: 0, com: { x: 0, y: 0 }, I: 0 }
const com = { x: sx / M, y: sy / M }
let I = 0
for (const t of tiles) { const m = t.mass ?? 1; const dx = t.cx - com.x, dy = t.cy - com.y; I += m * (dx * dx + dy * dy) }
I = Math.max(I, 0.2) // a 1-tile ship still turns finitely
return { M, com, I }
}
/** thrusters(tiles) — pull the actuator list out of a laid-out ship.
* tiles: [{cx,cy,th,part:{kind,thrust?,torque?,drain?},o}]
* → [{ i, pos:{x,y} (rel COM), dir:{x,y}, F, T (pure torque), drain }] */
function thrusters(tiles, com) {
const out = []
for (let i = 0; i < tiles.length; i++) {
const t = tiles[i], p = t.part
if (!p) continue
const F = p.thrust || 0, T = p.torque || 0
if (!F && !T) continue
// ROCKET CONVENTION (Galen: "engines don't push from the edge they appear
// on"): o marks the NOZZLE/EXHAUST edge — plume exits THERE, and the force
// on the ship is the opposite: dir = −normal(o). Aim the nozzle backward.
const nrm = edgeNormal(t, t.o ?? 0)
const dir = F ? { x: -nrm.x, y: -nrm.y } : { x: 0, y: 0 }
// GIMBAL: the mount's arc, centered on the part's facing. A fixed mount has
// half=0 (today's behavior, exactly). allocate() may aim anywhere inside.
const half = MOUNTS[t.mount] ? MOUNTS[t.mount].half : 0
if (T) {
// GYROS TORQUE BOTH WAYS — one entry per spin sense (E-rotation had no
// gyro at all before this: the allocator only ever saw +T)
out.push({ i, pos: { x: t.cx - com.x, y: t.cy - com.y }, dir: { x: 0, y: 0 }, ang: 0, half: 0, F: 0, T, drain: p.drain || 0 })
out.push({ i, pos: { x: t.cx - com.x, y: t.cy - com.y }, dir: { x: 0, y: 0 }, ang: 0, half: 0, F: 0, T: -T, drain: p.drain || 0 })
}
if (F) out.push({ i, pos: { x: t.cx - com.x, y: t.cy - com.y }, dir, ang: Math.atan2(dir.y, dir.x), half, F, T: 0, drain: p.drain || 0 })
}
// RCS FLOOR — hull-integrated reaction jets: a whisper of omni thrust + both-
// way torque at the COM, scaling gently with hull size. Every ship answers
// the stick; a real engine is ~20× the floor. rcs:true → no plume, no drain.
const nT = tiles.length
const rcsF = 0.35 + 0.1 * nT, rcsT = 0.25 + 0.08 * nT
out.push({ i: -1, rcs: true, pos: { x: 0, y: 0 }, dir: { x: 1, y: 0 }, ang: 0, half: Math.PI, F: rcsF, T: 0, drain: 0 })
out.push({ i: -1, rcs: true, pos: { x: 0, y: 0 }, dir: { x: 0, y: 0 }, ang: 0, half: 0, F: 0, T: rcsT, drain: 0 })
out.push({ i: -1, rcs: true, pos: { x: 0, y: 0 }, dir: { x: 0, y: 0 }, ang: 0, half: 0, F: 0, T: -rcsT, drain: 0 })
return out
}
const wrapA = (a) => Math.atan2(Math.sin(a), Math.cos(a))
/** aimGimbal(th, gx, gy) — point a gimballed thruster as close to the desired
* force direction (gx,gy) as its arc allows; returns the CLAMPED dir. */
function aimGimbal(th, gx, gy) {
if (!th.F || !(th.half > 0)) return th.dir
const wantA = Math.atan2(gy, gx)
const d = wrapA(wantA - th.ang)
const a = th.ang + Math.max(-th.half, Math.min(th.half, d))
return { x: Math.cos(a), y: Math.sin(a) }
}
/** wrench of one thruster at throttle u: { fx, fy, tq } (tq includes lever torque) */
function wrench(th, u) {
const fx = u * th.F * th.dir.x, fy = u * th.F * th.dir.y
const tq = u * (th.T + th.F * (th.pos.x * th.dir.y - th.pos.y * th.dir.x))
return { fx, fy, tq }
}
/** allocate(ths, want) — THE CONTROL SYSTEM (Galen's law: "going straight
* fires engines AS MUCH AS POSSIBLE in that direction, even if angled engines
* counter-balance"). want: { fwd:-1..1, lat:-1..1, turn:-1..1 }, ship frame.
*
* Solved as a tiny constrained optimization, not a cosine guess: maximize
* thrust ALONG the command while PENALIZING side-drift and unwanted torque —
* projected gradient ascent on u ∈ [0,1]ⁿ. Mirrored 45° engines both saturate
* to FULL (their lateral bleeds cancel — the old cosine allocator shyly gave
* them ~0.7); a lone skewed engine gets throttled back or countered by a gyro,
* because its side-effects have nothing to cancel against. Deterministic,
* ~ITER·n multiplies per tick, n is small. */
function allocate(ths, want) {
const n = ths.length
if (!n) return []
const wx = want.fwd, wy = want.lat, wt = want.turn
const wmag = Math.hypot(wx, wy)
// unit wrenches, force part normalized so big/small engines optimize fairly
const W = ths.map(th => wrench(th, 1))
const fscale = Math.max(...W.map(w => Math.hypot(w.fx, w.fy)), 1e-9)
const tscale = Math.max(...W.map(w => Math.abs(w.tq)), 1e-9)
// command axes: along = the wanted direction; perp = the drift to cancel
const ax = wmag > 1e-9 ? wx / wmag : 0, ay = wmag > 1e-9 ? wy / wmag : 0
const PEN = 2.2 // side-drift / stray-torque penalty weight
const ITER = 16
const us = new Array(n).fill(0)
const dirs = ths.map(th => th.dir) // live gimbal aims
for (let it = 0; it < ITER; it++) {
const STEP = 0.6 * Math.pow(0.78, it) // DAMPED — a fixed flyStep oscillates and can land on 0
// ── GIMBAL PASS: each mounted thruster swings toward its best use — the
// commanded direction, or (for pure turn) the tangent that spins the
// right way. Arc-clamped; a fixed mount never moves. Two RING engines
// on a turn command aim opposite tangents and spin the ship. ──
for (let i = 0; i < n; i++) {
const th = ths[i]
if (!th.F || !(th.half > 0)) continue
let gx = ax, gy = ay
if (wmag < 1e-9 && Math.abs(wt) > 1e-9) {
const r = Math.hypot(th.pos.x, th.pos.y)
if (r > 1e-6) { const sgn = Math.sign(wt); gx = -th.pos.y / r * sgn; gy = th.pos.x / r * sgn }
}
if (Math.abs(gx) + Math.abs(gy) > 1e-9) {
dirs[i] = aimGimbal(th, gx, gy)
W[i] = wrench({ ...th, dir: dirs[i] }, 1)
}
}
// current net (normalized)
let Fx = 0, Fy = 0, T = 0
for (let i = 0; i < n; i++) { Fx += us[i] * W[i].fx / fscale; Fy += us[i] * W[i].fy / fscale; T += us[i] * W[i].tq / tscale }
const along = Fx * ax + Fy * ay
const px = Fx - along * ax, py = Fy - along * ay // drift component
const tErr = T - wt * (Math.abs(wt) > 1e-9 ? Math.abs(T) + 1 : 0) // wanted torque handled below
for (let i = 0; i < n; i++) {
const fx = W[i].fx / fscale, fy = W[i].fy / fscale, tq = W[i].tq / tscale
// gradient of ( along − PEN·(|drift|² + torque-err²) )
let g = (fx * ax + fy * ay) * (wmag > 1e-9 ? 1 : 0)
- PEN * 2 * (px * fx + py * fy)
if (Math.abs(wt) > 1e-9) g += tq * Math.sign(wt) * Math.abs(wt) // torque wanted: reward agreeing spin
else g -= PEN * 2 * T * tq // torque unwanted: cancel it
us[i] = Math.max(0, Math.min(1, us[i] + STEP * g))
}
void tErr
}
us.dirs = dirs // live aims ride along (plumes + flyStep)
return us
}
/** net wrench for a throttle vector */
function netWrench(ths, us) {
let fx = 0, fy = 0, tq = 0
for (let i = 0; i < ths.length; i++) { const w = wrench(ths[i], us[i]); fx += w.fx; fy += w.fy; tq += w.tq }
return { fx, fy, tq }
}
/** envelope(tiles) — THE designer readout. What this hull can actually do:
* { aFwd, aBack, aLat, alpha, vMax } accelerations (per unit mass) + a top
* speed proxy. Rotating one part changes these numbers — that's the feature. */
function envelope(tiles) {
const { M, com, I } = massProps(tiles)
const ths = thrusters(tiles, com)
if (!ths.length || M <= 0) return { aFwd: 0, aBack: 0, aLat: 0, alpha: 0, vMax: 0 }
const probe = (want) => {
const us = allocate(ths, want)
// honor the LIVE gimbal aims (same as flyStep) — probing with the resting dirs
// made a ring-mounted engine look like it could only push backwards
const aimed = us.dirs ? ths.map((th, i) => ({ ...th, dir: us.dirs[i] })) : ths
const w = netWrench(aimed, us)
return { a: Math.hypot(w.fx, w.fy) / M, al: Math.abs(w.tq) / I, fx: w.fx, fy: w.fy }
}
const f = probe({ fwd: 1, lat: 0, turn: 0 })
const b = probe({ fwd: -1, lat: 0, turn: 0 })
const l = probe({ fwd: 0, lat: 1, turn: 0 })
const r = probe({ fwd: 0, lat: -1, turn: 0 })
const tP = probe({ fwd: 0, lat: 0, turn: 1 })
const tN = probe({ fwd: 0, lat: 0, turn: -1 })
const t = tP.al >= tN.al ? tP : tN // turn capability is direction-dependent (asymmetric ships): report the better side
// direction-honest: forward accel counts only the +x component of the forward
// probe, strafe only the ±y of the lateral probes — a diagonal thruster can't
// fake a clean number.
const aFwd = Math.max(0, f.fx) / M
const totF = ths.reduce((a2, t2) => a2 + (t2.rcs ? 0 : t2.F), 0)
const aBack = Math.max(Math.max(0, -b.fx) / M, BRAKE_FRAC * totF / M) // thrust-dump counts as braking
const aLat = Math.max(Math.max(0, l.fy), Math.max(0, -r.fy)) / M
const alpha = t.al
// top speed proxy: linear drag model v_max = a / DRAG
const vMax = aFwd / DRAG
return { aFwd, aBack, aLat, alpha, vMax }
}
const BRAKE_FRAC = 0.45 // thrust-dump braking: fraction of total thrust usable as pure decel
const DRAG = 0.35 // FORWARD drag (kept name: route's brake math reads it)
const DRAG_LAT = 2.8 // KEEL: sideways drag — the hull refuses to skate. This is
// Istrolid's "wings": turn the nose and the keel converts
// drift into the new heading. The single biggest feel fix.
const ANG_DRAG = 1.4
/** flyStep(state, tiles, want, dt) — integrate one tick of arcade flight.
* state: { x, y, vx, vy, th, om } (om = angular velocity). Mutates + returns. */
function flyStep(state, tiles, want, dt) {
const { M, com, I } = massProps(tiles)
const ths = thrusters(tiles, com)
const us = allocate(ths, want)
// thruster dirs are in SHIP frame (tile poses are ship-frame): rotate wrench to world
const aimed = us.dirs ? ths.map((th, i) => ({ ...th, dir: us.dirs[i] })) : ths
const w = netWrench(aimed, us)
const c = Math.cos(state.th), s = Math.sin(state.th)
let fx = w.fx * c - w.fy * s, fy = w.fx * s + w.fy * c
// ARCADE BRAKE (thrust-dump): a commanded decel vents main-engine power
// straight against the velocity vector (world frame) — up to BRAKE_FRAC of
// total thrust, no flip needed. A no-retro hull can now actually stop.
if ((want.fwd || 0) < -0.05) {
const sp = Math.hypot(state.vx, state.vy)
if (sp > 1e-4) {
const totF = ths.reduce((a, t2) => a + (t2.rcs ? 0 : t2.F), 0)
const bF = Math.min(sp * M / Math.max(dt, 1e-4), -want.fwd * BRAKE_FRAC * totF)
fx += -state.vx / sp * bF; fy += -state.vy / sp * bF
}
}
state.vx += (fx / M) * dt; state.vy += (fy / M) * dt
state.om += (w.tq / I) * dt
// keel drag: damp velocity in the SHIP frame — soft along the nose, hard sideways
{
const vf = c * state.vx + s * state.vy, vl = -s * state.vx + c * state.vy
const vf2 = vf - vf * DRAG * dt, vl2 = vl - vl * DRAG_LAT * dt
state.vx = c * vf2 - s * vl2; state.vy = s * vf2 + c * vl2
}
state.om -= state.om * ANG_DRAG * dt
state.x += state.vx * dt; state.y += state.vy * dt
state.th += state.om * dt
return { state, us, drain: ths.reduce((a, th, i) => a + th.drain * us[i], 0) }
}
return { massProps, edgeNormal, thrusters, wrench, allocate, netWrench, envelope, flyStep, DRAG, ANG_DRAG, MOUNTS, shipMass, aimGimbal }
})()
// ── V2/energy2 (generated from energy2.mjs — edit THAT file + rerun build-engine-v2) ──
const { gridOf, newBank, powerTick, powerBudget, BROWN_GUN, BROWN_THRUST, BROWNOUT_ENTER, BROWNOUT_EXIT } = (() => {
// energy2.mjs — PENTARCH power grid: generation → batteries → consumers, with
// the brownout rule. Render-free; consumed by battle (per-powerTick) and the
// designer (power-powerBudget readout). DESIGN-ship-systems.md §3.
//
// The design axis this creates: batteries buffer BURSTS (alpha strikes beyond
// generation), but sustained deficit browns the ship out — weapons at half
// rate, thrusters at 70%. Glass cannon = big weapons + small gen + big banks.
/** gridOf(tiles) — pull the power grid from a laid-out ship.
* Part fields: gen (P/s), batCap, batRate (max charge/discharge P/s). */
function gridOf(tiles) {
let gen = 0, batCap = 0, batRate = 0
for (const t of tiles) {
const p = t.part
if (!p) continue
gen += p.gen || 0
batCap += p.batCap || 0
batRate += p.batRate || 0
}
return { gen, batCap, batRate }
}
/** newBank(grid) — battery state, boots full (ships launch charged). */
const newBank = (grid) => ({ charge: grid.batCap })
const BROWNOUT_ENTER = 0.02 // bank fraction below which brownout latches
const BROWNOUT_EXIT = 0.25 // …and the recovery fraction that clears it
// hysteresis: without it the ship strobes in/out of brownout every powerTick at the
// boundary (the classic flicker); enter low, exit only after real recovery.
/** powerTick(grid, bank, demand, dt) — one power powerTick.
* demand: P/s requested by consumers this powerTick (weapons + thrusters).
* Returns { supplied (0..1 fraction of demand met), brownout } and mutates bank.
* Order: gen covers demand first; shortfall draws the bank (≤ batRate);
* surplus charges the bank (≤ batRate). */
function powerTick(grid, bank, demand, dt) {
const genE = grid.gen * dt
const needE = Math.max(0, demand) * dt
let supplied = 0
if (needE <= genE) {
supplied = 1
// surplus charges the bank, rate-limited
const room = grid.batCap - bank.charge
bank.charge += Math.min(room, Math.min(genE - needE, grid.batRate * dt))
} else {
const short = needE - genE
const draw = Math.min(short, grid.batRate * dt, bank.charge)
bank.charge -= draw
supplied = needE > 0 ? (genE + draw) / needE : 1
}
// brownout latch with hysteresis on bank fraction (or no storage at all)
const frac = grid.batCap > 0 ? bank.charge / grid.batCap : 0
if (bank.brown) { if (frac >= BROWNOUT_EXIT) bank.brown = false }
else if (supplied < 1 - 1e-9 && frac <= BROWNOUT_ENTER) bank.brown = true
return { supplied, brownout: !!bank.brown }
}
/** brownout multipliers — the whole rule in one place */
const BROWN_GUN = 0.5 // weapons fire at half rate
const BROWN_THRUST = 0.7 // thrusters at 70%
/** powerBudget(tiles, consumers) — the DESIGNER readout: can this ship sustain its
* own appetite? consumers: [{name, drain}] steady-state P/s.
* → { gen, drain, margin, burstSeconds } — burstSeconds = how long full
* appetite runs on batteries alone once gen is exceeded (Infinity if gen covers). */
function powerBudget(tiles, consumers) {
const grid = gridOf(tiles)
const drain = consumers.reduce((a, c) => a + (c.drain || 0), 0)
const margin = grid.gen - drain
// time until the bank empties at the actual draw rate (rate-capped); if the
// rate can't even cover the shortfall the ship browns out DURING the burst —
// fullBurst says whether the burst runs at full power
const short = Math.max(0, -margin)
const draw = Math.min(short, grid.batRate)
const burstSeconds = short === 0 ? Infinity : (draw > 0 ? grid.batCap / draw : 0)
return { gen: grid.gen, drain, margin, burstSeconds, fullBurst: short === 0 || grid.batRate >= short }
}
return { gridOf, newBank, powerTick, powerBudget, BROWN_GUN, BROWN_THRUST, BROWNOUT_ENTER, BROWNOUT_EXIT }
})()
// ── V2/turret (generated from turret.mjs — edit THAT file + rerun build-engine-v2) ──
const { arcOf, arcWidth, inArc, clampToArc, newMount, traverse, canFire, mountFire, mountCool, wrapAng, SECTOR_HALF, AIM_TOL } = (() => {
// turret.mjs — PENTARCH turrets: ARC EARNED BY PLACEMENT. Each FREE edge of the
// turret's tile grants a 72° firing sector centered on that edge's outward
// normal; adjacent free edges tile into one contiguous arc (36° half-widths meet
// exactly). An interior tile grants nothing — bury a turret and it is blind.
// Weapons SLOT ONTO turrets (two-layer mounts); the turret owns traverse.
// Render-free; DESIGN-ship-systems.md §2.
const ST = (2 * Math.PI) / 5
const SECTOR_HALF = Math.PI / 5 // 36° — one pentagon edge's share
const wrapAng = (a) => { let x = a % (2 * Math.PI); if (x > Math.PI) x -= 2 * Math.PI; if (x < -Math.PI) x += 2 * Math.PI; return x }
/** arcOf(tiles, i) — the firing sectors tile i has EARNED: [{center, half}]
* (ship-frame angles). Empty array = blind mount (interior tile). */
function arcOf(tiles, i) {
const free = freeEdgesV2(tiles).filter(f => f.i === i)
return free.map(f => {
const t = tiles[i]
const center = t.th + Math.PI / 2 + (f.e + 0.5) * ST
return { center: wrapAng(center), half: SECTOR_HALF }
})
}
/** total sweep in radians (the designer's one-number readout for a mount) */
const arcWidth = (sectors) => sectors.length * 2 * SECTOR_HALF
/** inArc(sectors, ang) — may the turret aim at ship-frame angle `ang`? */
function inArc(sectors, ang) {
return sectors.some(s => Math.abs(wrapAng(ang - s.center)) <= s.half + 1e-9)
}
/** clampToArc(sectors, ang) — the nearest permitted aim to `ang` */
function clampToArc(sectors, ang) {
if (!sectors.length) return null
if (inArc(sectors, ang)) return wrapAng(ang)
let best = null, bd = Infinity
for (const s of sectors) {
for (const edge of [s.center - s.half, s.center + s.half]) {
const d = Math.abs(wrapAng(ang - edge))
if (d < bd) { bd = d; best = wrapAng(edge) }
}
}
return best
}
/** newMount(tiles, i, spec) — turret state on tile i.
* spec: { rate (rad/s traverse), weapon: {range, damage, energyPerShot, cooldown} | null } */
function newMount(tiles, i, spec = {}) {
const sectors = arcOf(tiles, i)
const aim = sectors.length ? sectors[0].center : 0
return { i, sectors, aim, rate: spec.rate ?? 2.5, weapon: spec.weapon ?? null, cd: 0 }
}
/** traverse(mount, targetAng, dt) — rate-limited swing toward the nearest
* permitted aim. (v1 simplification, documented: the aim may pass through a
* blocked zone mid-swing — the CLAMP guarantees it never RESTS or FIRES there.) */
function traverse(mount, targetAng, dt) {
const goal = clampToArc(mount.sectors, targetAng)
if (goal == null) return mount.aim
const d = wrapAng(goal - mount.aim)
const step = Math.max(-mount.rate * dt, Math.min(mount.rate * dt, d))
mount.aim = wrapAng(mount.aim + step)
return mount.aim
}
const AIM_TOL = 0.06 // ~3.4° — close enough to loose a shot
/** canFire(mount, targetAng, dist) — aimed on target, target in arc, in range,
* off cooldown. Energy is the power grid's business (energy2), not ours. */
function canFire(mount, targetAng, dist) {
if (!mount.weapon || mount.cd > 0) return false
if (dist > mount.weapon.range) return false
if (!inArc(mount.sectors, targetAng)) return false
return Math.abs(wrapAng(targetAng - mount.aim)) <= AIM_TOL
}
/** mountFire(mount) — commit a shot: returns its energy price, starts cooldown */
function mountFire(mount) {
mount.cd = mount.weapon.cooldown
return mount.weapon.energyPerShot
}
const mountCool = (mount, dt) => { mount.cd = Math.max(0, mount.cd - dt) }
return { arcOf, arcWidth, inArc, clampToArc, newMount, traverse, canFire, mountFire, mountCool, wrapAng, SECTOR_HALF, AIM_TOL }
})()
// ── V2/route (generated from route.mjs — edit THAT file + rerun build-engine-v2) ──
const { arcToPoint, maxSpeedForKappa, clickCommand, resample, curvatures, speedProfile, follow, arcPath } = (() => {
// route.mjs — PENTARCH route command: click → a feasible arc to the point;
// click-HOLD → a drawn polyline fitted to WHAT IS POSSIBLE. The honest core:
// any path is traversable *slowly* (a ship can crawl a hairpin), so "possible"
// is a SPEED PROFILE — where the hull's envelope forces it to slow, and what
// the route will actually cost in time. The drawn wish renders as ghost, the
// feasible fit as solid; the gap teaches the hull. DESIGN-ship-systems.md §6.
// Render-free; consumes phys.envelope().
/** curvature demanded to arc from (pos, heading) onto target — the classic
* arc-to-point: κ = 2·sin(bearing)/distance (bearing = angle target sits off
* the nose). Sign = turn direction. */
function arcToPoint(pos, heading, target) {
const dx = target.x - pos.x, dy = target.y - pos.y
const d = Math.hypot(dx, dy)
if (d < 1e-9) return { kappa: 0, dist: 0 }
const bearing = Math.atan2(dy, dx) - heading
return { kappa: 2 * Math.sin(bearing) / d, dist: d }
}
/** the fastest speed at which curvature κ is holdable:
* lateral limit v ≤ √(aLat/|κ|) (centripetal budget)
* yaw limit v ≤ ω_max/|κ| (the nose must keep up; ω_max ≈ √(α)·damp) */
function maxSpeedForKappa(env, kappa) {
const k = Math.abs(kappa)
if (k < 1e-9) return env.vMax
const wMax = Math.sqrt(Math.max(env.alpha, 1e-9)) // drag-limited yaw-rate proxy
return Math.min(env.vMax, Math.sqrt(Math.max(env.aLat, 1e-9) / k), wMax / k)
}
/** arcPath(pos, heading, target, env, ds) — the actual CURVE a click plans:
* leaves along the CURRENT heading, bends at the arc-to-point curvature
* (capped to what the envelope can hold), marches to the target. This is what
* gets DRAWN, so the player sees the real path, not a teleport-line. */
function arcPath(pos, heading, target, env, ds = 0.45) {
const pts = [{ x: pos.x, y: pos.y }]
let p = { x: pos.x, y: pos.y }, h = heading
const maxSteps = Math.ceil((arcToPoint(pos, heading, target).dist * 3 + 8) / ds)
for (let i = 0; i < maxSteps; i++) {
const { kappa, dist } = arcToPoint(p, h, target)
if (dist < ds) break
const kCap = Math.max(env && env.aLat ? env.aLat : 1, 0.4) * 1.2 // generous geometric cap
const k = Math.max(-kCap, Math.min(kCap, kappa))
h += k * ds
p = { x: p.x + Math.cos(h) * ds, y: p.y + Math.sin(h) * ds }
pts.push({ x: p.x, y: p.y })
}
pts.push({ x: target.x, y: target.y })
return pts
}
/** click command → { kappa, dist, vAdvise } — steer this arc at this speed */
function clickCommand(pos, heading, target, env) {
const { kappa, dist } = arcToPoint(pos, heading, target)
return { kappa, dist, vAdvise: maxSpeedForKappa(env, kappa) }
}
/** resample(points, ds) — even spacing along a drawn polyline (input is raw
* mouse samples: jittery, uneven). */
function resample(points, ds = 0.25) {
if (points.length < 2) return points.map(p => ({ x: p.x, y: p.y }))
const out = [{ x: points[0].x, y: points[0].y }]
let prev = { x: points[0].x, y: points[0].y }
let need = ds
for (let i = 1; i < points.length; i++) {
const cur = { x: points[i].x, y: points[i].y }
let seg = Math.hypot(cur.x - prev.x, cur.y - prev.y)
while (seg >= need && seg > 1e-12) {
const t = need / seg
prev = { x: prev.x + (cur.x - prev.x) * t, y: prev.y + (cur.y - prev.y) * t }
out.push({ ...prev })
seg = Math.hypot(cur.x - prev.x, cur.y - prev.y)
need = ds
}
need -= seg
prev = cur
}
const last = points[points.length - 1]
const tail = out[out.length - 1]
if (Math.hypot(last.x - tail.x, last.y - tail.y) > 1e-9) out.push({ x: last.x, y: last.y })
return out
}
/** curvature at each sample of a polyline (circumcircle of consecutive triplets;
* endpoints inherit their neighbor's). */
function curvatures(pts) {
const n = pts.length
const ks = new Array(n).fill(0)
for (let i = 1; i < n - 1; i++) {
const a = pts[i - 1], b = pts[i], c = pts[i + 1]
const abx = b.x - a.x, aby = b.y - a.y
const bcx = c.x - b.x, bcy = c.y - b.y
const cross = abx * bcy - aby * bcx
const la = Math.hypot(abx, aby), lb = Math.hypot(bcx, bcy), lc = Math.hypot(c.x - a.x, c.y - a.y)
const denom = la * lb * lc
ks[i] = denom > 1e-12 ? (2 * cross) / denom : 0
}
if (n > 2) { ks[0] = ks[1]; ks[n - 1] = ks[n - 2] }
return ks
}
/** speedProfile(pts, env, v0) — THE "what is possible" calculation.
* Three passes: curvature cap per point → forward accel ramp from v0 →
* backward brake ramp (arrive at rest). Returns [{x, y, v, kappa}] + eta. */
function speedProfile(pts, env, v0 = 0) {
const n = pts.length
if (n === 0) return { points: [], eta: 0 }
if (n === 1) return { points: [{ ...pts[0], v: 0, kappa: 0 }], eta: 0 }
const ks = curvatures(pts)
const v = ks.map(k => maxSpeedForKappa(env, k))
const ds = []
for (let i = 0; i < n - 1; i++) ds.push(Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y))
const acc = Math.max(env.aFwd, 1e-6)
v[0] = Math.min(v[0], Math.max(v0, 0))
for (let i = 1; i < n; i++) v[i] = Math.min(v[i], Math.sqrt(v[i - 1] * v[i - 1] + 2 * acc * ds[i - 1]))
v[n - 1] = 0 // routes END — arrive, don't fly through
// BRAKE HONESTY: a hull with no retro thrust cannot "flip mains to brake" —
// it decelerates on aBack + DRAG only. (The old plan promised stops the ship
// couldn't perform → overshoot → limp-around. Buy retro JETS to go fast.)
const DRAG_R = 0.6 // mirror of phys.DRAG
for (let i = n - 2; i >= 0; i--) {
const brakeI = Math.max(env.aBack, 1e-6) + DRAG_R * v[i + 1]
v[i] = Math.min(v[i], Math.sqrt(v[i + 1] * v[i + 1] + 2 * brakeI * ds[i]))
}
let eta = 0
for (let i = 0; i < n - 1; i++) { const vm = Math.max((v[i] + v[i + 1]) / 2, 0.05); eta += ds[i] / vm }
return { points: pts.map((p, i) => ({ x: p.x, y: p.y, v: v[i], kappa: ks[i] })), eta }
}
/** follow(state, profile, env) — the steering command for the current tick:
* chase the nearest-ahead profile point with the arc command at its planned
* speed. Returns { want:{fwd,lat,turn}, done } for phys.step/allocate.
* v1: bang-bang on speed error, proportional on heading — game-grade. */
function follow(state, profile, env, lookahead = 0.9) {
const pts = profile.points
if (!pts.length) return { want: { fwd: 0, lat: 0, turn: 0 }, done: true }
const end = pts[pts.length - 1]
const dEnd = Math.hypot(end.x - state.x, end.y - state.y)
const speed = Math.hypot(state.vx, state.vy)
if (dEnd < 0.9 && speed < 0.9) return { want: { fwd: 0, lat: 0, turn: 0 }, done: true }
// nearest path point, then a lookahead point AHEAD of it along the path
let ni = 0, nd = Infinity
for (let i = 0; i < pts.length; i++) { const d = Math.hypot(pts[i].x - state.x, pts[i].y - state.y); if (d < nd) { nd = d; ni = i } }
let ti = ni
while (ti < pts.length - 1 && Math.hypot(pts[ti].x - state.x, pts[ti].y - state.y) < lookahead) ti++
const tgt = pts[ti]
// DESIRED VELOCITY: toward the lookahead point at the plan's speed — with a
// floor when far off-path/route so recovery actually closes the gap (the old
// controller crept at zero forever when the only near point was the vʼ=0 end)
const gx = tgt.x - state.x, gy = tgt.y - state.y
const gd = Math.hypot(gx, gy) || 1
// floors: recovery floor when far off-path, and a DOCKING floor so the
// v→0 endpoint never becomes an asymptote (zeno-crawl: 60s to cross 1 unit)
let vGoal = Math.max(tgt.v, Math.min(0.7, dEnd * 0.6),
Math.min(dEnd, nd) > 1.2 ? Math.min(2.2, (env.vMax || 2) * 0.5) : 0)
// TURN-RADIUS CAP: near the end, speed must shrink until the nose can swing
// inside the arrival zone (v/ω ≤ dEnd) — else the ship ORBITS the point
// forever at its minimum turn radius (the spiral the traces kept showing)
const omMax = Math.max(0.3, (env.alpha || 1) / 1.4)
vGoal = Math.min(vGoal, Math.max(0.45, dEnd * omMax * 0.5))
const vdx = gx / gd * vGoal, vdy = gy / gd * vGoal
// PURE PURSUIT (keel-era): point the nose at the pursuit point, throttle to
// the plan speed, let the keel turn drift into track. Reads like a ship.
const c = Math.cos(state.th), sn = Math.sin(state.th)
const hb = Math.atan2(vdy, vdx) - state.th
const b = Math.atan2(Math.sin(hb), Math.cos(hb))
const speedAlong = c * state.vx + sn * state.vy
const fwd = Math.max(-1, Math.min(1, (vGoal - speedAlong) * 1.3)) * (Math.abs(b) < 1.9 ? 1 : 0.25)
const lat = Math.max(-1, Math.min(1, b * 0.35)) // gentle side assist; the keel carves
const turn = Math.max(-1, Math.min(1, b * 2.0 - state.om * 0.45))
return { want: { fwd, lat, turn }, done: false }
}
return { arcToPoint, maxSpeedForKappa, clickCommand, resample, curvatures, speedProfile, follow, arcPath }
})()
// ── V2/slices (generated from slices.mjs — edit THAT file + rerun build-engine-v2) ──
const { packChamberSlices, chamberVolume } = (() => {
// slices.mjs — chamber slice packing (Galen: "made out of the pentagonal
// slices that make it up"; "IT MUST be calculated from the remainder of space
// in the area — whatever slices of that big pentagon could go into it").
//
// A chamber is the HOLE removed tiles left. Its interior is filled with ghost
// slices of the TILE-SIZED pentagon — the same pentagon the ship is built
// from. Packing is computed HERE, in tested JS, from the cavity's real radius:
// candidate ghost pentagons go center-first then in rings, and each of a
// ghost's 5 corner-to-center wedges is fit-tested INDIVIDUALLY against the
// cavity (all three wedge corners inside → the wedge fits). The result is a
// wedge MASK per ghost — partial pentagons appear as partial fans, exactly
// "whatever slices could go into it". The remainder no wedge claims stays
// negative space. The shader just draws the masks; it never packs.
//
// Units: tile units (SIDE = 1). CIRCUM = tile circumradius = 0.85065…
// Placement: { dx, dy, rot, mask } in chamber-local frame (rotate by the
// chamber's angle + translate to its center before pushing to the pop).
const CIRCUM_S = 1 / (2 * Math.sin(Math.PI / 5)) // 0.85065…
const APOTHEM_S = 1 / (2 * Math.tan(Math.PI / 5)) // 0.68819…
const STEP_S = (2 * Math.PI) / 5
/** All 5 wedge-corner triples of a ghost pentagon at (dx,dy) rotated rot.
* Wedge k = { center, vertex k, vertex k+1 }, vertex k at rot + 90° + k·72°. */
function wedgeCorners(dx, dy, rot, k) {
const a0 = rot + Math.PI / 2 + k * STEP_S
const a1 = rot + Math.PI / 2 + (k + 1) * STEP_S
return [
{ x: dx, y: dy },
{ x: dx + CIRCUM_S * Math.cos(a0), y: dy + CIRCUM_S * Math.sin(a0) },
{ x: dx + CIRCUM_S * Math.cos(a1), y: dy + CIRCUM_S * Math.sin(a1) },
]
}
const PENT_AREA = 1.7204774 // area of a unit (side-1) tile pentagon
/** chamberVolume(area) — the NEGATIVE-SPACE VOLUME of ONE chamber, in
* tile-pentagon units (Galen: power scales by "the VOLUME of the negative
* space… per chamber", not by chamber count). = the hole's own AREA (from
* classify, per-hole) / one pentagon's area — TRULY CONTINUOUS, so every size
* difference changes power (the old wedge-count measure stepped only at ring
* thresholds, so bays in the 1.0–1.5 range all read a flat 1.0 → "one copy
* isn't more powerful"). Floors so a tiny chamber still fires something. */
function chamberVolume(area) {
return Math.max(0.2, (+area || 0) / PENT_AREA)
}
/** Pack tile-pentagon slices into a circular cavity of radius r (tile units).
* Returns [{ dx, dy, rot, mask }] — mask bit k set ⇢ wedge k fits whole. */
function packChamberSlices(r) {
const fitR = r * 0.96 // small standoff from the wall
const out = []
// candidate ghosts: center, ring of 5 (edge-to-edge spacing), ring of 10
const cands = [{ dx: 0, dy: 0, rot: 0 }]
const d1 = 2 * APOTHEM_S // 1.376… — pentagons touching
for (let k = 0; k < 5; k++) {
const a = Math.PI / 2 + k * STEP_S
cands.push({ dx: Math.cos(a) * d1, dy: Math.sin(a) * d1, rot: a + Math.PI }) // face the hub
}
const d2 = 2 * d1
for (let k = 0; k < 10; k++) {
const a = Math.PI / 2 + STEP_S / 2 + k * STEP_S / 2
cands.push({ dx: Math.cos(a) * d2, dy: Math.sin(a) * d2, rot: a })
}
for (const c of cands) {
let mask = 0
for (let k = 0; k < 5; k++) {
const ok = wedgeCorners(c.dx, c.dy, c.rot, k)
.every(p => Math.hypot(p.x, p.y) <= fitR)
if (ok) mask |= (1 << k)
}
if (mask) out.push({ dx: c.dx, dy: c.dy, rot: c.rot, mask })
}
return out
}
return { packChamberSlices, chamberVolume }
})()
// ── V2/growth (generated from growth.mjs — edit THAT file + rerun build-engine-v2) ──
const { CHAMBER_GROWTH, GOLD_MULT, chamberPower, powerIndex, discoverCombos, COMBOS } = (() => {
// growth.mjs — the chamber WEAPON GROWTH + GOLD SUPERWEAPON + COMBO system.
//
// Galen's vision: "each pentagon made equals how much the weapon is improved.
// building out balanced growth scale array for all chamber base types … large
// enough hits superweapon GOLD state, which DOUBLES and FINALIZES how big the
// weapon can be. invent new superweapons for discovered combos."
//
// The chamber's slice packing (slices.mjs) yields a PENTAGON COUNT — how many
// tile-pentagons geometrically fit the cut-out. That count is the gameplay
// quantity: it drives the weapon up a BALANCED growth curve per chamber type,
// FINALIZING at a gold threshold where the weapon turns gold and DOUBLES.
// Distinct chamber shapes present on one ship DISCOVER combo superweapons.
//
// Pure math, no IO — unit-tested in test/growth.test.mjs (proper-always).
// Per base type: the weapon it powers, the primary stat it scales, the base
// value at 1 pentagon, the per-pentagon step, and the gold threshold (pentagon
// count at which the weapon FINALIZES and turns gold). Tuned so each type's
// effective combat budget is comparable at equal pentagon count — see the
// balance test which asserts the pre-gold "power index" bands stay within
// tolerance across every type.
const CHAMBER_GROWTH = {
// goldAt is a NEGATIVE-SPACE VOLUME threshold (tile-pentagon units): a genuinely
// big chamber (vol ~4-5, cavity radius ~2.2-2.5) crosses into gold.
// weapon stat base step goldAt blurb
diamond: { weapon: 'LASER', stat: 'damage', base: 5, step: 2.4, goldAt: 4.5, blurb: 'forward piercing beam' },
moon: { weapon: 'WAVE', stat: 'waveDmg', base: 6, step: 2.6, goldAt: 4.5, blurb: 'expanding shock-arc' },
star: { weapon: 'LANCE', stat: 'rateMul', base: 1.0, step: 0.42, goldAt: 4.0, blurb: 'fire-rate super-weapon' },
bay: { weapon: 'MISSILES', stat: 'missiles', base: 3, step: 0.9, goldAt: 5.0, blurb: 'tracking missile volley' },
circle: { weapon: 'SHIELD', stat: 'shieldCap', base: 12, step: 5.0, goldAt: 4.5, blurb: 'regenerating shield field' },
cell: { weapon: 'BATTERY', stat: 'battery', base: 10, step: 3.6, goldAt: 5.0, blurb: 'battery capacity' },
}
// Rough effective-combat weight per unit of each stat — ONLY used by the balance
// test to prove the growth curves stay in one band (so no chamber type is a
// runaway). Not read at runtime.
const STAT_WEIGHT = { damage: 1.0, waveDmg: 0.9, rateMul: 11.0, missiles: 3.4, shieldCap: 0.85, battery: 0.9 }
const GOLD_MULT = 3 // gold TRIPLES the finalized weapon (Galen: "superweapon needs to be REALLY powerful")
/** chamberPower(shape, volume) → the weapon's live power at this negative-space
* VOLUME (Galen: power scales by the volume of the negative space, not the
* number of chambers). `volume` is continuous (chamberVolume — tile-pentagon
* units; 1.0 = a full center pentagon). Growth is linear up to goldAt, then
* FINALIZES; crossing goldAt flips GOLD on and multiplies by GOLD_MULT. */
function chamberPower(shape, volume) {
const g = CHAMBER_GROWTH[shape] || CHAMBER_GROWTH.cell
const v = Math.max(0.2, +volume || 0.2) // continuous negative-space volume
const capped = Math.min(v, g.goldAt) // growth FINALIZES at goldAt
let value = g.base + g.step * (capped - 1)
const gold = v >= g.goldAt
if (gold) value *= GOLD_MULT // gold multiplies the finalized max
return {
shape, weapon: g.weapon, stat: g.stat,
tier: Math.round(capped * 100) / 100, volume: Math.round(v * 100) / 100, goldAt: g.goldAt, gold,
value: Math.round(Math.max(value, g.base * 0.35) * 1000) / 1000, // a chamber always fires SOMETHING
}
}
/** powerIndex — a chamber's abstract combat weight (for balance reasoning). */
function powerIndex(shape, pentCount) {
const p = chamberPower(shape, pentCount)
return (STAT_WEIGHT[p.stat] || 1) * p.value
}
// ── COMBO SUPERWEAPONS (Galen: "invent new superweapons for discovered
// combos") — distinct chamber shapes on one ship fuse. `need` is a shape→
// count requirement; the ship's shape multiset must meet ALL of it. Ordered
// most-specific first so the STRONGEST satisfied combo reads as the headline.
const COMBOS = [
{ id: 'ascendant', need: { star: 1, diamond: 1, moon: 1 }, name: 'ASCENDANT', effect: 'omni', desc: 'star, lance and wave align — a rotating omni-beam' },
{ id: 'prismlance', need: { star: 1, diamond: 1 }, name: 'PRISM LANCE', effect: 'pierce', desc: 'the star focuses the diamond into a piercing gold beam' },
{ id: 'swarmstar', need: { star: 1, bay: 1 }, name: 'SWARM STAR', effect: 'doubleVolley', desc: 'star-charged double missile swarm' },
{ id: 'aegispulse', need: { moon: 1, circle: 1 }, name: 'AEGIS PULSE', effect: 'shieldBurst', desc: 'the shield discharges a shock-wave when struck' },
{ id: 'novacore', need: { star: 2 }, name: 'NOVA CORE', effect: 'nova', desc: 'twin stars — a periodic nova blast' },
{ id: 'twinlance', need: { diamond: 2 }, name: 'TWIN LANCE', effect: 'heavyBeam', desc: 'two lasers cross into one heavy beam' },
{ id: 'carrier', need: { bay: 2 }, name: 'CARRIER', effect: 'barrage', desc: 'sustained missile barrage' },
{ id: 'resonance', need: { moon: 2 }, name: 'RESONANCE', effect: 'ampWave', desc: 'overlapping waves amplify each other' },
{ id: 'bastion', need: { circle: 2 }, name: 'BASTION', effect: 'fullDome', desc: 'two shields fuse into a full dome' },
]
/** discoverCombos(shapes) → the combo superweapons a ship's chamber shapes
* unlock. `shapes` is an array like ['star','diamond','bay']. */
function discoverCombos(shapes) {
const cnt = {}
for (const s of shapes || []) cnt[s] = (cnt[s] || 0) + 1
return COMBOS.filter(c => Object.entries(c.need).every(([s, n]) => (cnt[s] || 0) >= n))
}
return { CHAMBER_GROWTH, GOLD_MULT, chamberPower, powerIndex, discoverCombos, COMBOS }
})()
// ── V2/shiprender (generated from shiprender.mjs — edit THAT file + rerun build-engine-v2) ──
const { chamberPop, CHAMBER_CODE } = (() => {
// shiprender.mjs — the UNIVERSAL ship-structure renderer (Galen: "we need a
// universal ship rendering system. we have a split.").
//
// THE LAW: no hook hand-assembles ship-structure entities. Yard, flight, and
// elites all call THIS module; the shader draws what it emits. Any place that
// builds ship pop quads by hand is a split waiting to fail res-match.
//
// Already-universal pieces this module completes: tileCode (lib/catalogue, one
// truth) and packChamberSlices (slices.mjs). What was split three ways — the
// CHAMBER assembly (heartbeat glyph + packed slices + gold flag) — is now one
// function. Encodings are INTRINSIC (tile units in fract; the shader sizes via
// the mode's world-scale uniform S), so design==battle by construction.
//
// chamberPop(chambers, frame) → flat [x, y, ang, code, ...] quads.
// chambers: [{ shape, cx, cy, r, ang, gold, dead }] in SHIP-LOCAL tile units
// shape — diamond|moon|star|bay|circle|cell gold — burn gold (+400)
// dead — breached/destroyed: emits NOTHING (glyph goes dark by absence)
// frame: { ox, oy, rot, S } — ship origin in WORLD-UV, ship rotation, and
// the mode's world scale (yard's fit-S or battle's BS). The frame carries
// ALL mode-ness; the art channels never do.
const CHAMBER_CODE = { diamond: 71, moon: 72, star: 73, bay: 74, circle: 75, cell: 81 }
function chamberPop(chambers, frame) {
const S = frame.S || 0.1
const rot = frame.rot || 0
const ox = frame.ox || 0, oy = frame.oy || 0
const ca = Math.cos(rot), sa = Math.sin(rot)
const out = []
for (const ch of chambers || []) {
if (!ch || ch.dead) continue
const code = CHAMBER_CODE[ch.shape]
if (!code) continue
const g = ch.gold ? 400 : 0
const r = ch.r || 0.5
const wx = ox + (ch.cx * ca - ch.cy * sa) * S
const wy = oy + (ch.cx * sa + ch.cy * ca) * S
const aC = (ch.ang || 0) + rot
// ONE entity per chamber — the shader draws the whole chamber PROGRAMMATICALLY
// (Galen: "draw chambers programmatically… pentagons should not overlap,
// graphics fold into graphics"). No per-slice sprites: the sub-pentagons
// smin-FOLD into one continuous surface in the shader, from this heartbeat.
out.push(wx, wy, aC, g + code + Math.min(0.99, r * 0.5)) // heartbeat: fract = INTRINSIC r/2
}
return out
}
return { chamberPop, CHAMBER_CODE }
})()
// ═══════════════ end V2 ═══════════════
return { makeUnit, statOf, partOf, holes, routeGraph, aliveTiles, ringHolder, tickIncome, trySpawn, unitCost, steer, gunBeams, gunPorts, nearestEnemyTile, applyBeam, shedUnit, unitDead, starArmed, chargeStar, fireLance, enemyKey, checkWin, seatsWithUnits, allRingsHeldBy, DEFAULT_SCALE, CAPTURE_RADIUS, SPECIALS, holeKey, shapePayout, massProps, edgeNormal, thrusters, allocate, netWrench, envelope, flyStep, DRAG, MOUNTS, shipMass, aimGimbal, gridOf, newBank, powerTick, powerBudget, BROWN_GUN, BROWN_THRUST, arcOf, arcWidth, inArc, clampToArc, newMount, traverse, canFire, mountFire, mountCool, wrapAng, SECTOR_HALF, arcToPoint, maxSpeedForKappa, clickCommand, resample, curvatures, speedProfile, follow, arcPath, freeEdgesV2, packChamberSlices, chamberVolume, CHAMBER_GROWTH, GOLD_MULT, chamberPower, powerIndex, discoverCombos, COMBOS, chamberPop, CHAMBER_CODE };
})();
// ── penta math (inlined from the tested core) ──
const AP = 1 / (2 * Math.tan(Math.PI / 5)), CR = 1 / (2 * Math.sin(Math.PI / 5)), ST = 2 * Math.PI / 5
const ena = (t, e) => t.th + Math.PI / 2 + (e + 0.5) * ST
// ce = which of the CHILD's edges mates onto the parent edge (0 = canonical, today's
// behaviour). A flush contact is only fully described by BOTH edges — recording just
// the parent edge scrambled re-rooted subtrees after route-aware deletes.
const attach = (t, e, ce = 0) => { const n = ena(t, e); return { cx: t.cx + 2 * AP * Math.cos(n), cy: t.cy + 2 * AP * Math.sin(n), th: n + Math.PI / 2 - Math.PI / 5 - ce * ST } }
const verts = (t) => { const o = []; for (let k = 0; k < 5; k++) { const a = t.th + Math.PI / 2 + k * ST; o.push({ x: t.cx + CR * Math.cos(a), y: t.cy + CR * Math.sin(a) }) } return o }
const shrink = (t) => verts(t).map(v => ({ x: v.x + (t.cx - v.x) * 1e-4, y: v.y + (t.cy - v.y) * 1e-4 }))
const axes = (vs) => { const o = []; for (let i = 0; i < 5; i++) { const a = vs[i], b = vs[(i + 1) % 5]; const nx = -(b.y - a.y), ny = b.x - a.x, L = Math.hypot(nx, ny); o.push({ x: nx / L, y: ny / L }) } return o }
const overlaps = (t1, t2) => {
if (Math.hypot(t1.cx - t2.cx, t1.cy - t2.cy) > 2 * CR) return false
const v1 = shrink(t1), v2 = shrink(t2)
for (const ax of axes(v1).concat(axes(v2))) {
let a1 = Infinity, b1 = -Infinity, a2 = Infinity, b2 = -Infinity
for (const v of v1) { const p = v.x * ax.x + v.y * ax.y; if (p < a1) a1 = p; if (p > b1) b1 = p }
for (const v of v2) { const p = v.x * ax.x + v.y * ax.y; if (p < a2) a2 = p; if (p > b2) b2 = p }
if (b1 < a2 || b2 < a1) return false
}
return true
}
// ── THE MODULE CATALOGUE (parts.mjs, inlined) — the single source of truth for
// what a tile IS: cost, battle durability, design-stat, colour, category.
// The designer + the battle both read this. NAME/COST/STAT below are thin
// shims over it so the working v9 code is unchanged (values are identical). ──
const PARTS = [
{ code: 0, name: 'BLANK', category: 'BLANK', cost: 0, hp: 6, color: [0.30, 0.36, 0.46], stat: { mass: 0.5, hp: 4, dps: 0, thrust: 0, energy: 0 } },
{ code: 1, name: 'HULL', category: 'HULL', cost: 10, hp: 14, color: [0.36, 0.50, 0.65], stat: { mass: 1, hp: 10, dps: 0, thrust: 0, energy: 0 } },
{ code: 2, name: 'ARMOR', category: 'ARMOR', cost: 18, hp: 40, color: [0.54, 0.58, 0.65], stat: { mass: 4, hp: 30, dps: 0, thrust: 0, energy: 0 } }, // mass 4 (Galen): armor is HEAVY
{ code: 3, name: 'GUN', category: 'GUNS', cost: 30, hp: 12, color: [1.00, 0.48, 0.42], stat: { mass: 1.5, hp: 8, dps: 6, thrust: 0, energy: -2 } },
{ code: 4, name: 'ENGINE', category: 'DRIVE', cost: 22, hp: 12, color: [0.48, 0.86, 1.00], stat: { mass: 1, hp: 8, dps: 0, thrust: 4, energy: -1 } },
{ code: 5, name: 'GEN', category: 'POWER', cost: 26, hp: 10, color: [0.62, 1.00, 0.54], stat: { mass: 1, hp: 6, dps: 0, thrust: 0, energy: 4 } },
{ code: 6, name: 'JET', category: 'DRIVE', cost: 14, hp: 10, color: [0.62, 0.92, 1.00], stat: { mass: 0.7, hp: 6, dps: 0, thrust: 1.5, energy: -0.5 } },
{ code: 7, name: 'GYRO', category: 'DRIVE', cost: 16, hp: 10, color: [0.80, 0.78, 1.00], stat: { mass: 1, hp: 6, dps: 0, thrust: 0, energy: -1 } },
{ code: 8, name: 'BATTERY', category: 'POWER', cost: 20, hp: 10, color: [0.95, 1.00, 0.55], stat: { mass: 1.2, hp: 6, dps: 0, thrust: 0, energy: 0 } },
{ code: 9, name: 'FIXED', category: 'GUNS', cost: 18, hp: 12, color: [1.00, 0.66, 0.42], stat: { mass: 1.2, hp: 8, dps: 4, thrust: 0, energy: -1.5 } },
// TACTICS — a movement computer, not a thruster: fitting it flips the ship to
// ALWAYS-STRAFE flight (holds facing, repositions by the shortest sideways
// path). The lateral thrust still comes from JETS; this part only changes HOW
// the ship is flown. DRIVE category so it lives in the engine palette ring.
{ code: 10, name: 'TACTICS', category: 'DRIVE', cost: 24, hp: 10, color: [1.00, 0.55, 0.90], stat: { mass: 0.8, hp: 6, dps: 0, thrust: 0, energy: -1 } },
]
// V2 systems spec / facing / palette-variant rings (mirror of the ENG copy —
// same numbers; the designer scope needs its own view of them)
const V2SPEC = {
3: { turret: true, weapon: { range: 6, damage: 3, energyPerShot: 5, cooldown: 0.5 } },
4: { thrust: 10, drain: 2 },
5: { gen: 4 },
6: { thrust: 4, drain: 0.5 },
7: { torque: 6, drain: 1 },
8: { batCap: 20, batRate: 15 },
9: { fixed: true, weapon: { range: 5, damage: 2, energyPerShot: 3, cooldown: 0.4 } },
10: { tactics: true, drain: 1 }, // TACTICS — flips flight to always-strafe (read in flight.part.js)
}
const ORIENTABLE = { 0: 1, 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1, 9: 1, 10: 1 } // EVERY tile rotates with T (Galen: 'rotate any tile for bare min facing')
// TACTICS(10) rides the DRIVE ring: re-clicking the ENGINE slot cycles
// ENGINE → JET → GYRO → TACTICS, so it needs no new palette slot.
const PALCYCLE = { 3: [3, 9], 4: [4, 6, 7, 10], 5: [5, 8] }
const PALETTE = [1, 2, 3, 4, 5]
const CATEGORIES = ['HULL', 'ARMOR', 'GUNS', 'DRIVE', 'POWER']
const partOf = (part) => { if (part && typeof part === 'object') part = part.part; if (typeof part === 'string') { const up = part.toUpperCase(); const bn = PARTS.find(p => p.name === up); if (bn) return bn; part = Number(part) } return PARTS[part | 0] || PARTS[0] }
const statOf = (part) => { const p = partOf(part); return { mass: p.stat.mass, hp: p.stat.hp, dps: p.stat.dps, thrust: p.stat.thrust, energy: p.stat.energy, durability: p.hp, cost: p.cost, name: p.name, category: p.category, code: p.code } }
const NAME = PARTS.map((p) => p.name)
const COST = Object.fromEntries(PARTS.map((p) => [p.code, p.cost]))
// ── FUNCTION TEXT (Galen: "always on screen text the selected thing's
// function") — what each part DOES, in two short sidebar lines. 11 = HELM.
const DESC = {
0: ['Empty frame.', 'Cheap filler; can seal a shape.'],
1: ['Structure. Links parts together;', 'frames the sealed-shape tech tree.'],
2: ['Heavy plate. Soaks hits,', 'but weighs the ship down.'],
3: ['Turret gun. Tracks targets in its', 'arc. Mods: Y range · D dmg · P proj.'],
4: ['Main drive. Sets forward accel', 'and top speed. T aims the plume.'],
5: ['Reactor. Feeds drives and guns;', 'starved grids brown out to 70%.'],
6: ['Maneuver thruster. Strafe and', 'reverse live here, not in engines.'],
7: ['Gyro. Turn authority —', 'spins the nose faster.'],
8: ['Charge bank. Buffers burst drain', 'so volleys never starve.'],
9: ['Fixed cannon. Aim it with T.', 'Cheaper than a turret, hits hard.'],
10: ['Tactics computer. Always-strafe', 'flight: nose holds, moves sideways.'],
11: ['The Helm. Command core: base power,', 'small battery, helm authority.'],
}
// tile-code encoder — codes pack part + 10·orientation, which collides for
// part 10 (TACTICS reads as BLANK@o1). TACTICS is non-orientable, so it gets
// the single spare code 50; the shader decodes 50 → part 10 explicitly.
const tileCode = (part, o) => part === 10 ? 50 : part + 10 * (o || 0)
return { ENG, AP, CR, ST, ena, attach, verts, shrink, axes, overlaps, PARTS, V2SPEC, ORIENTABLE, PALCYCLE, PALETTE, CATEGORIES, partOf, statOf, NAME, COST, DESC, tileCode }
})()
globalThis.__PT_REV = '9411baa2bbc0'
}
hook · pt-frame
state frame: HOOK_V init/heal + starter + save mirror + undo
try {
const wd = sim.worldData
// ── STATE FRAME (build-base.mjs) — versioned + self-healing ──
const HOOK_V = 3
// STARTER SHIP (Galen: "ensure current ship design live on my account is
// starting design. so a player can just begin") — a fresh player boots into
// Galen's own 40-tile ship (baked from the live world into
// base/starter-ship.json at build time), battle-ready on tick one. Tests set
// wd.__bareYard BEFORE the first tick to boot the bare single-core yard the
// grow-from-nothing suites are written against. Existing states (v matches)
// are never touched.
const STARTER = {"tree": [{"m": 0, "o": 0, "edge": -1, "part": 1, "parent": -1}, {"m": 0, "o": 0, "ce": 0, "edge": 4, "part": 5, "parent": 0}, {"m": 0, "o": 0, "ce": 0, "edge": 0, "part": 5, "parent": 0}, {"m": 1, "o": 4, "ce": 0, "edge": 1, "part": 8, "parent": 0}, {"m": 1, "o": 0, "ce": 0, "edge": 2, "part": 9, "parent": 0}, {"m": 0, "o": 0, "ce": 0, "edge": 3, "part": 4, "parent": 1}, {"m": 0, "o": 0, "ce": 0, "edge": 2, "part": 8, "parent": 1}, {"m": 0, "o": 0, "ce": 0, "edge": 2, "part": 4, "parent": 2}, {"m": 0, "o": 0, "ce": 0, "edge": 3, "part": 8, "parent": 2}, {"m": 0, "o": 2, "ce": 0, "edge": 3, "part": 9, "parent": 4}, {"m": 0, "o": 3, "ce": 0, "edge": 2, "part": 9, "parent": 4}, {"m": 0, "o": 0, "ce": 1, "edge": 3, "part": 7, "parent": 5}, {"m": 0, "o": 0, "ce": 0, "edge": 3, "part": 4, "parent": 6}, {"m": 0, "o": 1, "ce": 0, "edge": 2, "part": 5, "parent": 6}, {"m": 0, "o": 0, "ce": 0, "edge": 2, "part": 4, "parent": 8}, {"m": 0, "o": 0, "ce": 0, "edge": 3, "part": 5, "parent": 8}, {"m": 2, "o": 2, "ce": 0, "edge": 3, "part": 9, "parent": 9}, {"m": 1, "o": 3, "ce": 0, "edge": 2, "part": 9, "parent": 9}, {"m": 2, "o": 3, "ce": 0, "edge": 2, "part": 9, "parent": 10}, {"m": 1, "o": 2, "ce": 0, "edge": 3, "part": 9, "parent": 10}, {"m": 0, "o": 0, "ce": 0, "edge": 3, "part": 4, "parent": 12}, {"m": 0, "o": 0, "ce": 3, "edge": 2, "part": 2, "parent": 13}, {"m": 0, "o": 0, "ce": 0, "edge": 3, "part": 2, "parent": 13}, {"m": 0, "o": 0, "ce": 0, "edge": 2, "part": 4, "parent": 14}, {"m": 0, "o": 0, "ce": 0, "edge": 3, "part": 2, "parent": 15}, {"m": 0, "o": 0, "ce": 0, "edge": 2, "part": 2, "parent": 15}, {"m": 0, "o": 1, "ce": 0, "edge": 3, "part": 4, "parent": 16}, {"m": 0, "o": 0, "ce": 0, "edge": 2, "part": 2, "parent": 17}, {"m": 0, "o": 4, "ce": 0, "edge": 2, "part": 4, "parent": 18}, {"m": 0, "o": 0, "ce": 3, "edge": 0, "part": 2, "parent": 21}, {"m": 0, "o": 0, "ce": 0, "edge": 3, "part": 7, "parent": 22}, {"m": 0, "o": 0, "ce": 0, "edge": 3, "part": 2, "parent": 24}, {"m": 0, "o": 0, "ce": 0, "edge": 2, "part": 7, "parent": 25}, {"m": 0, "o": 0, "ce": 0, "edge": 4, "part": 9, "parent": 29}, {"m": 0, "o": 0, "ce": 0, "edge": 3, "part": 6, "parent": 30}, {"m": 0, "o": 0, "ce": 0, "edge": 4, "part": 9, "parent": 31}, {"m": 0, "o": 0, "ce": 0, "edge": 2, "part": 6, "parent": 32}, {"edge": 3, "part": 8, "parent": 0}, {"edge": 4, "part": 5, "parent": 11}, {"edge": 2, "part": 5, "parent": 11}], "shapeChoices": {"0,1802": "reflect"}}
if (!wd.__pd || wd.__pd.v !== HOOK_V) {
wd.__pd = wd.__bareYard
? { v: HOOK_V, tree: [{ parent: -1, edge: -1, part: 1 }], sel: 0, rev: 0, t: 0 }
: { v: HOOK_V, tree: JSON.parse(JSON.stringify(STARTER.tree)), shapeChoices: JSON.parse(JSON.stringify(STARTER.shapeChoices)), sel: 0, rev: 0, t: 0 }
wd.__pderr = 0 // fresh state = clean slate: clear any stale heal-error counter carried from a corrupted __pd
}
const D = wd.__pd
// ── PER-PLAYER CONTINUITY (rides the engine's persist/save infrastructure) ──
// persist:true opts this world into per-user saves (self-deploying: the hook
// asserts it, no manual set_world_data needed).
if (wd.persist !== true) wd.persist = true
// SAVE-LOAD RACE FIX (Galen: "Save ship is not saving. reload kills it").
// The engine loads wd.save ASYNC (~1s after entry). The old mirror fired on
// tick 1 (rev 0 !== undefined) and wrote the fresh STARTER into wd.save —
// clobbering the real save BEFORE it loaded, and latching __saveAdopted so
// the returning ship was never restored. Fix: stamp the boot rev as already
// mirrored so the pristine boot is NEVER written; only a genuine change
// (the async adoption, or a real edit) ever mirrors.
if (D.__mirroredRev === undefined) { D.__bootRev = D.rev; D.__mirroredRev = D.rev }
// Adopt the saved CURRENT ship the moment it arrives, as long as the player
// hasn't edited yet (rev is still the boot rev). Advance __mirroredRev in
// lockstep so adopting does NOT mirror the adopted ship straight back.
if (!D.__saveAdopted && wd.save && wd.save.current && wd.save.current.tree && D.rev === D.__bootRev) {
D.__saveAdopted = true
D.tree = JSON.parse(JSON.stringify(wd.save.current.tree))
D.shapeChoices = JSON.parse(JSON.stringify(wd.save.current.shapeChoices || {}))
D.sel = 0; D.rev++; D.__mirroredRev = D.rev
}
// Mirror only a GENUINE post-boot change (a real edit bumps rev past the
// baseline) into wd.save.current — never the untouched boot/adopted state.
if (D.rev !== D.__mirroredRev && D.tree && D.tree.length) {
D.__mirroredRev = D.rev
wd.save = { ...(wd.save || {}), current: { tree: JSON.parse(JSON.stringify(D.tree)), shapeChoices: JSON.parse(JSON.stringify(D.shapeChoices || {})) } }
}
D.t += Math.min(dt, 1 / 30)
// UNDO (U): every mutation snapshots the tree first; U walks back (cap 40)
const pushU = () => { (D.undo = D.undo || []).push(JSON.stringify(D.tree)); if (D.undo.length > 40) D.undo.shift() }
if (sim.edge('yard-undo', !!wd.key_u) && D.undo && D.undo.length && D.mode !== 'battle') {
D.tree = JSON.parse(D.undo.pop()); D.sel = Math.min(D.sel, D.tree.length - 1); D.rev++; D.lastClick = null
wd.__play_sound = [{ frequency: 360, duration: 0.08, volume: 0.1, type: 'sine' }]
}
} catch (e) {
// NEVER a silent black world: heal soft (back to the yard, force relayout),
// then hard (fresh state) if it keeps throwing — and SAY SO on the HUD.
try {
const wd2 = sim.worldData, P = wd2.__pd
wd2.__pderr = (wd2.__pderr || 0) + 1
if (P && wd2.__pderr < 4) { P.mode = 'design'; P.bt = null; P.layoutRev = -999; P.sel = 0 }
else { wd2.__pd = null; wd2.__pderr = 0 }
wd2.hud = [{ id: 'err', type: 'text', x: '3%', y: '50%', text: 'RECOVERED: ' + String((e && e.message) || e).slice(0, 70), fontSize: '12px', color: '#ff8a7a' }]
} catch (e2) { }
}
hook · pt-menu
by claude-code
try {
const wd = sim.worldData
const D = wd.__pd
if (!D) return
const { ENG, AP, CR, ST, ena, attach, verts, shrink, axes, overlaps, PARTS, V2SPEC, ORIENTABLE, PALCYCLE, PALETTE, CATEGORIES, partOf, statOf, NAME, COST, DESC, tileCode } = globalThis.__PT
const pushU = () => { (D.undo = D.undo || []).push(JSON.stringify(D.tree)); if (D.undo.length > 40) D.undo.shift() }
// ── MAIN MENU (Galen: "make a main menu — bottom left design tool
// icon/button, back button in design mode, battle server on the main
// menu, logged in as 'cartridge user' text top right"; Aug 5: "we need
// main menu (not in yard)" — a full title page of its own: the player's
// LIVE ship slowly turning as the hero, DESIGN / BATTLE SERVER / QUICK
// BATTLE, nothing of the yard on screen (the shader hides yard chrome on
// uni(15)). The SERVERS/HOTSEAT screens live in hotseat.part.js; this
// file owns 'menu' + the design-mode ◂ MENU back pad. ──
// A fresh state (mode unset) and every fresh SESSION (engine __fresh latch)
// open on the menu. Harness sims (__bareYard) keep booting straight into the
// yard the 300-test suite is written against.
if (!wd.__bareYard) {
if (D.mode === undefined) D.mode = 'menu'
if (wd.__fresh) { delete wd.__fresh; D.mode = 'menu'; D.bt = null }
}
{
const ptrM = (wd.input && wd.input.pointer) || {}
const mxM = (typeof ptrM.x === 'number') ? ptrM.x : wd.mouse_x, myM = (typeof ptrM.y === 'number') ? ptrM.y : wd.mouse_y
const uxM = (typeof mxM === 'number') ? mxM / 256 - 1 : 0, uyM = (typeof myM === 'number') ? myM / 256 - 1 : 0
const clickM = sim.edge('menu-click', !!ptrM.down || wd.mouse_down === true)
const inRect = (x0, x1, y0, y1) => uxM >= x0 && uxM <= x1 && uyM >= y0 && uyM <= y1
// BACK (design → menu): the yard's ◂ MENU is a UI-SYSTEM button now
const t9d = wd.__uiClickT
if (D.mode === 'design' && t9d && t9d !== D.__ybkClickT0) {
D.__ybkClickT0 = t9d
if (String(wd.__uiClick || '') === 'y-menu') {
D.mode = 'menu'
wd.__play_sound = [{ frequency: 420, duration: 0.08, volume: 0.1, type: 'sine' }]
}
}
if (D.mode === 'menu') {
// chrome formats shared with the yard: 320 = panel (angle packs w*4096+h),
// 300+id+hw = button. RENDER uv is y-DOWN, same space as the mouse.
const out = []
const shS = 0.052 // showcase scale (units → uv)
// uni(7) is the tile radius scale the shader draws EVERY tile with — the
// showcase is invisible without it (R = uni(7)·0.85). uni(15)=1 → MENU
// SCENE backdrop, nothing of the yard underneath.
const uM = []; uM[0] = D.t; uM[7] = shS; uM[8] = uxM; uM[9] = uyM; uM[10] = 1; uM[15] = 1
for (let i = 0; i < 16; i++) if (uM[i] == null) uM[i] = 0
wd.__uniStage = uM
// UI-SYSTEM click routing (Galen Aug 10: "use ui model from design screen"):
// the boxes + labels are now ONE wd.ui tree solved into __uiRects (below),
// so a button hit arrives as wd.__uiClick — no hand rect math, no drift.
const t9m = wd.__uiClickT
if (t9m && t9m !== D.__menuClickT0) {
D.__menuClickT0 = t9m
const a9m = String(wd.__uiClick || '')
if (a9m === 'm-design') { D.mode = 'design'; sim.edge('yard-click', true); wd.__play_sound = [{ frequency: 560, duration: 0.1, volume: 0.12, type: 'sine' }]; return }
if (a9m === 'm-battle') { D.mode = 'servers'; D.__srvRows = null; sim.edge('hs-click', true); wd.__play_sound = [{ frequency: 400, duration: 0.08, volume: 0.1, type: 'sine' }, { frequency: 720, duration: 0.08, volume: 0.08, type: 'sine' }]; return }
if (a9m === 'm-quick') { D.__seatFoes = 0; D.mode = 'battle'; D.bt = null; wd.__play_sound = [{ frequency: 220, duration: 0.18, volume: 0.15, type: 'sawtooth' }, { frequency: 660, duration: 0.22, volume: 0.12, type: 'sine' }]; return }
}
// ── THE HERO: your live ship, slowly turning, bottom-right of the page.
// Same battle tile codes the flight scene draws — the real hull, not
// an icon. makeUnit lays the tree out exactly as battle will fly it;
// cached per design rev (a 40-tile layout is too heavy for every tick). ──
if (D.tree && D.tree.length) {
try {
if (!D.__showcase || D.__showcase.rev !== D.rev) {
const shU = ENG.makeUnit(D.tree, { seat: 0 })
D.__showcase = { rev: D.rev, tiles: shU.tiles.map((tl, ti) => ({ cx: tl.cx, cy: tl.cy, th: tl.th || 0, code: ti === 0 ? 200 : tileCode((D.tree[ti] || {}).part, (D.tree[ti] || {}).o || 0) })) }
}
const shTh = D.t * 0.25
const ca9 = Math.cos(shTh), sa9 = Math.sin(shTh)
const scx = 0.58, scy = 0.55
for (const tl of D.__showcase.tiles) {
out.push(scx + (tl.cx * ca9 - tl.cy * sa9) * shS, scy + (tl.cx * sa9 + tl.cy * ca9) * shS, tl.th + shTh, tl.code)
}
} catch (e9) { }
}
wd.gpuPopulation = out
const tilesN = (D.tree || []).length
const costN = (D.tree || []).reduce((a, c) => a + ((COST[c.part] != null ? COST[c.part] : 0)), 0)
// THE MENU AS ONE UI TREE (grid = 512 design units, gy y-down). Buttons
// carry click:'m-*' so the solver's rect IS the hit target — boxes and
// labels can never drift again. Bare labels use glass:false.
wd.ui = { rev: 1, root: [
{ id: 'm-title', kind: 'panel', glass: false, anchor: { gx: 256, gy: 46 }, align: 'tc', w: '64%', gap: 3, pad: 4, children: [
{ id: 'm-tt', kind: 'text', text: 'P E N T A R C H', fontSize: 24, color: '#cfe4ff', textAlign: 'center' },
{ id: 'm-ts', kind: 'text', text: 'pentagon hulls · edge-normal guns · the belt advances', fontSize: 8.5, color: '#8fb0d8', textAlign: 'center' },
] },
{ id: 'm-bs', kind: 'panel', click: 'm-battle', anchor: { gx: 256, gy: 196 }, align: 'tc', w: '30%', pad: 12, children: [
{ id: 'm-bst', kind: 'text', text: 'BATTLE SERVER', fontSize: 15, color: '#ffd9a8', textAlign: 'center' } ] },
{ id: 'm-qb', kind: 'panel', click: 'm-quick', anchor: { gx: 256, gy: 286 }, align: 'tc', w: '30%', pad: 9, children: [
{ id: 'm-qbt', kind: 'text', text: '⚔ QUICK BATTLE', fontSize: 12.5, color: '#ffb08a', textAlign: 'center' } ] },
{ id: 'm-ds', kind: 'panel', click: 'm-design', anchor: { gx: 12, gy: 500 }, align: 'bl', pad: 8, children: [
{ id: 'm-dst', kind: 'text', text: '⬠ DESIGN', fontSize: 14, color: '#9fd8ff' } ] },
{ id: 'm-who', kind: 'panel', glass: false, anchor: { gx: 500, gy: 12 }, align: 'tr', pad: 2, children: [
{ id: 'm-whot', kind: 'text', text: 'logged in as "cartridge user"', fontSize: 9, color: '#8fb0d8', textAlign: 'right' } ] },
{ id: 'm-sn', kind: 'panel', glass: false, anchor: { gx: 500, gy: 500 }, align: 'br', pad: 2, children: [
{ id: 'm-snt', kind: 'text', text: 'YOUR SHIP — ' + tilesN + ' tiles · ' + costN + ' ⬡', fontSize: 10, color: '#9fd8ff', textAlign: 'right' } ] },
] }
wd.hud = [] // the UI tree owns every label now — no floating DOM text
return
}
}
} catch (e) {
// NEVER a silent black world: heal soft (back to the yard, force relayout),
// then hard (fresh state) if it keeps throwing — and SAY SO on the HUD.
try {
const wd2 = sim.worldData, P = wd2.__pd
wd2.__pderr = (wd2.__pderr || 0) + 1
if (P && wd2.__pderr < 4) { P.mode = 'design'; P.bt = null; P.layoutRev = -999; P.sel = 0 }
else { wd2.__pd = null; wd2.__pderr = 0 }
wd2.hud = [{ id: 'err', type: 'text', x: '3%', y: '50%', text: 'RECOVERED: ' + String((e && e.message) || e).slice(0, 70), fontSize: '12px', color: '#ff8a7a' }]
} catch (e2) { }
}
hook · pt-hotseat
by claude-code
try {
const wd = sim.worldData
const D = wd.__pd
if (!D) return
if (D.mode !== 'servers' && D.mode !== 'hotseat') { /* not our scene */ }
const uxHval = () => { const p = (wd.input && wd.input.pointer) || {}; const mx = (typeof p.x === 'number') ? p.x : wd.mouse_x; return (typeof mx === 'number') ? mx / 256 - 1 : 0 }
const uyHval = () => { const p = (wd.input && wd.input.pointer) || {}; const my = (typeof p.y === 'number') ? p.y : wd.mouse_y; return (typeof my === 'number') ? my / 256 - 1 : 0 }
// ── SERVER BROWSER + HOTSEAT (Galen: "server hotseat screen (not in yard)") —
// its own scene pair, the Istrolid battleroom shape from STRUCTURE.md:
// 'servers' = the browser (rooms list); picking a room opens 'hotseat' =
// the seat screen — YOU hold seat 1 (★HOST), click empty seats to add AI
// commanders, START launches the battle with one extra squadron share per
// AI seat (D.__seatFoes). Runs AFTER menu (which owns the menu→servers
// transition) and BEFORE flight/yard; publishes its own frame + returns. ──
if (D.mode === 'servers' || D.mode === 'hotseat') {
// SERVER BROWSER + HOTSEAT on THE UI SYSTEM (Galen Aug 11: "battle server
// screen needs a pass"). Boxes + labels are ONE wd.ui tree solved into
// __uiRects; rows/seats/buttons carry click:'…' so hits arrive as
// wd.__uiClick — no shader chrome, no DOM %-text, no hand rect math.
const SERVERS = (Array.isArray(wd.__lobby) && wd.__lobby.length)
? wd.__lobby.map(r => ({ name: r.name || r.room || 'room', mode: r.mode || '1v1', players: r.players || 0, cap: Math.max(2, Math.min(6, r.capacity || 2)), live: !!r.started }))
: [
{ name: 'Rookie Skirmish', mode: '2v2', players: 2, cap: 4, live: false },
{ name: 'Asteroid Belt', mode: '1v1', players: 1, cap: 2, live: false },
{ name: 'Fleet Melee', mode: '3v3', players: 4, cap: 6, live: true },
{ name: 'Practice Range', mode: 'solo', players: 0, cap: 1, live: false },
]
// ── click routing (UI-system channel) ──
const t9h = wd.__uiClickT
if (t9h && t9h !== D.__hsClickT0) {
D.__hsClickT0 = t9h
const a = String(wd.__uiClick || '')
if (D.mode === 'servers') {
if (a === 'srv-back') { D.mode = 'menu'; wd.__play_sound = [{ frequency: 420, duration: 0.08, volume: 0.1, type: 'sine' }] }
else if (/^srvrow-\d+$/.test(a)) {
const sv = SERVERS[+a.slice(7)] || SERVERS[0]
D.room = { name: sv.name, mode: sv.mode, cap: Math.max(1, Math.min(6, sv.cap)) }
D.seats = [{ who: 'YOU' }]
for (let si = 1; si < D.room.cap; si++) D.seats.push({ who: sv.players > si ? 'AI' : null })
D.mode = 'hotseat'
wd.__play_sound = [{ frequency: 300, duration: 0.12, volume: 0.12, type: 'sine' }, { frequency: 450, duration: 0.1, volume: 0.1, type: 'sine' }]
}
} else {
if (a === 'hs-back') { D.mode = 'servers'; wd.__play_sound = [{ frequency: 420, duration: 0.08, volume: 0.1, type: 'sine' }] }
else if (a === 'hs-start') {
D.__seatFoes = (D.seats || []).filter(s => s && s.who === 'AI').length
D.mode = 'battle'; D.bt = null
wd.__play_sound = [{ frequency: 220, duration: 0.2, volume: 0.16, type: 'sawtooth' }, { frequency: 440, duration: 0.25, volume: 0.14, type: 'sine' }, { frequency: 880, duration: 0.3, volume: 0.1, type: 'sine' }]
} else if (/^seat-\d+$/.test(a)) {
const si = +a.slice(5), st = D.seats && D.seats[si]
if (st && si > 0) { st.who = st.who === 'AI' ? null : 'AI'; wd.__play_sound = [{ frequency: st.who ? 520 : 320, duration: 0.07, volume: 0.1, type: 'sine' }] }
}
}
}
// a click may have left this scene — let the new owner draw (menu ran already
// this tick, flight runs after us, so battle picks up same-tick)
if (D.mode !== 'servers' && D.mode !== 'hotseat') return
// ── uniforms: menu-scene backdrop, no yard chrome ──
const uH = []; uH[0] = D.t; uH[8] = uxHval(); uH[9] = uyHval(); uH[10] = 1; uH[15] = 1
for (let i = 0; i < 16; i++) if (uH[i] == null) uH[i] = 0
wd.__uniStage = uH
wd.gpuPopulation = []
wd.hud = []
const whoRow = { id: 'hswho', kind: 'panel', glass: false, anchor: { gx: 500, gy: 12 }, align: 'tr', pad: 2, children: [
{ id: 'hswhot', kind: 'text', text: 'logged in as "cartridge user"', fontSize: 9, color: '#8fb0d8', textAlign: 'right' } ] }
if (D.mode === 'servers') {
wd.ui = { rev: 1, root: [
{ id: 'srv-hd', kind: 'panel', glass: false, anchor: { gx: 256, gy: 44 }, align: 'tc', w: '70%', gap: 3, pad: 4, children: [
{ id: 'srv-ht', kind: 'text', text: 'OPEN SERVERS', fontSize: 17, color: '#cfe4ff', textAlign: 'center' },
{ id: 'srv-hs', kind: 'text', text: 'pick a room → take your seat', fontSize: 9, color: '#8fb0d8', textAlign: 'center' } ] },
{ id: 'srv-list', kind: 'panel', anchor: { gx: 256, gy: 120 }, align: 'tc', w: '64%', gap: 5, pad: 8, children:
SERVERS.map((sv, i) => {
const full = sv.players >= sv.cap
return { id: 'srvrow-' + i, kind: 'row', click: 'srvrow-' + i, gap: 6, children: [
{ kind: 'text', text: sv.name, fontSize: 12, color: '#ffe1b0' },
{ kind: 'spacer', flex: 1 },
{ kind: 'text', text: sv.mode + ' ' + sv.players + '/' + sv.cap, fontSize: 10, color: '#9fd8ff' },
{ kind: 'text', text: sv.live ? ' ● LIVE' : (full ? ' FULL' : ' ○ OPEN'), fontSize: 10, color: sv.live ? '#9fe8a8' : (full ? '#c07a7a' : '#9fd8ff') } ] }
}) },
{ id: 'srv-bk', kind: 'panel', click: 'srv-back', anchor: { gx: 12, gy: 500 }, align: 'bl', pad: 7, children: [
{ id: 'srv-bkt', kind: 'text', text: '◂ MENU', fontSize: 14, color: '#9fd8ff' } ] },
whoRow,
] }
return
}
// ── HOTSEAT battleroom ──
const room = D.room || { name: 'Skirmish', mode: '1v1', cap: 2 }
if (!Array.isArray(D.seats) || !D.seats.length) { D.seats = [{ who: 'YOU' }]; for (let si3 = 1; si3 < room.cap; si3++) D.seats.push({ who: null }) }
const seatRows = []
for (let si4 = 0; si4 < D.seats.length; si4 += 2) {
const cells = []
for (let c = 0; c < 2 && si4 + c < D.seats.length; c++) {
const si = si4 + c, who = D.seats[si] && D.seats[si].who
const label = si === 0 ? 'SEAT 1 — YOU ★HOST' : ('SEAT ' + (si + 1) + ' — ' + (who === 'AI' ? 'AI CMDR' : 'EMPTY · add AI'))
cells.push({ id: 'seat-' + si, kind: 'panel', click: si > 0 ? ('seat-' + si) : undefined, w: '48%', pad: 8,
glass: si === 0 ? { border: 'rgba(255,212,121,0.5)' } : true, children: [
{ id: 'seatt-' + si, kind: 'text', text: label, fontSize: 10.5, color: si === 0 ? '#ffd479' : who === 'AI' ? '#9fe8a8' : '#7b8daa', textAlign: 'center' } ] })
}
seatRows.push({ kind: 'row', gap: 8, children: cells })
}
const foes2 = D.seats.filter(s => s && s.who === 'AI').length
wd.ui = { rev: 1, root: [
{ id: 'hs-hd', kind: 'panel', glass: false, anchor: { gx: 256, gy: 40 }, align: 'tc', w: '80%', gap: 3, pad: 4, children: [
{ id: 'hs-ht', kind: 'text', text: room.name.toUpperCase(), fontSize: 17, color: '#cfe4ff', textAlign: 'center' },
{ id: 'hs-hs', kind: 'text', text: room.mode + ' battleroom · click a seat to add an AI commander', fontSize: 9, color: '#8fb0d8', textAlign: 'center' } ] },
{ id: 'hs-seats', kind: 'panel', glass: false, anchor: { gx: 256, gy: 108 }, align: 'tc', w: '72%', gap: 8, pad: 2, children: seatRows },
{ id: 'hs-foes', kind: 'panel', glass: false, anchor: { gx: 256, gy: 388 }, align: 'tc', w: '80%', pad: 2, children: [
{ id: 'hs-foest', kind: 'text', text: foes2 ? (foes2 + ' AI squadron' + (foes2 > 1 ? 's' : '') + ' will meet you') : 'solo run — the belt is all yours', fontSize: 10, color: '#9fd8ff', textAlign: 'center' } ] },
{ id: 'hs-start', kind: 'panel', click: 'hs-start', anchor: { gx: 256, gy: 420 }, align: 'tc', w: '34%', pad: 11,
glass: { border: 'rgba(255,225,150,0.6)' }, children: [
{ id: 'hs-startt', kind: 'text', text: '▸ START BATTLE', fontSize: 15, color: '#ffe9a8', textAlign: 'center' } ] },
{ id: 'hs-bk', kind: 'panel', click: 'hs-back', anchor: { gx: 12, gy: 500 }, align: 'bl', pad: 7, children: [
{ id: 'hs-bkt', kind: 'text', text: '◂ SERVERS', fontSize: 14, color: '#9fd8ff' } ] },
whoRow,
] }
return
}
} catch (e) {
// NEVER a silent black world: heal soft (back to the yard, force relayout),
// then hard (fresh state) if it keeps throwing — and SAY SO on the HUD.
try {
const wd2 = sim.worldData, P = wd2.__pd
wd2.__pderr = (wd2.__pderr || 0) + 1
if (P && wd2.__pderr < 4) { P.mode = 'design'; P.bt = null; P.layoutRev = -999; P.sel = 0 }
else { wd2.__pd = null; wd2.__pderr = 0 }
wd2.hud = [{ id: 'err', type: 'text', x: '3%', y: '50%', text: 'RECOVERED: ' + String((e && e.message) || e).slice(0, 70), fontSize: '12px', color: '#ff8a7a' }]
} catch (e2) { }
}
hook · pt-flight
by claude-code
try {
const wd = sim.worldData
const D = wd.__pd
if (!D) return
const { ENG, AP, CR, ST, ena, attach, verts, shrink, axes, overlaps, PARTS, V2SPEC, ORIENTABLE, PALCYCLE, PALETTE, CATEGORIES, partOf, statOf, NAME, COST, DESC, tileCode } = globalThis.__PT
const pushU = () => { (D.undo = D.undo || []).push(JSON.stringify(D.tree)); if (D.undo.length > 40) D.undo.shift() }
const HOOK_V = 3
if (D.mode !== 'menu' && D.mode !== 'servers' && D.mode !== 'hotseat') {
// ── BATTLE MODE (press B) — drive your designed hull. Reuses the tile codes the
// designer shader already draws; the drawn-from-stage ENG runs the sim. ──
// STAGE TRANSITION (Galen: "direct state transition that keeps what is in
// front of us") — B doesn't jump-cut: leaving battle CAPTURES the ship's
// screen pose for the yard to ease FROM; entering battle from design LOADS
// the battle stage AT the yard's exact view and eases out to combat zoom.
if (sim.edge("to-battle", !!wd.key_b) && D.mode !== "menu") {
if (D.mode === "battle" && D.bt && D.bt.fly && D.bt.cam) {
const zX = D.bzoom || 0.055
D.__fromBattle = { S: zX, bx: (D.bt.fly.x - D.bt.cam.x) * zX, by: (D.bt.fly.y - D.bt.cam.y) * zX, t: 0 }
} else if (D.mode === "design") D.__enterDesign = true
D.mode = (D.mode === "battle") ? "design" : "battle"; D.bt = null; D.__seatFoes = 0
}
if (D.mode === "battle") {
// ── BATTLE V2 (DESIGN-ship-systems.md): the tested phys/energy/route stack
// flies YOUR hull. CLICK → a feasible route to the point. HOLD + drag →
// draw a route; the ship flies WHAT IS POSSIBLE (speed profile: slows
// into corners, arrives at rest). Power: thrusters drain the grid;
// sustained deficit browns the drives to 70%. ──
const psig = D.tree.map(d2 => (d2.part || 0) + ':' + (d2.o || 0) + ':' + (d2.m || 0) + ':' + (d2.gy || 0) + (d2.gd || 0) + (d2.gp ? 1 : 0)).join(',')
// every FRESH battle opens at the standard zoom — a stale persisted bzoom
// (0.02 = old max-out survives in __pd across sessions) read as "massively
// zoomed out"; mid-battle rebuilds (live design edits) keep the player's zoom
if (!D.bt) {
// STAGE LOAD: from DESIGN, battle opens AT the yard's exact view (same
// scale, ship where you left it) and EASES out to combat zoom — "keep
// what is in front of us". Menu/hotseat entries open at standard zoom.
const V0 = (D.__enterDesign && wd.__view) ? wd.__view : null; D.__enterDesign = false
D.bzoom = V0 ? Math.max(0.03, Math.min(0.2, V0.S)) : 0.055
D.__bzEase = !!V0
D.__enterView = V0 ? { bx: V0.bx, by: V0.by } : null
D.__stageF = V0 ? 0 : 1 // backdrop crossfade (uni 13)
}
if (!D.bt || D.bt.rev !== D.rev || D.bt.psig !== psig || D.bt.hv !== HOOK_V) { // hv guard: a persisted bt from an OLDER hook lacks new fields → rebuild, never throw on it
// KEEP THE SHIP FROM DESIGN (Galen: "delete loading ship in battle") —
// battle BORROWS the design stage's own unit (D.shipU, built by the yard
// as you edit): the same object you were just looking at, no reload.
// makeUnit remains only as the fallback for entries that never visited
// the yard (menu QUICK BATTLE on a fresh boot).
const u0 = (D.shipU && D.shipU.psig === psig && D.shipU.u) ? D.shipU.u : ENG.makeUnit(D.tree, { seat: 0, x: 0, y: 0, shapeChoices: D.shapeChoices })
u0.tileHp = u0.tileMaxHp.slice() // a fresh battle starts repaired — damage is battle state, not design state
const pT2 = u0.tiles.map((t, i) => { const cd = D.tree[i] || {}; const sp = i === 0 ? {} : (V2SPEC[cd.part] || {})
const st = (PARTS[cd.part] || PARTS[0]).stat
const mnt = ['fixed', 'swivel', 'wide', 'ring'][cd.m || 0]
return { cx: t.cx, cy: t.cy, th: t.th, o: cd.o || 0, mount: mnt, mass: (i === 0 ? 1.2 : st.mass) + ((ENG.MOUNTS[mnt] || {}).mass || 0) + ((cd.gy || 0) + (cd.gd || 0)) * 0.15 + (cd.gp ? 0.3 : 0), // TUNING WEIGHS: hot guns carry their hardware
part: i === 0 ? { torque: 1.2, drain: 0 } : (sp.thrust || sp.torque) ? { thrust: sp.thrust || 0, torque: sp.torque || 0, drain: sp.drain || 0 } : null } })
const grid = { gen: 1, batCap: 10 + (u0.battery || 0), batRate: 6 } // CORE base + CELL specials
for (let gi = 1; gi < D.tree.length; gi++) { const sp = V2SPEC[D.tree[gi].part] || {}; grid.gen += sp.gen || 0; grid.batCap += sp.batCap || 0; grid.batRate += sp.batRate || 0 }
// WEAPON MOUNTS — the arc is what you BOUGHT, centered on the facing.
// Hull blocking is a TRUE ray test at fire time (the old sector model
// waffle-blocked whole 72° wedges that the ray actually cleared).
const mounts = []
for (let i = 0; i < D.tree.length; i++) {
const cd = D.tree[i] || {}, sp = i === 0 ? {} : (V2SPEC[cd.part] || {})
if (!sp.weapon || !u0.tiles[i]) continue
const face = u0.tiles[i].th + Math.PI / 2 + ((cd.o || 0) + 0.5) * (2 * Math.PI / 5)
const H = (ENG.MOUNTS[['fixed', 'swivel', 'wide', 'ring'][cd.m || 0]] || {}).half || 0
// A TURRET(3) traverses by NATURE — floor its arc at WIDE (±90°) even
// at tier 0. The old ±36° floor only ever intersected targets on toy
// ships; on a real 38-tile warship every mount's slit pointed at empty
// sky and all seven guns sat silent (Galen: "no gun shot when fireball
// is loaded to slot 3"). M still buys RING (360°) on top.
// FIXED(9) stays truly fixed: you aim it with T; M widens it.
const Hfloor = sp.turret ? Math.max(H, ENG.MOUNTS.wide.half) : H
const eff = [{ center: face, half: Math.max(0.07, Hfloor) }]
// ── GUN CLASS (Galen: "guns as class with energy drain, projectile type,
// etc."): every gun tile is an INSTANCE — base stats from V2SPEC, the
// yard's per-tile mods layered on: Y = range levels (gy), D = damage
// levels (gd), P = projectile conversion (gp). A hotter gun drains
// more energy per shot; a projectile gun trades fire rate for a
// travelling round (dodgeable, but hits carry the full punch).
const wB = sp.weapon, gy = cd.gy || 0, gd = cd.gd || 0, gp = !!cd.gp
const weapon = {
range: wB.range * (1 + 0.30 * gy),
damage: wB.damage * (1 + 0.35 * gd),
energyPerShot: wB.energyPerShot * (1 + 0.18 * (gy + gd)) * (gp ? 1.25 : 1),
cooldown: wB.cooldown * (gp ? 1.6 : 1),
proj: gp ? { speed: 14 } : null,
}
mounts.push({ i, sectors: eff, aim: face, rate: 3.0, weapon, cd: 0 })
}
const ev0 = ENG.envelope(pT2)
// ── ARCADE FLIGHT STATS (Galen's model) — the PART TYPE sets the stat, not
// the mounting angle (a pentagon hull can't point an engine dead-aft, so
// directional thrust made W barely move). ENGINES = forward SPEED · JETS =
// strafe AND reverse · GYROS = turn. Orientation is cosmetic (the plume).
// The CORE grants the floor so a bare helm still crawls + turns. ──
const mass0 = Math.max(0.5, ENG.massProps(pT2).M || 1) // was .m (typo) — armor/mounts never weighed the ship
// M (mount tier) on an ENGINE/JET buys it a wider GIMBAL — a swiveled
// thruster is vectored for more effective thrust, not just a wider aim
// cone (which is all M used to do for guns). fixed→1.0, swivel→1.15,
// wide→1.35, ring→1.6 — matches the tier's arc, gives M real teeth here.
const GIMBAL_BONUS = [1.0, 1.15, 1.35, 1.6]
let engThrust = 0, jetThrust = 0, gyroTq = 0, hasTactics = false
for (let gi = 1; gi < D.tree.length; gi++) {
const cd2 = D.tree[gi], p = cd2.part, sp = V2SPEC[p] || {}
const gb = GIMBAL_BONUS[cd2.m || 0] || 1
if (p === 4) engThrust += (sp.thrust || 0) * gb // ENGINE → forward
else if (p === 6) jetThrust += (sp.thrust || 0) * gb // JET → strafe + reverse
else if (p === 7) gyroTq += sp.torque || 0 // GYRO → turn
else if (p === 10) hasTactics = true // TACTICS → always-strafe control mode
}
const twE = engThrust / mass0, twJ = jetThrust / mass0
// MASS IS DESTINY (Galen: "mass is not being calculated to reduce turn and
// speed and recoil"): massK falls toward 0 as the hull grows — it scales
// the free CORE floors and the turn base, so a 40-tile battleship WALLOWS
// unless its thrust keeps pace, while a scout stays a scout. (twE/twJ are
// already thrust/mass, so powered stats scale naturally.)
const massK = 8 / (8 + mass0)
const vmax0 = Math.max(Math.max(0.8, 1.9 * massK), 1.2 * massK * 2 + twE * 2.6)
const ARC = {
turn: 0.4 + 1.5 * massK + 0.5 * ev0.alpha * massK + gyroTq / mass0 * 0.6,
acc: Math.max(Math.max(0.25, 1.1 * massK), twE * 3.2),
vmax: vmax0,
strafe: 0.9 * (0.35 + massK) + twJ * 3.0,
canReverse: true, // CORE law (Galen): a ship can ALWAYS back up
revMax: Math.max(Math.max(0.5, 1.5 * massK), vmax0 * 0.45 + Math.min(3.0, twJ * 2.2)), // reverse rides the hull (30% of vmax) and JETS always ADD on top (Galen: "reverse doesn't work well")
tactics: hasTactics, // TACTICS module fitted → always-strafe flight
}
ARC.brake = Math.max(ARC.acc * 1.1, 0.9)
ARC.mass = mass0 // collision/recoil reads the true mass
// ── THE BUILD SHAPES THE ELLIPSE (Galen: capability from the real ship;
// "should especially work with thrusters in that direction") — probe
// the actual thruster layout through the v1 allocator along the NOSE
// frame (design nose = (0,−1); the old +x probe is the historic
// "W barely moves" bug). Ratios EXTEND the arcade floors: aft/lateral
// nozzles buy real speed in their direction; nothing drops below the
// core floors — orientation rewards, never cripples. ──
{
const ths0 = ENG.thrusters(pT2, ENG.massProps(pT2).com)
const probeAx = (dx, dy) => {
const usP = ENG.allocate(ths0, { fwd: dx, lat: dy, turn: 0 })
const aimedP = usP.dirs ? ths0.map((thp, ip) => ({ ...thp, dir: usP.dirs[ip] })) : ths0
const wP = ENG.netWrench(aimedP, usP)
return Math.max(0, wP.fx * dx + wP.fy * dy) / Math.max(mass0, 0.5)
}
const aFn = probeAx(0, -1), aBn = probeAx(0, 1), aLn = Math.max(probeAx(1, 0), probeAx(-1, 0))
ARC.dirBack = Math.max(0, Math.min(1, aBn / Math.max(aFn, 1e-6)))
ARC.dirLat = Math.max(0, Math.min(1, aLn / Math.max(aFn, 1e-6)))
}
// SHIELD FIELDS (Galen): moon/circle holes project regenerating discs out
// into space — world pos = ship pos + hull-rotated design offset, blocking
// ranged damage that crosses them, regenerating slowly (cap/12 per s).
const shieldF = (u0.shields || []).map(s0 => ({ ...s0, hp: s0.cap, regen: s0.cap / 12 }))
D.bt = { hv: HOOK_V, unit: u0, rev: D.rev, psig, pT: pT2, ev: ev0, ARC, grid, mounts, shieldF,
kills: (D.bt && D.bt.kills) || 0, seed: (D.bt && D.bt.seed) || 12345,
ths: ENG.thrusters(pT2, ENG.massProps(pT2).com),
// SPAWN FACING UP: fly.th is the FORWARD/velocity heading — simple, one
// number, no offset threaded through navigation. The hull DRAW angle adds
// a fixed BODY_OFFSET (below) so the ship still renders in its design pose;
// that offset is applied in exactly one place (the draw loop), not here.
bank: ENG.newBank(grid), fly: { x: 0, y: 0, vx: 0, vy: 0, th: -Math.PI / 2, om: 0 }, route: null, holding: null, lastDrain: 0 }
// ── DEATH HAS TEETH (Galen: "destroyed components do not stop working —
// should also explode"): record each tile's contribution at build so a
// death SUBTRACTS its function live, and give every chamber its SEAL
// tile list — breach the seal, lose the chamber. ──
{
const GIMB = [1.0, 1.15, 1.35, 1.6]
D.bt.driveMap = []; D.bt.powerMap = []
for (let gi = 1; gi < D.tree.length; gi++) {
const cd2 = D.tree[gi], sp2 = V2SPEC[cd2.part] || {}
const gb2 = GIMB[cd2.m || 0] || 1
if (cd2.part === 4) D.bt.driveMap.push({ i: gi, eng: (sp2.thrust || 0) * gb2, jet: 0, gyro: 0, tac: 0 })
else if (cd2.part === 6) D.bt.driveMap.push({ i: gi, eng: 0, jet: (sp2.thrust || 0) * gb2, gyro: 0, tac: 0 })
else if (cd2.part === 7) D.bt.driveMap.push({ i: gi, eng: 0, jet: 0, gyro: sp2.torque || 0, tac: 0 })
else if (cd2.part === 10) D.bt.driveMap.push({ i: gi, eng: 0, jet: 0, gyro: 0, tac: 1 })
if (sp2.gen || sp2.batCap || sp2.batRate) D.bt.powerMap.push({ i: gi, gen: sp2.gen || 0, batCap: sp2.batCap || 0, batRate: sp2.batRate || 0 })
}
const sealOf = (cx9, cy9, r9) => { const s9 = []; for (let ti = 0; ti < u0.tiles.length; ti++) { const t9 = u0.tiles[ti]; if (Math.hypot(t9.cx - cx9, t9.cy - cy9) < r9 + 1.05) s9.push(ti) } return s9 }
D.bt.chSeal = (u0.chambers || []).map(ch => sealOf(ch.cx, ch.cy, ch.r || 0.8))
// CHAMBERS ARE DESTRUCTIBLE (Galen): each carries its own hp; wall
// hits chip it (chSplash), and at 0 the chamber is gone.
D.bt.chMax = (u0.chambers || []).map(ch => Math.round(6 + (ch.r || 0.8) * 5))
D.bt.chHp = D.bt.chMax.slice()
D.bt.lzCh = (u0.lasers || []).map(lz => {
let bi9 = -1, bdd = 1e9
for (let ci9 = 0; ci9 < (u0.chambers || []).length; ci9++) { const ch = u0.chambers[ci9]; const dd = Math.hypot(ch.cx - lz.cx, ch.cy - lz.cy); if (dd < bdd) { bdd = dd; bi9 = ci9 } }
return bi9
})
D.bt.lzSeal = (u0.lasers || []).map(lz => {
let bc = null, bdd = 1e9
for (const ch of (u0.chambers || [])) { const dd = Math.hypot(ch.cx - lz.cx, ch.cy - lz.cy); if (dd < bdd) { bdd = dd; bc = ch } }
return sealOf(lz.cx, lz.cy, (bc && bc.r) || 0.8)
})
}
}
const B = D.bt, u = B.unit
// HEAL stale battle units built before the hull-buffer feature (Galen: "says
// NO HULL when I have hull") — recompute the buffer from surviving tiles so
// it works instantly, no re-enter. HULL(1)=2× hp, ARMOR(2)=1.4× into the pool.
if (u && u.hullMax == null && Array.isArray(u.tileMaxHp)) {
let hm = 0
for (let i = 0; i < u.tiles.length; i++) { const pk = u.tiles[i].part; if (pk === 1) hm += u.tileMaxHp[i] * 2.0; else if (pk === 2) hm += u.tileMaxHp[i] * 1.4 }
u.hullMax = Math.round(hm); u.hullBuffer = u.hullMax
}
// FLIGHT ZOOM: much wider than the yard — testing routes needs sky.
// [ and ] step the zoom; defaults far out.
if (sim.edge('zoom-in', !!wd.key_bracketright)) { D.bzoom = Math.min(0.22, (D.bzoom || 0.055) * 1.35); D.__bzEase = false }
if (sim.edge('zoom-out', !!wd.key_bracketleft)) { D.bzoom = Math.max(0.028, (D.bzoom || 0.055) / 1.35); D.__bzEase = false }
// ── PINCH / WHEEL = WORLD ZOOM (Galen: "pinch zooms the grid size, not the
// world detail") — the engine's wheel_opt hands us the wheel stream as a
// monotonic accumulator (wd.wheel_y); consume the delta split_n-style.
// Scroll/pinch down = zoom out, up = zoom in. ──
{
const wY = wd.wheel_y || 0
if (B.__wheelSeen == null) B.__wheelSeen = wY
const wDelta = wY - B.__wheelSeen
if (wDelta) {
B.__wheelSeen = wY
D.bzoom = Math.max(0.028, Math.min(0.22, (D.bzoom || 0.055) * Math.exp(-wDelta * 0.0016)))
D.__bzEase = false
}
}
// STAGE-LOAD EASE: glide from the yard's scale out to combat zoom (any
// manual zoom takes over instantly — the ease never fights the player)
if (D.__bzEase) {
D.bzoom += (0.055 - D.bzoom) * Math.min(1, dt * 1.6)
if (Math.abs(D.bzoom - 0.055) < 0.0015) { D.bzoom = 0.055; D.__bzEase = false }
}
if ((D.__stageF ?? 1) < 1) D.__stageF = Math.min(1, D.__stageF + dt * 0.7) // backdrop crossfade dock→space
const BS = D.bzoom || 0.055
// ── CAMERA (Galen: "a much larger battle mode") — the view soft-follows
// the ship across an OPEN arena instead of pinning world-origin to
// screen-center. Applied once, at publish (world entities shift by
// −cam·BS); the pointer maps back through the same offset here. ──
if (!B.cam) {
// FIRST FRAME = the yard's frame: place the camera so the ship renders at
// the EXACT screen position the yard drew it (then the soft-follow takes
// over and the view breathes out to combat framing)
B.cam = D.__enterView
? { x: B.fly.x - D.__enterView.bx / BS, y: B.fly.y - D.__enterView.by / BS }
: { x: B.fly.x, y: B.fly.y }
D.__enterView = null
}
const ptrB = (wd.input && wd.input.pointer) || {}
const mxB = (typeof ptrB.x === "number") ? ptrB.x : wd.mouse_x, myB = (typeof ptrB.y === "number") ? ptrB.y : wd.mouse_y
const cuxB = (typeof mxB === "number") ? mxB / 256 - 1 : 0, cuyB = (typeof myB === "number") ? myB / 256 - 1 : 0
const wxP = cuxB / BS + B.cam.x, wyP = cuyB / BS + B.cam.y // pointer, battle-world units (through the camera)
const down = !!ptrB.down || wd.mouse_down === true
// ── ◂ EXIT pad (every scene's bottom-left nav convention) — battle had no
// clickable way out (B is invisible knowledge). Checked BEFORE route
// input so the click never doubles as a fly-here command. ──
if (sim.edge('bt-exit', down) && cuxB >= -0.99 && cuxB <= -0.72 && cuyB >= 0.82 && cuyB <= 0.99) {
D.mode = 'menu'; D.bt = null; D.__seatFoes = 0
wd.__play_sound = [{ frequency: 420, duration: 0.08, volume: 0.1, type: 'sine' }]
return
}
// ── RIGHT-CLICK: STRAFE to a point, holding CURRENT facing — the ship
// keeps its nose (and guns) trained where it already points while it
// repositions, instead of turning to face travel direction like the
// left-click route does. A simple press/release (no drag-to-face; a
// strafe run has nothing to face). Cancels any active route. ──
const downR = wd.mouse_down_right === true
if (downR && !B.holdingR) B.holdingR = true
else if (!downR && B.holdingR) {
B.holdingR = false
B.queue = [{ x: wxP, y: wyP, hold: 1 }]; B.arriveFace = null
wd.__play_sound = [{ frequency: 500, duration: 0.06, volume: 0.09, type: 'triangle' }]
}
// ── ONE MOVEMENT LAW (Galen: "movement needs to be one single thing…
// the ship CALCULATES movement — it may phase shift and turn at the same
// time if it can. Real spaceships don't have air."):
// click = destination (the press point)
// click+drag = destination + ARRIVAL facing (the drag direction)
// SHIFT-click = chain waypoints
// right-click = same solver with the nose PINNED (strafe run)
// Every order feeds the same Newtonian solver below — no modes. ──
if (down && !B.holding) { B.holding = { t0: D.t, pts: [{ x: wxP, y: wyP }], shift: !!wd.key_shift } }
else if (down && B.holding) { const hp = B.holding.pts, lp = hp[hp.length - 1]; if (Math.hypot(wxP - lp.x, wyP - lp.y) > 0.35) hp.push({ x: wxP, y: wyP }) }
else if (!down && B.holding) {
const h = B.holding; B.holding = null
const p0 = h.pts[0], pn = h.pts[h.pts.length - 1]
let span = 0; for (const q of h.pts) span = Math.max(span, Math.hypot(q.x - p0.x, q.y - p0.y))
// THE GESTURE, settled (Galen: "click drag goes to CLICK point while
// turning to and maintaining drag direction"): destination = the PRESS
// point; the drag chooses the heading, held while flying AND on arrival.
// A plain click BEHIND the ship (rear cone, no drag) = the CORE law:
// hold facing and BACK UP to it — no U-turn.
const face = span > 0.6 ? Math.atan2(pn.y - p0.y, pn.x - p0.x) : null
const angB = Math.atan2(p0.y - B.fly.y, p0.x - B.fly.x)
const rearOff = Math.abs(Math.atan2(Math.sin(angB - (B.fly.th + Math.PI)), Math.cos(angB - (B.fly.th + Math.PI))))
const isRear = face == null && rearOff < 0.61 && Math.hypot(p0.x - B.fly.x, p0.y - B.fly.y) > 0.9
const wp = isRear ? { x: p0.x, y: p0.y, hold: 1 } : { x: p0.x, y: p0.y, face }
if (h.shift && B.queue && B.queue.length) B.queue.push(wp)
else B.queue = [wp]
B.arriveFace = null
wd.__play_sound = face != null
? [{ frequency: 620, duration: 0.07, volume: 0.1, type: 'sine' }, { frequency: 480, duration: 0.09, volume: 0.08, type: 'sine' }]
: [{ frequency: 620, duration: 0.07, volume: 0.1, type: 'sine' }]
}
// ── fly: WASD hand-flying overrides the autopilot. W/S = ahead/astern,
// A/D = STRAFE left/right, Q/E = ROTATE (Galen's convention). Manual is a
// VELOCITY SERVO, not raw thrust — W holds a dead-straight track down the
// nose even with tilted pentagon engines (the servo cancels the wobble). ──
// ╔══════════════════════════════════════════════════════════════════╗
// ║ HARDLOCKED (Galen, Aug 5 2026: "This motion is perfect.") ║
// ║ Do NOT tune this core — budgets, caps, arrival, turn assist, ║
// ║ integration — without his explicit order. The lock lives in ║
// ║ test/motion-hardlock.test.mjs (golden trajectories + constants). ║
// ╚══════════════════════════════════════════════════════════════════╝
// ═══ NEWTONIAN FLIGHT CORE (the synthesis of every model this game has
// had — Galen: "find all version movement models and synthesise a real
// option"). The lineage and what each taught:
// v1 phys.mjs wrench allocator — thrust is DIRECTIONAL and the body is
// FREE (real force/torque); too twitchy raw, but the truth.
// arcade ARC servo — part TYPE → stat (engine=fwd, jet=lat/rev,
// gyro=turn) is the right TUNING layer; but it glued velocity to the
// nose (a rail — every turn instantly redirected momentum: AIR).
// route/arcPath + pure-pursuit, strafe modes, one-law rail solver —
// all fought the rail, none could feel right.
// THE SYNTHESIS: a FREE VELOCITY VECTOR (no air, real momentum) driven by
// a velocity servo whose acceleration budget is DIRECTIONAL in the live
// nose frame — engines feed the nose cone, jets feed the flanks + stern,
// the core floors it so every hull answers the stick. One order law on
// top (click=dest · drag=+facing · shift=chain · right-click=nose-pinned).
// The vector arc, the phase shift, the drone slide, honest collision
// shoves and projectile impacts ALL emerge from the same three lines. ═══
const dtB = Math.min(dt, 1 / 30)
const A2 = B.ARC
const wrapA2 = (a) => Math.atan2(Math.sin(a), Math.cos(a))
let thDes = B.fly.th, rotCmd = 0
let vdx = 0, vdy = 0 // DESIRED velocity (world frame)
// W/S ahead-astern · A/D strafe (A=LEFT of the nose) · Q/E rotate (Q=turn LEFT,
// CCW on screen). All relative to the CORE's nose. Screen +y is DOWN, so the
// strafe axis (−s9,c9) points RIGHT of the nose → A must be −1 to go left, and
// a positive rotCmd swings the nose right (CW) → Q must be −1 to turn left.
const iv9 = wd.input || {}
const mf = Math.max(-1, Math.min(1, (wd.key_w ? 1 : 0) - (wd.key_s ? 1 : 0) - (typeof iv9.moveY === 'number' ? Math.round(iv9.moveY) : 0)))
const ml = Math.max(-1, Math.min(1, (wd.key_d ? 1 : 0) - (wd.key_a ? 1 : 0) + (typeof iv9.moveX === 'number' ? Math.round(iv9.moveX) : 0))) // +1 = strafe RIGHT (D)
const mr = (wd.key_e ? 1 : 0) - (wd.key_q ? 1 : 0) // +1 = turn right (E)
if (mf || ml || mr) { // hand-flying: desired velocity in the nose frame
B.queue = []; B.arriveFace = null
const c0 = Math.cos(B.fly.th), s0 = Math.sin(B.fly.th)
const fwd0 = mf > 0 ? A2.vmax : mf < 0 ? -(A2.canReverse ? A2.revMax : 0) : 0
const lat0 = Math.max(A2.strafe, A2.vmax * 0.38) // A/D strafe rides the hull like the solver's lateral cap
vdx = c0 * fwd0 - s0 * (ml * lat0)
vdy = s0 * fwd0 + c0 * (ml * lat0)
rotCmd = mr
} else if (B.queue && B.queue.length) {
const q = B.queue[0]
const dxT = q.x - B.fly.x, dyT = q.y - B.fly.y
const distT = Math.hypot(dxT, dyT)
if (distT < 0.85) {
B.queue.shift() // waypoint consumed
if (!B.queue.length) {
B.fly.v = 0; B.fly.vx = 0; B.fly.vy = 0 // hard stop at the final point
if (q.face != null) B.arriveFace = q.face // then pivot to the promised heading
else wd.__play_sound = [{ frequency: 520, duration: 0.1, volume: 0.1, type: 'sine' }]
}
} else {
// PROPER FACING = travel direction — unless this order PINS the nose
// (right-click hold) or CHOSE one (click-drag: "rotates ship to facing
// DURING movement" — the nose seeks the drag direction the whole leg,
// and the directional budget moves the body there regardless)
thDes = q.hold ? B.fly.th : (q.face != null ? q.face : Math.atan2(dyT, dxT))
// CAPABILITY ELLIPSE: top speed toward the target depends on where the
// target sits in the CURRENT nose frame (engines rule the nose axis,
// jets the flanks, revMax the stern) — "moves direct if engine
// directions allow", literally.
const c0 = Math.cos(B.fly.th), s0 = Math.sin(B.fly.th)
const caT = (dxT * c0 + dyT * s0) / distT
const laT = (-dxT * s0 + dyT * c0) / distT
// floors keep every hull flyable; the BUILD's real directional thrust
// (ARC.dirBack/dirLat, probed through the v1 allocator) extends them —
// point real nozzles aft/sideways and the ship truly flies that way
const capF = caT >= 0 ? A2.vmax : Math.max(A2.revMax, A2.vmax * (A2.dirBack || 0))
const capL = Math.max(A2.strafe, A2.vmax * 0.38, A2.vmax * (A2.dirLat || 0))
const cap = Math.max(0.45, Math.hypot(caT * capF, laT * capL))
// arrive: full cap until the real braking envelope, then bleed in
const aBrk = Math.max(A2.brake, 0.5)
const spd = distT > (cap * cap) / (2 * aBrk) ? cap : Math.max(0.35, Math.sqrt(2 * aBrk * distT))
vdx = dxT / distT * spd; vdy = dyT / distT * spd
}
} else if (B.arriveFace != null) { // hold the promised facing
thDes = B.arriveFace
if (Math.abs(wrapA2(B.arriveFace - B.fly.th)) < 0.1) { B.arriveFace = null; wd.__play_sound = [{ frequency: 520, duration: 0.1, volume: 0.1, type: 'sine' }] }
}
// power gate → turn the nose → serve the velocity
const pw = ENG.powerTick(B.grid, B.bank, B.lastDrain, dtB)
const bf = pw.brownout ? ENG.BROWN_THRUST : 1
// GYROS WORK WITH THE THRUSTERS (Galen): while an order is live and the
// nose is far off the wanted axis, the gyros run hot (×1.35) to swing the
// hull's BEST thrust direction onto the move — alignment first, speed follows.
const faceErrT = Math.abs(wrapA2(thDes - B.fly.th))
const turnR9 = A2.turn * (faceErrT > 0.5 && B.queue && B.queue.length ? 1.35 : 1)
const dTh = rotCmd !== 0 ? rotCmd * A2.turn * dtB : Math.max(-turnR9 * dtB, Math.min(turnR9 * dtB, wrapA2(thDes - B.fly.th)))
B.fly.th = wrapA2(B.fly.th + dTh * bf)
const c9 = Math.cos(B.fly.th), s9 = Math.sin(B.fly.th)
// the servo: accelerate the FREE velocity toward the desire, budgeted by
// direction in the live frame. BOOST (Galen: "engines/jets need more of a
// boost"): budgets run hot — engines 1.6× on the nose, jets 2.6× on the
// flanks, a strong stern brake — so orders BITE instead of easing in.
let ax9 = vdx - (B.fly.vx || 0), ay9 = vdy - (B.fly.vy || 0)
const aMag = Math.hypot(ax9, ay9)
let effort = 0
if (aMag > 1e-6) {
const df9 = (ax9 * c9 + ay9 * s9) / aMag // demand along the nose
const dl9 = (-ax9 * s9 + ay9 * c9) / aMag // demand across it
const aF9 = df9 >= 0 ? A2.acc * 1.6 : Math.max(A2.brake * 1.4, A2.acc) * 1.8 // stern budget runs HOT — braking and backing both BITE (Galen: "clicking behind doesn't move much")
const aL9 = Math.max(A2.strafe * 2.6, A2.acc * 0.7, 0.5) // jets answer NOW; the hull's own accel backs the flanks
const budget = Math.max(0.6, Math.hypot(df9 * aF9, dl9 * aL9)) * bf
const step9 = Math.min(aMag, budget * dtB)
B.fly.vx = (B.fly.vx || 0) + ax9 / aMag * step9
B.fly.vy = (B.fly.vy || 0) + ay9 / aMag * step9
effort = Math.min(1, aMag > 0.05 ? 1 : 0)
}
B.fly.x += (B.fly.vx || 0) * dtB
B.fly.y += (B.fly.vy || 0) * dtB
B.fly.v = (B.fly.vx || 0) * c9 + (B.fly.vy || 0) * s9 // derived: speed along the nose (HUD/legacy writers still zero all three)
B.fly.om = dTh / Math.max(dtB, 1e-4)
// ── COLLISION RECOIL (Galen: "collision should have a recoil… momentum/spin
// based on direction"): a decaying FREE-BODY kick, integrated outside the
// servo so the autopilot can't instantly cancel the shove. The collision
// block below fills B.kick; here it moves the ship, spins the nose, and
// bleeds off over ~⅓s (the pilot shrugs it off and re-converges). ──
if (B.kick) {
B.fly.x += B.kick.x * dtB; B.fly.y += B.kick.y * dtB
B.fly.th = wrapA2(B.fly.th + B.kick.spin * dtB)
const kd9 = Math.exp(-3.2 * dtB)
B.kick.x *= kd9; B.kick.y *= kd9; B.kick.spin *= kd9
if (Math.hypot(B.kick.x, B.kick.y) < 0.02 && Math.abs(B.kick.spin) < 0.02) B.kick = null
}
// engine effort for plumes + power drain: how hard are we pushing?
effort = Math.max(effort, Math.min(1, Math.hypot(B.fly.vx || 0, B.fly.vy || 0) / Math.max(A2.vmax, 0.1) * 0.35))
const engDrain = B.pT.reduce((a9, t9) => a9 + ((t9.part && t9.part.drain) || 0), 0)
const st2 = { us: [], drain: engDrain * effort }
const latV9 = -(B.fly.vx || 0) * s9 + (B.fly.vy || 0) * c9
B.__effort = effort; B.__turning = Math.abs(dTh) > 0.002 ? Math.sign(dTh) : 0
B.__strafe = Math.max(-1, Math.min(1, latV9 / Math.max(A2.strafe, 0.1)))
// ── AUDIO BED (Galen: the game is nearly silent between shots) — engine
// rumble under thrust, a rising tick as the star lance charges, a warning
// buzz on brownout. Rate-limited; combat sounds always win (we only speak
// when nothing else did, and later assignments overwrite us anyway). ──
B.__snd = B.__snd || { rum: 0, chg: 0, brn: 0 }
B.__snd.rum -= dtB; B.__snd.chg -= dtB; B.__snd.brn -= dtB
const chg9 = (u.starCharge || 0)
if (chg9 >= 1 && !B.__starReadyWas) wd.__play_sound = [{ frequency: 980, duration: 0.12, volume: 0.14, type: 'sine' }, { frequency: 1240, duration: 0.16, volume: 0.12, type: 'sine' }] // LANCE READY
B.__starReadyWas = chg9 >= 1
if (!wd.__play_sound) {
if (pw.brownout && B.__snd.brn <= 0) { B.__snd.brn = 2.0; wd.__play_sound = [{ frequency: 140, duration: 0.09, volume: 0.15, type: 'square' }, { frequency: 108, duration: 0.13, volume: 0.13, type: 'square' }] }
else if (chg9 > 0.02 && chg9 < 1 && B.__snd.chg <= 0) { B.__snd.chg = 0.4; wd.__play_sound = [{ frequency: 280 + 520 * chg9, duration: 0.12, volume: 0.08, type: 'sine' }] }
else if (effort > 0.55 && B.__snd.rum <= 0) { B.__snd.rum = 0.34; wd.__play_sound = [{ frequency: 60 + 22 * Math.min(1, Math.hypot(B.fly.vx || 0, B.fly.vy || 0) / Math.max(A2.vmax, 0.1)), duration: 0.32, volume: 0.10, type: 'triangle' }] }
}
// ── WAVES (Galen: "if you destroy all blocks. You win… but the blocks
// themselves need to advance — they start to get little AI designs,
// coming after you"). No infinite respawn: each wave is a FINITE
// squadron; clear it → WIN fanfare → a stronger wave. The advance:
// wave 1 dumb static hulls (the old drones)
// wave 2+ ENGINES + pursuit AI — the blocks HUNT you
// wave 3+ some grow GUNS (the same weapon class you use) and shoot back
// deeper bigger mixed designs, faster, more guns — bounded, not silly
// Deterministic LCG throughout (replayable). ──
const rnd = () => { B.seed = (B.seed * 1664525 + 1013904223) >>> 0; return B.seed / 4294967296 }
if (!B.targets) B.targets = B.target ? [B.target] : [] // pre-wave battle-state carry
if (B.wave == null) { B.wave = 1; B.waveWon = 0 } // adopt an old state as wave 1
// ELITE (Galen: "elite enemies with chambers") — the star specimen's chain
// solved from its real geometry: a snake ring that seals a TRUE pentagram.
// While the elite's star LIVES its guns fire ×2 (the same law the player
// has) — break the ring to strip it.
const ELITE_SEQ = [2, 2, 1, 3, 1, 3, 2, 1, 3]
const spawnWave = (n) => {
// hotseat: every AI commander seated in the battleroom brings +2 to the
// squadron (D.__seatFoes; 0 for solo/quick battle — the tested baseline)
const count = (n === 1 ? 6 : Math.min(2 + n, 6)) + 2 * (D.__seatFoes || 0)
for (let k = 0; k < count; k++) {
// a little AI design: armor core, hull/armor body, wave-grown extras.
// Wave 4+ leads with a STAR-RING ELITE (chamber + ×2 fire while it lives).
const elite = n >= 4 && k === 0
const size = Math.min(4 + n + (rnd() * 2 | 0), 12)
// snake-chain builds (parent = previous tile, edges jittered in {1,2,3})
// — the proven non-self-overlapping family the old drones used; random
// parent/edge trees can fold onto themselves (double-HP glitch stacks)
const tt = [{ parent: -1, edge: -1, part: 2 }]
if (elite) for (const e9 of ELITE_SEQ) tt.push({ parent: tt.length - 1, edge: e9, part: rnd() < 0.35 ? 2 : 1 })
else for (let b = 1; b < size; b++) tt.push({ parent: b - 1, edge: 1 + (rnd() * 3 | 0), part: rnd() < 0.3 ? 2 : 1 })
const un = ENG.makeUnit(tt, { seat: 1 })
// FAR RING (the larger arena): squadrons muster off-screen (~26-40u out
// vs the ~18u view radius) and close in — the approach is the drama
const ang = rnd() * 6.2831853, dd = 26 + rnd() * 14 + n * 2
un.x = B.fly.x + Math.cos(ang) * dd; un.y = B.fly.y + Math.sin(ang) * dd; un.a = rnd() * 6.2831853
un.vx = 0; un.vy = 0
un.spd = n >= 2 ? Math.min(1.0 + n * 0.35, 4.0) * (elite ? 1.25 : 1) : 0 // wave 2+: they COME AFTER you; elites press harder
// wave 3+: guns — one per 2 waves past 2, capped 3; the same class the player fires
un.guns = []
const gn = elite ? Math.min(2 + ((n - 4) / 2 | 0), 4) : (n >= 3 ? Math.min(1 + ((n - 3) / 2 | 0), 3) : 0)
for (let g = 0; g < gn; g++) un.guns.push({ cd: rnd() * 1.5, range: 5 + n * 0.3, dmg: 1 + n * 0.4, cool: Math.max(0.8, 2.2 - n * 0.15) })
B.targets.push(un)
}
}
if (!B.__waveSeeded) { B.__waveSeeded = true; if (B.targets.length === 0) spawnWave(B.wave) }
// CELLS PURGED — nanobot-war destruction score: every hostile pentagon (cell)
// that died this frame (direct hit or sheared orphan), via the alive-count diff.
if (B.cellsDestroyed == null) B.cellsDestroyed = 0
for (const tg of B.targets) { const av = ENG.aliveTiles(tg).size; if (tg.__pa == null) tg.__pa = av; if (av < tg.__pa) B.cellsDestroyed += (tg.__pa - av); tg.__pa = av }
for (let ti = B.targets.length - 1; ti >= 0; ti--) if (ENG.unitDead(B.targets[ti])) {
B.kills++; wd.__play_sound = [{ frequency: 240, duration: 0.25, volume: 0.16, type: 'sawtooth' }, { frequency: 160, duration: 0.3, volume: 0.12, type: 'triangle' }]
B.targets.splice(ti, 1)
}
// EARLY ADVANCE (Galen: "asteroid clusters begin to advance to simple
// enemies within 4 asteroids dead") — on the 4th kill of wave 1, two
// simple hunters (wave-2 pattern) join the remaining rocks. One shot.
if (B.wave === 1 && (B.kills || 0) >= 4 && !B.__advanced) {
B.__advanced = true
for (let k2 = 0; k2 < 2; k2++) {
const size2 = 5 + (rnd() * 2 | 0)
const tt2 = [{ parent: -1, edge: -1, part: 2 }]
for (let b2 = 1; b2 < size2; b2++) tt2.push({ parent: b2 - 1, edge: 1 + (rnd() * 3 | 0), part: rnd() < 0.3 ? 2 : 1 })
const un2 = ENG.makeUnit(tt2, { seat: 1 })
const ang2 = rnd() * 6.2831853
un2.x = B.fly.x + Math.cos(ang2) * 30; un2.y = B.fly.y + Math.sin(ang2) * 30
un2.a = 0; un2.vx = 0; un2.vy = 0; un2.spd = 1.3; un2.guns = []
B.targets.push(un2)
}
wd.__play_sound = [{ frequency: 180, duration: 0.4, volume: 0.18, type: 'sawtooth' }]
}
// WIN: the wave is CLEARED — fanfare, breathe, then the blocks advance
if (B.targets.length === 0 && !(B.waveWon > 0)) {
B.waveWon = 3.0
wd.__play_sound = [{ frequency: 660, duration: 0.18, volume: 0.2, type: 'sine' }, { frequency: 990, duration: 0.25, volume: 0.16, type: 'sine' }, { frequency: 1320, duration: 0.35, volume: 0.12, type: 'sine' }]
}
if (B.waveWon > 0) {
B.waveWon -= dtB
if (B.waveWon <= 0) { B.wave++; spawnWave(B.wave); B.waveWon = 0 }
}
B.target = B.targets[0] // legacy single-target readers (HUD fallback) still see A target
// BODY — the hull's DRAWN/targeting rotation. fly.th is the pure velocity
// heading (spawns at −90°=up); BODY adds the fixed +90° so the hull renders
// in its EXACT design pose at spawn (BODY=0) and turns rigidly with flight
// thereafter. One constant, used everywhere a TILE position/aim is rotated —
// never in navigation (velocity/route/arrow stay in the simple fly.th frame).
const BODY = B.fly.th + Math.PI / 2
const cw = Math.cos(BODY), sw = Math.sin(BODY)
// camera soft-follow: chases the ship, never snaps — the hull leads a
// little at speed, which is what sells the size of the field
{ const cf = Math.min(1, dtB * 4); B.cam.x += (B.fly.x - B.cam.x) * cf; B.cam.y += (B.fly.y - B.cam.y) * cf }
const tgtTrig = B.targets.map(tg => ({ ca: Math.cos(tg.a || 0), sa: Math.sin(tg.a || 0) }))
// ── DEATH HAS TEETH: watch every hull (ours + theirs) for tiles crossing to
// dead — EXPLODE there; on OUR ship, re-derive flight + power from what
// remains (a full wave-heal flips tiles back alive and restores them). ──
if (!B.__fxBoom) B.__fxBoom = []
{
let boomed = false
if (!B.__wasAlive || B.__wasAlive.length !== u.tileHp.length) B.__wasAlive = u.tileHp.map(h => h > 0)
let changed = false, revived9 = false
for (let di = 0; di < u.tileHp.length; di++) {
const a9 = u.tileHp[di] > 0
if (a9 === B.__wasAlive[di]) continue
changed = true; B.__wasAlive[di] = a9
if (a9) revived9 = true
if (!a9) { const tD = u.tiles[di]; B.__fxBoom.push({ x: B.fly.x + (tD.cx * cw - tD.cy * sw), y: B.fly.y + (tD.cx * sw + tD.cy * cw), t: 0 }); boomed = true }
}
if (changed) {
let mA = 0; for (let ti = 0; ti < B.pT.length; ti++) if (u.tileHp[ti] > 0) mA += B.pT[ti].mass || 0
const mass9 = Math.max(0.5, mA)
let e9 = 0, j9 = 0, gy9 = 0, tac9 = false
for (const dm of (B.driveMap || [])) if (u.tileHp[dm.i] > 0) { e9 += dm.eng; j9 += dm.jet; gy9 += dm.gyro; if (dm.tac) tac9 = true }
const twE9 = e9 / mass9, twJ9 = j9 / mass9, mKa = 8 / (8 + mass9)
const A9 = B.ARC
A9.turn = 0.4 + 1.5 * mKa + 0.5 * B.ev.alpha * mKa + gy9 / mass9 * 0.6
A9.acc = Math.max(Math.max(0.25, 1.1 * mKa), twE9 * 3.2)
A9.vmax = Math.max(Math.max(0.8, 1.9 * mKa), 1.2 * mKa * 2 + twE9 * 2.6)
A9.strafe = 0.9 * (0.35 + mKa) + twJ9 * 3.0
A9.revMax = Math.max(Math.max(0.5, 1.5 * mKa), A9.vmax * 0.45 + Math.min(3.0, twJ9 * 2.2))
A9.brake = Math.max(A9.acc * 1.1, 0.9); A9.mass = mass9; A9.tactics = tac9
{
// directional ratios follow the SURVIVORS too — lose your aft jets,
// lose your fast reverse (death keeps its teeth in every direction)
const tilesA9 = []
for (let ti = 0; ti < B.pT.length; ti++) if (u.tileHp[ti] > 0) tilesA9.push(B.pT[ti])
const ths9 = ENG.thrusters(tilesA9, ENG.massProps(tilesA9).com)
const probe9 = (dx, dy) => {
const usP = ENG.allocate(ths9, { fwd: dx, lat: dy, turn: 0 })
const aimedP = usP.dirs ? ths9.map((thp, ip) => ({ ...thp, dir: usP.dirs[ip] })) : ths9
const wP = ENG.netWrench(aimedP, usP)
return Math.max(0, wP.fx * dx + wP.fy * dy) / mass9
}
const aFn = probe9(0, -1), aBn = probe9(0, 1), aLn = Math.max(probe9(1, 0), probe9(-1, 0))
A9.dirBack = Math.max(0, Math.min(1, aBn / Math.max(aFn, 1e-6)))
A9.dirLat = Math.max(0, Math.min(1, aLn / Math.max(aFn, 1e-6)))
}
const g2 = { gen: 1, batCap: 10 + (u.battery || 0), batRate: 6 }
for (const pm of (B.powerMap || [])) if (u.tileHp[pm.i] > 0) { g2.gen += pm.gen; g2.batCap += pm.batCap; g2.batRate += pm.batRate }
B.grid = g2
}
for (let tgi = 0; tgi < B.targets.length; tgi++) {
const tg = B.targets[tgi], trig = tgtTrig[tgi]
if (!tg.__wasAlive || tg.__wasAlive.length !== tg.tileHp.length) tg.__wasAlive = tg.tileHp.map(h => h > 0)
for (let di = 0; di < tg.tileHp.length; di++) {
if (!tg.__wasAlive[di] || tg.tileHp[di] > 0) continue
tg.__wasAlive[di] = false
const t9 = tg.tiles[di]
B.__fxBoom.push({ x: tg.x + (t9.cx * trig.ca - t9.cy * trig.sa), y: tg.y + (t9.cx * trig.sa + t9.cy * trig.ca), t: 0 })
boomed = true
}
}
if (boomed) wd.__play_sound = [{ frequency: 110, duration: 0.22, volume: 0.17, type: 'square' }, { frequency: 70, duration: 0.3, volume: 0.12, type: 'sawtooth' }]
if (revived9 && B.chMax) B.chHp = B.chMax.slice() // a wave-heal rebuilds the chambers too
if (revived9) u.hullBuffer = u.hullMax // …and refills the hull buffer
}
// CHAMBERS ARE DESTRUCTIBLE — the one rule: a hit on a chamber's wall
// chips the chamber by the same damage; at 0 it explodes and stops
// working (the gates below read chHp).
const chSplash = (ti9, dmg9) => {
if (!B.chHp) return
for (let ci9 = 0; ci9 < (u.chambers || []).length; ci9++) {
if (B.chHp[ci9] <= 0) continue
if (!(((B.chSeal && B.chSeal[ci9]) || []).includes(ti9))) continue
B.chHp[ci9] -= dmg9
if (B.chHp[ci9] > 0) continue
const ch9 = u.chambers[ci9]
B.__fxBoom.push({ x: B.fly.x + (ch9.cx * cw - ch9.cy * sw), y: B.fly.y + (ch9.cx * sw + ch9.cy * cw), t: 0 })
wd.__play_sound = [{ frequency: 110, duration: 0.22, volume: 0.17, type: 'square' }]
}
}
// ── AUTO-FIRE: each mount traverses inside its arc and shoots what it can —
// searching across EVERY live target, not just one, and firing on
// whichever tile (on whichever target) is nearest in range. ──
let shotE = 0
// STAR CHAMBER = ×2 SHOOTING RATE (Galen) — while the pentagram hole
// LIVES (starArmed re-checks the alive shape every tick: break the
// chamber, lose the rate). __star2F is the test seam.
B.__star2 = (B.__star2F != null) ? !!B.__star2F : !!(ENG.starArmed && ENG.starArmed(u))
if (B.__star2Was && !B.__star2) { // THE STAR BROKE — lose the rate, HEAR it
wd.__play_sound = [{ frequency: 880, duration: 0.08, volume: 0.14, type: 'sine' }, { frequency: 440, duration: 0.12, volume: 0.14, type: 'sine' }, { frequency: 180, duration: 0.5, volume: 0.16, type: 'sawtooth' }]
B.__fxBoom.push({ x: B.fly.x, y: B.fly.y, t: 0 }, { x: B.fly.x, y: B.fly.y, t: -0.12 }, { x: B.fly.x, y: B.fly.y, t: -0.24 })
}
B.__star2Was = B.__star2
// COMBO SUPERWEAPONS (Galen: "invent new superweapons for discovered
// combos") — computed ONCE from the ship's chamber shapes; member chambers
// turn gold. Ship-wide ×2 rate if a combo is live (the discovered payoff).
if (B.combos === undefined) {
B.combos = (ENG.discoverCombos && ENG.discoverCombos(u.shapes || [])) || []
B.__comboShapes = new Set()
for (const c of B.combos) for (const s of Object.keys(c.need)) B.__comboShapes.add(s)
// per-chamber GOLD (size-gold OR combo-gold) — drives the gold PAINT (+400
// entity flag) and the entry fanfare
B.__chGold = (u.chambers || []).map(ch => {
const p9 = ENG.chamberPower ? ENG.chamberPower(ch.shape, ENG.chamberVolume(ch.area)) : { gold: false }
return p9.gold || B.__comboShapes.has(ch.shape)
})
if (B.__chGold.some(Boolean)) {
// THE GOLD FANFARE (Galen: "special noise when you get it") — a bright
// rising major chord + shimmer, once, on entering battle with gold armed
wd.__play_sound = [
{ frequency: 523, duration: 0.5, volume: 0.16, type: 'triangle' },
{ frequency: 659, duration: 0.5, volume: 0.14, type: 'triangle' },
{ frequency: 784, duration: 0.55, volume: 0.13, type: 'sine' },
{ frequency: 1568, duration: 0.7, volume: 0.09, type: 'sine' },
]
}
}
if (B.combos.length && !B.__star2) B.__star2 = true // a live combo ALSO grants the ×2 rate buff
for (const mt of B.mounts) {
ENG.mountCool(mt, dtB)
if (mt.__beamT > 0) mt.__beamT -= dtB // decay FIRST — `continue` below froze the last beam on screen forever (Galen: trace lines that don't go away)
if (u.tileHp[mt.i] <= 0) continue // a DESTROYED gun is SILENT (its last trace still fades out above)
const tl = B.pT[mt.i]
const mx = B.fly.x + (tl.cx * cw - tl.cy * sw), my = B.fly.y + (tl.cx * sw + tl.cy * cw)
// PIXEL-TRUE targeting: a tile is a DISK (r≈0.85), not a point — an edge
// poking into range/arc is shootable; range measures to the NEAR edge
const RT = 0.851
// TARGET SELECTION: prefer the nearest tile the mount can LEGALLY AIM AT
// (inside its arc) — a narrow/fixed gun must shoot what's on its ray, not
// sulk because some unreachable tile is marginally nearer. Fall back to
// nearest-overall only so the turret still traverses toward its arc edge.
let best = -1, bd = 1e9, ix = 0, iy = 0, bestTgt = -1
let bestA = -1, bdA = 1e9, ixA = 0, iyA = 0, bestTgtA = -1
for (let tgi = 0; tgi < B.targets.length; tgi++) {
const tg = B.targets[tgi], trig = tgtTrig[tgi]
for (const i3 of ENG.aliveTiles(tg)) {
const t3 = tg.tiles[i3]
const wx3 = tg.x + (t3.cx * trig.ca - t3.cy * trig.sa), wy3 = tg.y + (t3.cx * trig.sa + t3.cy * trig.ca)
const d3 = Math.hypot(wx3 - mx, wy3 - my)
if (d3 - RT > mt.weapon.range) continue // near EDGE out of range
if (d3 < bd) { bd = d3; best = i3; ix = wx3; iy = wy3; bestTgt = tgi }
const span3 = Math.asin(Math.min(1, RT / Math.max(d3, RT)))
const ang3 = Math.atan2(wy3 - my, wx3 - mx) - BODY
if (ENG.inArc(mt.sectors.map(s9 => ({ center: s9.center, half: s9.half + span3 })), ang3) && d3 < bdA) { bdA = d3; bestA = i3; ixA = wx3; iyA = wy3; bestTgtA = tgi }
}
}
if (bestA >= 0) { best = bestA; bd = bdA; ix = ixA; iy = iyA; bestTgt = bestTgtA }
mt.__engaged = bestA >= 0 // the cone HUD draws only for a gun with a legal target
if (best < 0) continue
const tu = B.targets[bestTgt]
const spanB = Math.asin(Math.min(1, RT / Math.max(bd, RT)))
const aimShip = Math.atan2(iy - my, ix - mx) - BODY
ENG.traverse(mt, aimShip, dtB)
// DECK MOUNTS, NOT GUN PORTS (Istrolid rule): a turret sits ON TOP of the
// hull and fires OVER own tiles — friendly hull never blocks. The old
// "true ray test" made every mid-hull gun permanently silent on a real
// packed warship (Galen's 38-tile ship: 5 of 7 mounts LOS-dead while
// aimed dead-on — "no gun shot when fireball is loaded to slot 3").
const los = true
const aimErr = Math.abs(Math.atan2(Math.sin(aimShip - mt.aim), Math.cos(aimShip - mt.aim)))
// arc check is SPAN-WIDENED like range ("an edge poking into range/arc is
// shootable") — a target disk overlapping the arc edge is a legal shot even
// when its center is outside the raw sector (decisive at point-blank).
if (los && mt.weapon && mt.cd <= 0 && bd - RT <= mt.weapon.range && ENG.inArc(mt.sectors.map(s9 => ({ center: s9.center, half: s9.half + spanB })), aimShip) && aimErr <= 0.06 + spanB) {
shotE += ENG.mountFire(mt)
if (B.__star2) mt.cd *= 0.5 // ★ the star chamber doubles the rate
if (pw.brownout) mt.cd = mt.cd / ENG.BROWN_GUN // starving guns fire at half rate
if (mt.weapon.proj) {
// PROJECTILE: a travelling round from the barrel tip — damage lands on
// arrival (the shots integrator below), not instantly
const aimW = mt.aim + BODY
const bpx = mx + Math.cos(aimW) * 0.7, bpy = my + Math.sin(aimW) * 0.7
;(B.shots = B.shots || []).push({ x: bpx, y: bpy, vx: Math.cos(aimW) * mt.weapon.proj.speed, vy: Math.sin(aimW) * mt.weapon.proj.speed, dmg: mt.weapon.damage, ttl: mt.weapon.range / mt.weapon.proj.speed + 0.3 })
wd.__play_sound = [{ frequency: 520, duration: 0.05, volume: 0.10, type: 'triangle' }]
} else {
const died = ENG.applyBeam(tu, best, mt.weapon.damage)
ENG.shedUnit(tu)
mt.__bx = mx; mt.__by = my; mt.__ix = ix; mt.__iy = iy; mt.__beamT = 0.1
wd.__play_sound = [{ frequency: died ? 300 : 760, duration: 0.05, volume: 0.09, type: 'square' }]
}
}
}
// ── SHAPE EMITTERS (Galen): DIAMONDS fire a laser along THEIR OWN AXIS
// (auto, when a target sits in that beam corridor). MOONS are WAVE
// BLASTERS: an expanding shock-arc rolling out along the moon's radial,
// striking everything it sweeps ONCE. All directions hull-rotate live. ──
if (B.lasers === undefined) B.lasers = (u.lasers || []).map(lz => ({ cx: lz.cx, cy: lz.cy, dx: lz.dx != null ? lz.dx : 0, dy: lz.dy != null ? lz.dy : -1, cd: 0, wave: !!lz.wave, star: !!lz.star, msl: !!lz.msl, sz: lz.sz, r: lz.r })) // sz+r MUST ride along — dropping them froze every chamber at tier 1 (the "bay never grows" bug)
{
for (let li9 = 0; li9 < (B.lasers || []).length; li9++) {
const lz = B.lasers[li9]
// GROWTH (Galen: "each pentagon made = how much the weapon is improved"):
// the SAME slice packer that DRAWS the chamber's pentagons yields the
// count → ENG.chamberPower gives the balanced value + GOLD state. What
// you see (packed pentagons) IS the weapon power.
const shape9 = lz.msl ? 'bay' : lz.star ? 'star' : lz.wave ? 'moon' : 'diamond'
if (lz.__vol == null) lz.__vol = ENG.chamberVolume(lz.sz) // THIS chamber's own negative-space AREA (continuous) — not chamber count, not total
const pw9 = ENG.chamberPower(shape9, lz.__vol)
// COMBO GOLD (Galen: "invent superweapons for discovered combos") — a
// discovered combo turns its member chambers GOLD even below threshold.
if (!pw9.gold && B.__comboShapes && B.__comboShapes.has(shape9)) { pw9.value *= (ENG.GOLD_MULT || 3); pw9.gold = true; pw9.__combo = true }
lz.__gold = pw9.gold; lz.__val = pw9.value; lz.__tier = pw9.tier
const scl = Math.max(0.6, Math.min(6, 0.6 + (pw9.tier - 1) * 0.34)) * (pw9.gold ? 1.5 : 1) // legacy effect-reach, now DERIVED from the growth tier
lz.cd -= dtB
if (((B.lzSeal && B.lzSeal[li9]) || []).some(ti => u.tileHp[ti] <= 0) || (B.chHp && B.lzCh && B.lzCh[li9] >= 0 && B.chHp[B.lzCh[li9]] <= 0)) { if (lz.__t > 0) lz.__t -= dtB; continue } // BREACHED or DESTROYED chamber: the weapon dies
const ox = B.fly.x + (lz.cx * cw - lz.cy * sw), oy = B.fly.y + (lz.cx * sw + lz.cy * cw)
const dwx = lz.dx * cw - lz.dy * sw, dwy = lz.dx * sw + lz.dy * cw // the shape's OWN direction, in the world
if (lz.msl) {
// MISSILE BAY (Galen): every 3.2s, when anything lives within 20,
// the bay VOLLEYS 3 TRACKING MISSILES in a wide arc — they fan out,
// then curve onto their prey.
if (lz.cd <= 0) {
let any9 = false
for (const tg of B.targets) if (Math.hypot(tg.x - ox, tg.y - oy) < 20) { any9 = true; break }
if (any9) {
lz.cd = 3.2
const nMsl = Math.max(3, Math.round(pw9.value)) // missiles = the bay's growth value (gold DOUBLES it)
shotE += nMsl
const aim9 = Math.atan2(dwy, dwx)
for (let m9 = 0; m9 < nMsl; m9++) {
const la = aim9 + (m9 - (nMsl - 1) / 2) * (1.7 / nMsl) // fan spread across the arc
;(B.__msl = B.__msl || []).push({ x: ox, y: oy, vx: Math.cos(la) * 8, vy: Math.sin(la) * 8, ttl: 4 })
}
wd.__play_sound = [{ frequency: 640, duration: 0.12, volume: 0.13, type: 'square' }, { frequency: 420, duration: 0.16, volume: 0.1, type: 'triangle' }]
}
}
} else if (lz.star) {
// STAR — AUTO-FOCUS LONG LASER (Galen): locks the nearest enemy tile
// within 26 in ANY direction and pulses a heavy focused beam.
if (lz.cd <= 0) {
let bi9 = -1, bd9 = 26, bTg = null, hx9 = 0, hy9 = 0
for (const tg of B.targets) {
if (Math.hypot(tg.x - ox, tg.y - oy) > 30) continue
const trig9 = { ca: Math.cos(tg.a || 0), sa: Math.sin(tg.a || 0) }
for (const i9 of ENG.aliveTiles(tg)) {
const t9 = tg.tiles[i9]
const wx9 = tg.x + (t9.cx * trig9.ca - t9.cy * trig9.sa), wy9 = tg.y + (t9.cx * trig9.sa + t9.cy * trig9.ca)
const dd = Math.hypot(wx9 - ox, wy9 - oy)
if (dd < bd9) { bd9 = dd; bi9 = i9; bTg = tg; hx9 = wx9; hy9 = wy9 }
}
}
if (bTg) {
lz.cd = Math.max(0.12, 0.7 / pw9.value) // star = fire-RATE growth (gold DOUBLES the rate)
shotE += 3
ENG.applyBeam(bTg, bi9, Math.round(4 * pw9.value * 0.7)); ENG.shedUnit(bTg)
lz.__bx = ox; lz.__by = oy; lz.__ix = hx9; lz.__iy = hy9; lz.__t = 0.18
wd.__play_sound = [{ frequency: 1560, duration: 0.1, volume: 0.12, type: 'sawtooth' }, { frequency: 780, duration: 0.14, volume: 0.08, type: 'sine' }]
}
}
} else if (lz.wave) {
// WAVE BLASTER — fire on cadence when anything lives in the arc ahead
if (lz.cd <= 0) {
let sees = false
for (const tg of B.targets) {
const ddx = tg.x - ox, ddy = tg.y - oy, dd = Math.hypot(ddx, ddy)
if (dd > 24) continue // long sight — the wave carries far
const ang9 = Math.atan2(ddy, ddx), aim9 = Math.atan2(dwy, dwx)
if (Math.abs(Math.atan2(Math.sin(ang9 - aim9), Math.cos(ang9 - aim9))) < 0.9) { sees = true; break }
}
if (sees) {
lz.cd = 2.0
shotE += 4
;(B.__wavesOut = B.__wavesOut || []).push({ x: ox, y: oy, ax: Math.atan2(dwy, dwx), r: 0.6, hit: {}, scl, dmg: pw9.value })
wd.__play_sound = [{ frequency: 320, duration: 0.2, volume: 0.15, type: 'sawtooth' }, { frequency: 160, duration: 0.28, volume: 0.12, type: 'triangle' }]
}
}
} else {
// DIAMOND LASER — pulse when a target tile sits in the slit's corridor
// CONE OF ATTACK (Galen): the slit projects a widening cone — anything
// inside it triggers the beam. And it hits HARD.
let bi9 = -1, bAl = 14.5, bTg = -1, hx9 = 0, hy9 = 0
for (let tgi = 0; tgi < B.targets.length; tgi++) {
const tg = B.targets[tgi], trig = tgtTrig[tgi]
if (Math.hypot(tg.x - ox, tg.y - oy) > 18) continue
for (const i9 of ENG.aliveTiles(tg)) {
const t9 = tg.tiles[i9]
const wx9 = tg.x + (t9.cx * trig.ca - t9.cy * trig.sa), wy9 = tg.y + (t9.cx * trig.sa + t9.cy * trig.ca)
const al = (wx9 - ox) * dwx + (wy9 - oy) * dwy
if (al <= 0.3 || al >= bAl) continue
if (Math.abs((wx9 - ox) * dwy - (wy9 - oy) * dwx) > 0.851 + al * 0.36) continue // the cone widens
bi9 = i9; bAl = al; bTg = tgi; hx9 = wx9; hy9 = wy9
}
}
if (bi9 >= 0 && lz.cd <= 0) {
lz.cd = 0.45
shotE += 2
const tu9 = B.targets[bTg]
ENG.applyBeam(tu9, bi9, Math.round(pw9.value)); ENG.shedUnit(tu9) // diamond damage = growth value (gold DOUBLES)
lz.__bx = ox; lz.__by = oy; lz.__ix = hx9; lz.__iy = hy9; lz.__t = 0.12
wd.__play_sound = [{ frequency: 1180, duration: 0.06, volume: 0.09, type: 'sawtooth' }]
}
}
if (lz.__t > 0) lz.__t -= dtB
}
// advance the WAVES: an arc front expanding at 7/s to range 10; every
// enemy tile it crosses takes 3, once per wave
for (let wi = (B.__wavesOut || []).length - 1; wi >= 0; wi--) {
const wv = B.__wavesOut[wi]
wv.r += 10 * dtB
if (wv.r > 24) { B.__wavesOut.splice(wi, 1); continue } // MUCH longer blast (Galen)
for (let tgi = 0; tgi < B.targets.length; tgi++) {
const tg = B.targets[tgi], trig = tgtTrig[tgi]
if (Math.hypot(tg.x - wv.x, tg.y - wv.y) > wv.r + 5) continue
for (const i9 of ENG.aliveTiles(tg)) {
const key9 = tgi + ':' + i9
if (wv.hit[key9]) continue
const t9 = tg.tiles[i9]
const wx9 = tg.x + (t9.cx * trig.ca - t9.cy * trig.sa), wy9 = tg.y + (t9.cx * trig.sa + t9.cy * trig.ca)
const dd = Math.hypot(wx9 - wv.x, wy9 - wv.y)
if (Math.abs(dd - wv.r) > 1.4) continue // THICK slice blast (Galen)
const ang9 = Math.atan2(wy9 - wv.y, wx9 - wv.x)
if (Math.abs(Math.atan2(Math.sin(ang9 - wv.ax), Math.cos(ang9 - wv.ax))) > 0.9) continue
wv.hit[key9] = 1
ENG.applyBeam(tg, i9, Math.round(wv.dmg || 6)); ENG.shedUnit(tg) // wave damage = moon growth value (gold DOUBLES)
}
}
}
}
// ── TRACKING MISSILES: fan out, then curve onto the nearest prey ──
for (let mi = (B.__msl || []).length - 1; mi >= 0; mi--) {
const ms = B.__msl[mi]
ms.ttl -= dtB
if (ms.ttl <= 0) { B.__msl.splice(mi, 1); continue }
// home: steer toward the nearest enemy tile (turn-limited — they CURVE)
let hx = null, hy = null, hd = 24, hTg = null, hTi = -1
for (const tg of B.targets) {
if (Math.hypot(tg.x - ms.x, tg.y - ms.y) > hd + 4) continue
const trig9 = { ca: Math.cos(tg.a || 0), sa: Math.sin(tg.a || 0) }
for (const i9 of ENG.aliveTiles(tg)) {
const t9 = tg.tiles[i9]
const wx9 = tg.x + (t9.cx * trig9.ca - t9.cy * trig9.sa), wy9 = tg.y + (t9.cx * trig9.sa + t9.cy * trig9.ca)
const dd = Math.hypot(wx9 - ms.x, wy9 - ms.y)
if (dd < hd) { hd = dd; hx = wx9; hy = wy9; hTg = tg; hTi = i9 }
}
}
if (hx != null) {
const want = Math.atan2(hy - ms.y, hx - ms.x)
const cur = Math.atan2(ms.vy, ms.vx)
const dA = Math.atan2(Math.sin(want - cur), Math.cos(want - cur))
const turn = Math.max(-3.5 * dtB, Math.min(3.5 * dtB, dA))
const na = cur + turn
ms.vx = Math.cos(na) * 8; ms.vy = Math.sin(na) * 8
}
ms.x += ms.vx * dtB; ms.y += ms.vy * dtB
if (hTg && hd < 0.8) { // STRIKE
ENG.applyBeam(hTg, hTi, 4); ENG.shedUnit(hTg)
;(B.__mineFx = B.__mineFx || []).push({ x: ms.x, y: ms.y, t: 0.2 })
wd.__play_sound = [{ frequency: 210, duration: 0.14, volume: 0.16, type: 'sawtooth' }]
B.__msl.splice(mi, 1)
}
}
// ── PROJECTILES in flight: integrate every live round; a round that touches
// a target tile (disk r≈0.85) delivers its damage there and dies; ttl
// expires the misses. Deterministic — no spread, aim is the whole skill. ──
if (B.shots && B.shots.length) {
for (let si = B.shots.length - 1; si >= 0; si--) {
const sh = B.shots[si]
sh.x += sh.vx * dtB; sh.y += sh.vy * dtB; sh.ttl -= dtB
let hit = false
for (let tgi = 0; tgi < B.targets.length && !hit; tgi++) {
const tg = B.targets[tgi], trig = tgtTrig[tgi]
let bi7 = -1, bd7 = 0.851
for (const i7b of ENG.aliveTiles(tg)) {
const t7 = tg.tiles[i7b]
const wx7 = tg.x + (t7.cx * trig.ca - t7.cy * trig.sa), wy7 = tg.y + (t7.cx * trig.sa + t7.cy * trig.ca)
const d7 = Math.hypot(wx7 - sh.x, wy7 - sh.y)
if (d7 < bd7) { bd7 = d7; bi7 = i7b }
}
if (bi7 >= 0) {
const died7 = ENG.applyBeam(tg, bi7, sh.dmg); ENG.shedUnit(tg); hit = true
// IMPACT (Galen): the P-mod round carries MOMENTUM — it shoves the
// hull back along the shot line, scaled down by the hull's surviving
// mass. A rusher eating slugs is visibly STOPPED; beams never shove.
const spd7 = Math.max(Math.hypot(sh.vx, sh.vy), 1e-6)
const kb7 = 2.2 / Math.max(1, ENG.aliveTiles(tg).size * 0.35)
tg.x += sh.vx / spd7 * kb7 * 0.35; tg.y += sh.vy / spd7 * kb7 * 0.35
tg.vx = (tg.vx || 0) + sh.vx / spd7 * kb7; tg.vy = (tg.vy || 0) + sh.vy / spd7 * kb7
wd.__play_sound = [{ frequency: died7 ? 280 : 660, duration: 0.06, volume: 0.10, type: 'square' }]
}
}
if (hit || sh.ttl <= 0) B.shots.splice(si, 1)
}
}
B.lastDrain = st2.drain + shotE / Math.max(dtB, 1e-3)
// ── COLLISION: ship↔target physical contact — bounce + impact damage +
// shear. Tile-vs-tile circle test (r≈0.85 each, the same pixel-true
// convention the weapon-range math above already uses). On contact:
// push the ships apart along the contact normal (position correction —
// this model has no free 2D velocity to impulse, just a heading+speed),
// kill most of the player's speed, damage the NEAREST tile on both
// hulls by how hard they were closing, and shed whatever breaks off
// (the same route-BFS law weapon damage already uses). ──
// ── DEFENSE MINES (Galen: "each shield SHOOTS ITSELF in red when something
// gets too close — a detonating defense mine"): a docked cell that senses
// an enemy tile inside its trigger radius LAUNCHES ITSELF at the threat,
// burning red, homes in, and DETONATES — real damage + a shove; the cell
// is spent (regen rebirths it at the rim later). While flying it no
// longer blocks beams — it left its post. ──
if (B.rim) {
// ONE regen (RESTORED — the mine-swap surgery ate this block): a spent
// mine regrows every 3s, ALWAYS at its original rim slot (dx/dy is its
// birth post — Galen: "must regenerate where they were first"), with a
// visible shimmer while it knits back.
// mine REBIRTH needs a LIVING source chamber (circle or moon — both make
// shields, Galen). A rim with no source chambers (legacy/injected) keeps
// its regen: there is no seal to breach.
let circleOK = true, sawSrc9 = false
for (let ci9b = 0; ci9b < (u.chambers || []).length; ci9b++) {
const ch9 = u.chambers[ci9b]
if (ch9.shape !== 'circle' && ch9.shape !== 'moon') continue
if (!sawSrc9) { sawSrc9 = true; circleOK = false }
if (!(((B.chSeal && B.chSeal[ci9b]) || []).some(ti => u.tileHp[ti] <= 0)) && !(B.chHp && B.chHp[ci9b] <= 0)) circleOK = true
}
B.rim.acc = (B.rim.acc || 0) + dtB
if (B.rim.acc >= 3 && circleOK) {
B.rim.acc = 0
const dead5 = B.rim.cells.find(c5 => !c5.alive)
if (dead5) {
dead5.alive = true; dead5.fly = null; dead5.grow = 0
wd.__play_sound = [{ frequency: 880, duration: 0.1, volume: 0.08, type: 'sine' }]
}
}
const TRIG = 2.4, MSPD = 9, MDMG = 5
// PERF: only targets whose CENTER is near the ship can possibly trip a
// mine (rim ~4 + trigger 2.4 + tile sprawl ~3.5) — broad-phase once.
B.__mineTick = ((B.__mineTick || 0) + 1) % 3
const nearTgs = B.targets.filter(tg => Math.hypot(tg.x - B.fly.x, tg.y - B.fly.y) < 11)
for (let ci9 = 0; ci9 < B.rim.cells.length; ci9++) {
const pl = B.rim.cells[ci9]
if (!pl.alive) continue
if (!pl.fly) {
if (nearTgs.length === 0) break // nothing near — no cell can sense
if (ci9 % 3 !== B.__mineTick) continue // stagger the scans (sense lag ≤ 0.05s)
const pwx = B.fly.x + (pl.dx * cw - pl.dy * sw), pwy = B.fly.y + (pl.dx * sw + pl.dy * cw)
let sense = null, sd9 = TRIG
for (const tg of nearTgs) {
const trig9 = { ca: Math.cos(tg.a || 0), sa: Math.sin(tg.a || 0) }
for (const i6c of ENG.aliveTiles(tg)) {
const t6c = tg.tiles[i6c]
const wxc = tg.x + (t6c.cx * trig9.ca - t6c.cy * trig9.sa), wyc = tg.y + (t6c.cx * trig9.sa + t6c.cy * trig9.ca)
const dc = Math.hypot(wxc - pwx, wyc - pwy)
if (dc < sd9) { sd9 = dc; sense = tg }
}
}
if (sense) {
pl.fly = { x: pwx, y: pwy, tg: sense, ttl: 1.8 }
wd.__play_sound = [{ frequency: 1040, duration: 0.06, volume: 0.1, type: 'square' }]
}
} else {
const F9 = pl.fly
F9.ttl -= dtB
const tg = F9.tg
const gone = !tg || !B.targets.includes(tg) || ENG.unitDead(tg)
if (gone || F9.ttl <= 0) { pl.alive = false; pl.fly = null; continue } // spent — fizzle
const trig9 = { ca: Math.cos(tg.a || 0), sa: Math.sin(tg.a || 0) }
let bx9 = tg.x, by9 = tg.y, bd9 = Infinity, bi9 = 0
for (const i6c of ENG.aliveTiles(tg)) {
const t6c = tg.tiles[i6c]
const wxc = tg.x + (t6c.cx * trig9.ca - t6c.cy * trig9.sa), wyc = tg.y + (t6c.cx * trig9.sa + t6c.cy * trig9.ca)
const dc = Math.hypot(wxc - F9.x, wyc - F9.y)
if (dc < bd9) { bd9 = dc; bx9 = wxc; by9 = wyc; bi9 = i6c }
}
const dl9 = Math.hypot(bx9 - F9.x, by9 - F9.y) || 1
F9.x += (bx9 - F9.x) / dl9 * MSPD * dtB
F9.y += (by9 - F9.y) / dl9 * MSPD * dtB
if (bd9 < 0.8) { // DETONATE
ENG.applyBeam(tg, bi9, MDMG); ENG.shedUnit(tg)
tg.vx = (tg.vx || 0) + (bx9 - B.fly.x) / Math.max(1, Math.hypot(bx9 - B.fly.x, by9 - B.fly.y)) * 2.4
tg.vy = (tg.vy || 0) + (by9 - B.fly.y) / Math.max(1, Math.hypot(bx9 - B.fly.x, by9 - B.fly.y)) * 2.4
;(B.__mineFx = B.__mineFx || []).push({ x: F9.x, y: F9.y, t: 0.22 })
wd.__play_sound = [{ frequency: 180, duration: 0.16, volume: 0.18, type: 'sawtooth' }, { frequency: 90, duration: 0.22, volume: 0.14, type: 'triangle' }]
pl.alive = false; pl.fly = null
}
}
}
}
{
const RTc = 0.85, sepR = RTc * 2
let bestPd = Infinity, bestPi = -1, bestTi = -1, bestTgi = -1, bestNx = 0, bestNy = 0, bestWx = 0, bestWy = 0
// PERF broad phase: a target whose center is beyond hull-spread + its own
// sprawl can't touch any tile pair — skip its whole tile×tile scan.
const nearIdx = []
for (let tgi = 0; tgi < B.targets.length; tgi++) {
const tg = B.targets[tgi]
if (Math.hypot(tg.x - B.fly.x, tg.y - B.fly.y) < 13) nearIdx.push(tgi)
}
if (nearIdx.length) for (const i6 of ENG.aliveTiles(u)) {
const t6 = u.tiles[i6]
const wx6 = B.fly.x + (t6.cx * cw - t6.cy * sw), wy6 = B.fly.y + (t6.cx * sw + t6.cy * cw) // cw/sw = cos/sin(BODY), already in scope
for (const tgi of nearIdx) {
const tg = B.targets[tgi], trig = tgtTrig[tgi]
for (const j6 of ENG.aliveTiles(tg)) {
const tj = tg.tiles[j6]
const wxj = tg.x + (tj.cx * trig.ca - tj.cy * trig.sa), wyj = tg.y + (tj.cx * trig.sa + tj.cy * trig.ca)
const ddx = wxj - wx6, ddy = wyj - wy6, dd6 = Math.hypot(ddx, ddy)
if (dd6 < sepR && dd6 < bestPd) { bestPd = dd6; bestPi = i6; bestTi = j6; bestTgi = tgi; bestNx = ddx / Math.max(dd6, 1e-6); bestNy = ddy / Math.max(dd6, 1e-6); bestWx = wx6; bestWy = wy6 }
}
}
}
if (bestPi >= 0) {
const tu = B.targets[bestTgi]
const overlap = sepR - bestPd
const closing = Math.abs(B.fly.v) + 0.5
B.fly.x -= bestNx * overlap; B.fly.y -= bestNy * overlap // push apart, out of the target
B.fly.v *= 0.15 // a hit kills most of your speed
B.fly.vx = (B.fly.vx || 0) * 0.15; B.fly.vy = (B.fly.vy || 0) * 0.15 // the FREE vector takes the hit too
// RECOIL (Galen): the hit shoves the ship back along the contact normal
// at a share of closing speed, and an OFF-CENTER contact turns the shove
// into SPIN via the lever arm (cross of contact-offset × impulse) — a
// nose-on ram bounces straight back; clipping a corner whips the hull
// around. Written into B.kick; the integrator above plays + decays it.
// MASS RESISTS RECOIL (Galen): the shove and the spin both divide by the
// hull's true mass — a scout gets thrown, a battleship barely nods.
const mK9 = 9 / (4 + (A2.mass || 2))
const kmag = Math.min(6, closing * 1.1 * mK9)
const rxK = bestWx - B.fly.x, ryK = bestWy - B.fly.y // lever arm to the struck tile
B.kick = B.kick || { x: 0, y: 0, spin: 0 }
B.kick.x += -bestNx * kmag; B.kick.y += -bestNy * kmag
B.kick.spin = Math.max(-3, Math.min(3, B.kick.spin + (rxK * (-bestNy * kmag) - ryK * (-bestNx * kmag)) * 0.6 * mK9))
const dmg = Math.max(2, closing * 1.8)
// SHIELD RIM = regenerating ARMOR: same damage pathway as the hull — a
// cell near the contact point pops (asteroid rams included) and the
// hull is spared this impact; recoil still shoves you.
let rimAte = false
if (B.rim) {
let best5 = null, bd5 = 1.1
for (const pl of B.rim.cells) {
if (!pl.alive) continue
const pwx = B.fly.x + (pl.dx * cw - pl.dy * sw), pwy = B.fly.y + (pl.dx * sw + pl.dy * cw)
const d5 = Math.hypot(pwx - bestWx, pwy - bestWy)
if (d5 < bd5) { bd5 = d5; best5 = pl }
}
if (best5) { best5.alive = false; best5.deadAt = D.t; rimAte = true; wd.__play_sound = [{ frequency: 540, duration: 0.08, volume: 0.12, type: 'triangle' }] }
}
if (!rimAte) { ENG.applyBeam(u, bestPi, u.reflective ? dmg * 0.8 : dmg); ENG.shedUnit(u); chSplash(bestPi, dmg); wd.__play_sound = [{ frequency: 90, duration: 0.14, volume: 0.16, type: 'square' }] } // REFLECT special: 20% less impact damage taken — and a hull impact is HEARD
ENG.applyBeam(tu, bestTi, dmg); ENG.shedUnit(tu)
wd.__play_sound = [{ frequency: 140, duration: 0.12, volume: 0.18, type: 'sawtooth' }, { frequency: 90, duration: 0.18, volume: 0.14, type: 'triangle' }]
B.__hitFlash = 0.25 // degradation feedback: a brief HUD flash
if (ENG.unitDead(u)) { // the player's own hull broke — respawn
B.fly.x = 0; B.fly.y = 0; B.fly.v = 0; B.fly.vx = 0; B.fly.vy = 0
for (let k = 0; k < u.tileHp.length; k++) u.tileHp[k] = u.tileMaxHp[k]
u.hullBuffer = u.hullMax
}
}
}
B.__hitFlash = Math.max(0, (B.__hitFlash || 0) - dtB)
// ── THE SHIELD RIM (Galen): ONE unified shell of iron cells on the ship's
// OUTER RIM — regenerating ARMOR, not a forcefield. Circles set the cell
// BUDGET (denser rim per circle, growing from the circles' sides); regen
// is ONE slow rate no matter how many circles (no double regen — two
// circles used to stack fields and set the yard on fire). One hit to a
// cell and POP. Cells share the hull's damage pathway: beams AND
// collisions (asteroids included) pop cells before the hull pays. ──
if (B.rim === undefined) {
B.rim = null
const budget = (B.shieldF || []).reduce((a, s0) => a + Math.round((s0.cap || 12) / 1.5), 0)
if (budget > 0) {
let sd = 4321
const rnd2 = () => { sd = (sd * 1664525 + 1013904223) >>> 0; return sd / 4294967296 }
const anchors = []
let cgx = 0, cgy = 0
for (const tl of u.tiles) { cgx += tl.cx; cgy += tl.cy }
cgx /= u.tiles.length; cgy /= u.tiles.length
for (const tl of u.tiles) {
const tR = Math.hypot(tl.cx - cgx, tl.cy - cgy)
for (let e5 = 0; e5 < 5; e5++) {
const aA = tl.th + Math.PI / 2 + (e5 + 0.5) * 1.2566371
const ax = tl.cx + Math.cos(aA) * 1.38, ay = tl.cy + Math.sin(aA) * 1.38
if (u.tiles.some(o5 => Math.hypot(o5.cx - ax, o5.cy - ay) < 0.9)) continue
// OUTER RIM ONLY (Galen): a free edge facing an interior hole points
// INWARD — its anchor sits closer to the centroid than its tile. Drop it.
if (Math.hypot(ax - cgx, ay - cgy) < tR) continue
if (anchors.some(q5 => Math.hypot(q5.x - ax, q5.y - ay) < 0.7)) continue
anchors.push({ x: ax, y: ay, ea: aA }) // ea = edge normal (for aligned art)
}
}
// grow the rim OUT FROM the circles' sides: nearest-to-a-circle first
const dirs = (B.shieldF || []).map(s0 => Math.atan2(s0.cy, s0.cx || 1e-6))
const wrapS = (a) => Math.atan2(Math.sin(a), Math.cos(a))
anchors.sort((a5, b5) => {
const da = Math.min(...dirs.map(d0 => Math.abs(wrapS(Math.atan2(a5.y, a5.x) - d0))))
const db = Math.min(...dirs.map(d0 => Math.abs(wrapS(Math.atan2(b5.y, b5.x) - d0))))
return da - db
})
const picked5 = anchors.slice(0, Math.min(anchors.length, budget)).sort((a5, b5) => Math.atan2(a5.y, a5.x) - Math.atan2(b5.y, b5.x))
const shell5 = []
for (let i5 = 0; i5 < picked5.length; i5++) { // DENSER: seam plugs between every neighbor pair
const a5 = picked5[i5], b5 = picked5[(i5 + 1) % picked5.length]
shell5.push(a5)
if (Math.hypot(a5.x - b5.x, a5.y - b5.y) < 2.0) shell5.push({ x: (a5.x + b5.x) / 2 * 1.06, y: (a5.y + b5.y) / 2 * 1.06 })
}
B.rim = { cells: shell5.map(q5 => ({ dx: q5.x * 1.03, dy: q5.y * 1.03, ang: (q5.ea != null ? q5.ea : Math.atan2(q5.y, q5.x)) + (rnd2() - 0.5) * 0.6, alive: true })), acc: 0 } // TIGHT + edge-aligned, slight jitter
}
}
if (B.rim) {
// SHIELD REGEN (Galen): each fallen cell blocks ONE hit, then regrows 10s
// after IT died — independent per-cell cooldown, not a global drip.
for (const c5 of B.rim.cells) {
if (!c5.alive && c5.deadAt != null && (D.t - c5.deadAt) >= 10) { c5.alive = true; c5.deadAt = null }
}
}
// ── ENEMY AI: the advanced blocks act. // ── ENEMY AI: the advanced blocks act. Pursuit (wave 2+): seek the player,
// slowing inside 2.5 so they harry rather than pile in — the collision
// law (bounce/damage/recoil) referees actual contact. Guns (wave 3+):
// nearest player tile in range eats a beam on a cooldown; the player's
// own respawn law already handles death. ──
// ── ENEMY↔ENEMY SEPARATION (Galen: "we lost collision detection" — the
// ship-side law was alive and provably firing, but enemy hulls piled
// THROUGH each other into one blob, which reads as no collision at all).
// Soft-body pairwise shove on hull spreads; O(n²) on ≤ a dozen units. ──
{
const T9 = B.targets
for (const tg of T9) if (tg.__spread === undefined) {
let r9 = 0.85
for (const i9s of ENG.aliveTiles(tg)) { const t9s = tg.tiles[i9s]; r9 = Math.max(r9, Math.hypot(t9s.cx, t9s.cy) + 0.85) }
tg.__spread = r9
}
for (let a9 = 0; a9 < T9.length; a9++) for (let b9 = a9 + 1; b9 < T9.length; b9++) {
const eA = T9[a9], eB = T9[b9]
const want9 = (eA.__spread || 2) * 0.55 + (eB.__spread || 2) * 0.55
const dx9 = eB.x - eA.x, dy9 = eB.y - eA.y, dd9 = Math.hypot(dx9, dy9)
if (dd9 >= want9) continue
// EXACT coincidence (mass spawns / test slams) still needs a normal —
// derive one deterministically from the pair indices, never skip
let nx9, ny9
if (dd9 < 1e-6) { const ja9 = a9 * 2.399963 + b9; nx9 = Math.cos(ja9); ny9 = Math.sin(ja9) }
else { nx9 = dx9 / dd9; ny9 = dy9 / dd9 }
const push9 = (want9 - dd9) * 0.5
eA.x -= nx9 * push9; eA.y -= ny9 * push9
eB.x += nx9 * push9; eB.y += ny9 * push9
}
}
for (const tg of B.targets) {
if (tg.spd > 0) {
const dxE = B.fly.x - tg.x, dyE = B.fly.y - tg.y, dE = Math.hypot(dxE, dyE) || 1
const want = dE > 2.5 ? tg.spd : tg.spd * Math.max(0.15, (dE - 1.2) / 1.3)
tg.vx = (tg.vx || 0) + (dxE / dE * want - (tg.vx || 0)) * Math.min(1, dtB * 1.6)
tg.vy = (tg.vy || 0) + (dyE / dE * want - (tg.vy || 0)) * Math.min(1, dtB * 1.6)
tg.x += tg.vx * dtB; tg.y += tg.vy * dtB
tg.a = Math.atan2(tg.vy, tg.vx) // face the chase
}
if (tg.guns && tg.guns.length) {
const trigE = { ca: Math.cos(tg.a || 0), sa: Math.sin(tg.a || 0) }
for (const g of tg.guns) {
g.cd -= dtB
if (g.cd > 0) continue
let bi = -1, bdE = g.range, bxE = 0, byE = 0
for (const i8 of ENG.aliveTiles(u)) {
const t8 = u.tiles[i8]
const wx8 = B.fly.x + (t8.cx * cw - t8.cy * sw), wy8 = B.fly.y + (t8.cx * sw + t8.cy * cw)
const d8 = Math.hypot(wx8 - tg.x, wy8 - tg.y)
if (d8 < bdE) { bdE = d8; bi = i8; bxE = wx8; byE = wy8 }
}
if (bi < 0) continue
g.cd = (ENG.starArmed && ENG.starArmed(tg)) ? g.cool * 0.5 : g.cool // ★ their star, their ×2 — break the ring to strip it
// SHIELD CHECK: does the shot's path cross a live projected field?
// The nearest crossing eats the damage; the beam dies AT the disc.
let blocked = null
if (B.rim) {
// the nearest ALIVE rim cell on the shot's path pops — dead cells
// are real HOLES the fire pours through
const ddxS = bxE - tg.x, ddyS = byE - tg.y, LLS = Math.hypot(ddxS, ddyS) || 1
let bT = Infinity
for (const pl of B.rim.cells) {
if (!pl.alive || pl.fly) continue // a launched mine left its post — no block
const pwx = B.fly.x + (pl.dx * cw - pl.dy * sw), pwy = B.fly.y + (pl.dx * sw + pl.dy * cw)
const tpr = Math.max(0, Math.min(LLS, ((pwx - tg.x) * ddxS + (pwy - tg.y) * ddyS) / LLS))
const pxS = tg.x + ddxS / LLS * tpr, pyS = tg.y + ddyS / LLS * tpr
if (Math.hypot(pwx - pxS, pwy - pyS) <= 0.5 && tpr < bT) { bT = tpr; blocked = { pl, x: pxS, y: pyS } }
}
}
if (blocked) {
blocked.pl.alive = false; blocked.pl.deadAt = D.t // ONE hit — POP! (regens in 10s)
;(B.__ebeams = B.__ebeams || []).push({ bx: tg.x, by: tg.y, ix: blocked.x, iy: blocked.y, t: 0.1 })
wd.__play_sound = [{ frequency: 620, duration: 0.07, volume: 0.11, type: 'triangle' }]
continue
}
ENG.applyBeam(u, bi, g.dmg); ENG.shedUnit(u)
chSplash(bi, g.dmg)
;(B.__ebeams = B.__ebeams || []).push({ bx: tg.x, by: tg.y, ix: bxE, iy: byE, t: 0.1 })
B.__hitFlash = 0.2
wd.__play_sound = [{ frequency: 210, duration: 0.06, volume: 0.09, type: 'square' }]
if (ENG.unitDead(u)) { // same respawn law as collision death
B.fly.x = 0; B.fly.y = 0; B.fly.v = 0; B.fly.vx = 0; B.fly.vy = 0
for (let k8 = 0; k8 < u.tileHp.length; k8++) u.tileHp[k8] = u.tileMaxHp[k8]
u.hullBuffer = u.hullMax
}
}
}
}
// MEND special: slow passive regen on the most-damaged alive tile
if (u.regenRate > 0) {
let worst = -1, worstFrac = 1
for (const i7 of ENG.aliveTiles(u)) {
const frac = u.tileHp[i7] / u.tileMaxHp[i7]
if (frac < 1 && frac < worstFrac) { worstFrac = frac; worst = i7 }
}
if (worst >= 0) u.tileHp[worst] = Math.min(u.tileMaxHp[worst], u.tileHp[worst] + u.regenRate * dtB)
}
// ── draw: hull at flight pose + the route + the live draw ──
const outB = []
// CHROME (harvested from pentarch-stage/chrome.mjs) — a drawn panel backing
// the flight status readout, instead of bare text floating on black. Packs
// both half-sizes into one float exactly as chrome.mjs's chPackWH does;
// visual.wgsl's ch_unpackW/H invert it (code 320 = PANEL).
const chPanel = (cx, cy, hw, hh) => {
const w = Math.max(0, Math.min(1, hw)), h = Math.max(0, Math.min(0.9999, hh))
outB.push(cx, cy, Math.round(w * 4096) + h, 320)
}
// (flight HUD backing card removed — HULL/ENERGY bars carry their own UI-system glass, Galen Aug 11)
outB.push(-0.865, 0.905, 0, 300 + 21 + 0.145) // ◂ EXIT pad (id 21) — BOTTOM-LEFT CORNER, same seat every scene
const caB = Math.cos(BODY), saB = Math.sin(BODY)
const actT = {} // gyros VISIBLY firing (+400)
if (B.__turning) for (const th6 of B.ths) { if (!th6.rcs && th6.T) actT[th6.i] = 1 }
for (const i of ENG.aliveTiles(u)) {
const t = u.tiles[i]
const wx = B.fly.x + (t.cx * caB - t.cy * saB), wy = B.fly.y + (t.cx * saB + t.cy * caB)
const oD = (D.tree[i] || {}).o || 0
// ICONRY: battle hull carries the same mod byte as the yard (fract of code).
// DAMAGE RIDES THE FRACT (Galen: "shows brokenness per pentagon damage"):
// non-gun tiles broadcast 1−hp/max in the whole byte; gun tiles keep the
// mod byte and spend bit 1 (free — gp is only ever 0/1) as a coarse
// damaged flag. The shader draws fracture webs + ember from it.
const cdB = D.tree[i] || {}
const dmgFB = Math.max(0, Math.min(0.996, 1 - u.tileHp[i] / (u.tileMaxHp[i] || 1)))
const modFB = (i > 0 && { 3: 1, 9: 1 }[cdB.part])
? ((cdB.m || 0) * 64 + (cdB.gy || 0) * 16 + (cdB.gd || 0) * 4 + (cdB.gp ? 1 : 0) + (dmgFB > 0.5 ? 2 : 0)) / 256
: Math.round(dmgFB * 255) / 256
outB.push(wx * BS, wy * BS, (t.th || 0) + BODY, (i === 0 ? 200 : tileCode(u.tiles[i].part, oD)) + (actT[i] ? 400 : 0) + modFB)
}
// ── TURRET HEADS (Galen: "guns should swivel and aim… within their
// radius"): every TURRET(3) rides a kind-68 overlay at its tile,
// carrying the CONTINUOUS mt.aim — ENG.traverse already clamps it to
// the mount's bought arc, so the drawn barrel aims exactly where the
// gun legally can. The base pentagon stays in its hull pose. ──
for (const mt of B.mounts) {
const cdm = D.tree[mt.i] || {}
if (cdm.part !== 3 || u.tileHp[mt.i] <= 0) continue
const tlm = u.tiles[mt.i]
const wxm = B.fly.x + (tlm.cx * caB - tlm.cy * saB), wym = B.fly.y + (tlm.cx * saB + tlm.cy * caB)
const dmgM = Math.max(0, Math.min(0.996, 1 - u.tileHp[mt.i] / (u.tileMaxHp[mt.i] || 1)))
const modM = ((cdm.m || 0) * 64 + (cdm.gy || 0) * 16 + (cdm.gd || 0) * 4 + (cdm.gp ? 1 : 0) + (dmgM > 0.5 ? 2 : 0)) / 256
outB.push(wxm * BS, wym * BS, mt.aim + BODY, 68 + modM)
}
// NO velocity-vector arrow (Galen: "isn't necessary") — the hull itself
// (drawn in its design pose, BODY-locked to heading) already reads facing.
// ── ENGINES/JETS FIRING: the arcade model drives by PART TYPE, not each
// tile's own design rotation ("orientation is cosmetic") — so a plume
// drawn from each tile's own o-facing could shoot sideways/backward while
// the ship visibly drives straight ("thrusters shoot the wrong way").
// Instead: one WORLD exhaust vector — opposite whatever is actually
// pushing the ship this tick (forward drive + strafe/reverse combined) —
// applied at every live engine/jet tile's position. ──
if ((B.__effort || 0) > 0.08) {
// THE v1 ALLOCATOR RUNS THE FIRE (Galen: "one of the first movement
// methods calculated all of this very well") — phys.mjs's allocate() is
// exactly that calculation: per-thruster throttles + gimbal aims that
// pour maximum force into the wanted direction while cancelling drift
// and stray torque (angled engines visibly counter-balance). The
// Newtonian servo above still flies the ship; the allocator decides
// WHICH thrusters burn, HOW hard, and WHERE their gimbals point — so
// no plume can ever fire in a direction its thruster cannot make.
const pushX = vdx - (B.fly.vx || 0), pushY = vdy - (B.fly.vy || 0)
const pmag9 = Math.hypot(pushX, pushY)
const wtd9 = Math.max(-1, Math.min(1, (B.fly.om || 0) / Math.max(A2.turn, 0.1)))
if (!B.__wths || B.__wthsTh !== B.fly.th) { // thrusters in the WORLD frame (design → BODY rotation)
B.__wths = B.ths.map(t5 => ({ ...t5,
pos: { x: t5.pos.x * caB - t5.pos.y * saB, y: t5.pos.x * saB + t5.pos.y * caB },
dir: { x: t5.dir.x * caB - t5.dir.y * saB, y: t5.dir.x * saB + t5.dir.y * caB },
// VIRTUAL GIMBAL for the fire: the arcade law says orientation is
// cosmetic, so a design-rotated engine must still burn — grant
// engines ±57°, jets omni. Flames BEND believably; none reverses.
// ALLOCATION half is generous (engines 0.6, jets omni) so design-
// rotated builds still get throttled fire; rHalf remembers the REAL
// bought arc — the ART obeys rHalf, so a FIXED engine's flame rides
// its own nozzle axis exactly and only a bought gimbal visibly swivels.
half: t5.F ? Math.max(t5.half || 0, (D.tree[t5.i] || {}).part === 6 ? Math.PI / 2 : 0.6) : (t5.half || 0), // jets ±90° (directional RCS), engines ±34°
rHalf: t5.half || 0,
ang: t5.ang + BODY }))
B.__wthsTh = B.fly.th
}
const ax9a = pmag9 > 1e-6 ? pushX / pmag9 : 0, ay9a = pmag9 > 1e-6 ? pushY / pmag9 : 0
const us9 = ENG.allocate(B.__wths, { fwd: ax9a, lat: ay9a, turn: wtd9 })
// SMOOTHED throttles + hysteresis (Galen: "thrusters flicker") — the
// allocator re-solves from zero each tick and its raw throttles can
// oscillate; the drawn fire eases (~110ms) and latches on/off at
// different thresholds, so no flame strobes or snaps direction.
if (!B.__usS || B.__usS.length !== us9.length) { B.__usS = us9.slice(); B.__usOn = us9.map(() => false) }
else for (let k9 = 0; k9 < us9.length; k9++) B.__usS[k9] += ((us9[k9] || 0) - B.__usS[k9]) * Math.min(1, dtB * 9)
// BROWNOUT GUTTERS (Galen: "does a brownout stop guns/thrusters?" — it
// DEGRADES: thrust ×BROWN_THRUST, guns half rate; now it SHOWS): starving
// flames run dim and unsteady.
const brF9 = pw.brownout ? (0.45 + 0.18 * Math.sin(D.t * 40)) : 1
let anyFire9 = false
for (let ti9 = 0; ti9 < B.__wths.length; ti9++) {
const th2 = B.__wths[ti9], u9 = B.__usS[ti9] || 0
const on9 = u9 > (B.__usOn[ti9] ? 0.12 : 0.22)
B.__usOn[ti9] = on9
if (!th2.F || th2.rcs || !on9) continue
anyFire9 = true
const tl = B.pT[th2.i]
if (!tl) continue
// this thruster's own aimed exhaust (arc-clamped gimbal — never a lie)
let gx9 = ax9a, gy9 = ay9a
if (pmag9 < 1e-6 && Math.abs(wtd9) > 1e-6) {
const r9g = Math.hypot(th2.pos.x, th2.pos.y)
if (r9g > 1e-6) { const sg9 = Math.sign(wtd9); gx9 = -th2.pos.y / r9g * sg9; gy9 = th2.pos.x / r9g * sg9 }
}
// THE FLAME OBEYS THE MOUNT (Galen: "still have any engine shooting any
// way it likes") — jets vector freely (they are RCS blocks), engines
// swivel only their REAL bought arc; a fixed engine burns dead on-axis.
const isJet9 = (D.tree[th2.i] || {}).part === 6
const armHalf9 = isJet9 ? Math.PI / 2 : th2.rHalf // jets ±90° of their nozzle; engines their bought arc
const dir9 = ENG.aimGimbal({ ...th2, half: armHalf9 }, gx9, gy9)
// CONTRIBUTION GATE (Galen: "multiple engines can fire to create the
// momentum in the desired direction"). The old gate darkened any mount
// that couldn't aim AT the demand — so two engines angled ±30° off a
// straight push (which the allocator IS throttling to combine into that
// push) both went dark, and fire only showed dead-ahead. Now: a thruster
// burns when its REAL-arc exhaust HELPS the push (positive component
// along the demand, ≳75° cone) — angled engines visibly combine, each
// flame riding its OWN nozzle. A mount whose real aim would fight the
// push stays dark (still never a lie).
if (dir9.x * gx9 + dir9.y * gy9 < 0.25) continue
const ex9 = -dir9.x, ey9 = -dir9.y
// NO ARTIFICIAL DAMPENING (Galen: "only way it is dampened is if the
// player doesn't build the ship to do that") — every throttled thruster
// burns at its real strength along its real mount; the BUILD is the
// only governor of the fire.
const cxw = B.fly.x + (tl.cx * caB - tl.cy * saB), cyw = B.fly.y + (tl.cx * saB + tl.cy * caB)
outB.push((cxw + ex9 * 0.72) * BS, (cyw + ey9 * 0.72) * BS, Math.atan2(ey9, ex9), 56 + Math.min(0.99, u9 * Math.max(B.__effort, 0.35) * brF9))
}
// RCS MADE VISIBLE: when no mounted thruster can make this push, the v1
// RCS floor is doing the real work ("hull-integrated reaction jets") —
// show its honest little puff from the hull, opposite the acceleration,
// instead of a dead-looking ship being dragged by nothing.
if (!anyFire9 && pmag9 > 0.25) {
const exF = -pushX / pmag9, eyF = -pushY / pmag9
outB.push((B.fly.x + exF * 0.9) * BS, (B.fly.y + eyF * 0.9) * BS, Math.atan2(eyF, exF), 56 + Math.min(0.4, (0.18 + B.__effort * 0.2) * brF9))
}
}
// every target's hull (what remains of each) — the battlefield, not one dummy
for (let tgi = 0; tgi < B.targets.length; tgi++) {
const tg = B.targets[tgi], trig = tgtTrig[tgi]
for (const i3 of ENG.aliveTiles(tg)) {
const t3 = tg.tiles[i3]
// +800 = FOE flag (shader tints hostiles ember-red — violet armor hulls
// were reading as the player's own gyros escaping) + damage in fract
const dmg3 = Math.max(0, Math.min(0.996, 1 - tg.tileHp[i3] / (tg.tileMaxHp[i3] || 1)))
outB.push((tg.x + (t3.cx * trig.ca - t3.cy * trig.sa)) * BS, (tg.y + (t3.cx * trig.sa + t3.cy * trig.ca)) * BS, (t3.th || 0) + (tg.a || 0), t3.part + 800 + Math.round(dmg3 * 255) / 256)
}
// an ELITE wears its chamber glyph — the burning star IS the warning.
// Same UNIVERSAL renderer as the player + yard (one assembly, no split).
outB.push(...ENG.chamberPop((tg.chambers || []).map(chE => ({
shape: chE.shape, cx: chE.cx, cy: chE.cy, r: chE.r || 0.8, ang: chE.ang,
dead: chE.shape === 'star' && !(ENG.starArmed && ENG.starArmed(tg)), // a broken star goes dark
})), { ox: tg.x * BS, oy: tg.y * BS, rot: tg.a || 0, S: BS }))
}
// WEAPON CONES — the attack zone made visible: boundary rays (bought arc)
// + dashed range arc at the weapon's true range. Rotates with the ship.
for (const mt of B.mounts) {
const tl6 = B.pT[mt.i]; if (!tl6 || u.tileHp[mt.i] <= 0 || !mt.__engaged) continue // no cone for a destroyed OR idle gun (arcs read as combat info, and idle rings were most of the entity load)
const mx6 = B.fly.x + (tl6.cx * cw - tl6.cy * sw), my6 = B.fly.y + (tl6.cx * sw + tl6.cy * cw)
const rng = mt.weapon.range * BS
for (const sc6 of mt.sectors) { // FULL cone: every sector
const cen = sc6.center + BODY, half6 = sc6.half
const full = half6 >= Math.PI - 0.01
if (!full) for (const bnd of [cen - half6, cen + half6]) { // boundary rays, TRUE length
const hl6 = Math.min(0.49, rng / 2)
outB.push(mx6 * BS + Math.cos(bnd) * hl6, my6 * BS + Math.sin(bnd) * hl6, bnd, 58 + Math.min(0.99, hl6 * 2))
}
// FIXED dash budget per ring — dash count used to scale with arc width,
// so the ±90° turret arcs pushed ~18 entities PER GUN per frame (144 of
// a 242-entity peak = the per-pixel entity loop choking = "getting laggy")
const nD = full ? 12 : 8
for (let di = 0; di < nD; di++) {
const a6 = full ? (di / nD) * 2 * Math.PI : cen - half6 + (di + 0.5) * (half6 * 2 / nD)
outB.push(mx6 * BS, my6 * BS, a6, 59 + Math.min(0.99, rng / 2))
}
}
}
// weapon beams + impact sparks
for (const mt of B.mounts) {
if (!(mt.__beamT > 0)) continue
const mxu = mt.__bx * BS, myu = mt.__by * BS, ixu = mt.__ix * BS, iyu = mt.__iy * BS
const hl = Math.min(0.49, Math.hypot(ixu - mxu, iyu - myu) / 2)
outB.push((mxu + ixu) / 2, (myu + iyu) / 2, Math.atan2(iyu - myu, ixu - mxu), 58 + hl / 0.5 * 0.99)
outB.push(ixu, iyu, 0, 70)
}
// CHAMBER ART IN BATTLE — via the UNIVERSAL renderer (Galen: "we have a
// split" → ENG.chamberPop is the ONE assembly, shared verbatim with the
// yard + elites; the frame carries ALL mode-ness, the art channels none).
outB.push(...ENG.chamberPop((u.chambers || []).map((ch, ci9a) => ({
shape: ch.shape, cx: ch.cx, cy: ch.cy, r: ch.r || 0.8, ang: ch.ang,
gold: !!(B.__chGold && B.__chGold[ci9a]),
dead: ((B.chSeal && B.chSeal[ci9a]) || []).some(ti => u.tileHp[ti] <= 0) || !!(B.chHp && B.chHp[ci9a] <= 0),
})), { ox: B.fly.x * BS, oy: B.fly.y * BS, rot: BODY, S: BS }))
if (B.rim) for (const pl of B.rim.cells) { // the shield RIM: blue docked · RED flying mine
if (!pl.alive) continue // a spent mine = a visible hole
if (pl.fly) {
outB.push(pl.fly.x * BS, pl.fly.y * BS, (pl.ang += dtB * 22) + BODY, 67 + Math.min(0.99, (0.5 * BS) / 2))
} else {
if (pl.grow != null && pl.grow < 1) pl.grow = Math.min(1, pl.grow + dtB / 0.8)
const g9 = pl.grow == null ? 1 : pl.grow
const pwx = B.fly.x + (pl.dx * cw - pl.dy * sw), pwy = B.fly.y + (pl.dx * sw + pl.dy * cw)
outB.push(pwx * BS, pwy * BS, pl.ang + BODY + (1 - g9) * 6 * Math.sin(D.t * 20), 66 + Math.min(0.99, (0.5 * BS * (0.35 + 0.65 * g9)) / 2))
}
}
for (let mi = (B.__mineFx || []).length - 1; mi >= 0; mi--) { // detonation flash
const fx = B.__mineFx[mi]
outB.push(fx.x * BS, fx.y * BS, 0, 70); outB.push(fx.x * BS, fx.y * BS, 0, 70)
fx.t -= dtB
if (fx.t <= 0) B.__mineFx.splice(mi, 1)
}
for (let ei = (B.__ebeams || []).length - 1; ei >= 0; ei--) { // enemy beams: same ray grammar, they fade fast
const eb = B.__ebeams[ei]
const mxu2 = eb.bx * BS, myu2 = eb.by * BS, ixu2 = eb.ix * BS, iyu2 = eb.iy * BS
const hl2 = Math.min(0.49, Math.hypot(ixu2 - mxu2, iyu2 - myu2) / 2)
outB.push((mxu2 + ixu2) / 2, (myu2 + iyu2) / 2, Math.atan2(iyu2 - myu2, ixu2 - mxu2), 58 + hl2 / 0.5 * 0.99)
eb.t -= dtB
if (eb.t <= 0) B.__ebeams.splice(ei, 1)
}
for (const ms of (B.__msl || [])) { // tracking missiles: red darts, drawn for real
outB.push(ms.x * BS, ms.y * BS, Math.atan2(ms.vy, ms.vx), 67 + Math.min(0.99, (0.42 * BS) / 2))
outB.push((ms.x - ms.vx * 0.04) * BS, (ms.y - ms.vy * 0.04) * BS, 0, 70)
}
for (let fi = (B.__fxBoom || []).length - 1; fi >= 0; fi--) { // component EXPLOSIONS: a glint ring bursting outward
const fx = B.__fxBoom[fi]; fx.t += dtB
if (fx.t > 0.55) { B.__fxBoom.splice(fi, 1); continue }
// NANITE DISASSEMBLY (the style's own death): a dying pentagon returns
// to the swarm — kind 69 carries the phase; the shader grows the spark
// shell and fades the hot core. One entity, not five glints.
outB.push(fx.x * BS, fx.y * BS, fx.t, 69 + 0.5)
}
for (const wv of (B.__wavesOut || [])) { // wave crests: a THICK double-row fan rolling outward
for (let a9 = -4; a9 <= 4; a9 += 2) {
const aa = wv.ax + a9 * 0.2
outB.push(wv.x * BS, wv.y * BS, aa, 59 + Math.min(0.99, (wv.r * BS) / 2))
outB.push(wv.x * BS, wv.y * BS, aa + 0.1, 59 + Math.min(0.99, ((wv.r - 0.7) * BS) / 2))
}
}
for (const lz of (B.lasers || [])) { // diamond lasers: hard bright ray while firing
if (!(lz.__t > 0)) continue
const mxu3 = lz.__bx * BS, myu3 = lz.__by * BS, ixu3 = lz.__ix * BS, iyu3 = lz.__iy * BS
const hl3 = Math.min(0.49, Math.hypot(ixu3 - mxu3, iyu3 - myu3) / 2)
outB.push((mxu3 + ixu3) / 2, (myu3 + iyu3) / 2, Math.atan2(iyu3 - myu3, ixu3 - mxu3), 58 + hl3 / 0.5 * 0.99)
outB.push(ixu3, iyu3, 0, 70)
}
for (const sh of (B.shots || [])) { // projectile rounds: a bright glint + trail
outB.push(sh.x * BS, sh.y * BS, 0, 70)
outB.push((sh.x - sh.vx * 0.03) * BS, (sh.y - sh.vy * 0.03) * BS, 0, 70)
}
for (const q of (B.queue || [])) {
outB.push(q.x * BS, q.y * BS, 0, 70); outB.push(q.x * BS, q.y * BS, 0, 70) // waypoints glow double
if (q.face != null) { // FACING waypoint: an arrow ray along the arrival heading
const rl = 0.10
outB.push(q.x * BS + Math.cos(q.face) * rl / 2, q.y * BS + Math.sin(q.face) * rl / 2, q.face, 58 + Math.min(0.99, rl))
}
}
if (B.holding) { // live gesture: the DESTINATION + the facing being chosen
// (Matches the command's meaning now that a drag is facing-only: the press
// point glows as the destination; dragging grows the same arrival-facing
// arrow a committed facing waypoint shows — never a drawn path.)
const hp8 = B.holding.pts, p08 = hp8[0], pn8 = hp8[hp8.length - 1]
outB.push(p08.x * BS, p08.y * BS, 0, 70); outB.push(p08.x * BS, p08.y * BS, 0, 70)
const span8 = Math.hypot(pn8.x - p08.x, pn8.y - p08.y)
if (span8 > 0.6) {
const fa8 = Math.atan2(pn8.y - p08.y, pn8.x - p08.x), rl8 = 0.10
outB.push(p08.x * BS + Math.cos(fa8) * rl8 / 2, p08.y * BS + Math.sin(fa8) * rl8 / 2, fa8, 58 + Math.min(0.99, rl8))
}
}
{
// PERF + CAMERA: world entities (codes <300) shift by −cam·BS — the ONE
// place the camera touches the drawn frame — then cull what's far outside
// the view before publishing. Chrome (codes 300+) is screen-fixed, stays.
const camU = B.cam.x * BS, camV = B.cam.y * BS
const culled = []
for (let k = 0; k + 3 < outB.length; k += 4) {
const code9 = outB[k + 3]
// SCREEN-FIXED means CHROME (buttons 300-319 · panel 320 · banner 321 ·
// portrait 330) and nothing else. The old `< 300` test let every WORLD
// entity whose code carries a high flag escape the camera — a turning
// ship's act-flagged gyros (+400) and even the CORE (600: the helm is a
// torque source) rendered at RAW world coords, visibly TEARING OUT of
// the constellation (Galen: "core is still coming loose"), and FOE
// hulls (+800) floated un-cameraed. Range-test the chrome instead.
const scr9 = code9 >= 300 && code9 < 340
const ex = scr9 ? outB[k] : outB[k] - camU
const ey = scr9 ? outB[k + 1] : outB[k + 1] - camV
if (!scr9 && (ex < -1.35 || ex > 1.35 || ey < -1.35 || ey > 1.35)) continue
culled.push(ex, ey, outB[k + 2], outB[k + 3])
}
// ── MINIMAP (the large arena's eye): a top-right card, hostiles as
// glints, the ship a bright double-glint at its true offset. Screen-
// fixed — pushed AFTER the camera pass. ±MMR world units across. ──
const MMC = { x: 0.80, y: -0.78 }, MMH = 0.165, MMR = 48
culled.push(MMC.x, MMC.y, Math.round(MMH * 4096) + MMH, 320)
const mmDot = (wx9, wy9, twice) => {
const dx9 = (wx9 - B.cam.x) / MMR, dy9 = (wy9 - B.cam.y) / MMR
if (dx9 < -1 || dx9 > 1 || dy9 < -1 || dy9 > 1) return
const px9 = MMC.x + dx9 * MMH * 0.92, py9 = MMC.y + dy9 * MMH * 0.92
culled.push(px9, py9, 0, 70); if (twice) culled.push(px9, py9, 0, 70)
}
mmDot(B.fly.x, B.fly.y, true)
for (const tg9 of B.targets) mmDot(tg9.x, tg9.y, false)
wd.gpuPopulation = culled
}
// uni(15)=2 → BATTLE SCENE: deep-space backdrop, no yard shelf; uni(2/3)
// carry the camera for star parallax (the field visibly streams past)
const uuB = []; uuB[0] = D.t; uuB[2] = B.cam.x; uuB[3] = B.cam.y; uuB[7] = BS; uuB[13] = (D.__stageF == null ? 1 : D.__stageF); uuB[14] = B.fly.om; uuB[15] = 2; for (let k = 0; k < 16; k++) if (uuB[k] == null) uuB[k] = 0; wd.__uniStage = uuB
const spdB = Math.hypot(B.fly.vx, B.fly.vy)
// ═══ BATTLE HUD on THE UI SYSTEM — one glass card top-left (title/
// telemetry/power/wave/hull), the corner EXIT pad caption, the help
// strip, and the wave toast. Same tree contract as the yard console.
const etaT = (B.queue && B.queue.length) ? (() => { let dE = 0, pxE = B.fly.x, pyE = B.fly.y; for (const qq of B.queue) { dE += Math.hypot(qq.x - pxE, qq.y - pyE); pxE = qq.x; pyE = qq.y } return ' \u00b7 ETA ' + (dE / Math.max(B.ARC.vmax, 0.1)).toFixed(0) + 's' })() : ''
wd.ui = { rev: 1, root: [
// HULL + ENERGY BARS (Galen Aug 11: "we dont need the entity-0 ship-stats
// panel — just hull and energy as bars on top for the selected ship").
// Two meters top-center; the verbose telemetry/wave card is retired.
{ id: 'btbars', kind: 'panel', anchor: { gx: 256, gy: 12 }, align: 'tc', w: '40%', gap: 4, pad: 6, children: [
{ id: 'btbh', kind: 'row', gap: 5, children: [
{ kind: 'text', text: 'HULL'.padEnd(6), fontSize: 8, color: '#8fd8a8' },
{ kind: 'meter', value: (u.hullMax > 0 ? Math.max(0, Math.min(1, u.hullBuffer / u.hullMax)) : 0), flex: 1, h: 7, hue: (u.hullBuffer <= 0 && u.hullMax > 0) ? '#ff8a7a' : ((B.__hitFlash || 0) > 0 ? '#ffb08a' : '#8fd8a8') },
{ kind: 'text', text: (u.hullMax > 0 ? (Math.ceil(u.hullBuffer) + '/' + u.hullMax) : 'NONE').padStart(7), fontSize: 8, color: (u.hullBuffer <= 0 && u.hullMax > 0) ? '#ff8a7a' : '#8fd8a8' },
] },
{ id: 'btbe', kind: 'row', gap: 5, children: [
{ kind: 'text', text: 'ENERGY'.padEnd(6), fontSize: 8, color: '#a8e8ff' },
{ kind: 'meter', value: Math.max(0, Math.min(1, B.bank.charge / Math.max(1, B.grid.batCap))), flex: 1, h: 7, hue: pw.brownout ? '#ffb08a' : '#a8e8ff' },
{ kind: 'text', text: (B.bank.charge.toFixed(0) + '/' + B.grid.batCap).padStart(7), fontSize: 8, color: pw.brownout ? '#ffb08a' : '#a8e8ff' },
] },
] },
{ id: 'btbk', kind: 'panel', anchor: { x: -0.865, y: 0.905 }, glass: false, draggable: false, w: 'auto', children: [
{ kind: 'text', text: '\u25c2 EXIT', fontSize: 12, color: '#cfe0f5' } ] },
{ id: 'bthelp', kind: 'panel', anchor: { gx: 268, gy: 476 }, align: 'tc', w: '72%', pad: 3, glass: false, draggable: false, children: [
{ id: 'bth', kind: 'text', wrap: true, textAlign: 'center', fontSize: 8, color: '#7b8daa',
text: 'click\u2192move \u00b7 click behind\u2192reverse \u00b7 drag\u2192move to click, face the drag \u00b7 shift\u2192chain \u00b7 R-click\u2192strafe (nose holds) \u00b7 WASD fly \u00b7 Q/E turn \u00b7 [ ] zoom \u00b7 B\u2192yard' } ] },
...((B.waveWon > 0) ? [{ id: 'btw', kind: 'panel', anchor: { gx: 256, gy: 215 }, w: 'auto', pad: 6, glass: { border: 'rgba(255,225,150,0.6)' }, children: [
{ kind: 'text', text: '\u2605 WAVE ' + B.wave + ' CLEARED \u2014 the blocks are advancing\u2026', fontSize: 13, color: '#ffe9a8' } ] }] : []),
] }
wd.hud = []
return
}
}
} catch (e) {
// NEVER a silent black world: heal soft (back to the yard, force relayout),
// then hard (fresh state) if it keeps throwing — and SAY SO on the HUD.
try {
const wd2 = sim.worldData, P = wd2.__pd
wd2.__pderr = (wd2.__pderr || 0) + 1
if (P && wd2.__pderr < 4) { P.mode = 'design'; P.bt = null; P.layoutRev = -999; P.sel = 0 }
else { wd2.__pd = null; wd2.__pderr = 0 }
wd2.hud = [{ id: 'err', type: 'text', x: '3%', y: '50%', text: 'RECOVERED: ' + String((e && e.message) || e).slice(0, 70), fontSize: '12px', color: '#ff8a7a' }]
} catch (e2) { }
}
hook · pt-yard
DESIGNER scene — shipyard (45-tile cap + fleet-bar click-band guards, Aug 20)
try {
const wd = sim.worldData
const D = wd.__pd
if (!D) return
const { ENG, AP, CR, ST, ena, attach, verts, shrink, axes, overlaps, PARTS, V2SPEC, ORIENTABLE, PALCYCLE, PALETTE, CATEGORIES, partOf, statOf, NAME, COST, DESC, tileCode } = globalThis.__PT
const pushU = () => { (D.undo = D.undo || []).push(JSON.stringify(D.tree)); if (D.undo.length > 40) D.undo.shift() }
if (D.mode === 'menu' || D.mode === 'servers' || D.mode === 'hotseat' || D.mode === 'battle') return
// layout — recomputed whenever the tree changes. A FUNCTION (not an inline if)
// so it can run AGAIN after input mutates the tree (delete/grow), so the publish
// never sees a stale, reindexed tile list.
const doLayout = () => {
const tiles = [{ cx: 0, cy: 0, th: D.rootTh || 0 }]
for (let i = 1; i < D.tree.length; i++) { const d = D.tree[i]; tiles.push(attach(tiles[d.parent], d.edge, d.ce || 0)) }
// ── DEFAULT GUN FACING = OUTBOARD. A gun placed with no explicit rotation
// (o == null) gets the facing nearest to straight-away-from-its-parent —
// a fresh gun points OUT, not back across its own hull (which the fire
// LOS rule rightly blocks; guns were being born silent). T still cycles
// from here; a player-chosen o (any number, incl. 0) is never touched. ──
for (let i = 1; i < D.tree.length; i++) {
const d = D.tree[i]
if (d.o == null && { 3: 1, 9: 1 }[d.part]) {
const par = tiles[d.parent] || tiles[0]
const outb = Math.atan2(tiles[i].cy - par.cy, tiles[i].cx - par.cx)
let bo = 0, bs2 = Infinity
for (let oc = 0; oc < 5; oc++) {
const fc = tiles[i].th + Math.PI / 2 + (oc + 0.5) * (2 * Math.PI / 5)
let dd = Math.abs(((fc - outb) % (2 * Math.PI) + 3 * Math.PI) % (2 * Math.PI) - Math.PI)
if (dd < bs2) { bs2 = dd; bo = oc }
}
d.o = bo
}
}
// contacts → used edges
const used = new Set()
for (let i = 0; i < tiles.length; i++) for (let j = i + 1; j < tiles.length; j++) {
if (Math.hypot(tiles[i].cx - tiles[j].cx, tiles[i].cy - tiles[j].cy) > 2 * AP + 0.01) continue
for (let ei = 0; ei < 5; ei++) for (let ej = 0; ej < 5; ej++) {
const na = ena(tiles[i], ei), nb = ena(tiles[j], ej)
const ma = { x: tiles[i].cx + AP * Math.cos(na), y: tiles[i].cy + AP * Math.sin(na) }
const mb = { x: tiles[j].cx + AP * Math.cos(nb), y: tiles[j].cy + AP * Math.sin(nb) }
if (Math.hypot(ma.x - mb.x, ma.y - mb.y) < 1e-3) { used.add(i + ':' + ei); used.add(j + ':' + ej) }
}
}
// legal ghosts
const ghosts = []
for (let i = 0; i < tiles.length; i++) for (let e = 0; e < 5; e++) {
if (used.has(i + ':' + e)) continue
const g = attach(tiles[i], e)
let bad = false
for (const t of tiles) if (overlaps(g, t)) { bad = true; break }
if (!bad) ghosts.push({ i, e, g })
}
// voids: coincident vertices, 360 - n·108 in (1°,108°)
const pts = []
tiles.forEach((t, i) => verts(t).forEach(v => pts.push({ i, x: v.x, y: v.y })))
const voids = []
const seen = new Set()
for (let a = 0; a < pts.length; a++) {
if (seen.has(a)) continue
const cl = [pts[a]]; seen.add(a)
for (let b = a + 1; b < pts.length; b++) { if (!seen.has(b) && Math.hypot(pts[a].x - pts[b].x, pts[a].y - pts[b].y) < 1e-3) { cl.push(pts[b]); seen.add(b) } }
const gap = 360 - 108 * cl.length
if (cl.length >= 2 && gap > 1 && gap < 108) {
let dx = 0, dy = 0
for (const p of cl) { const t = tiles[p.i]; dx += t.cx - p.x; dy += t.cy - p.y }
const L = Math.hypot(dx, dy) || 1
voids.push({ x: pts[a].x - dx / L * 0.22, y: pts[a].y - dy / L * 0.22 })
}
}
// ── ENCLOSED HOLES — ONE TRUTH (Galen: "we don't have the same code for
// ship in design and ship in battle. come on"): the yard now reads the
// SAME classifier battle's makeUnit uses (ENG.holes). No inline twin to
// drift. Remap {cx,cy}→{x,y} + rMax for the yard's draw conventions. ──
const holesL = (ENG.holes(tiles) || []).map(hh => {
let rMax = 0
for (const p of (hh.poly || [])) rMax = Math.max(rMax, Math.hypot(p.x - hh.cx, p.y - hh.cy))
return { shape: hh.shape, x: hh.cx, y: hh.cy, area: hh.area, r: rMax, poly: hh.poly, cx: hh.cx, cy: hh.cy }
})
const nowShapes = {}
for (const hh of holesL) { if (hh.shape !== 'gap') nowShapes[hh.shape] = (nowShapes[hh.shape] || 0) + 1 }
const before = D.sealed || {}
for (const s2 of Object.keys(nowShapes)) {
if ((before[s2] || 0) < nowShapes[s2]) {
D.flash = 1.2
D.flashKind = s2
wd.__play_sound = [{ frequency: s2 === 'star' ? 1320 : s2 === 'moon' ? 880 : 660, duration: 0.4, volume: 0.22, type: 'sine' }, { frequency: s2 === 'star' ? 1980 : 1320, duration: 0.5, volume: 0.12, type: 'sine' }]
}
}
D.sealed = nowShapes // deletion re-opens: sealed mirrors the LIVE holes
D.holesL = holesL
// ── UNIFY: a void is ONE region. Wedges on a sealed hole belong to it;
// the rest merge by proximity into single open-pinch markers. ──
{
const open = voids.filter(v => !holesL.some(hh => Math.hypot(hh.x - v.x, hh.y - v.y) < (hh.r || 0.6) + 0.35))
const merged = []
for (const v of open) {
const g = merged.find(m => Math.hypot(m.x - v.x, m.y - v.y) < 0.95)
if (g) { g.x = (g.x * g.n + v.x) / (g.n + 1); g.y = (g.y * g.n + v.y) / (g.n + 1); g.n++ }
else merged.push({ x: v.x, y: v.y, n: 1 })
}
D.voidsL = merged
}
D.tilesL = tiles; D.ghostsL = ghosts; D.layoutRev = D.rev
// THE SHIP LIVES HERE (Galen: "delete loading ship in battle — keep ship
// from design"): the design stage OWNS the one true unit; battle borrows
// it (same object, no reload). psig = the same build signature flight
// checks, so a stale ship is never handed over.
D.shipU = {
psig: D.tree.map(d2 => (d2.part || 0) + ':' + (d2.o || 0) + ':' + (d2.m || 0) + ':' + (d2.gy || 0) + (d2.gd || 0) + (d2.gp ? 1 : 0)).join(','),
u: ENG.makeUnit(D.tree, { seat: 0, x: 0, y: 0, shapeChoices: D.shapeChoices }),
}
}
if (D.rev !== D.layoutRev) doLayout()
if (D.__limitNote > 0) D.__limitNote -= Math.min(dt, 1 / 30)
let tiles = D.tilesL, ghosts = D.ghostsL, voids = D.voidsL
// ── view transform: fit the hull (recomputed after a mutation, below).
// WIDER GRID (Galen): fit per-AXIS, not by max radius — the screen square
// has more usable width (0.95) than height (0.68, palette + HUD bands), so
// a wide-built ship keeps its size far longer before auto-shrink instead
// of scaling by its own wingspan. Tall ships still respect the bands. ──
let mx = 0, my = 0, S = 0.12
const computeView = () => {
mx = 0; my = 0; let exX = 1, exY = 1
for (const t of tiles) { mx += t.cx; my += t.cy }
mx /= tiles.length; my /= tiles.length
for (const t of tiles) {
exX = Math.max(exX, Math.abs(t.cx - mx) + 1.2)
exY = Math.max(exY, Math.abs(t.cy - my) + 1.2)
}
// fit LEFT of the permanent sidebar (x > ~0.61) and ABOVE the bottom bar:
// the ship view lives in x ∈ [-0.83, 0.49] (center −0.17), y ∈ [−0.66, 0.66]
S = Math.min(0.12, 0.58 / exX, 0.66 / exY)
}
computeView()
// STAGE TRANSITION IN (Galen: "keep what is in front of us") — returning
// from battle, the yard's view EASES from the battle's last ship pose (the
// captured D.__fromBattle frame) into the fit view: the ship never jumps.
// ox/oy = uv position of the ship-local ORIGIN (toUV(0,0)); blending S and
// origin together morphs the whole mapping continuously. applyView() is a
// FUNCTION because computeView() reruns after mid-frame tree edits — the
// blend must reapply on the fresh fit (the glide itself advances ONCE).
let ox = 0, oy = 0
if (D.__fromBattle) {
const fb0 = D.__fromBattle
fb0.t = Math.min(1, (fb0.t || 0) + dt * 1.5)
if (fb0.t >= 1) delete D.__fromBattle
}
const applyView = () => {
ox = -mx * S - 0.17; oy = -my * S
if (D.__fromBattle) {
const fb = D.__fromBattle
const e9 = fb.t * fb.t * (3 - 2 * fb.t) // smoothstep glide
S = fb.S + (S - fb.S) * e9
ox = fb.bx + (ox - fb.bx) * e9
oy = fb.by + (oy - fb.by) * e9
}
}
applyView()
const toUV = (x, y) => ({ x: x * S + ox, y: y * S + oy })
// PUBLISH the view transform (screen = design·S + b) — tests and probes read
// this instead of replicating view math (which silently breaks every click
// helper each time the transform changes — sidebar offset, Aug 1). If you
// change toUV above, this MUST stay in sync with it.
wd.__view = { S, bx: ox, by: oy }
// ── input ──
const ptr = (wd.input && wd.input.pointer) || {}
const mxp = (typeof ptr.x === 'number') ? ptr.x : wd.mouse_x
const myp = (typeof ptr.y === 'number') ? ptr.y : wd.mouse_y
const ux = (typeof mxp === 'number') ? mxp / 256 - 1 : null
const uy = (typeof myp === 'number') ? myp / 256 - 1 : null
const click = sim.edge('yard-click', !!ptr.down || wd.mouse_down === true)
// CHAMBERS ARE SOLID PUZZLE SPACE (Galen: "i cannot place a pentagon in a
// chamber. id have to approach it from a different direction. part of core
// puzzle"): a would-be tile whose CENTER falls inside a chamber's cavity is
// refused — you must build AROUND the chamber, never drop a tile into it.
const ptInPoly = (px, py, poly) => {
if (!poly || poly.length < 3) return false
let inside = false
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const a2 = poly[i], b2 = poly[j]
if (((a2.y > py) !== (b2.y > py)) && (px < (b2.x - a2.x) * (py - a2.y) / (b2.y - a2.y + 1e-12) + a2.x)) inside = !inside
}
return inside
}
const chamberAt = (cx, cy) => {
for (const hh of (D.holesL || [])) { if (hh.shape === 'gap') continue; if (ptInPoly(cx, cy, hh.poly)) return hh }
return null
}
// hover: nearest legal ghost (in uv space) — never under the bottom bar, the
// FLEET BAR band (berth cells start uy≈0.71 — a berth click was reaching a low
// ghost through the old 0.74 gate and firing the HULL LIMIT toast mid-switch),
// or the sidebar
let hover = -1, hd = 0.10
if (ux != null && uy < 0.69 && ux < 0.60) for (let k = 0; k < ghosts.length; k++) {
if (chamberAt(ghosts[k].g.cx, ghosts[k].g.cy)) continue // can't fill a chamber from inside
const p = toUV(ghosts[k].g.cx, ghosts[k].g.cy)
const d = Math.hypot(p.x - ux, p.y - uy)
if (d < hd) { hd = d; hover = k }
}
// nearest tile (for select) — same fleet-bar band guard: a berth click must
// never select (or double-click-delete) a low-hanging hull tile
let tSel = -1, td = 0.09
if (ux != null && uy < 0.69) for (let i = 0; i < tiles.length; i++) {
const p = toUV(tiles[i].cx, tiles[i].cy)
const d = Math.hypot(p.x - ux, p.y - uy)
if (d < td) { td = d; tSel = i }
}
// CHAMBER hit — a click INSIDE a chamber cavity (uv point-in-poly, or very
// near its centroid) selects the chamber. Resolved BEFORE tile-select so a
// click in the negative space picks the chamber, not the tile beside it.
let chHit = -1
if (ux != null && uy < 0.69 && ux < 0.60) for (let ci = 0; ci < (D.holesL || []).length; ci++) {
const hh = D.holesL[ci]; if (hh.shape === 'gap') continue
const pc = toUV(hh.x, hh.y)
if (ptInPoly(ux, uy, (hh.poly || []).map(q => toUV(q.x, q.y))) || Math.hypot(pc.x - ux, pc.y - uy) < 0.05) { chHit = ci; break }
}
// (the chamber weapon-rule DROPDOWN was removed — Galen: "deprecated dropdown
// menu option… should be none of that code." Chambers are AUTOMATIC.)
// (the DESIGN LIBRARY strip was removed — Galen Aug 9: "deprecated library
// bar for loading ships"; the bottom fleet/berth bar is the ONE ship
// selector. wd.save.library data stays dormant/harmless.)
if ((() => {
// ── MOD MENU click, ENGINE-ROUTED (the UI SYSTEM): the solver hit-tests
// its OWN rects and delivers wd.__uiClick — the canvas never sees the
// press (engine swallows it), so this branch keys on __uiClickT edge,
// not the click edge. Buttons and keys stay the same verbs.
const t9 = wd.__uiClickT
if (t9 && t9 !== D.__uiClickT0) {
D.__uiClickT0 = t9
const a9 = String(wd.__uiClick || '')
if (a9.indexOf('ymod-') === 0) {
const k9 = a9.slice(5)
wd['key_' + k9] = false // clear the phys-key so the button is the ONLY trigger
D.__modClick = k9 // the handlers fire on (D.__modClick === key)
return true
}
if (a9.indexOf('pc-') === 0) { // PALETTE card, seated in a slot → engine-routed
const tok = a9.slice(3)
if (tok === 'del') {
D.delMode = !D.delMode
wd.__play_sound = [{ frequency: D.delMode ? 260 : 420, duration: 0.1, volume: 0.14, type: 'triangle' }]
} else if (!D.delMode) {
const s = +tok
const ring = PALCYCLE[s + 1] || [s + 1]
const nextPart = (cur) => { const at = ring.indexOf(cur); return (D.__armSlot === s && at >= 0) ? ring[(at + 1) % ring.length] : ring[0] }
if (D.sel > 0 && D.tree[D.sel]) {
pushU()
const np = nextPart(D.tree[D.sel].part)
if (np !== D.tree[D.sel].part) { delete D.tree[D.sel].m; delete D.tree[D.sel].gy; delete D.tree[D.sel].gd; delete D.tree[D.sel].gp }
D.tree[D.sel].part = np; D.brush = np; D.rev++
} else {
D.brush = nextPart(D.brush); D.brushArmed = true; D.sel = -1
}
D.__armSlot = s; D.selCh = null; D.lastClick = null
wd.__play_sound = [{ frequency: 500 + s * 90 + ((D.brush || 0) % 3) * 40, duration: 0.08, volume: 0.12, type: 'sine' }]
}
return true
}
}
return false
})()) {
// handled — the key blocks below see D.__modClick and fire the same action
} else if (click && ux != null) {
if (ux > 0.60 && uy > -0.66 && uy < 0.47) {
// SIDEBAR surface (missed every button) — consumed, never deselects
} else if (D.brushArmed && D.brush != null && tSel > 0 && !D.delMode) {
// ── REPAINT (Galen: "select button then click ANY pentagon to apply it").
// An ARMED brush CONVERTS the clicked pentagon — and this runs BEFORE
// chamber-select so an armed brush ALWAYS applies to a pentagon, even
// one beside a chamber (clicking the empty chamber VOID, tSel<0, still
// falls through to select the chamber). Pure paint: never selects,
// never arms the double-click delete window; the helm (tile 0) is not
// paintable. Gun mods survive an ori-only repaint but belong to the
// old part when the part itself changes. ──
const curT = D.tree[tSel]
if (curT.part !== D.brush || (curT.o || 0) !== (D.brushOri || 0)) {
pushU()
if (curT.part !== D.brush) { delete curT.m; delete curT.gy; delete curT.gd; delete curT.gp }
curT.part = D.brush
curT.o = D.brushOri || 0
D.sel = tSel; D.rev++; D.lastClick = null
wd.__play_sound = [{ frequency: 640, duration: 0.06, volume: 0.11, type: 'sine' }, { frequency: 880, duration: 0.05, volume: 0.08, type: 'sine' }]
} else {
wd.__play_sound = [{ frequency: 340, duration: 0.04, volume: 0.05, type: 'sine' }] // already exactly this — soft tick
}
} else if (chHit >= 0) { // CLICK THE CHAMBER VOID to select it (only when no brush is applying a tile above)
D.selCh = chHit; D.sel = -1; D.brush = null; D.brushArmed = false; D.lastClick = null
wd.__play_sound = [{ frequency: 620, duration: 0.07, volume: 0.11, type: 'sine' }, { frequency: 930, duration: 0.06, volume: 0.07, type: 'sine' }]
} else if (tSel > 0 && tSel === D.sel && !D.delMode && !wd.key_control && !wd.key_ctrl && !wd.key_meta && !wd.key_x
&& !(D.lastClick && D.lastClick.tile === tSel && (D.t - D.lastClick.at) < 0.4)) {
// CLICK THE SELECTION SPOT ITSELF (the tile you already have selected) to
// CYCLE its variant — same ring as re-clicking its palette slot, but right
// on the ship. Checked BEFORE plain re-select, so engaging the selected
// tile cycles it — but a FAST re-click (the existing double-click-delete
// window) still deletes; only a slower, deliberate re-click cycles.
pushU()
const cur = D.tree[tSel].part
let ring = null
for (const k of Object.keys(PALCYCLE)) if (PALCYCLE[k].includes(cur)) { ring = PALCYCLE[k]; break }
if (ring) { const at = ring.indexOf(cur); D.tree[tSel].part = ring[(at + 1) % ring.length] }
D.brush = D.tree[tSel].part
D.lastClick = { tile: tSel, at: D.t }
wd.__play_sound = [{ frequency: 560 + (ring ? 120 : 0), duration: 0.07, volume: 0.11, type: 'sine' }]
} else if (tSel >= 0 && tSel !== 0 && (D.delMode || wd.key_control || wd.key_ctrl || wd.key_meta || wd.key_x || (D.lastClick && D.lastClick.tile === tSel && (D.t - D.lastClick.at) < 0.4))) {
// ROUTE-AWARE DELETE (Galen): ships re-touch, so connectivity is the
// CONTACT GRAPH, not the build tree. Remove the tile; keep everything
// still routed to the base through any flush contact; re-root the tree.
pushU()
const surv = []
for (let i = 0; i < tiles.length; i++) if (i !== tSel) surv.push(i)
// contacts among survivors (flush edge midpoints coincide)
const adj = {}
for (const i of surv) adj[i] = []
for (let a2 = 0; a2 < surv.length; a2++) for (let b2 = a2 + 1; b2 < surv.length; b2++) {
const i = surv[a2], j = surv[b2]
if (Math.hypot(tiles[i].cx - tiles[j].cx, tiles[i].cy - tiles[j].cy) > 2 * AP + 0.01) continue
for (let ei = 0; ei < 5; ei++) for (let ej = 0; ej < 5; ej++) {
const na = ena(tiles[i], ei), nb = ena(tiles[j], ej)
const ma = { x: tiles[i].cx + AP * Math.cos(na), y: tiles[i].cy + AP * Math.sin(na) }
const mb = { x: tiles[j].cx + AP * Math.cos(nb), y: tiles[j].cy + AP * Math.sin(nb) }
// record BOTH edges — parent side (ei) AND child side (ej). Rebuilding with
// only ei snapped re-rooted tiles to canonical rotation → scrambled geometry
// → broken contacts → the NEXT delete ate whole linked arcs.
if (Math.hypot(ma.x - mb.x, ma.y - mb.y) < 1e-3) { adj[i].push({ j, ei, ej }); adj[j].push({ j: i, ei: ej, ej: ei }) }
}
}
// BFS from base over contacts → reachable + a fresh spanning tree (edge + ce
// reproduce the EXACT survivor geometry — proved: 25/25 flush, reversible, 1e-14)
const newIdx = { 0: 0 }
const nt = [{ parent: -1, edge: -1, part: D.tree[0].part, o: D.tree[0].o || 0, m: D.tree[0].m || 0 }]
const qq = [0]
while (qq.length) {
const i = qq.shift()
for (const { j, ei, ej } of (adj[i] || [])) {
if (newIdx[j] != null) continue
newIdx[j] = nt.length
nt.push({ parent: newIdx[i], edge: ei, ce: ej, part: D.tree[j].part, o: D.tree[j].o || 0, m: D.tree[j].m || 0 })
qq.push(j)
}
}
const orphans = surv.length - (nt.length)
D.tree = nt; D.sel = 0; D.rev++; D.lastClick = null
if (orphans > 0) wd.__play_sound = [{ frequency: 180, duration: 0.2, volume: 0.14, type: 'triangle' }]
wd.__play_sound = [{ frequency: 220, duration: 0.12, volume: 0.14, type: 'triangle' }]
} else if (tSel === 0 && D.lastClick && D.lastClick.tile === 0 && (D.t - D.lastClick.at) < 0.4) {
// double-click the HELM = STRIP THE HULL (Galen): every non-core pentagon
// deleted, back to the bare core. Undo-able (U) — pushU first. (The old
// behavior nulled __pd + returned mid-hook: a black frame AND, since the
// starter-ship law, a reset to the 40-tile starter instead of a clean slate.)
pushU()
D.tree = [D.tree[0]]
D.sel = 0; D.rev++; D.brush = null; D.lastClick = null
wd.__play_sound = [{ frequency: 220, duration: 0.3, volume: 0.14, type: 'sawtooth' }, { frequency: 110, duration: 0.4, volume: 0.1, type: 'triangle' }]
} else if (tSel >= 0) { // select a tile (double-click deletes)
D.lastClick = { tile: tSel, at: D.t }
D.sel = tSel; D.selCh = null
if (tSel > 0) { D.brush = D.tree[tSel].part; D.brushOri = D.tree[tSel].o || 0 } // selecting a component makes it the BRUSH (at its rotation)
D.brushArmed = false // a selection-derived brush places on ghosts but does NOT repaint on click
wd.__play_sound = [{ frequency: 340, duration: 0.05, volume: 0.08, type: 'sine' }]
} else if (hover >= 0) { // grow at the ghost — the BRUSH's part (a
// selected/last-placed component), HULL if nothing has been picked yet
// (Galen: "clicked ghosts hull to start unless player has something
// preselected" — a bare click grows real structure, not a BLANK). `??`
// not `||`: a deliberately-preselected BLANK brush (0) is still honored.
// HULL LIMIT (Galen, 45 as of Aug 20 — was 40): a buzzer + HUD note refuses
// the grow — never `return` (that would skip the tick's publish → a black
// frame). The celebratory flash is NOT used: refusal isn't a payout.
if (D.tree.length >= 45) {
wd.__play_sound = [{ frequency: 120, duration: 0.18, volume: 0.16, type: 'sawtooth' }]
D.__limitNote = 1.6
} else {
pushU()
D.tree.push({ parent: ghosts[hover].i, edge: ghosts[hover].e, part: D.brush ?? 1, o: D.brushOri || 0 }) // placed at the brush's rotation
D.sel = D.tree.length - 1
D.rev++
wd.__play_sound = [{ frequency: 700, duration: 0.05, volume: 0.10, type: 'sine' }, { frequency: 980, duration: 0.06, volume: 0.07, type: 'sine' }]
}
} else { // click on NOTHING — deselect the tile
// KEEP the armed brush (Galen: "always find a way to build") — the tool
// persists so the next ghost click still spawns it. Only picking another
// item, or DELETE mode, changes the tool.
D.sel = -1; D.selCh = null; D.lastClick = null
wd.__play_sound = [{ frequency: 260, duration: 0.05, volume: 0.06, type: 'sine' }]
}
}
// NO hook-level R binding — R is the PLATFORM's game-reset (Galen's law).
// We register the designer state in __resets instead: when the platform
// reset fires (if enabled), the yard comes back fresh through THAT door.
if (!wd.__resets) wd.__resets = ['__pd']
// ── HOVER IS THE TARGET (Galen: "whatever I am hovered over when I rotate
// is selected to rotate. same for upgrades") — a verb key (T rotate,
// M mount, Y/D/P gun mods) acts on the pentagon under the CURSOR:
// hovering one selects it as the verb's target. No hover → the selected
// tile, exactly as before (keyboard-only flow unchanged). Hovering a
// GHOST with an armed brush still rotates the BRUSH (tSel < 0 there). ──
if (tSel > 0 && (wd.key_t || wd.key_m || wd.key_y || wd.key_d || wd.key_p)) { D.sel = tSel; D.selCh = null }
// ── T: rotate the selected part's FACING (thrust dir / fixed barrel). Five
// stops, one per pentagon edge — orientation is destiny (phys envelope). ──
if (sim.edge('core-flip', !!wd.key_t) && D.sel === 0 && D.tree.length === 1) {
D.rootTh = D.rootTh ? 0 : Math.PI; D.rev++ // flip the lone HELM 180° (locks once you build)
wd.__play_sound = [{ frequency: 480, duration: 0.08, volume: 0.1, type: 'sine' }]
}
const rotEdge9 = sim.edge('rot-part', !!wd.key_t) || D.__modClick === 't' // consume ONCE (edge is stateful)
if (rotEdge9 && D.sel >= 0 && D.tree[D.sel] && ORIENTABLE[D.tree[D.sel].part]) {
pushU()
D.tree[D.sel].o = ((D.tree[D.sel].o || 0) + 1) % 5
wd.__play_sound = [{ frequency: 430 + D.tree[D.sel].o * 45, duration: 0.06, volume: 0.1, type: 'sine' }]
} else if (rotEdge9 && D.sel < 0 && D.brushArmed && D.brush != null && ORIENTABLE[D.brush]) {
// T rotates the ARMED BRUSH before you place it (Galen: rotate then spawn)
D.brushOri = ((D.brushOri || 0) + 1) % 5
wd.__play_sound = [{ frequency: 430 + (D.brushOri || 0) * 45, duration: 0.06, volume: 0.1, type: 'sine' }]
}
// ── M: buy/cycle the MOUNT TIER on engines & guns — the arc of rotation.
// fixed → swivel ±36° → wide ±90° → ring 360°. Costs money AND weight;
// a gimballed engine vectors its thrust (the allocator aims it live). ──
const MOUNTABLE = { 3: 1, 4: 1, 6: 1, 9: 1 }
const TIERS = ['fixed', 'swivel', 'wide', 'ring']
if ((sim.edge('mount-tier', !!wd.key_m) || D.__modClick === 'm') && D.sel >= 0 && D.tree[D.sel] && MOUNTABLE[D.tree[D.sel].part]) {
const cd = D.tree[D.sel]
pushU()
cd.m = ((cd.m || 0) + 1) % TIERS.length
wd.__play_sound = [{ frequency: 300 + cd.m * 110, duration: 0.09, volume: 0.12, type: 'triangle' }]
}
// ── GUN MODS (Galen): each gun tile is a CLASS INSTANCE the yard tunes —
// Y cycles RANGE levels (0-3, ⬡8 each) · D cycles DAMAGE levels (0-3,
// ⬡10 each) · P toggles PROJECTILE conversion (⬡12). Battle derives the
// live weapon from these (flight.part.js); psig includes them so a mod
// change rebuilds the battle unit. Buttons for all of these live on the
// right-side MOD MENU below — the keys and the buttons are the same verbs. ──
const GUNPART = { 3: 1, 9: 1 }
const modTile = D.sel >= 0 ? D.tree[D.sel] : null
// MOD POINTS (Galen: "weapons each get 3 mod points") — Y levels, D levels
// and P each spend ONE point from a per-weapon budget of 3. Cycling a stat
// past the budget wraps it to 0 (refunds its points); arming P over budget
// refuses with a flat beep. M (mount tier) is hardware, not a weapon mod.
const MODCAP = 3
const modPts = (t9) => (t9.gy || 0) + (t9.gd || 0) + (t9.gp ? 1 : 0)
if ((sim.edge('gun-rng', !!wd.key_y) || D.__modClick === 'y') && modTile && GUNPART[modTile.part]) {
pushU()
const ny = (modTile.gy || 0) + 1
modTile.gy = (ny > 3 || modPts(modTile) - (modTile.gy || 0) + ny > MODCAP) ? 0 : ny
D.rev++
wd.__play_sound = [{ frequency: 500 + modTile.gy * 90, duration: 0.07, volume: 0.11, type: 'sine' }]
}
if ((sim.edge('gun-dmg', !!wd.key_d) || D.__modClick === 'd') && modTile && GUNPART[modTile.part]) {
pushU()
const nd = (modTile.gd || 0) + 1
modTile.gd = (nd > 3 || modPts(modTile) - (modTile.gd || 0) + nd > MODCAP) ? 0 : nd
D.rev++
wd.__play_sound = [{ frequency: 380 + modTile.gd * 80, duration: 0.07, volume: 0.11, type: 'square' }]
}
if ((sim.edge('gun-proj', !!wd.key_p) || D.__modClick === 'p') && modTile && GUNPART[modTile.part]) {
if (!modTile.gp && modPts(modTile) + 1 > MODCAP) {
wd.__play_sound = [{ frequency: 160, duration: 0.12, volume: 0.12, type: 'square' }] // over budget — refused
} else {
pushU(); modTile.gp = !modTile.gp; D.rev++
wd.__play_sound = [{ frequency: modTile.gp ? 720 : 320, duration: 0.09, volume: 0.12, type: 'triangle' }]
}
}
D.__modClick = null // one shot — consumed by the handlers above
// (DEPRECATED FLEET KEYBOARD MECHANIC REMOVED — Galen Aug 9: "fleet 1,2,3
// load save text and function needs to go." The 1/2/3-load / S-save / L-load
// keyboard layer and its caption are gone. wd.save.fleet DATA is left intact
// so the per-player save + the pentarch-fleetbar berth bar keep working;
// D.slot is kept only as the berth-highlight index.)
if (!wd.save) wd.save = {}
if (D.slot == null) D.slot = 1
// ── publish ──
// GUARD: if input just mutated the tree (delete/grow/load/undo bump D.rev), the
// cached layout from the top of this tick is STALE — a shorter/longer, reindexed
// tile list. Re-run it so the publish matches D.tree (else it reads .part of an
// undefined tile → red error, and draws phantom tiles = "deleted more than one").
if (D.rev !== D.layoutRev) { doLayout(); tiles = D.tilesL; ghosts = D.ghostsL; voids = D.voidsL; computeView(); applyView(); hover = -1 }
const out = []
// facing digit rides IN the tile code (part + 10·edge): the shader draws the
// nozzle/barrel itself — no pip dots. Turrets aim their barrel at the middle
// of their free-edge run (the arc they earned).
const feByTile = {}
for (const f of ENG.freeEdgesV2(tiles)) (feByTile[f.i] = feByTile[f.i] || []).push(f.e)
const faceOf = (i) => {
const cd = D.tree[i]
if (ORIENTABLE[cd.part]) return cd.o || 0
if ((V2SPEC[cd.part] || {}).turret) { const fs = feByTile[i] || []; return fs.length ? fs[Math.floor(fs.length / 2)] : 0 }
return 0
}
const turretHeads = [] // pushed AFTER the loop — tiles stay pop[0..N-1] (click contract)
for (let i = 0; i < tiles.length; i++) {
const p = toUV(tiles[i].cx, tiles[i].cy)
// tile 0 is the CORE/HELM — a unique thing (+200 flag): the ship's one
// irreplaceable tile; the engine's own law already kills the ship when it
// dies (aliveTiles = reachable-from-0)
// ICONRY (Galen: "iconry on ship shall graphically update") — pack the
// tile's live mod state into the code's FRACTION so the shader can DRAW it:
// modByte = m·64 + gy·16 + gd·4 + gp, /256 (exact in f32). Gun tiles only;
// everything else keeps fract 0 (incl. selected core = exact 300, which the
// button branch relies on).
const cdI = D.tree[i]
const modF = (i > 0 && { 3: 1, 9: 1 }[cdI.part]) ? ((cdI.m || 0) * 64 + (cdI.gy || 0) * 16 + (cdI.gd || 0) * 4 + (cdI.gp ? 1 : 0)) / 256 : 0
out.push(p.x, p.y, tiles[i].th, (i === 0 ? 200 : tileCode(cdI.part, faceOf(i))) + (i === D.sel ? 100 : 0) + modF)
// TURRET HEAD preview (kind 68), COLLECTED here and pushed AFTER the loop —
// tiles must stay pop[0..N-1] in tree order (the published click contract)
if (i > 0 && cdI.part === 3) turretHeads.push([p.x, p.y, tiles[i].th + Math.PI / 2 + ((faceOf(i)) + 0.5) * (2 * Math.PI / 5), 68 + modF])
}
for (const th8 of turretHeads) out.push(th8[0], th8[1], th8[2], th8[3])
if (hover >= 0) { const p = toUV(ghosts[hover].g.cx, ghosts[hover].g.cy); out.push(p.x, p.y, ghosts[hover].g.th, 60) }
// ── DRAWN SHIELD UNIT (Galen: "can see the special as a drawn shield unit"):
// a sealed CIRCLE previews its rim of iron cells in the yard — the free-
// edge slots the shell will occupy in battle, seam plugs included. ──
if ((D.holesL || []).some(hh => hh.shape === 'circle')) {
const anchors9 = []
let cgx9 = 0, cgy9 = 0
for (const tl9 of tiles) { cgx9 += tl9.cx; cgy9 += tl9.cy }
cgx9 /= tiles.length; cgy9 /= tiles.length
for (const tl9 of tiles) {
const tR9 = Math.hypot(tl9.cx - cgx9, tl9.cy - cgy9)
for (let e9 = 0; e9 < 5; e9++) {
const aA9 = tl9.th + Math.PI / 2 + (e9 + 0.5) * 1.2566371
const ax9 = tl9.cx + Math.cos(aA9) * 1.38, ay9 = tl9.cy + Math.sin(aA9) * 1.38
if (tiles.some(o9 => Math.hypot(o9.cx - ax9, o9.cy - ay9) < 0.9)) continue
if (Math.hypot(ax9 - cgx9, ay9 - cgy9) < tR9) continue // OUTER rim only — never inside a hole
if (anchors9.some(q9 => Math.hypot(q9.x - ax9, q9.y - ay9) < 0.7)) continue
anchors9.push({ x: ax9, y: ay9, ea: aA9 })
}
}
anchors9.sort((a9, b9) => Math.atan2(a9.y, a9.x) - Math.atan2(b9.y, b9.x))
let pi9 = 0
for (let i9 = 0; i9 < anchors9.length; i9++) {
const a9 = anchors9[i9], b9 = anchors9[(i9 + 1) % anchors9.length]
const p9 = toUV(a9.x * 1.03, a9.y * 1.03)
out.push(p9.x, p9.y, a9.ea + ((pi9++) % 3 - 1) * 0.25, 66 + Math.min(0.99, (0.5 * S) / 2))
if (Math.hypot(a9.x - b9.x, a9.y - b9.y) < 2.0) {
const m9 = toUV((a9.x + b9.x) / 2 * 1.06, (a9.y + b9.y) / 2 * 1.06)
out.push(m9.x, m9.y, a9.ea + ((pi9++) % 3 - 1) * 0.3, 66 + Math.min(0.99, (0.5 * S) / 2))
}
}
}
// selected WEAPON: its TRUE attack cone — bought arc at true range (rays +
// range dashes). What you buy is what you see.
if (D.sel > 0 && D.tree[D.sel] && (V2SPEC[D.tree[D.sel].part] || {}).weapon) {
const cdW = D.tree[D.sel]
const wpn = V2SPEC[cdW.part].weapon
const p0 = toUV(tiles[D.sel].cx, tiles[D.sel].cy)
const face = tiles[D.sel].th + Math.PI / 2 + ((cdW.o || 0) + 0.5) * (2 * Math.PI / 5)
const H = Math.max(0.07, (ENG.MOUNTS[['fixed', 'swivel', 'wide', 'ring'][cdW.m || 0]] || {}).half || 0)
const rng = Math.min(1.9, wpn.range * S)
const full = H >= Math.PI - 0.01
if (!full) for (const bnd of [face - H, face + H]) {
const hl = Math.min(0.49, rng / 2)
out.push(p0.x + Math.cos(bnd) * hl, p0.y + Math.sin(bnd) * hl, bnd, 58 + hl / 0.5 * 0.55)
}
const nD = full ? 22 : Math.max(4, Math.ceil(H * 2 / 0.24))
for (let di = 0; di < nD; di++) {
const a7 = full ? (di / nD) * 2 * Math.PI : face - H + (di + 0.5) * (H * 2 / nD)
out.push(p0.x, p0.y, a7, 59 + Math.min(0.99, rng / 2))
}
}
// circle + cell INCLUDED — a missing entry here pushed NaN codes for every
// circle/cell hole (undefined + arithmetic), flooding the population with
// NaN entities: the "two circles and the yard catches fire" bug. Never
// let a classifiable shape miss this map.
const SHO = { diamond: 76, moon: 77, star: 78, bay: 79, circle: 80, cell: 76 }
// PER-CHAMBER GOLD (size-gold via the packer count OR combo-membership) —
// computed ONCE here, drives the +400 gold-paint flag AND the yard fanfare
// ONE SOURCE: gold computed over the UNIT's chambers (the same objects
// rendered + flown), never over the yard's own holesL re-derivation.
const chU = (D.shipU && D.shipU.u && D.shipU.u.chambers) || []
const combosG = (ENG.discoverCombos ? ENG.discoverCombos(chU.map(c9 => c9.shape)) : [])
const comboSh = new Set(); for (const c of combosG) for (const s of Object.keys(c.need)) comboSh.add(s)
const holdGold = chU.map(c9 =>
(ENG.chamberPower ? ENG.chamberPower(c9.shape, ENG.chamberVolume(c9.area)).gold : false) || comboSh.has(c9.shape))
const goldNow = holdGold.filter(Boolean).length
if (goldNow > (D.__goldWas || 0)) {
// THE GOLD FANFARE (Galen: "special noise when you get it") — rings the
// moment a chamber crosses into gold in the yard
wd.__play_sound = [
{ frequency: 523, duration: 0.5, volume: 0.16, type: 'triangle' },
{ frequency: 659, duration: 0.5, volume: 0.14, type: 'triangle' },
{ frequency: 784, duration: 0.55, volume: 0.13, type: 'sine' },
{ frequency: 1568, duration: 0.7, volume: 0.09, type: 'sine' },
]
}
D.__goldWas = goldNow
for (let _hi = 0; _hi < (D.holesL || []).length; _hi++) {
const hh = D.holesL[_hi]
if (hh.shape === 'gap') continue // NO YELLOW NODES — only real sealed shapes draw; loose pinches/gaps are gone
const code = SHO[hh.shape]
// INSET: erode the void polygon INWARD by a uniform buffer from its bounding
// pentagon edges, so the figure sits CENTERED in the gap and stays clear of the
// tiles — a crescent keeps its crescent shape (no scaling toward its off-centre
// centroid, which distorted concave moons/stars). Offset each CCW edge inward
// (interior on the left → inward normal (-dy,dx)) and intersect neighbours.
const _poly = hh.poly || []
const _b = Math.min(0.13, (hh.r || 0.4) * 0.42) // buffer from the tile edge, world units (adapts to void size)
let pp2 = _poly
if (_poly.length >= 3) {
const lines = []
for (let k = 0; k < _poly.length; k++) {
const a = _poly[k], c = _poly[(k + 1) % _poly.length]
let dx = c.x - a.x, dy = c.y - a.y; const L = Math.hypot(dx, dy) || 1; dx /= L; dy /= L
lines.push({ px: a.x - dy * _b, py: a.y + dx * _b, dx, dy })
}
pp2 = []
for (let k = 0; k < lines.length; k++) {
const e0 = lines[(k - 1 + lines.length) % lines.length], e1 = lines[k]
const det = e0.dx * e1.dy - e0.dy * e1.dx
if (Math.abs(det) < 1e-9) { pp2.push({ x: (e0.px + e1.px) / 2, y: (e0.py + e1.py) / 2 }); continue }
const s = ((e1.px - e0.px) * e1.dy - (e1.py - e0.py) * e1.dx) / det
pp2.push({ x: e0.px + s * e0.dx, y: e0.py + s * e0.dy })
}
}
for (let i = 0; i < pp2.length; i++) {
const a = pp2[i], b = pp2[(i + 1) % pp2.length]
const m = toUV((a.x + b.x) / 2, (a.y + b.y) / 2)
const ang = Math.atan2(b.y - a.y, b.x - a.x)
const hl = Math.min(0.49, Math.hypot(b.x - a.x, b.y - a.y) / 2 * S)
out.push(m.x, m.y, ang, code + hl)
}
// SELECTED CHAMBER — a bright glint ring so the click reads (Galen: "click
// to select the chamber"). Marks whichever chamber index D.selCh holds.
if (D.selCh === _hi) {
for (let g = 0; g < 8; g++) {
const ga = g / 8 * 2 * Math.PI
const gp2 = toUV(hh.x + Math.cos(ga) * (hh.r || 0.4) * 0.9, hh.y + Math.sin(ga) * (hh.r || 0.4) * 0.9)
out.push(gp2.x, gp2.y, 0, 70)
}
}
}
// ── CHAMBERS FROM THE ONE TRUE SHIP (Galen: "some kind of state isn't being
// transferred") — the yard does NOT re-derive chamber state (center/axis/
// radius) from its own holesL; it renders D.shipU.u.chambers, the EXACT
// objects battle flies, through the same ENG.chamberPop. holesL stays for
// hit-testing/outlines only. rot carries the yard's root rotation so the
// unit-frame chambers land on the yard-frame tiles.
if (D.shipU && D.shipU.u && D.shipU.u.chambers) {
const o0y = toUV(0, 0)
out.push(...ENG.chamberPop(D.shipU.u.chambers.map((c9, ci9) => ({
shape: c9.shape, cx: c9.cx, cy: c9.cy, r: c9.r, ang: c9.ang,
gold: !!holdGold[ci9],
})), { ox: o0y.x, oy: o0y.y, rot: D.rootTh || 0, S }))
}
// ── SHIELD PREVIEW (Galen: "design layer and battle layer must align") —
// the yard draws the SAME projected umbrellas battle will fly: same source
// of truth (ENG.makeUnit → unit.shields, the hull.mjs math), same code-65
// reflective glass, same design-frame coords through toUV. Cached per
// rev + specials choices; design previews at full charge (z = 1).
{
const ck = D.rev + '|' + JSON.stringify(D.shapeChoices || {})
// (the old code-65 field-ring preview lived here — removed: code 65 no
// longer exists and the RIM preview above is the real drawn shield unit)
}
// (chamber weapon-rule dropdown removed — chambers are automatic, Galen)
// (◂ MENU is a real UI-SYSTEM button now — solver-drawn + engine-routed, Galen Aug 11)
// (DESIGN LIBRARY strip removed — deprecated; see the click-chain note)
if (D.selCh != null && !(D.holesL && D.holesL[D.selCh] && D.holesL[D.selCh].shape !== 'gap')) D.selCh = null
wd.gpuPopulation = out
const TIERN = ['fixed', 'swivel', 'wide', 'ring']
const CORE = { mass: 1.2, hp: 20, gen: 1, batCap: 10, batRate: 6, torque: 1.2 } // the HELM: base power, a small battery, and helm authority (base turn)
// mod prices: Y range ⬡8/level · D damage ⬡10/level · P projectile ⬡12 (Galen: "each has a cost")
let cost = 0; for (let ci = 1; ci < D.tree.length; ci++) { const d = D.tree[ci]; cost += (COST[d.part] || 0) + ((ENG.MOUNTS[TIERN[d.m || 0]] || {}).cost || 0) + (d.gy || 0) * 8 + (d.gd || 0) * 10 + (d.gp ? 12 : 0) }
// ── SHIP STATS: the design's meaning. Parts: [mass, hp, dps, thrust, power] ──
const STAT = Object.fromEntries(PARTS.map((p) => [p.code, [p.stat.mass, p.stat.hp, p.stat.dps, p.stat.thrust, p.stat.energy]])) // [mass,hp,dps,thrust,energy], from the catalogue
let sMass = CORE.mass, sHp = CORE.hp, sDps = 0, sThr = 0, sPwr = CORE.gen
for (let si = 1; si < D.tree.length; si++) { const st = STAT[D.tree[si].part] || STAT[0]; sMass += st[0]; sHp += st[1]; sDps += st[2]; sThr += st[3]; sPwr += st[4] }
// ── the ladder pays out: sealed geometry IS the tech tree ──
const nSh = { diamond: 0, moon: 0, star: 0, bay: 0 }
for (const hh of (D.holesL || [])) { if (hh.shape !== 'gap') nSh[hh.shape] = (nSh[hh.shape] || 0) + 1 }
sHp = Math.round(sHp * (1 + 0.15 * nSh.diamond)) // diamond: structural lattice +15% HP each
if (nSh.moon) sPwr = Math.round(sPwr + 3 * nSh.moon) // moon: resonance chamber +3 power each
const brownout = sPwr < 0
const spd = sMass > 0 ? (sThr / sMass * 10) : 0
// ── V2 FLIGHT ENVELOPE + POWER GRID — the numbers T (rotate) visibly changes ──
const pT = tiles.map((t, i) => { const cd = D.tree[i]; const sp = i === 0 ? {} : (V2SPEC[cd.part] || {}); const st = STAT[cd.part] || STAT[0]
const mnt = ['fixed', 'swivel', 'wide', 'ring'][cd.m || 0]
return { cx: t.cx, cy: t.cy, th: t.th, o: cd.o || 0, mount: mnt, mass: (i === 0 ? CORE.mass : st[0]) + (ENG.MOUNTS[mnt] || {}).mass || 0,
part: i === 0 ? { torque: CORE.torque, drain: 0 } : (sp.thrust || sp.torque) ? { thrust: sp.thrust || 0, torque: sp.torque || 0, drain: sp.drain || 0 } : null } })
const EV = ENG.envelope(pT)
const vGrid = { gen: CORE.gen, batCap: CORE.batCap, batRate: CORE.batRate }; let vDrain = 0, vDrainThrust = 0, vDrainGuns = 0
for (const cd of D.tree) { const sp = V2SPEC[cd.part] || {}; vGrid.gen += sp.gen || 0; vGrid.batCap += sp.batCap || 0; vGrid.batRate += sp.batRate || 0; vDrain += sp.drain || 0; vDrainThrust += sp.drain || 0
if (sp.weapon) { const ap = sp.weapon.energyPerShot / sp.weapon.cooldown * 0.35; vDrain += ap; vDrainGuns += ap } } // sustained-fire appetite share
if (nSh.moon) vGrid.gen += 3 * nSh.moon // moons keep paying power in v2
const vShort = Math.max(0, vDrain - vGrid.gen)
const vBurst = vShort <= 0 ? '∞' : (vGrid.batRate > 0 ? (vGrid.batCap / Math.min(vShort, vGrid.batRate)).toFixed(0) + 's burst' : 'STARVED')
const effDps = brownout ? Math.round(sDps * 0.5) : sDps // starving guns fire at half rate
// ═══ THE CONSOLE (Galen, Aug 5: "complete reimagine of existing components")
// The scattered text ladder becomes a drawn VITALS RAIL: a QUINTESSENCE
// glass panel on the left holding the ship's identity, POWER as real meter
// BARS (amber draw / red weapons / green gen / cyan battery), and FLIGHT as
// three capability bars (cyan spd / violet strafe / gold turn). Numbers a
// player used to parse ("−24.0/s") are now lengths they can read at a glance.
// Meter kinds 324-329 (drawn in visual.wgsl): z = round(hw·4096) + fill.
// ═══ THE UI SYSTEM (Aug 9 — engine UI-SYSTEM.md): the console is a
// DECLARATIVE TREE in wd.ui. The engine's ui-solver resolves it to ONE
// rect table (wd.__uiRects) that draws the glass + glyphs as REAL ENGINE
// PIXELS (no DOM, no html — probes/recordings see the true UI), routes
// mod-row clicks (wd.__uiClick), and feeds UI EDIT mode. Box + text +
// hit rect are ONE declaration — nothing can drift from anything.
// Monospace alignment law: label/value columns are padEnd/padStart —
// exact because every glyph advances 0.62em.
const chamberN0 = D.holesL ? D.holesL.filter(hh => hh.shape !== 'gap').length : 0
// a vitals meter row: [ label \u00b7 bar(flex) \u00b7 value ] — one row node; the
// solver aligns the columns, never matched coordinates
const uiBar = (label, val, frac, hue) => ({ kind: 'row', gap: 3, children: [
{ kind: 'text', text: String(label).padEnd(7), fontSize: 8, color: '#afc4e0' },
{ kind: 'meter', value: Math.max(0, Math.min(1, frac)), flex: 1, h: 6.5, hue },
{ kind: 'text', text: String(val).padStart(5), fontSize: 8, color: hue },
] })
const uiSection = (t) => ({ kind: 'text', text: t, fontSize: 9, color: '#cfe4ff' })
const uiStat = (label, val, hue) => ({ kind: 'row', gap: 3, children: [
{ kind: 'text', text: String(label).padEnd(7), fontSize: 8, color: '#afc4e0' },
{ kind: 'spacer', flex: 1 },
{ kind: 'text', text: String(val).padStart(6), fontSize: 8, color: hue },
] })
const railKids = [
{ id: 'yc', kind: 'text', wrap: true, text: 'COST ' + cost + ' \u00b7 TILES ' + tiles.length + '/45 \u00b7 CH ' + chamberN0, fontSize: 8, color: '#9fd8ff' },
uiSection('SHIP'),
uiStat('MASS', sMass.toFixed(0), '#cfe0f5'),
uiStat('HULL', sHp, '#8fd8a8'),
uiStat('DPS', effDps, brownout ? '#ffb08a' : '#ff9d94'),
uiStat('PWR', (sPwr >= 0 ? '+' : '') + sPwr + (brownout ? ' \u26a0' : ''), brownout ? '#ffb08a' : '#9fe8a8'),
uiSection('POWER'),
uiBar('THRUST', '\u2212' + vDrainThrust.toFixed(0), vDrainThrust / 45, '#ffd9a8'),
uiBar('WEAPONS', '\u2212' + vDrainGuns.toFixed(0), vDrainGuns / 45, '#ff9d8a'),
uiBar('GEN', '+' + vGrid.gen.toFixed(0), vGrid.gen / 45, '#9fe8a8'),
uiBar('BATTERY', vGrid.batCap.toFixed(0), vGrid.batCap / 60, '#9fd8ff'),
uiSection('FLIGHT'),
{ id: 'fspV', kind: 'row', gap: 3, children: uiBar('SPEED', EV.vMax.toFixed(1), EV.vMax / 6, '#a8e8ff').children },
uiBar('STRAFE', EV.aLat.toFixed(1), EV.aLat / 4, '#c9b0ff'),
uiBar('TURN', EV.alpha.toFixed(1), EV.alpha / 3, '#ffe9a8'),
]
D.flash = Math.max(0, (D.flash || 0) - dt * 1.4)
const u = []
u[0] = D.t; u[7] = S
// ARMED PALETTE SLOT + its rotation (Galen: "click once highlights it… click
// multiple times rotates"): map the brush to its slot so the shader lights
// that card and shows the placement rotation.
const SLOT_OF = { 1: 0, 2: 1, 3: 2, 9: 2, 4: 3, 6: 3, 7: 3, 10: 3, 5: 4, 8: 4 }
u[4] = D.brush || 0 // the armed brush's CURRENT variant part — the card shows what you cycled TO (Galen)
u[5] = D.brushOri || 0
// the armed item ICON stays GLOWING while the brush is armed (Galen: "icon
// stays selected even after first click on a pentagon") — keyed on brushArmed,
// NOT D.sel, so applying to a pentagon (which selects that tile) doesn't dim
// the tool you're still holding. Clicking a tile to SELECT (disarms) or a
// chamber clears brushArmed, and the glow goes out then.
u[6] = (D.brushArmed && D.brush != null && SLOT_OF[D.brush] != null) ? SLOT_OF[D.brush] + 1 : 0
u[11] = D.flash || 0
u[12] = D.flashKind === 'star' ? 3 : D.flashKind === 'moon' ? 2 : 1
u[13] = D.delMode ? 1 : 0
if (ux != null) { u[8] = ux; u[9] = uy; u[10] = 1 } else { u[10] = 0 }
for (let i = 0; i < 16; i++) if (u[i] == null) u[i] = 0
wd.__uniStage = u
let selName = D.sel === -1 ? 'none' + (D.brush ? ' (brush: ' + NAME[D.brush] + ')' : '') : D.sel === 0 ? 'CORE · THE HELM' : NAME[D.tree[D.sel] ? D.tree[D.sel].part : 1]
if (D.sel > 0 && D.tree[D.sel] && { 3: 1, 9: 1 }[D.tree[D.sel].part]) selName += ' · MODS ' + ((D.tree[D.sel].gy || 0) + (D.tree[D.sel].gd || 0) + (D.tree[D.sel].gp ? 1 : 0)) + '/3'
// ── MOD MENU (Galen: "all moddable letters as buttons on right menu in design
// mode") — a right-edge column of the verbs the selected tile answers to.
// Each row = one button: drawn panel + HUD label + a stored hit rect the
// click chain tests. Keys and buttons are the SAME verbs — pressing Y and
// clicking [Y RANGE] run the identical handler. ──
D.__sbPanel = null; D.__mgPanel = null
{
// ── SIDEBAR GRID (Galen: "always on screen text the selected thing's
// function · sidebar in grid") — a permanent right rail: hyperreal item
// portrait on top (shader code 330), then grid rows: NAME / FUNCTION /
// NUMBERS, then the mod verbs as grid-cell buttons. Shows the SELECTED
// tile; with nothing selected, the armed BRUSH; failing both, the HELM —
// the rail always teaches something.
const SBX = 0.80, SBW = 0.165, RH = 0.042, GAP = 0.096
const selChH = (D.selCh != null && D.holesL) ? D.holesL[D.selCh] : null
const selTile = D.sel > 0 ? D.tree[D.sel] : null
const showPart = selTile ? selTile.part : D.sel === 0 ? 11 : (D.brush != null ? D.brush : 11)
const eyebrow = selChH ? 'CHAMBER' : selTile ? 'FITTED PART' : D.sel === 0 ? 'COMMAND CORE' : (D.brush != null ? 'BRUSH — next placement' : 'COMMAND CORE')
// the grid's frame
// (sidebar shader frame removed — sbinfo + modgrid carry their own CSS glass)
// (shader portrait removed — a CSS pentagon now lives INSIDE the fitted-part box)
// CHAMBER READOUT (Galen: "click to select the chamber") — the rail names
// the sealed shape + its automatic power, so a selected chamber teaches too.
// REAL battle numbers, pulled from the emitters in flight.part.js so the
// readout can't drift from the sim.
const CHAMBER_STATS = {
diamond: { name: 'FORWARD CONE LASER', dmg: 5, line: 'dmg 5/hit · continuous · range ~14 · widening cone', d2: 'auto-fires a beam along the slit axis' },
moon: { name: 'WAVE BLASTER', dmg: 6, line: 'dmg 6/hit · expands to range 24 · pierces armor', d2: 'a shock-arc rolls out, striking all it sweeps' },
bay: { name: 'MISSILE BAY', dmg: 4, line: '3 missiles/volley · dmg 4 ea · homing · every 3.2s', d2: 'tracking missiles fan out then curve in' },
star: { name: 'AUTO-FOCUS LASER', dmg: 4, line: 'dmg 4/pulse · range 26 · fires every 0.7s', d2: 'locks the nearest foe in ANY direction' },
circle: { name: 'DEFENSE-MINE RIM', dmg: 5, line: 'mine dmg 5 · triggers < 2.4 · regenerates', d2: 'blue rim cells detonate on anything close' },
cell: { name: 'BATTERY', dmg: 0, line: '+battery capacity · no weapon', d2: 'extra power reserve for the grid' },
}
let title, D2, statLine
if (selChH) {
const cs = CHAMBER_STATS[selChH.shape] || { name: 'SEALED SPACE', line: 'automatic', d2: 'sealed negative space' }
title = selChH.shape.toUpperCase() + ' · ' + cs.name
D2 = [cs.d2, 'solid cavity — build AROUND it, cannot fill from inside']
// REAL per-chamber power (Galen: "power per bay is by its OWN space, not
// total") — this chamber's negative-space volume → its actual weapon value.
const volS = ENG.chamberVolume ? ENG.chamberVolume(selChH.area) : 1
const pwS = ENG.chamberPower ? ENG.chamberPower(selChH.shape, volS) : null
const unit = { diamond: 'dmg', moon: 'dmg', star: 'rate×', bay: 'missiles', circle: 'shield', cell: 'battery' }[selChH.shape] || 'pwr'
statLine = pwS
? ('◧ vol ' + volS.toFixed(1) + ' · ' + unit + ' ' + (selChH.shape === 'bay' ? Math.max(3, Math.round(pwS.value)) : pwS.value)
+ (pwS.gold ? ' · ★ GOLD' : ''))
: cs.line
} else {
title = showPart === 11 ? 'THE HELM' : NAME[showPart]
D2 = DESC[showPart] || ['', '']
if (showPart === 11) statLine = 'mass 1.2 · hp 20 · +1 pwr · torque 1.2'
else {
const P3 = PARTS[showPart], sp3 = V2SPEC[showPart] || {}
statLine = '⬡' + P3.cost + ' · mass ' + P3.stat.mass + ' · hp ' + P3.hp
if (sp3.thrust) statLine += ' · thr ' + sp3.thrust
if (sp3.torque) statLine += ' · tq ' + sp3.torque
if (sp3.gen) statLine += ' · +' + sp3.gen + ' pwr'
if (sp3.batCap) statLine += ' · cap ' + sp3.batCap
if (sp3.weapon) statLine += ' · rng ' + sp3.weapon.range + ' · dmg ' + sp3.weapon.damage
}
}
// part → neon colour (matches the shader py_col) for the portrait seat
const PART_COL = { 0:'#5a7a9e', 1:'#4dbfff', 2:'#9e8cff', 3:'#ff4785', 4:'#40f2ff', 5:'#8cff59', 6:'#8ce6ff', 7:'#b88cff', 8:'#faff4d', 9:'#ff8c40', 10:'#ff66e6', 11:'#ffd159' }
const pcol = PART_COL[showPart] || '#5a7a9e'
// SIDEBAR — ONE ui panel: portrait SLOT (the SHADER's 330 pentagon is
// seated INTO this rect via wd.__uiRects — graphics anchored INTO the UI,
// zero drift) beside eyebrow+name, then the wrapping description, stats.
D.__sbPanel = null; const __sbRetired = { id: 'sbinfo', kind: 'panel', anchor: { gx: 389, gy: 30 }, align: 'tl', w: '21.5%', gap: 3, pad: 6,
glass: { border: pcol + '55' },
children: [
{ kind: 'row', gap: 5, children: [
{ id: 'sbport', kind: 'slot', w: 26, h: 26 },
{ kind: 'col', gap: 1, children: [
{ id: 'sbe', kind: 'text', text: eyebrow, fontSize: 7.5, color: '#7b8daa' },
{ id: 'sbn', kind: 'text', text: title, fontSize: 11, color: '#ffd479' } ] } ] },
{ id: 'sbd', kind: 'text', wrap: true, text: ((D2[0] || '') + ' ' + (D2[1] || '')).trim(), fontSize: 8, color: '#cfe0f5' },
{ id: 'sbs1', kind: 'text', wrap: true, text: statLine, fontSize: 8, color: '#9fd8ff' },
] }
// the mod verbs — grid-cell rows (selected tile only). Cells are PANELs
// (320) sized to the hit rect, so the visual IS the click target.
if (selTile) {
const rows = []
if (ORIENTABLE[selTile.part]) rows.push({ key: 't', verb: 'ROTATE', pips: '', cost: '' })
if ({ 3: 1, 4: 1, 6: 1, 9: 1 }[selTile.part]) rows.push({ key: 'm', verb: ['MOUNT·FIX', 'MOUNT±36', 'MOUNT±90', 'MOUNT·360'][selTile.m || 0], pips: '\u25cf'.repeat(selTile.m || 0) + '\u25cb'.repeat(3 - (selTile.m || 0)), cost: '' })
if ({ 3: 1, 9: 1 }[selTile.part]) {
// THE BUDGET, IN YOUR FACE (Galen: "are mod points clearly shown?" — the
// old '/3' per stat lied: 3 is the SHARED pool). Pips: ● spent ○ free.
const spent9 = (selTile.gy || 0) + (selTile.gd || 0) + (selTile.gp ? 1 : 0)
const noPts = spent9 >= 3
rows.push({ key: null, label: 'MOD PTS ' + '\u25cf'.repeat(spent9) + '\u25cb'.repeat(3 - spent9) + ' ' + spent9 + '/3 SHARED' })
rows.push({ key: 'y', verb: 'RANGE', pips: '\u25cf'.repeat(selTile.gy || 0) + '\u25cb'.repeat(3 - (selTile.gy || 0)), cost: '⬡8' + (noPts && !(selTile.gy > 0) ? ' ✕' : '') , label: noPts && !(selTile.gy > 0) ? 'NO PTS' : '' })
rows.push({ key: 'd', verb: 'DAMAGE', pips: '\u25cf'.repeat(selTile.gd || 0) + '\u25cb'.repeat(3 - (selTile.gd || 0)), cost: '⬡10' + (noPts && !(selTile.gd > 0) ? ' ✕' : ''), label: noPts && !(selTile.gd > 0) ? 'NO PTS' : '' })
rows.push({ key: 'p', verb: selTile.gp ? 'PROJ \u2713' : 'BEAM\u2192PROJ', pips: selTile.gp ? '\u25cf' : '\u25cb', cost: '⬡12' + (noPts && !selTile.gp ? ' ✕' : ''), label: noPts && !selTile.gp ? 'NO PTS' : '' })
}
// ── THE MOD GRID — ui rows with click:'ymod-<key>': the ENGINE owns
// the hit rects now (wd.__uiClick routing — zero hand rect math, the
// D.__modRows era is over). Hover = compare the mouse against the
// PUBLISHED rects (wd.__uiRects, the same table the pixels came
// from) — one frame behind, one authority.
const hovId = (() => {
const R9 = wd.__uiRects && wd.__uiRects.rects
if (!R9 || ux == null) return null
const gx9 = (ux + 1) * 256, gy9 = (uy + 1) * 256
for (const r of rows) { if (!r.key) continue
const rr = R9['ymodr-' + r.key]
if (rr && gx9 >= rr.x && gx9 <= rr.x + rr.w && gy9 >= rr.y && gy9 <= rr.y + rr.h) return r.key }
return null
})()
D.__rowNodes = rows.map((r) => {
if (!r.key) return { id: 'ymodpts', kind: 'text', text: r.label, fontSize: 8, color: '#cfe4ff' }
const dead = (r.label || '').includes('NO PTS')
const kc = dead ? '#8a93a8' : '#ffd479', vc = dead ? '#8a93a8' : (hovId === r.key ? '#ffffff' : '#cfe0f5')
return { id: 'ymodr-' + r.key, kind: 'row', click: 'ymod-' + r.key, gap: 3, children: [
{ kind: 'text', text: r.key.toUpperCase(), fontSize: 9, color: kc },
{ id: 'ymodv-' + r.key, kind: 'text', text: r.verb || '', fontSize: 8, color: vc },
{ kind: 'spacer', flex: 1 },
...(r.pips ? [{ kind: 'text', text: r.pips, fontSize: 8, color: dead ? '#8a93a8' : '#9fd8ff' }] : []),
...(r.cost ? [{ kind: 'text', text: r.cost, fontSize: 8, color: dead ? '#8a93a8' : '#9fe8a8' }] : []),
] }
})
}
// hoist for the MOD BOX (built at tree level, outside this block's scope)
D.__mbCtx = { ch: !!selChH, tile: !!selTile, title: title, d2: (D2 && D2[0]) || '', statLine: statLine, pcol: pcol }
}
// ═══ THE CONSOLE, on THE UI SYSTEM — one declarative tree; the engine's
// solver owns every rect. hud stays EMPTY (kills the DOM layer; a stale
// tab's elements clear). Non-glass text panels sit over SHADER-owned
// seats (palette cards, library boxes, corner pad) — the seats are those
// nodes' constants, unchanged.
const chamberN = D.holesL ? D.holesL.filter(hh => hh.shape !== 'gap').length : 0
const chLine = (nSh.star ? '\u2605 SUPER WEAPON ' : '') + (nSh.bay ? '\u25ce HANGAR \u00d7' + nSh.bay + ' ' : '')
+ (chamberN ? 'CH: ' + (() => { const c9 = {}; for (const h of D.holesL) if (h.shape !== 'gap') c9[h.shape] = (c9[h.shape] || 0) + 1; return Object.keys(c9).map(k9 => k9.toUpperCase() + (c9[k9] > 1 ? '\u00d7' + c9[k9] : '')).join(' \u00b7 ') })() : '')
// ── THE MOD BOX (Galen Aug 11): actions by default · a fitted part's stats +
// mod verbs · a selected chamber's stats. The controls live HERE now, so the
// footer strip is gone; a chamber selection teaches its weapon again.
const C9 = D.__mbCtx || {}
const mbBorder = (C9.ch || C9.tile) ? ((C9.pcol || '#5a7a9e') + '66') : 'rgba(120,150,190,0.35)'
let modboxKids
if (C9.ch) {
modboxKids = [
{ id: 'mbe', kind: 'text', text: 'CHAMBER', fontSize: 8, color: '#7b8daa' },
{ id: 'mbt', kind: 'text', wrap: true, text: C9.title || '', fontSize: 10, color: '#ffd479' },
{ id: 'mbd', kind: 'text', wrap: true, text: C9.d2 || '', fontSize: 8, color: '#cfe0f5' },
{ id: 'mbs', kind: 'text', wrap: true, text: C9.statLine || '', fontSize: 8, color: '#9fd8ff' },
]
} else if (C9.tile) {
modboxKids = [
{ id: 'mbe', kind: 'text', text: 'FITTED PART', fontSize: 8, color: '#7b8daa' },
{ id: 'mbt', kind: 'text', wrap: true, text: C9.title || '', fontSize: 10, color: '#ffd479' },
{ id: 'mbs', kind: 'text', wrap: true, text: C9.statLine || '', fontSize: 8, color: '#9fd8ff' },
...(D.__rowNodes || []),
]
} else {
modboxKids = [
{ id: 'mbe', kind: 'text', text: 'ACTIONS', fontSize: 8, color: '#7b8daa' },
{ id: 'mba', kind: 'text', wrap: true, text: 'pick a card above, then click the ship to place \u00b7 click a tile to select \u00b7 click a chamber to inspect \u00b7 double-click deletes \u00b7 T rotates \u00b7 M sets a gun arc', fontSize: 8, color: '#9fd8ff' },
...(chLine ? [{ id: 'mbch', kind: 'text', wrap: true, text: chLine, fontSize: 7.5, color: '#ffe9a8' }] : []),
...(combosG.length ? [{ id: 'mbco', kind: 'text', wrap: true, text: '\u2b20 ' + combosG.map(c => c.name).join(' + '), fontSize: 7.5, color: '#ffe08a' }] : []),
...(goldNow ? [{ id: 'mbg', kind: 'text', text: '\u2605 GOLD \u00d7' + goldNow, fontSize: 7.5, color: '#ffd24a' }] : []),
]
}
wd.ui = { rev: 1, root: [
// ── PALETTE (top-center) — SEATED IN SLOTS: the shader draws the item cards
// INTO these slot rects (kind 345/347) so they align to the grid and move
// with the panel in UI EDIT. Each slot routes its own click (pc-N / pc-del).
{ id: 'palette', kind: 'panel', glass: { bg: 'rgba(0,0,0,0)', border: 'rgba(120,180,220,0.45)' }, anchor: { gx: 256, gy: 14 }, align: 'tc', gap: 8, pad: 6, children: [
{ kind: 'row', gap: 7, children: [
{ id: 'pc0', kind: 'slot', click: 'pc-0', w: 42, h: 42 },
{ id: 'pc1', kind: 'slot', click: 'pc-1', w: 42, h: 42 },
{ id: 'pc2', kind: 'slot', click: 'pc-2', w: 42, h: 42 },
{ id: 'pc3', kind: 'slot', click: 'pc-3', w: 42, h: 42 },
{ id: 'pc4', kind: 'slot', click: 'pc-4', w: 42, h: 42 },
{ id: 'pcdel', kind: 'slot', click: 'pc-del', w: 42, h: 42 },
] } ] },
// ── LEFT RAIL — ship vitals
{ id: 'vitals', kind: 'panel', anchor: { gx: 12, gy: 12 }, align: 'tl', w: '20%', gap: 3, pad: 6, children: railKids },
// ── RIGHT — PRESS B alone (top-right), then the adaptive MOD BOX under it
{ id: 'startbtn', kind: 'panel', anchor: { gx: 500, gy: 12 }, align: 'tr', w: '23%', pad: 9, glass: { border: 'rgba(255,70,80,0.55)' }, children: [
{ id: 'yst', kind: 'text', text: 'PRESS B TO START', fontSize: 10, color: '#ff2a33', textAlign: 'center' } ] },
{ id: 'modbox', kind: 'panel', anchor: { below: 'startbtn', gap: 6 }, w: '23%', gap: 2.5, pad: 6, glass: { border: mbBorder }, children: modboxKids },
// ── ◂ MENU — a real UI-SYSTEM button: box, label and hit rect are ONE node
{ id: 'ybk', kind: 'panel', click: 'y-menu', anchor: { gx: 12, gy: 500 }, align: 'bl', pad: 7, children: [
{ id: 'ybkt', kind: 'text', text: '\u25c2 MENU', fontSize: 12, color: '#cfe0f5' } ] },
// ── TOASTS — the one sanctioned floating layer
...((D.__limitNote > 0) ? [{ id: 'ylim', kind: 'panel', anchor: { gx: 256, gy: 256 }, w: 'auto', pad: 5, glass: { border: 'rgba(255,120,100,0.6)' }, children: [
{ kind: 'text', text: '\u2b21 HULL LIMIT \u2014 45 pentagons', fontSize: 12, color: '#ff8a7a' } ] }] : []),
...(D.delMode ? [{ id: 'ydm', kind: 'panel', anchor: { gx: 256, gy: 282 }, w: 'auto', pad: 4, glass: { border: 'rgba(255,120,100,0.6)' }, children: [
{ kind: 'text', text: '\u232b DELETE MODE \u2014 click tiles to remove', fontSize: 10, color: '#ff8a7a' } ] }] : []),
// (palette captions deleted — icons only, Galen Aug 11: "with no text")
] }
wd.hud = []
// THE SHADER PORTRAIT, SEATED INTO THE UI — code 330 reads its seat from
// the PUBLISHED rect of the 'sbport' slot (one frame behind on layout
// change; identical at rest). Graphics anchored INTO the UI = zero drift.
{
const R0 = wd.__uiRects && wd.__uiRects.rects && wd.__uiRects.rects['sbport']
if (R0 && Array.isArray(wd.gpuPopulation)) {
const cxU = (R0.x + R0.w / 2) / 256 - 1, cyU = (R0.y + R0.h / 2) / 256 - 1
const rU = (R0.w / 2) / 256
const showP = (D.sel > 0 && D.tree[D.sel]) ? D.tree[D.sel].part : D.sel === 0 ? 11 : (D.brush != null ? D.brush : 11)
wd.gpuPopulation.push(cxU, cyU, showP + Math.min(0.99, rU), 330)
}
}
// PALETTE CARDS seated into the 'palette' slots (kind 345 base · 347 armed · 346 delete)
{
const rr = wd.__uiRects && wd.__uiRects.rects
if (rr && Array.isArray(wd.gpuPopulation)) {
const uvC = (R) => [(R.x + R.w / 2) / 256 - 1, (R.y + R.h / 2) / 256 - 1, Math.min(0.13, (Math.min(R.w, R.h) / 2) / 256)]
for (let s = 0; s < 5; s++) {
const R = rr['pc' + s]; if (!R) continue
const [cx, cy, rC] = uvC(R)
const base = (PALCYCLE[s + 1] && PALCYCLE[s + 1][0]) || (s + 1)
const armed = D.brushArmed && D.__armSlot === s
const part = armed ? (D.brush || base) : base
wd.gpuPopulation.push(cx, cy, part + rC, armed ? 347 : 345)
}
const RD = rr['pcdel']; if (RD) { const [cx, cy, rC] = uvC(RD); wd.gpuPopulation.push(cx, cy, rC, 346) }
}
}
// pixel→node: record OUR final length LAST — everything a later node
// appends beyond this (fleetbar) attributes to that node, not the yard
if (Array.isArray(wd.gpuPopulation)) wd.__popN = wd.gpuPopulation.length
} catch (e) {
// NEVER a silent black world: heal soft (back to the yard, force relayout),
// then hard (fresh state) if it keeps throwing — and SAY SO on the HUD.
try {
const wd2 = sim.worldData, P = wd2.__pd
wd2.__pderr = (wd2.__pderr || 0) + 1
if (P && wd2.__pderr < 4) { P.mode = 'design'; P.bt = null; P.layoutRev = -999; P.sel = 0 }
else { wd2.__pd = null; wd2.__pderr = 0 }
wd2.hud = [{ id: 'err', type: 'text', x: '3%', y: '50%', text: 'RECOVERED: ' + String((e && e.message) || e).slice(0, 70), fontSize: '12px', color: '#ff8a7a' }]
} catch (e2) { }
}
hook · pt-flush
sole gpuUniforms publisher: flushes the scene uniform stage
const wd = sim.worldData
if (wd.__uniStage) { wd.gpuUniforms = wd.__uniStage; wd.__uniStage = null }
hook · pt-inspect
pixel→node tracking: publishes wd.__entities (kind + authoring node per pop entry) for the INSPECT eye
// inspect.part — PIXEL → NODE tracking (Galen, Aug 5: "I can't click it with
// inspect pixel click to source — we have a pixel to node tracking bug").
// The engine's INSPECT resolves field + visual, but pentarch is ONE field/ONE
// visual — useless for a 7-node world. The engine's designed escape hatch is
// worldData.__entities: the WORLD publishes projected entities ({id, kind,
// label, sx, sy, r} in screen-grid space) and inspect names the nearest at the
// click. We publish EVERY pop entry with a kind name + the AUTHORING NODE:
// design mode — entries 0..wd.__popN-1 = pt-yard; beyond = pentarch-fleetbar
// battle mode — everything = pt-flight (it assigns the final culled array)
// Gated on wd.__clicks existing (inspect touched at least once) so normal play
// pays nothing.
{
const wd = sim.worldData, D = wd.__pd
if (D && wd.__clicks && Array.isArray(wd.gpuPopulation)) {
const pop = wd.gpuPopulation
const mode9 = D.mode
const NM = (code) => {
const k = Math.trunc(code) % 100, c = Math.trunc(code)
if (c >= 300 && c < 320 && code !== c) return 'BUTTON' // buttons ALWAYS pack a fract half-width; exact 300.0 is a SELECTED HELM (the documented collision)
if (c === 320) return 'PANEL'
if (c === 330) return 'PORTRAIT'
const flags = Math.trunc(code / 100)
const part = k === 50 ? 10 : k % 10
if (k < 56) {
const PN = ['BLANK', 'HULL', 'ARMOR', 'GUN', 'ENGINE', 'GEN', 'JET', 'GYRO', 'BATTERY', 'FIXED GUN', 'TACTICS']
const base = flags >= 8 ? 'FOE ' : Math.trunc(flags / 2) % 2 === 1 ? 'HELM ' : '' // INTEGER division — the shader's i32 math, not JS floats
return base + (PN[part] || 'TILE')
}
return ({ 56: 'PLUME', 57: 'ARC', 58: 'BEAM', 59: 'RANGE', 60: 'GHOST', 65: 'SHIELD', 66: 'SHIELD CELL', 67: 'MINE', 68: 'TURRET HEAD', 69: 'DISASSEMBLY', 70: 'GLINT', 71: 'DIAMOND CHAMBER', 72: 'MOON CHAMBER', 73: 'STAR CHAMBER', 74: 'BAY CHAMBER', 75: 'CIRCLE CHAMBER', 84: 'CHAMBER SLICE' })[k] || (k > 80 ? 'CELL CHAMBER' : 'FX')
}
const yardN = typeof wd.__popN === 'number' ? wd.__popN : Infinity
const ents = []
for (let k = 0; k + 3 < pop.length; k += 4) {
const idx = k / 4
const node = mode9 === 'battle' ? 'pt-flight' : (mode9 === 'menu' || mode9 === 'servers' || mode9 === 'hotseat') ? 'pt-menu/hotseat' : (k < yardN ? 'pt-yard' : 'pentarch-fleetbar') // no-mode = bare yard (pt-yard's own gate)
// REAL EXTENTS (Galen: "containers of smaller things" missed) — a
// container is hit anywhere INSIDE it, not near its center: decode the
// chrome packings the engine can't know.
const code9i = pop[k + 3], ci = Math.trunc(code9i)
let r9 = 26
if (ci === 320) { const a9i = pop[k + 2]; r9 = Math.max(Math.floor(a9i) / 4096, a9i - Math.floor(a9i)) * 256 + 6 } // PANEL: max(hw,hh)
else if (ci >= 300 && ci < 320 && code9i !== ci) r9 = (code9i - ci) * 256 + 6 // BUTTON: packed half-width
else if (ci === 330) r9 = (code9i - ci) * 256 + 10 // PORTRAIT: packed radius
else if ((ci % 100) >= 71 && (ci % 100) <= 75) r9 = (code9i - ci) * 256 + 8 // CHAMBER: hole radius
ents.push({ id: idx, kind: ci, label: NM(code9i) + ' · ' + node, sx: (pop[k] + 1) * 256, sy: (pop[k + 1] + 1) * 256, r: r9 })
}
// SHADER-DRAWN CHROME has no pop entry at all — synthesize inspectables for
// the yard's shelf glass, five item cards, and the delete pad (their
// constants mirror visual.wgsl's bottom-bar section).
if (mode9 !== 'battle' && mode9 !== 'menu' && mode9 !== 'servers' && mode9 !== 'hotseat') {
ents.push({ id: 9000, kind: -1, label: 'ITEM SHELF (shader) · visual.wgsl', sx: (0.13 + 1) * 256, sy: (0.865 + 1) * 256, r: 0.78 * 256 })
for (let s9 = 0; s9 < 5; s9++) ents.push({ id: 9001 + s9, kind: -1, label: 'ITEM CARD ' + (s9 + 1) + ' (shader) · visual.wgsl', sx: ((-0.52 + s9 * 0.26) + 1) * 256, sy: (0.86 + 1) * 256, r: 26 })
ents.push({ id: 9006, kind: -1, label: 'DELETE PAD (shader) · visual.wgsl', sx: (0.78 + 1) * 256, sy: (0.86 + 1) * 256, r: 22 })
}
wd.__entities = ents
}
}
hook · pentarch-fleetbar
fleet berth bar + auto-save + mini ships (CAP 12 + band-tail sentinel perf, Aug 20)
// pentarch-fleetbar — the 8 FLEET slots as a clickable BOTTOM BAR + AUTO-SAVE.
// Additive node (the pathway: owns its slice, never edits the 257KB yard blob).
// Runs AFTER the yard, reads its state (wd.__pd, wd.save.library) and APPENDS to
// wd.gpuPopulation + wd.hud. Redesign (Galen): 8 slots; click a berth → LOAD+fly it
// and it becomes the WORKED-ON berth; the ship then AUTO-SAVES into that berth on
// every edit (no S/L, no manual save). Click an EMPTY berth → the current ship starts
// auto-saving there. Persists per-player via wd.save (engine persist-sync fix).
// Boot-safe: __wslot stays null until you click a berth, so nothing auto-saves over
// a loaded berth before you choose one. Lives in the chrome band (uv.y ~0.80, below
// the build canvas at 0.68), clear of the bottom-left design tool (x < -0.56).
try {
const wd = sim.worldData, D = wd.__pd;
const __PTf = globalThis.__PT || {};
if (D && Array.isArray(wd.gpuPopulation)) {
if (wd.persist !== true) wd.persist = true; // opt into per-player saves
if (!wd.save) wd.save = {};
if (!wd.save.library) wd.save.library = {};
const LIB = wd.save.library;
// The fleet UI + HUD relayout is DESIGN-mode only — in menu/battle/servers the
// yard owns the screen (flight stats, lobby, etc.); don't touch it there.
if (D.mode !== 'design') return;
// fleet bar in the clean GAP between the build canvas (uy<0.68) and the palette
// strip (uy>0.76) — the old uy=0.93 sat INSIDE the palette band → overlap + click
// conflict. uy=0.72 is clear of both (no pentagon overlap, no palette misclick).
const N = 8, BY = 0.75, HW = 0.075, HH = 0.042, X0 = -0.5425, DX = 0.155; // bigger berths (Galen Aug 11: see the ships more)
// REMOVE the OLD library UI at the TOP: the yard draws its 8 slot panels (kind 320)
// + gold glints (kind 70) at y=-0.90. This node owns fleet/library now, so filter
// that top strip out of the population before we draw our own bottom bar.
{
const src = wd.gpuPopulation, kept = [];
for (let i = 0; i + 3 < src.length; i += 4) {
const y = src[i + 1];
if (y >= -0.93 && y <= -0.87) continue; // old library top strip → drop
kept.push(src[i], src[i + 1], src[i + 2], src[i + 3]);
}
wd.gpuPopulation = kept;
}
const pop = wd.gpuPopulation, rects = [];
const cur = D.__wslot; // worked-on berth (null until a click)
// MINI SHIPS (Galen Aug 11: "fleet tab should show a mini version of each
// ship") — each filled berth draws its saved hull as kind-340 mini tiles,
// laid out by the same makeUnit battle flies, cached per savedAt.
D.__fbMini = D.__fbMini || {};
const miniOf = (k) => {
const rec = LIB[k];
if (!rec || !Array.isArray(rec.tree) || !rec.tree.length || !__PTf.ENG) return null;
const c = D.__fbMini[k];
if (c && c.at === (rec.savedAt || 0) && c.tiles.length <= 12) return c.tiles;
try {
const uM = __PTf.ENG.makeUnit(rec.tree, { seat: 0 });
let ext = 1;
for (const tl of uM.tiles) ext = Math.max(ext, Math.abs(tl.cx), Math.abs(tl.cy));
// CAP 40→12 (Aug 20): the yard shader walks the WHOLE population per
// pixel, so 8 filled berths × 40 mini tiles ≈ tripled the design-screen
// population (447 entities, 29ms frames). At ~24px a berth, 12 BFS-from-
// core tiles read the same silhouette; the cache check above ejects old
// 40-tile entries.
const CAP = 12; // draw the FIRST CAP tiles (makeUnit is BFS from the core, so these stay CONNECTED — no scatter)
const tiles = [];
for (let ti = 0; ti < uM.tiles.length && tiles.length < CAP; ti++) {
const tl = uM.tiles[ti];
tiles.push({ cx: tl.cx / ext, cy: tl.cy / ext,
code: ti === 0 ? 200 : Math.floor(__PTf.tileCode ? __PTf.tileCode((rec.tree[ti] || {}).part || 1, 0) : ((rec.tree[ti] || {}).part || 1)) });
}
D.__fbMini[k] = { at: rec.savedAt || 0, tiles };
return tiles;
} catch (e9) { return null }
};
const px = (u) => ((u + 1) / 2 * 100).toFixed(1) + '%';
// ── TWO CONTAINER DIVS (Galen) — a panel for the ship-design buttons and a
// SEPARATE panel for the fleet, so they read as distinct groups, not loose text.
// TAIL SENTINEL (perf, Aug 20): the shader walks the WHOLE population per
// pixel; everything this hook pushes lives in the fleet-bar band, so code
// 339 marks the tail start — pixels outside |uv.y − BY| ≤ 0.10 break here
// instead of iterating ~100 chrome entries twice. Must be the FIRST push.
pop.push(0.0, BY, 0, 339);
// fleet container: its OWN bar in the gap above the palette (clear of the tiles)
pop.push(0.0, BY, Math.round(0.66 * 4096) + (HH + 0.018), 320);
// (ship-design container REMOVED — Galen, Aug 5: "I see three boxes?" The
// yard's shader shelf already frames the palette; two authors were framing
// the same cards. One box per band now: fleet bar here, shelf below.)
for (let i = 0; i < N; i++) {
const k = i + 1, x = X0 + i * DX, filled = !!LIB[k], w = cur === k;
if (w) pop.push(x, BY, Math.round((HW + 0.012) * 4096) + (HH + 0.010), 320); // worked-on ring behind the berth
pop.push(x, BY, Math.round(HW * 4096) + HH, 320); // berth cell (320 = chrome panel)
if (filled) {
const mini = miniOf(k);
if (mini) {
const sc = HW * 0.62, mr = Math.min(0.45, Math.max(0.008, sc * 0.22)); // fewer tiles → slightly larger so the mini still reads as a hull
for (const tl of mini) pop.push(x + tl.cx * sc, BY + tl.cy * sc * 0.72, tl.code, 340 + mr);
} else pop.push(x, BY - 0.004, 0, 70); // fallback glint
}
rects.push({ k, x0: x - HW, x1: x + HW, y0: BY - HH, y1: BY + HH });
}
// (HUD RELAYOUT REMOVED — Galen's containment law, Aug 5: "nothing should
// overlap unless it is a child of a larger piece"; the yard's CONSOLE is
// the ONE layout authority now. This node keeps only its own fleet bar +
// berth labels, and still hides the yard rows its berth bar replaces.)
// FLEET TEXT REMOVED (Galen Aug 9: "text for the fleet is not aligned with
// the buttons… we dont need that text there anyways — a html overlay").
// The old DOM hud labels (flbar-h "FLEET" + flb1-8 berth numbers) floated
// off the grid because hud text is a DOM overlay. The berth BAR is shader-
// drawn (kind 320, in-grid): a GOLD GLINT marks a filled berth and the
// worked-on RING marks the active one — the state reads without any text.
// flbar-h/flb* are added to HIDE so a stale tab's overlays clear too.
const HIDE = /^(ylibt|ylib[1-8]|yfl|yz|flbar-h|flb[1-8])$/;
wd.hud = (Array.isArray(wd.hud) ? wd.hud : []).filter(h => !(h && HIDE.test(h.id)));
// CLICK a berth → make it the worked-on berth. Filled → load its ship; empty →
// the current ship starts auto-saving here on the next edit.
const ptr = (wd.input && wd.input.pointer) || {};
const mx = (typeof ptr.x === 'number') ? ptr.x : wd.mouse_x;
const my = (typeof ptr.y === 'number') ? ptr.y : wd.mouse_y;
const ux = (typeof mx === 'number') ? mx / 256 - 1 : null;
const uy = (typeof my === 'number') ? my / 256 - 1 : null;
if (ux != null && sim.edge('flbar-click', !!ptr.down || wd.mouse_down === true)) {
for (const r of rects) {
if (ux >= r.x0 && ux <= r.x1 && uy >= r.y0 && uy <= r.y1) {
D.__wslot = r.k;
if (LIB[r.k] && Array.isArray(LIB[r.k].tree)) {
// CLICK a FILLED berth → LOAD & fly that ship. No save on the click.
D.tree = LIB[r.k].tree.map(t => ({ ...t }));
D.shapeChoices = JSON.parse(JSON.stringify(LIB[r.k].shapeChoices || {}));
D.sel = 0; D.rev++; D.lastClick = null;
wd.__play_sound = [{ frequency: 440 + r.k * 40, duration: 0.12, volume: 0.13, type: 'sine' }, { frequency: 660 + r.k * 40, duration: 0.1, volume: 0.09, type: 'sine' }];
} else {
// CLICK an EMPTY berth → just make it the active berth. NO save on click;
// the NEXT change to the ship auto-saves here.
wd.__play_sound = [{ frequency: 520, duration: 0.09, volume: 0.11, type: 'sine' }];
}
D.__wsaveRev = D.rev; // click never saves — mark current state as already-saved; only a real CHANGE mirrors
break;
}
}
}
// AUTO-SAVE the current ship into the worked-on berth on every change (ONLY after
// the player has chosen a berth — so nothing is overwritten before then).
if (D.__wslot != null && D.tree && D.tree.length && D.rev !== D.__wsaveRev) {
D.__wsaveRev = D.rev;
LIB[D.__wslot] = { tree: D.tree.map(t => ({ ...t })), shapeChoices: JSON.parse(JSON.stringify(D.shapeChoices || {})), cost: (LIB[D.__wslot] && LIB[D.__wslot].cost) || 0, savedAt: D.t };
wd.save = { ...wd.save }; // fresh identity → the engine's debounced autosave persists it
}
}
} catch (e) { /* never disturb the yard */ }
hook · perf:pop-bounds
region-gate data: tick-fresh world+chrome AABBs for the shipyard shader (owns u40-48)
// ── perf:pop-bounds — THE REGION GATE's data half (Galen, Sep 4). Runs LAST
// (insertion order), reads the FINAL gpuPopulation whichever scene hook
// published it, and writes tick-fresh AABBs the shipyard shader gates on:
// u40-43 world box (+3R margin) · u45-48 chrome box (+0.6 fat margin for
// packed panel half-sizes) · u44 armed. Empty class => inverted box (never
// hit). Owns u[40..48] and nothing else.
const wd = sim.worldData
const P = wd.gpuPopulation
const u = wd.gpuUniforms
if (!u) { return }
while (u.length < 49) { u.push(0) }
if (!Array.isArray(P) || P.length < 4) { u[44] = 0; return }
const S = u[7] || 0.06
const wm = Math.max(3 * S * 0.85065, 0.12)
const cm = 0.6
let wx0 = 1e9, wy0 = 1e9, wx1 = -1e9, wy1 = -1e9, hasW = false
let cx0 = 1e9, cy0 = 1e9, cx1 = -1e9, cy1 = -1e9, hasC = false
for (let i = 0; i + 3 < P.length; i += 4) {
const x = P[i], y = P[i + 1], code = Math.floor(P[i + 3])
if (!isFinite(x) || !isFinite(y)) { continue }
if (code >= 300 && code <= 349) {
hasC = true
if (x < cx0) cx0 = x; if (y < cy0) cy0 = y
if (x > cx1) cx1 = x; if (y > cy1) cy1 = y
} else {
hasW = true
if (x < wx0) wx0 = x; if (y < wy0) wy0 = y
if (x > wx1) wx1 = x; if (y > wy1) wy1 = y
}
}
u[40] = hasW ? wx0 - wm : 9; u[41] = hasW ? wy0 - wm : 9
u[42] = hasW ? wx1 + wm : -9; u[43] = hasW ? wy1 + wm : -9
u[45] = hasC ? cx0 - cm : 9; u[46] = hasC ? cy0 - cm : 9
u[47] = hasC ? cx1 + cm : -9; u[48] = hasC ? cy1 + cm : -9
u[44] = 1