This commit is contained in:
root
2026-09-20 14:16:41 +02:00
parent 0798933b05
commit b6d1909d7f
56 changed files with 3752 additions and 1989 deletions
+22 -7
View File
@@ -1,5 +1,20 @@
// Client for the belt's HTTP API.
/**
* The project used to be called "wh". Carry over anything an earlier visit stored
* under the old prefix (the access key, drafts, choices), so nobody is logged out.
*/
export function migrateStorage() {
try {
for (const k of Object.keys(localStorage)) {
if (!k.startsWith('wh.')) continue;
const next = `halcyon.${k.slice(3)}`;
if (localStorage.getItem(next) === null) localStorage.setItem(next, localStorage.getItem(k));
localStorage.removeItem(k);
}
} catch { /* storage unavailable: nothing to carry over */ }
}
export class ApiError extends Error {
constructor(status, message) {
super(message);
@@ -7,7 +22,7 @@ export class ApiError extends Error {
}
}
const KEY = 'wh.key';
const KEY = 'halcyon.key';
export const keyStore = {
get() {
try { return localStorage.getItem(KEY); } catch { return null; }
@@ -64,8 +79,12 @@ export const api = {
me: (key) => call('GET', '/me', { key }),
ships: () => call('GET', '/ships'),
assemble: (source) => call('POST', '/assemble', { body: source, type: 'text/plain', auth: false }),
setProgram: (id, bytes) => call('PUT', `/ships/${id}/program`, { body: bytes, type: 'application/octet-stream' }),
launch: (id) => call('POST', `/ships/${id}/launch`),
// Launch a docked ship, flying the program in code slot `slot`.
launch: (id, slot) => call('POST', `/ships/${id}/launch`, { json: { slot } }),
slots: () => call('GET', '/slots'),
slot: (n) => call('GET', `/slots/${n}`),
saveSlot: (n, name, source) => call('PUT', `/slots/${n}`, { json: { name, source } }),
deleteSlot: (n) => call('DELETE', `/slots/${n}`),
setUplink: (id, bytes) => call('PUT', `/ships/${id}/uplink`, { body: bytes, type: 'application/octet-stream' }),
async downlink(id) {
const res = await call('GET', `/ships/${id}/downlink`, { raw: true });
@@ -79,10 +98,6 @@ export const api = {
if (!res.ok) throw new ApiError(res.status, `example ${name} not found`);
return res.text();
},
async examples() {
const res = await fetch('/examples/index.json');
return res.ok ? res.json() : [];
},
};
export function b64ToBytes(b64) {
+12 -16
View File
@@ -1,8 +1,8 @@
// Halcyon Flight Control: application shell and routing.
import { api, keyStore, onUnauthorized } from './api.js';
import { api, keyStore, migrateStorage, onUnauthorized } from './api.js';
import { h, clear, toast } from './dom.js';
import { startStarfield } from './starfield.js';
import { startBackdrop } from './backdrop.js';
import { statusOf } from './status.js';
import { authView } from './view-auth.js';
import { fleetView } from './view-fleet.js';
@@ -18,9 +18,8 @@ const TABS = [
['downlink', 'DOWNLINK'],
];
const VIEWS = { fleet: fleetView, code: codeView, uplink: uplinkView, downlink: downlinkView };
const ORE_TAGS = { iron: 'FE', nickel: 'NI', ice: 'H2O', platinum: 'PT' };
const state = { info: null, me: null, market: null, ships: [], selected: null, tab: 'fleet' };
const state = { info: null, me: null, ships: [], selected: null, tab: 'fleet' };
let shell = null; // { top, nav, select, main }
const tabFromHash = () => {
@@ -36,7 +35,7 @@ const ctx = {
},
selectShip(id, tab) {
state.selected = id;
try { localStorage.setItem('wh.ship', String(id)); } catch { /* ignore */ }
try { localStorage.setItem('halcyon.ship', String(id)); } catch { /* ignore */ }
renderNav();
if (tab) ctx.go(tab); else renderMain();
},
@@ -49,9 +48,9 @@ const ctx = {
};
async function loadAccount() {
const [me, ships, market, info] = await Promise.all([api.me(), api.ships(), api.market().catch(() => null), api.info().catch(() => state.info)]);
Object.assign(state, { me, ships, market, info });
const remembered = Number(safeGet('wh.ship'));
const [me, ships, info] = await Promise.all([api.me(), api.ships(), api.info().catch(() => state.info)]);
Object.assign(state, { me, ships, info });
const remembered = Number(safeGet('halcyon.ship'));
if (!ships.some((s) => s.id === state.selected)) {
state.selected = ships.some((s) => s.id === remembered) ? remembered : (ships[0]?.id ?? null);
}
@@ -98,22 +97,18 @@ function buildShell() {
shell.top,
shell.nav,
shell.main,
h('footer', { class: 'foot' }, 'HALCYON INSTRUMENT & CONTROL // WORMHOLE LINK 1 KB/DAY // THE BELT IS SIMULATED ONCE A DAY'),
h('footer', { class: 'foot' }, 'HALCYON INSTRUMENT & CONTROL'),
);
}
function renderTop() {
if (!shell) return;
const { me, info, market } = state;
const prices = market && info?.ores
? info.ores.map((o, i) => h('span', { class: 'chip ore', title: `${o} price per kg` }, h('b', null, ORE_TAGS[o] ?? o.toUpperCase()), ` ${market[i]}`))
: [h('span', { class: 'chip dim', title: 'Prices appear after the first daily run' }, 'MARKET: NO DATA YET')];
const { me, info } = state;
clear(shell.top).append(
h('div', { class: 'top-brand' }, h('span', { class: 'logo sm' }, 'HALCYON'), h('span', { class: 'dim' }, 'FLIGHT CONTROL')),
h('div', { class: 'top-stats' },
h('span', { class: 'chip' }, 'DAY ', h('b', null, String(info?.day ?? me?.day ?? 0))),
h('span', { class: 'chip' }, 'CREDITS ', h('b', null, (me?.credits ?? 0).toLocaleString('en-US'))),
...prices,
),
h('div', { class: 'top-user' },
h('span', { class: 'callsign' }, me?.name?.toUpperCase() ?? ''),
@@ -134,7 +129,7 @@ function renderNav() {
clear(shell.nav).append(
h('div', { class: 'tabs' },
TABS.map(([id, label]) => h('a', { class: `tab ${state.tab === id ? 'on' : ''}`, href: `#/${id}`, 'aria-current': state.tab === id ? 'page' : null }, label)),
h('a', { class: 'tab', href: '/manual.txt', target: '_blank', rel: 'noopener' }, 'MANUAL'),
h('a', { class: 'tab', href: '/manual.pdf', target: '_blank', rel: 'noopener' }, 'MANUAL'),
),
h('label', { class: 'shipsel' }, 'ACTIVE SHIP', select),
);
@@ -160,7 +155,8 @@ document.addEventListener('visibilitychange', () => {
// ---- boot -------------------------------------------------------------------
startStarfield(document.getElementById('stars'));
migrateStorage(); // before anything reads the key
startBackdrop(document.getElementById('backdrop'));
onUnauthorized(() => logout('SESSION REJECTED: ACCESS KEY NOT RECOGNISED'));
api.info().then((i) => { state.info = i; }).catch(() => {});
if (keyStore.get()) enter(); else showAuth();
+263
View File
@@ -0,0 +1,263 @@
// The animated backdrop, all on one canvas: drifting, twinkling stars with the
// odd shooting star, the striped sun, and a perspective grid floor sliding
// towards you. One canvas is one composited layer: no CSS 3D transforms, masks or
// animations, which is what made the old CSS floor flicker in mobile Safari.
//
// Built to survive mobile browsers: star positions are stored as fractions of
// the viewport so a resize (iOS fires them constantly as the toolbar collapses)
// never re-scatters them; a frame that throws cannot kill the loop; and the loop
// is restarted if it is ever found dead (dropped frame, back/forward cache,
// throttled tab), so the animation cannot silently stop.
const STAR_COUNT = 320;
const TINTS = ['#ffffff', '#ffffff', '#ffffff', '#9ff8ff', '#ffb3f0'];
export function startBackdrop(canvas) {
const ctx = canvas.getContext('2d');
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
// Positions are fractions of the viewport (nx, ny in [0, 1)).
const stars = Array.from({ length: STAR_COUNT }, () => ({
nx: Math.random(),
ny: Math.random(),
z: 0.25 + Math.random() * 0.75,
phase: Math.random() * Math.PI * 2,
speed: 0.6 + Math.random() * 1.8,
tint: TINTS[Math.floor(Math.random() * TINTS.length)],
}));
let shooters = [];
let nextShot = 4000;
let w = 0;
let h = 0;
let dpr = 1;
let horizonY = 0; // where the floor starts
let floorH = 0;
let staticLayer = null; // sun + floor base, painted once per size
let staticTop = 0; // the layer is empty above this row, so only the part below it is drawn
let fade = null; // gradient that dissolves the grid into the horizon
let shown = 0; // how many stars fit the current viewport
let raf = 0;
let last = performance.now();
let lastFrame = performance.now();
let resizeTimer = 0;
function resize() {
// The canvas is a fixed box of 100vh anchored to the bottom (see the CSS), so
// it does not change size when a phone's toolbar slides in and out.
const nw = canvas.clientWidth;
const nh = canvas.clientHeight;
if (!nw || !nh) return;
dpr = Math.min(window.devicePixelRatio || 1, 2);
if (nw !== w || nh !== h || canvas.width !== Math.round(nw * dpr)) {
w = nw;
h = nh;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
floorH = Math.round(h * 0.17);
horizonY = h - floorH;
staticLayer = buildStatic();
fade = ctx.createLinearGradient(0, horizonY, 0, horizonY + floorH * 0.55);
fade.addColorStop(0, 'rgba(34,9,63,1)');
fade.addColorStop(1, 'rgba(34,9,63,0)');
}
shown = Math.min(STAR_COUNT, Math.round((w * h) / 5500));
draw(performance.now());
}
/** The parts that never move, painted once: the striped sun and the floor's base. */
function buildStatic() {
const c = document.createElement('canvas');
c.width = canvas.width;
c.height = canvas.height;
const g = c.getContext('2d');
g.setTransform(dpr, 0, 0, dpr, 0, 0);
const S = Math.max(180, Math.min(400, 0.34 * Math.min(w, h)));
const R = S / 2;
const cx = w / 2;
const cy = horizonY - 0.16 * S; // its lower third is hidden behind the floor
staticTop = Math.max(0, Math.floor(cy - R - 60)); // top of the sun's halo
const halo = g.createRadialGradient(cx, cy, R, cx, cy, R + 60);
halo.addColorStop(0, 'rgba(255,58,216,0.30)');
halo.addColorStop(1, 'rgba(255,58,216,0)');
g.fillStyle = halo;
g.beginPath();
g.arc(cx, cy, R + 60, 0, Math.PI * 2);
g.fill();
const t = document.createElement('canvas'); // the disc, with its stripes cut out
t.width = t.height = Math.ceil(S * dpr);
const tg = t.getContext('2d');
tg.scale(dpr, dpr);
const sun = tg.createLinearGradient(0, 0, 0, S);
sun.addColorStop(0, '#fff27a');
sun.addColorStop(0.32, '#ffb62e');
sun.addColorStop(0.72, '#ff3ad8');
sun.addColorStop(1, '#8a2cff');
tg.fillStyle = sun;
tg.beginPath();
tg.arc(R, R, R, 0, Math.PI * 2);
tg.fill();
tg.globalCompositeOperation = 'destination-out';
for (const [from, to] of [[0.46, 0.49], [0.56, 0.6], [0.67, 0.72], [0.79, 0.85], [0.91, 0.98]]) {
tg.fillRect(0, from * S, S, (to - from) * S);
}
g.drawImage(t, cx - R, cy - R, S, S);
const base = g.createLinearGradient(0, horizonY, 0, h);
base.addColorStop(0, '#22093f');
base.addColorStop(1, '#0c0420');
g.fillStyle = base;
g.fillRect(0, horizonY, w, floorH);
const glow = g.createLinearGradient(0, horizonY - 28, 0, horizonY);
glow.addColorStop(0, 'rgba(40,245,255,0)');
glow.addColorStop(1, 'rgba(40,245,255,0.45)');
g.fillStyle = glow;
g.fillRect(0, horizonY - 28, w, 28);
g.fillStyle = '#28f5ff';
g.fillRect(0, horizonY - 1, w, 2);
return c;
}
/** The grid: lines at even steps in depth, projected onto the floor. */
function drawGrid(now) {
const Z_FAR = 26;
const phase = reduce.matches ? 0 : ((now / 1000) * 0.42) % 1; // depth steps per second
ctx.save();
ctx.beginPath();
ctx.rect(0, horizonY, w, floorH);
ctx.clip();
ctx.strokeStyle = '#ff3ad8';
// lines across, sliding towards the viewer
for (let j = 0; j < Z_FAR; j++) {
const z = 1 + j - phase;
const y = horizonY + floorH / z;
ctx.globalAlpha = 0.85;
ctx.lineWidth = 0.7 + 1.5 / Math.max(z, 1);
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
// lines running into the distance, meeting at the vanishing point
const spacing = floorH * 0.55;
const n = Math.ceil((w / 2 / spacing) * 6);
const cx = w / 2;
ctx.globalAlpha = 0.7;
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let i = -n; i <= n; i++) {
ctx.moveTo(cx + (i * spacing) / Z_FAR, horizonY + floorH / Z_FAR);
ctx.lineTo(cx + (i * spacing) / 0.4, horizonY + floorH / 0.4);
}
ctx.stroke();
// dissolve everything into the horizon so distant lines cannot shimmer
ctx.globalAlpha = 1;
ctx.fillStyle = fade;
ctx.fillRect(0, horizonY, w, floorH * 0.55);
ctx.restore();
}
function draw(now) {
if (!staticLayer) return;
ctx.clearRect(0, 0, w, h);
for (let i = 0; i < shown; i++) {
const s = stars[i];
const tw = 0.5 + 0.5 * Math.sin((now / 1000) * s.speed + s.phase);
ctx.globalAlpha = reduce.matches ? 0.5 * s.z : (0.25 + 0.75 * tw) * s.z;
ctx.fillStyle = s.tint;
const r = 0.4 + s.z * 1.3;
ctx.fillRect(s.nx * w, s.ny * h, r, r);
}
for (const s of shooters) {
const g = ctx.createLinearGradient(s.x, s.y, s.x - s.vx * 9, s.y - s.vy * 9);
g.addColorStop(0, 'rgba(255,255,255,0.95)');
g.addColorStop(1, 'rgba(255,58,216,0)');
ctx.globalAlpha = Math.max(0, Math.min(1, s.life));
ctx.strokeStyle = g;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(s.x, s.y);
ctx.lineTo(s.x - s.vx * 9, s.y - s.vy * 9);
ctx.stroke();
}
ctx.globalAlpha = 1;
// sun and floor over the stars; only the part of the layer that has anything in it
ctx.drawImage(staticLayer, 0, staticTop * dpr, canvas.width, canvas.height - staticTop * dpr, 0, staticTop, w, h - staticTop);
drawGrid(now);
}
function update(dt) {
for (let i = 0; i < shown; i++) {
const s = stars[i];
s.nx -= (s.z * 0.012 * dt) / w;
if (s.nx < 0) {
s.nx += 1;
s.ny = Math.random();
}
}
nextShot -= dt;
if (nextShot <= 0) {
nextShot = 7000 + Math.random() * 9000;
shooters.push({ x: Math.random() * w * 0.8 + w * 0.2, y: Math.random() * h * 0.4, vx: -(6 + Math.random() * 4), vy: 2 + Math.random() * 2, life: 1.4 });
}
for (const s of shooters) {
s.x += (s.vx * dt) / 16;
s.y += (s.vy * dt) / 16;
s.life -= dt / 900;
}
shooters = shooters.filter((s) => s.life > 0);
}
function frame(now) {
raf = 0;
try {
const dt = Math.max(0, Math.min(64, now - last));
last = now;
update(dt);
draw(now);
} catch (e) {
console.error('starfield frame failed; continuing', e);
}
lastFrame = performance.now();
schedule();
}
function schedule() {
if (raf || reduce.matches || document.visibilityState !== 'visible') return;
raf = requestAnimationFrame(frame);
}
/** (Re)start the loop from a clean state. Safe to call at any time. */
function restart() {
cancelAnimationFrame(raf);
raf = 0;
last = lastFrame = performance.now();
if (reduce.matches) draw(last);
else schedule();
}
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(resize, 120);
});
window.addEventListener('orientationchange', () => setTimeout(resize, 250));
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') restart(); });
window.addEventListener('pageshow', restart); // back/forward cache restore
window.addEventListener('focus', restart);
reduce.addEventListener('change', restart);
// Watchdog: a visible page whose loop has gone quiet gets restarted.
setInterval(() => {
if (!reduce.matches && document.visibilityState === 'visible' && performance.now() - lastFrame > 1500) restart();
}, 1000);
resize();
restart();
}
+27
View File
@@ -52,6 +52,33 @@ export function confirmDialog({ title, body, confirmText = 'CONFIRM', cancelText
});
}
/**
* A modal dialog around caller-built content (form fields and so on). Returns
* { result, confirm, close }: `result` resolves true on the confirm button and
* false on cancel or Escape; the caller can enable/disable `confirm` as it
* validates, and reads its own fields after `result` resolves.
*/
export function customDialog({ title, content, confirmText = 'OK', cancelText = 'CANCEL', danger = false }) {
const dlg = document.getElementById('dialog');
let resolve;
const result = new Promise((r) => { resolve = r; });
const finish = (v) => {
if (dlg.open) dlg.close();
resolve(v);
};
const confirm = h('button', { class: `btn ${danger ? 'danger' : 'primary'}`, type: 'button', onclick: () => finish(true) }, confirmText);
clear(dlg).append(
h('div', { class: `panel dialog ${danger ? 'danger' : ''}` },
h('div', { class: 'panel-title' }, title),
h('div', { class: 'panel-body stack' }, ...content),
h('div', { class: 'actions' }, h('button', { class: 'btn', type: 'button', onclick: () => finish(false) }, cancelText), confirm),
),
);
dlg.oncancel = () => resolve(false);
dlg.showModal();
return { result, confirm, close: finish };
}
export function download(filename, bytes, type = 'application/octet-stream') {
const url = URL.createObjectURL(new Blob([bytes], { type }));
const a = h('a', { href: url, download: filename });
+73 -30
View File
@@ -31,50 +31,89 @@ export function describe(bytes, i) {
return parts.join(' | ');
}
// Row layout, in character cells: offset, gap, hex, gap, ASCII.
const BYTES_PER_ROW = [16, 8, 4]; // widest first
const GAP = 1.2;
const rowChars = (n) => 4 + GAP * 2 + n * 3 + (n === 16 ? 1 : 0) + n;
/**
* Render a hex dump. Returns { el, inspector } where inspector is the element
* that shows the decoded value of the hovered/selected byte.
* Render a hex dump that always fits its container: it picks 16, 8 or 4 bytes
* per row and sizes the font so a whole row fits without sideways scrolling, and
* re-fits whenever the container changes size (rotation, window resize).
* Returns { el, inspector }; the inspector shows the decoded hovered/selected byte.
*
* Options: maxBytes (show only the first N bytes), maxFont / minFont (px).
*/
export function hexView(bytes, { rows: maxRows = Infinity } = {}) {
export function hexView(bytes, { maxBytes = Infinity, maxFont = 15, minFont = 9.5 } = {}) {
const inspector = h('div', { class: 'hx-inspector', 'aria-live': 'polite' }, 'HOVER OR CLICK A BYTE TO DECODE IT');
const body = h('div', { class: 'hx-body', tabindex: '0', role: 'group', 'aria-label': 'Hex dump' });
const total = Math.min(bytes.length, maxBytes);
let pinned = null;
const cells = new Map();
let cells = new Map();
let perRow = 0;
let ratio = 0; // width of one character as a fraction of the font size
const rowCount = Math.min(Math.ceil(bytes.length / 16), maxRows);
for (let r = 0; r < rowCount; r++) {
const start = r * 16;
const hexCells = [];
const ascCells = [];
for (let k = 0; k < 16; k++) {
const i = start + k;
if (i >= bytes.length) {
hexCells.push(h('span', { class: 'hx-b pad' }, ' '));
continue;
function build(n) {
perRow = n;
cells = new Map();
const rows = [];
for (let start = 0; start < total; start += n) {
const hexCells = [];
const ascCells = [];
for (let k = 0; k < n; k++) {
const i = start + k;
if (i >= total) {
hexCells.push(h('span', { class: 'hx-b pad' }, ' '));
continue;
}
const v = bytes[i];
const cls = v === 0 ? 'hx-b z' : 'hx-b';
const a = h('span', { class: `${cls} a`, 'data-i': i }, printable(v));
const x = h('span', { class: cls, 'data-i': i }, hex2(v));
cells.set(i, [x, a]);
hexCells.push(x);
ascCells.push(a);
if (n === 16 && k === 7) hexCells.push(h('span', { class: 'hx-gap' }, ' '));
}
const v = bytes[i];
const cls = v === 0 ? 'hx-b z' : 'hx-b';
const a = h('span', { class: `${cls} a`, 'data-i': i }, printable(v));
const x = h('span', { class: cls, 'data-i': i }, hex2(v));
cells.set(i, [x, a]);
hexCells.push(x);
ascCells.push(a);
if (k === 7) hexCells.push(h('span', { class: 'hx-gap' }, ' '));
rows.push(
h('div', { class: 'hx-row' },
h('span', { class: 'hx-off' }, pad(start.toString(16).toUpperCase(), 4)),
h('span', { class: 'hx-hex' }, hexCells),
h('span', { class: 'hx-asc' }, ascCells),
),
);
}
body.append(
h('div', { class: 'hx-row' },
h('span', { class: 'hx-off' }, pad(start.toString(16).toUpperCase(), 4)),
h('span', { class: 'hx-hex' }, hexCells),
h('span', { class: 'hx-asc' }, ascCells),
),
);
body.replaceChildren(...rows);
if (pinned !== null) cells.get(pinned)?.forEach((c) => c.classList.add('sel'));
}
function measure() {
if (ratio) return ratio;
const probe = h('span', { class: 'hx-probe' }, 'M'.repeat(20));
body.append(probe);
ratio = probe.getBoundingClientRect().width / 20 / 100;
probe.remove();
return ratio || 0.6;
}
function layout() {
const cs = getComputedStyle(body);
const avail = body.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
if (!(avail > 0)) return; // not laid out yet
const r = measure();
const fontFor = (n) => avail / (rowChars(n) * r);
// The widest layout that still gives a readable font; keep the current one a
// little longer than that, so a scrollbar appearing cannot make it flap.
let n = BYTES_PER_ROW.find((c) => fontFor(c) >= minFont - (c === perRow ? 0.6 : 0)) ?? 4;
body.style.fontSize = `${Math.min(maxFont, fontFor(n)).toFixed(2)}px`;
if (n !== perRow) build(n);
}
const select = (i, pin) => {
if (pin) {
if (pinned !== null) cells.get(pinned)?.forEach((c) => c.classList.remove('sel'));
pinned = pinned === i ? null : i;
if (pinned !== null) cells.get(pinned).forEach((c) => c.classList.add('sel'));
if (pinned !== null) cells.get(pinned)?.forEach((c) => c.classList.add('sel'));
}
const shown = pinned ?? i;
inspector.textContent = shown === null ? 'HOVER OR CLICK A BYTE TO DECODE IT' : describe(bytes, shown);
@@ -87,6 +126,10 @@ export function hexView(bytes, { rows: maxRows = Infinity } = {}) {
body.addEventListener('mouseleave', () => { if (pinned === null) select(null, false); });
body.addEventListener('click', (e) => { const i = target(e); if (i !== null) select(i, true); });
build(16);
if (typeof ResizeObserver !== 'undefined') new ResizeObserver(layout).observe(body); // fires once attached, before first paint
else window.addEventListener('resize', layout);
return { el: h('div', { class: 'hex' }, body, inspector), inspector };
}
+1 -5
View File
@@ -11,11 +11,7 @@
<script type="module" src="app.js"></script>
</head>
<body>
<canvas id="stars" aria-hidden="true"></canvas>
<div class="horizon" aria-hidden="true">
<div class="sun"></div>
<div class="floor"></div>
</div>
<canvas id="backdrop" aria-hidden="true"></canvas>
<div class="crt" aria-hidden="true"></div>
<div id="app">
-90
View File
@@ -1,90 +0,0 @@
// Slowly drifting, twinkling starfield with the odd shooting star.
export function startStarfield(canvas) {
const ctx = canvas.getContext('2d');
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
const tints = ['#ffffff', '#ffffff', '#ffffff', '#9ff8ff', '#ffb3f0'];
let w = 0;
let h = 0;
let stars = [];
let shooters = [];
let nextShot = 4000;
let last = performance.now();
let raf = 0;
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
w = window.innerWidth;
h = window.innerHeight;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const n = Math.min(320, Math.round((w * h) / 5500));
stars = Array.from({ length: n }, () => ({
x: Math.random() * w,
y: Math.random() * h,
z: 0.25 + Math.random() * 0.75,
phase: Math.random() * Math.PI * 2,
speed: 0.6 + Math.random() * 1.8,
tint: tints[Math.floor(Math.random() * tints.length)],
}));
draw(performance.now());
}
function draw(now) {
ctx.clearRect(0, 0, w, h);
for (const s of stars) {
const tw = 0.5 + 0.5 * Math.sin(now / 1000 * s.speed + s.phase);
ctx.globalAlpha = reduce.matches ? 0.5 * s.z : (0.25 + 0.75 * tw) * s.z;
ctx.fillStyle = s.tint;
const r = 0.4 + s.z * 1.3;
ctx.fillRect(s.x, s.y, r, r);
}
for (const s of shooters) {
const g = ctx.createLinearGradient(s.x, s.y, s.x - s.vx * 9, s.y - s.vy * 9);
g.addColorStop(0, 'rgba(255,255,255,0.95)');
g.addColorStop(1, 'rgba(255,58,216,0)');
ctx.globalAlpha = Math.min(1, s.life);
ctx.strokeStyle = g;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(s.x, s.y);
ctx.lineTo(s.x - s.vx * 9, s.y - s.vy * 9);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
function frame(now) {
const dt = Math.min(64, now - last);
last = now;
for (const s of stars) {
s.x -= s.z * 0.012 * dt;
if (s.x < -2) { s.x = w + 2; s.y = Math.random() * h; }
}
nextShot -= dt;
if (nextShot <= 0) {
nextShot = 7000 + Math.random() * 9000;
shooters.push({ x: Math.random() * w * 0.8 + w * 0.2, y: Math.random() * h * 0.4, vx: -(6 + Math.random() * 4), vy: 2 + Math.random() * 2, life: 1.4 });
}
for (const s of shooters) {
s.x += s.vx * dt / 16;
s.y += s.vy * dt / 16;
s.life -= dt / 900;
}
shooters = shooters.filter((s) => s.life > 0);
draw(now);
raf = requestAnimationFrame(frame);
}
function sync() {
cancelAnimationFrame(raf);
if (!reduce.matches) raf = requestAnimationFrame((t) => { last = t; frame(t); });
else draw(performance.now());
}
window.addEventListener('resize', resize);
reduce.addEventListener('change', sync);
resize();
sync();
}
+63 -49
View File
@@ -26,7 +26,7 @@ body {
margin: 0;
min-height: 100vh;
color: var(--ink);
background: linear-gradient(180deg, #07030f 0%, #0f0626 40%, #2a0a4d 72%, #6a1268 100%) fixed;
background: #07030f;
font-family: var(--mono);
font-size: 17px;
line-height: 1.5;
@@ -35,50 +35,21 @@ body {
/* ---- backdrop ------------------------------------------------------------ */
#stars { position: fixed; inset: 0; width: 100%; height: 100%; z-index: 0; }
.horizon {
position: fixed; left: 0; right: 0; bottom: 0; height: 46vh;
z-index: 1; pointer-events: none; overflow: hidden;
--sun: clamp(180px, 34vmin, 400px);
--floor: 17vh;
/* The whole backdrop is one canvas. It is a fixed box of 100vh anchored to the
bottom of the screen, so it never resizes when a phone's toolbar comes and goes
and the floor always sits at the visible bottom. */
#backdrop {
position: fixed; left: 0; bottom: 0; width: 100%; height: 100vh; height: 100lvh; z-index: 0;
background: linear-gradient(180deg, #07030f 0%, #0f0626 40%, #2a0a4d 72%, #6a1268 100%);
}
.sun {
position: absolute; left: 50%; margin-left: calc(var(--sun) / -2);
bottom: calc(var(--floor) - var(--sun) * 0.34);
width: var(--sun); height: var(--sun); border-radius: 50%;
background: linear-gradient(180deg, #fff27a 0%, #ffb62e 32%, #ff3ad8 72%, #8a2cff 100%);
box-shadow: 0 0 70px 12px rgba(255, 58, 216, 0.5);
-webkit-mask-image: linear-gradient(#000 0 46%, transparent 46% 49%, #000 49% 56%, transparent 56% 60%, #000 60% 67%, transparent 67% 72%, #000 72% 79%, transparent 79% 85%, #000 85% 91%, transparent 91% 98%, #000 98%);
mask-image: linear-gradient(#000 0 46%, transparent 46% 49%, #000 49% 56%, transparent 56% 60%, #000 60% 67%, transparent 67% 72%, #000 72% 79%, transparent 79% 85%, #000 85% 91%, transparent 91% 98%, #000 98%);
}
.floor {
position: absolute; left: 0; right: 0; bottom: 0; height: var(--floor);
overflow: hidden; background: linear-gradient(#22093f, #0c0420);
border-top: 2px solid var(--cyan);
box-shadow: 0 -2px 26px rgba(40, 245, 255, 0.7);
}
.floor::before {
content: ""; position: absolute; left: -60%; right: -60%; top: 0; height: 400%;
transform-origin: 50% 0; transform: perspective(240px) rotateX(64deg);
background-image:
linear-gradient(rgba(255, 58, 216, 0.8) 2px, transparent 2px),
linear-gradient(90deg, rgba(255, 58, 216, 0.8) 2px, transparent 2px);
background-size: 64px 64px;
animation: grid-move 2.6s linear infinite;
}
@keyframes grid-move { to { background-position: 0 64px; } }
/* Static scanlines and vignette. (They used to flicker; that was the flicker.) */
.crt {
position: fixed; inset: 0; z-index: 100; pointer-events: none;
background:
repeating-linear-gradient(to bottom, rgba(0, 0, 0, 0) 0 2px, rgba(0, 0, 0, 0.17) 3px, rgba(0, 0, 0, 0) 4px),
radial-gradient(ellipse at center, transparent 58%, rgba(0, 0, 0, 0.5) 100%);
animation: flicker 7s infinite;
}
@keyframes flicker { 0%, 100% { opacity: 1; } 47% { opacity: 1; } 48% { opacity: 0.86; } 50% { opacity: 1; } 83% { opacity: 0.93; } 84% { opacity: 1; } }
#app, dialog, #toasts { position: relative; z-index: 10; }
@@ -120,20 +91,23 @@ a:hover { color: var(--mag); }
position: sticky; top: 0; z-index: 20;
display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 1.4rem;
padding: 0.6rem 1.2rem;
background: rgba(7, 3, 15, 0.88); backdrop-filter: blur(6px);
background: rgba(7, 3, 15, 0.96);
border-bottom: 1px solid var(--line); box-shadow: 0 2px 24px rgba(40, 245, 255, 0.12);
}
.top-brand { display: flex; align-items: baseline; gap: 0.9rem; letter-spacing: 0.25em; font-size: 0.85rem; }
.top-stats { display: flex; flex-wrap: wrap; gap: 0.5rem; flex: 1; }
.top-user { display: flex; align-items: center; gap: 0.6rem; margin-left: auto; }
.callsign { color: var(--lime); letter-spacing: 0.15em; text-shadow: 0 0 8px rgba(125, 255, 138, 0.6); }
/* Callsigns can be 40 characters: truncate them, never the buttons beside them. */
.top-user { min-width: 0; max-width: 100%; }
.callsign { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.top-user .btn { flex: none; }
.chip { border: 1px solid var(--faint); padding: 0.05rem 0.65rem; font-size: 0.9rem; letter-spacing: 0.08em; background: rgba(0, 0, 0, 0.3); }
.chip b { color: var(--amber); font-weight: 700; }
.chip.ore b { color: var(--cyan); }
.navbar { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 0.6rem; max-width: 1100px; margin: 1rem auto 0; padding: 0 1rem; }
.tabs { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.tabs { display: flex; flex-wrap: nowrap; gap: 0.3rem; min-width: 0; }
.tab {
font-family: var(--display); font-size: 0.85rem; letter-spacing: 0.22em; text-transform: uppercase; text-decoration: none;
padding: 0.6rem 1.2rem; color: var(--dim); background: rgba(13, 6, 34, 0.7);
@@ -233,9 +207,28 @@ textarea { resize: vertical; }
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin: 0.6rem 0; }
.toolbar.pad { margin: 0; }
.sealrow { justify-content: space-between; margin-top: 0.8rem; }
.sealrow .grow { flex: 1 1 260px; }
.panel-title .dim { white-space: nowrap; }
/* Button bars in the code editor: one control height and style throughout. */
.bar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 1rem; margin: 0.8rem 0; }
.bar-label { flex: none; width: 4.2rem; font-size: 0.72rem; letter-spacing: 0.24em; color: var(--dim); }
.bar-buttons { display: flex; flex: 1; flex-wrap: wrap; gap: 0.5rem; min-width: 0; }
.bar .btn, .bar select {
height: 2.5rem; margin: 0; padding: 0 1.1rem; font-size: 0.82rem; line-height: 1; white-space: nowrap;
display: inline-flex; align-items: center; justify-content: center;
}
.bar .push { margin-left: auto; }
.bar select { min-width: 0; max-width: 100%; text-overflow: ellipsis; }
.stack-lg > * { min-width: 0; }
/* The example chooser looks like the buttons beside it. */
.bar select {
-webkit-appearance: none; appearance: none; width: auto; cursor: pointer;
font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--cyan);
background: rgba(40, 245, 255, 0.06) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%2328f5ff' stroke-width='1.6'/%3E%3C/svg%3E") no-repeat right 0.9rem center / 0.65rem;
border: 1px solid var(--cyan); box-shadow: 0 0 10px rgba(40, 245, 255, 0.25); padding-right: 2.4rem;
}
.bar select:hover { background-color: rgba(40, 245, 255, 0.16); }
.nowrap { white-space: nowrap; }
.panel-title .dim { min-width: 0; text-align: right; overflow-wrap: anywhere; }
.badge { display: inline-block; padding: 0.05rem 0.7rem; font-size: 0.8rem; letter-spacing: 0.15em; border: 1px solid currentColor; text-shadow: 0 0 8px currentColor; white-space: nowrap; }
.badge.amber { color: var(--amber); }
@@ -315,15 +308,13 @@ textarea { resize: vertical; }
.drop.disabled { opacity: 0.4; cursor: not-allowed; border-color: var(--faint); }
.uplink-grid { grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); }
.fleet-grid { grid-template-columns: minmax(0, 1fr) 330px; }
.uplink-preview .hx-body { font-size: 13px; }
.uplink-preview .hx-row { gap: 0.9rem; }
.panel-body > .hint, .panel-body > .hx-inspector + .hint { margin-top: 0.7rem; }
.uplink-preview:empty::before { content: "NOTHING TO SEND YET."; color: var(--dim); letter-spacing: 0.15em; }
/* ---- hex viewer ---------------------------------------------------------- */
.hx-body { background: #05020c; border: 1px solid var(--faint); padding: 0.7rem 0.9rem; overflow-x: auto; font-size: 15px; line-height: 1.55; }
.hx-row { display: flex; gap: 1.4rem; white-space: pre; width: max-content; }
.hx-row { display: flex; gap: 1.2ch; white-space: pre; width: max-content; }
.hx-off { color: var(--amber); }
.hx-b { display: inline-block; width: 2ch; text-align: center; cursor: default; }
.hx-hex .hx-b { margin-right: 1ch; }
@@ -333,6 +324,7 @@ textarea { resize: vertical; }
.hx-b:hover { background: rgba(40, 245, 255, 0.4); color: #fff; }
.hx-b.sel { background: var(--mag); color: #fff; }
.hx-gap { display: inline-block; width: 1ch; }
.hx-probe { position: absolute; visibility: hidden; font-size: 100px; white-space: pre; }
.hx-inspector { margin-top: 0.7rem; padding: 0.5rem 0.9rem; min-height: 2.6rem; border: 1px solid var(--line); color: var(--cyan); font-size: 0.95rem; letter-spacing: 0.06em; overflow-wrap: anywhere; }
.nosignal { text-align: center; padding: 2.5rem 1rem; }
@@ -360,6 +352,12 @@ dialog::backdrop { background: rgba(3, 1, 10, 0.78); backdrop-filter: blur(3px);
.dialog { width: min(480px, 92vw); }
.dialog.danger { border-color: var(--red); }
.dialog .panel-body p + p { margin-top: 0.7rem; }
.dialog select, .dialog input[type="text"] { width: 100%; }
.dialog select { color: var(--cyan); border-color: var(--line); }
.launch-status { min-height: 1.5em; letter-spacing: 0.04em; }
.launch-status.ok { color: var(--lime); }
.launch-status.bad { color: var(--red); }
.slot-status { margin: 0.2rem 0 0.7rem; }
.dialog .actions { display: flex; justify-content: flex-end; gap: 0.6rem; padding: 0 1rem 1rem; }
/* ---- small screens ------------------------------------------------------- */
@@ -372,8 +370,15 @@ dialog::backdrop { background: rgba(3, 1, 10, 0.78); backdrop-filter: blur(3px);
body { font-size: 16px; }
.topbar { position: static; }
.toolbar .btn { padding: 0.4rem 0.8rem; font-size: 0.9rem; letter-spacing: 0.08em; }
.sealrow .btn { width: 100%; }
.panel-title { letter-spacing: 0.16em; }
/* phones: a tidy two-column grid; the chooser and the primary action span the row */
.bar { flex-direction: column; align-items: stretch; gap: 0.3rem; }
.bar-label { width: auto; }
.bar-buttons { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.bar select { width: 100%; }
.bar .span2 { grid-column: 1 / -1; }
.bar .push { margin-left: 0; }
.bar .btn, .bar select { padding: 0 0.6rem; }
.panel-title { letter-spacing: 0.16em; flex-wrap: wrap; row-gap: 0.15rem; }
.auth { padding-bottom: 10rem; }
.top-user { margin-left: 0; }
.manifest thead { display: none; }
@@ -382,10 +387,19 @@ dialog::backdrop { background: rgba(3, 1, 10, 0.78); backdrop-filter: blur(3px);
.manifest td::before { content: attr(data-label); color: var(--dim); font-size: 0.75rem; letter-spacing: 0.2em; }
.manifest td.actions { justify-content: flex-end; }
.manifest td.actions::before { content: none; }
.tab { padding: 0.5rem 0.8rem; letter-spacing: 0.12em; }
.navbar { flex-direction: column; align-items: stretch; gap: 0.6rem; }
.tabs { width: 100%; gap: 0.2rem; }
.tab { flex: 1 1 auto; min-width: 0; padding: 0.55rem 0.2rem; font-size: 0.68rem; letter-spacing: 0.05em; text-align: center; white-space: nowrap; }
.shipsel { justify-content: space-between; }
.hx-body { padding: 0.6rem 0.5rem; }
.editor { height: 52vh; }
}
@media (max-width: 360px) {
.bar .btn, .bar select { font-size: 0.7rem; letter-spacing: 0.05em; padding: 0 0.4rem; }
.tab { font-size: 0.62rem; letter-spacing: 0.02em; padding-left: 0.1rem; padding-right: 0.1rem; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
+173 -63
View File
@@ -1,27 +1,37 @@
// Code editor: write assembly, check it, download it, seal it into a ship.
// Code editor with sixteen saved slots. Slots 1-3 are read-only examples; 4-16
// are the player's own. Launching a ship (Fleet tab) picks which slot it flies.
import { api, b64ToBytes } from './api.js';
import { h, clear, toast, confirmDialog, download } from './dom.js';
import { h, clear, toast, confirmDialog, customDialog, download, pad } from './dom.js';
import { createEditor } from './editor.js';
const draftKey = (ship) => `wh.draft.${ship?.id ?? 0}`;
const load = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
const save = (k, v) => { try { localStorage.setItem(k, v); } catch { /* ignore */ } };
const DEFAULT_SLOT = 2; // the telemetry example, a working program to start from
const readJSON = (k) => { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } };
const writeJSON = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* ignore */ } };
export function codeView(ctx) {
const ship = ctx.ship;
const limit = ctx.state.info?.program_bytes ?? 4096;
const key = draftKey(ship);
const canSeal = ship?.status === 'inventory';
// The unsaved working copy survives reloads, per account and per browser.
const draftKey = `halcyon.editor.${ctx.state.me?.name ?? ''}`;
const cursor = h('span', { class: 'dim' }, 'LN 1 COL 1');
let slots = []; // [{ slot, name, builtin, empty }] from the server
let current = { slot: DEFAULT_SLOT, name: '', builtin: true, saved: '' }; // what the editor is bound to
let ready = false;
let timer = 0;
const info = (n) => slots.find((s) => s.slot === n) ?? { slot: n, name: '', builtin: false, empty: true };
const slotLabel = (s) => `${pad(s.slot, 2)} ${s.builtin ? `${s.name} (EXAMPLE)` : s.empty ? '(EMPTY)' : s.name}`;
const cursor = h('span', { class: 'dim nowrap' }, 'LN 1 COL 1');
const editor = createEditor({
value: load(key) ?? '',
onInput: (v) => { clearTimeout(timer); timer = setTimeout(() => save(key, v), 300); },
value: '',
onInput: (v) => {
clearTimeout(timer);
timer = setTimeout(() => writeJSON(draftKey, { slot: current.slot, text: v }), 150);
renderStatus();
},
onCursor: ({ line, col }) => { cursor.textContent = `LN ${line} COL ${col}`; },
});
if (load(key) === null) api.example('telemetry').then((t) => { editor.value = t; }).catch(() => {});
// ---- console -----------------------------------------------------------
const output = h('div', { class: 'console', role: 'log', 'aria-live': 'polite' },
@@ -33,14 +43,13 @@ export function codeView(ctx) {
try {
const r = await api.assemble(editor.value);
editor.setError(null);
const bytes = b64ToBytes(r.program);
assembled = { bytes, ...r };
assembled = { bytes: b64ToBytes(r.program), ...r };
const pct = Math.min(100, Math.round((r.size / r.limit) * 100));
say(
h('p', { class: r.fits ? 'ok' : 'bad' }, r.fits ? 'ASSEMBLED OK' : (r.size === 0 ? 'NOTHING TO ASSEMBLE' : 'PROGRAM TOO LARGE FOR THIS BELT')),
h('p', null, `${r.size} BYTES / ${r.instructions} INSTRUCTIONS / LIMIT ${r.limit} BYTES`),
h('div', { class: `meter ${r.fits ? '' : 'over'}`, role: 'meter', 'aria-valuemin': '0', 'aria-valuemax': String(r.limit), 'aria-valuenow': String(r.size), 'aria-label': 'Program size' },
h('div', { class: 'fill', style: null, 'data-pct': String(pct) })),
h('div', { class: 'fill' })),
);
output.querySelector('.fill')?.style.setProperty('width', `${pct}%`);
return assembled;
@@ -52,50 +61,138 @@ export function codeView(ctx) {
return null;
}
}
// ---- toolbar -----------------------------------------------------------
const btn = (label, onclick, opts = {}) =>
h('button', { class: `btn ${opts.cls ?? ''}`, type: 'button', onclick, disabled: opts.disabled, title: opts.title }, label);
const exampleSel = h('select', { id: 'example', 'aria-label': 'Example program' }, h('option', { value: '' }, 'EXAMPLES...'));
api.examples().then((names) => {
for (const n of names.filter((n) => n !== 'ports')) exampleSel.append(h('option', { value: n }, n.toUpperCase()));
});
exampleSel.addEventListener('change', async () => {
const name = exampleSel.value;
exampleSel.value = '';
if (!name) return;
if (editor.value.trim() && !(await confirmDialog({ title: 'REPLACE EDITOR CONTENTS?', body: `LOAD THE ${name.toUpperCase()} EXAMPLE OVER YOUR CURRENT TEXT.`, confirmText: 'REPLACE' }))) return;
try { editor.value = await api.example(name); say(h('p', { class: 'dim' }, `LOADED ${name.toUpperCase()}.`)); } catch (e) { toast(e.message, 'err'); }
});
const bytesOrNull = async () => {
const r = await assemble();
if (r && r.size === 0) { toast('NOTHING TO ASSEMBLE', 'err'); return null; }
return r;
};
const seal = btn(ship ? `SEAL INTO SHIP #${ship.id}` : 'NO SHIP', async () => {
const r = await bytesOrNull();
if (!r) return;
if (!r.fits) { toast(`PROGRAM IS ${r.size} BYTES; THE LIMIT IS ${r.limit}`, 'err'); return; }
try {
await api.setProgram(ship.id, r.bytes);
toast(`PROGRAM SEALED IN SHIP #${ship.id} (${r.size} BYTES). READY TO LAUNCH.`);
await ctx.refresh();
} catch (e) { toast(`UPLOAD FAILED: ${e.message}`, 'err'); }
}, { cls: 'primary', disabled: !canSeal, title: canSeal ? 'Store this program in the ship' : 'Only docked ships can be reprogrammed' });
// ---- slots -------------------------------------------------------------
const btn = (label, onclick, opts = {}) =>
h('button', { class: `btn ${opts.cls ?? ''}`, type: 'button', onclick, disabled: opts.disabled, title: opts.title }, label);
const toolbar = h('div', { class: 'toolbar' },
exampleSel,
const slotSel = h('select', { id: 'slot', class: 'span2', 'aria-label': 'Code slot', disabled: true });
const status = h('p', { class: 'hint slot-status', 'aria-live': 'polite' }, 'LOADING YOUR SLOTS...');
const saveBtn = btn('SAVE', () => save(), { disabled: true });
const saveAsBtn = btn('SAVE AS...', () => saveAs(), { disabled: true });
const clearBtn = btn('CLEAR SLOT', () => clearSlot(), { cls: 'span2', disabled: true });
const dirty = () => editor.value !== current.saved;
function renderSelect() {
slotSel.replaceChildren(...slots.map((s) => h('option', { value: s.slot, selected: s.slot === current.slot }, slotLabel(s))));
slotSel.value = String(current.slot);
}
function renderStatus() {
if (!ready) return;
const s = info(current.slot);
status.textContent = `SLOT ${current.slot}: ${s.builtin ? `${s.name} (EXAMPLE, READ-ONLY)` : s.empty ? 'EMPTY' : s.name}${dirty() ? ' // UNSAVED CHANGES' : ''}`;
status.classList.toggle('warn', dirty());
saveBtn.disabled = current.builtin;
saveBtn.title = current.builtin ? 'The examples are read-only: use SAVE AS to keep a copy in your own slot' : `Save in slot ${current.slot}`;
saveAsBtn.disabled = false;
clearBtn.disabled = current.builtin || s.empty;
}
async function openSlot(n, { keepText = null } = {}) {
const s = await api.slot(n);
current = { slot: n, name: s.name, builtin: s.builtin, saved: s.source };
editor.value = keepText ?? s.source;
writeJSON(draftKey, { slot: n, text: editor.value });
renderSelect();
renderStatus();
}
slotSel.addEventListener('change', async () => {
const n = Number(slotSel.value);
if (dirty() && !(await confirmDialog({
title: 'DISCARD UNSAVED CHANGES?',
body: `YOU HAVE CHANGES IN SLOT ${current.slot} THAT ARE NOT SAVED. OPENING SLOT ${n} REPLACES THEM.`,
confirmText: 'DISCARD', danger: true,
}))) { slotSel.value = String(current.slot); return; }
try { await openSlot(n); say(h('p', { class: 'dim' }, `OPENED SLOT ${n}.`)); } catch (e) { toast(e.message, 'err'); slotSel.value = String(current.slot); }
});
async function put(n, name) {
try {
await api.saveSlot(n, name, editor.value);
slots = slots.map((s) => (s.slot === n ? { slot: n, name, builtin: false, empty: false } : s));
current = { slot: n, name, builtin: false, saved: editor.value };
writeJSON(draftKey, { slot: n, text: editor.value });
renderSelect();
renderStatus();
toast(`SAVED IN SLOT ${n}: ${name}`);
return true;
} catch (e) {
toast(`SAVE FAILED: ${e.message}`, 'err');
return false;
}
}
async function save() {
if (current.builtin) return saveAs();
if (!current.name) return saveAs();
return put(current.slot, current.name);
}
async function saveAs() {
const writable = slots.filter((s) => !s.builtin);
const firstFree = writable.find((s) => s.empty);
const target = current.builtin ? (firstFree ?? writable[0]).slot : current.slot;
const sel = h('select', { id: 'sa-slot' }, writable.map((s) => h('option', { value: s.slot, selected: s.slot === target }, slotLabel(s))));
const name = h('input', { id: 'sa-name', type: 'text', maxlength: '40', value: current.name || 'MY PROGRAM', spellcheck: 'false' });
const warn = h('p', { class: 'warn', 'aria-live': 'polite' });
const dlg = customDialog({
title: 'SAVE IN A SLOT',
content: [h('label', { for: 'sa-slot' }, 'SLOT'), sel, h('label', { for: 'sa-name' }, 'NAME'), name, warn],
confirmText: 'SAVE',
});
const check = () => {
const s = info(Number(sel.value));
warn.textContent = s.empty || s.slot === current.slot ? '' : `THIS REPLACES "${s.name}".`;
dlg.confirm.disabled = !name.value.trim();
};
sel.addEventListener('change', check);
name.addEventListener('input', check);
name.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
e.preventDefault(); // otherwise the same keystroke lands on the SAVE AS button that regains focus, and reopens this dialog
if (!dlg.confirm.disabled) dlg.close(true);
});
check();
name.select();
if (await dlg.result) await put(Number(sel.value), name.value.trim());
}
async function clearSlot() {
const s = info(current.slot);
if (!(await confirmDialog({ title: `CLEAR SLOT ${s.slot}?`, body: [`"${s.name}" WILL BE DELETED FROM THE SLOT.`, 'THE TEXT NOW IN THE EDITOR STAYS UNTIL YOU CHANGE SLOT.'], confirmText: 'CLEAR SLOT', danger: true }))) return;
try {
await api.deleteSlot(s.slot);
slots = slots.map((x) => (x.slot === s.slot ? { slot: s.slot, name: '', builtin: false, empty: true } : x));
current = { slot: s.slot, name: '', builtin: false, saved: '' };
renderSelect();
renderStatus();
toast(`SLOT ${s.slot} CLEARED`);
} catch (e) { toast(`CLEAR FAILED: ${e.message}`, 'err'); }
}
// ---- bars --------------------------------------------------------------
const bar = (label, ...buttons) =>
h('div', { class: 'bar', role: 'group', 'aria-label': label }, h('span', { class: 'bar-label' }, label), h('div', { class: 'bar-buttons' }, ...buttons));
const slotBar = bar('SLOT', slotSel, saveBtn, saveAsBtn, clearBtn);
const buildBar = bar('BUILD',
btn('ASSEMBLE', assemble),
btn('DOWNLOAD .BIN', async () => { const r = await bytesOrNull(); if (r) download(`slot-${current.slot}-program.bin`, r.bytes); }),
);
const fileBar = bar('FILE',
btn('INSERT EQUATES', async () => {
if (/\bP_UPNEW\b/.test(editor.value)) { toast('EQUATES ALREADY PRESENT'); return; }
try { editor.insertAtTop(`${await api.example('ports')}\n`); } catch (e) { toast(e.message, 'err'); }
}),
h('span', { class: 'spacer' }),
btn('ASSEMBLE', assemble),
btn('DOWNLOAD .BIN', async () => { const r = await bytesOrNull(); if (r) download(`ship-${ship?.id ?? 'x'}-program.bin`, r.bytes); }),
btn('SAVE .S', () => download(`ship-${ship?.id ?? 'x'}.s`, editor.value, 'text/plain')),
btn('DOWNLOAD .S', () => download(`slot-${current.slot}.s`, editor.value, 'text/plain')),
);
// ---- reference ---------------------------------------------------------
@@ -104,36 +201,49 @@ export function codeView(ctx) {
if (!e.target.open || card.dataset.loaded) return;
card.dataset.loaded = '1';
try {
const txt = await (await fetch('/manual.txt')).text();
const a = txt.indexOf('APPENDIX E QUICK REFERENCE CARD');
const b = txt.indexOf('* * * END OF MANUAL');
card.textContent = txt.slice(a, b).split('\n').slice(3).join('\n').trim();
// Appendix E of the manual source is one fenced block: the quick reference card.
const txt = await (await fetch('/manual.md')).text();
const open = txt.indexOf('```', txt.indexOf('# Appendix E'));
const close = txt.indexOf('```', open + 3);
card.textContent = txt.slice(txt.indexOf('\n', open) + 1, close).trim();
} catch { card.textContent = 'COULD NOT LOAD THE MANUAL.'; }
} },
h('summary', { class: 'panel-title' }, 'QUICK REFERENCE (HC-33 MANUAL, APPENDIX E)'),
card,
h('p', { class: 'hint pad' }, h('a', { href: '/manual.txt', target: '_blank', rel: 'noopener' }, 'OPEN THE FULL MANUAL')),
h('p', { class: 'hint pad' }, h('a', { href: '/manual.pdf', target: '_blank', rel: 'noopener' }, 'OPEN THE FULL MANUAL (PDF)')),
);
const note = !ship
? 'YOU HAVE NO SHIP TO PROGRAM.'
: canSeal
? `PROGRAMMING SHIP #${ship.id}. SEALING REPLACES ANY EARLIER PROGRAM UNTIL LAUNCH.`
: `SHIP #${ship.id} IS ${ship.status.toUpperCase()}: ITS PROGRAM IS SEALED. YOU CAN STILL EDIT AND DOWNLOAD HERE.`;
const view = h('div', { class: 'stack-lg' },
h('section', { class: 'panel' },
h('div', { class: 'panel-title' }, 'FLIGHT PROGRAM EDITOR', h('span', { class: 'spacer' }), cursor),
h('div', { class: 'panel-body' },
toolbar,
h('p', { class: 'hint' }, 'YOU HAVE 16 CODE SLOTS: 1-3 HOLD EXAMPLES, 4-16 ARE YOURS. TO FLY A PROGRAM, LAUNCH A SHIP FROM THE FLEET TAB AND CHOOSE ITS SLOT.'),
slotBar,
status,
editor.el,
h('div', { class: 'toolbar sealrow' }, h('p', { class: 'hint grow' }, note), seal),
h('p', { class: 'hint' }, 'DRAFTS ARE KEPT IN THIS BROWSER ONLY. TAB INDENTS; ESC THEN TAB LEAVES THE EDITOR.'),
h('p', { class: 'hint' }, 'YOUR UNSAVED TEXT IS KEPT IN THIS BROWSER. TAB INDENTS; ESC THEN TAB LEAVES THE EDITOR.'),
buildBar,
fileBar,
output,
),
),
ref,
);
(async () => {
try {
slots = await api.slots();
const draft = readJSON(draftKey);
const start = draft && slots.some((s) => s.slot === draft.slot) ? draft.slot : DEFAULT_SLOT;
ready = true;
slotSel.disabled = false;
await openSlot(start, { keepText: draft && draft.slot === start && typeof draft.text === 'string' ? draft.text : null });
} catch (e) {
status.textContent = `COULD NOT LOAD YOUR SLOTS: ${e.message}`;
status.classList.add('bad');
}
})();
queueMicrotask(() => editor.render());
return view;
}
+59 -19
View File
@@ -1,26 +1,70 @@
// Fleet manifest with launch controls.
import { api } from './api.js';
import { h, toast, confirmDialog } from './dom.js';
import { h, toast, customDialog, pad } from './dom.js';
import { statusOf } from './status.js';
export function fleetView(ctx) {
const { ships, selected } = ctx.state;
async function launch(ship) {
const ok = await confirmDialog({
title: `LAUNCH SHIP #${ship.id}?`,
body: [
'THE SHIP ENTERS THE BELT ON THE NEXT DAILY RUN.',
'ITS PROGRAM IS SEALED AT LAUNCH. IT CANNOT BE CHANGED, RECALLED OR RESTARTED.',
let slots;
try { slots = await api.slots(); } catch (e) { toast(`COULD NOT LOAD YOUR CODE SLOTS: ${e.message}`, 'err'); return; }
const slotLabel = (s) => `${pad(s.slot, 2)} ${s.builtin ? `${s.name} (EXAMPLE)` : s.empty ? '(EMPTY)' : s.name}`;
let remembered = 0;
try { remembered = Number(localStorage.getItem('halcyon.launchSlot')); } catch { /* ignore */ }
const usable = slots.filter((s) => !s.empty);
const first = usable.find((s) => s.slot === remembered) ?? usable.find((s) => !s.builtin) ?? usable[0];
const sel = h('select', { id: 'launch-slot' },
slots.map((s) => h('option', { value: s.slot, disabled: s.empty, selected: s.slot === first.slot }, slotLabel(s))));
const status = h('p', { class: 'launch-status', role: 'status', 'aria-live': 'polite' }, 'CHECKING...');
const dlg = customDialog({
title: `LAUNCH SHIP #${ship.id}`,
content: [
h('p', null, 'CHOOSE THE CODE SLOT THIS SHIP WILL FLY. IT IS ASSEMBLED NOW AND SEALED INTO THE SHIP.'),
h('label', { for: 'launch-slot' }, 'CODE SLOT'),
sel,
status,
h('p', { class: 'warn' }, 'AT LAUNCH THE PROGRAM IS FIXED. IT CANNOT BE CHANGED, RECALLED OR RESTARTED.'),
],
confirmText: 'LAUNCH',
danger: true,
});
if (!ok) return;
// Assemble the chosen slot as soon as it is chosen, so problems show before launch.
let seq = 0;
const check = async () => {
const mine = ++seq;
const n = Number(sel.value);
dlg.confirm.disabled = true;
status.className = 'launch-status';
status.textContent = 'ASSEMBLING...';
let text;
let good = false;
try {
const src = await api.slot(n);
const r = await api.assemble(src.source);
if (r.size === 0) text = 'THIS SLOT HAS NO CODE.';
else if (!r.fits) text = `TOO LARGE: ${r.size} BYTES, THE LIMIT IS ${r.limit}.`;
else { good = true; text = `${r.size} BYTES / ${r.instructions} INSTRUCTIONS: READY TO LAUNCH.`; }
} catch (e) {
text = `DOES NOT ASSEMBLE: ${e.message}`;
}
if (mine !== seq) return; // a newer choice superseded this one
status.className = `launch-status ${good ? 'ok' : 'bad'}`;
status.textContent = text;
dlg.confirm.disabled = !good;
};
sel.addEventListener('change', check);
check();
if (!(await dlg.result)) return;
const n = Number(sel.value);
try {
await api.launch(ship.id);
toast(`SHIP #${ship.id} CLEARED FOR LAUNCH. IT FLIES ON THE NEXT DAILY RUN.`);
const r = await api.launch(ship.id, n);
try { localStorage.setItem('halcyon.launchSlot', String(n)); } catch { /* ignore */ }
toast(`SHIP #${ship.id} CLEARED FOR LAUNCH WITH SLOT ${n} (${r?.program_bytes ?? '?'} BYTES). IT FLIES ON THE NEXT DAILY RUN.`);
await ctx.refresh();
} catch (e) {
toast(`LAUNCH FAILED: ${e.message}`, 'err');
@@ -34,11 +78,7 @@ export function fleetView(ctx) {
const out = [];
if (s.status === 'inventory') {
out.push(btn('CODE', () => ctx.selectShip(s.id, 'code')));
out.push(btn('LAUNCH', () => launch(s), {
cls: 'primary',
disabled: s.program_bytes === 0,
title: s.program_bytes === 0 ? 'Upload a program first' : 'Launch on the next daily run',
}));
out.push(btn('LAUNCH', () => launch(s), { cls: 'primary', title: 'Choose a code slot and launch on the next daily run' }));
}
if (s.status === 'launching' || s.status === 'active') out.push(btn('UPLINK', () => ctx.selectShip(s.id, 'uplink')));
if (s.downlink_day >= 0) out.push(btn('DOWNLINK', () => ctx.selectShip(s.id, 'downlink')));
@@ -72,13 +112,13 @@ export function fleetView(ctx) {
h('aside', { class: 'panel brief' },
h('div', { class: 'panel-title' }, 'MISSION BRIEF'),
h('ol', { class: 'steps' },
step('1', 'WRITE', 'a flight program in the CODE editor. Your ship cannot be flown by hand.'),
step('2', 'SEAL', 'it into a docked ship. You can replace it until launch.'),
step('3', 'LAUNCH', 'the ship. It enters the belt on the next daily run and the program is fixed for good.'),
step('1', 'WRITE', 'a flight program in the CODE editor and save it in one of your 16 code slots. Your ship cannot be flown by hand.'),
step('2', 'TEST', 'it: assemble it in the editor, and improve it in as many slots as you like.'),
step('3', 'LAUNCH', 'the ship and choose which slot it flies. It enters the belt on the next daily run and the program is fixed for good.'),
step('4', 'TALK', 'to it once a day: 1 KB up, 1 KB down, over the wormhole link.'),
),
h('p', { class: 'hint pad' }, 'The belt is simulated once a day. Ore delivered to the station earns credits.'),
h('p', { class: 'pad' }, h('a', { class: 'btn small', href: '/manual.txt', target: '_blank', rel: 'noopener' }, 'OPEN THE HC-33 MANUAL')),
h('p', { class: 'hint pad' }, 'The belt runs once a day. Ore delivered to the station earns credits.'),
h('p', { class: 'pad' }, h('a', { class: 'btn small', href: '/manual.pdf', target: '_blank', rel: 'noopener' }, 'OPEN THE HC-33 MANUAL (PDF)')),
),
);
}
+2 -2
View File
@@ -32,7 +32,7 @@ export function uplinkView(ctx) {
send.disabled = !open || over || b.length === 0;
clear(preview);
if (b.length) {
preview.append(hexView(b, { rows: 8 }).el);
preview.append(hexView(b, { maxBytes: 128, maxFont: 13 }).el);
if (b.length > 128) preview.append(h('p', { class: 'hint' }, `SHOWING THE FIRST 128 OF ${b.length} BYTES.`));
}
}
@@ -94,7 +94,7 @@ export function uplinkView(ctx) {
h('div', { class: 'panel-title' }, 'OUTGOING MESSAGE'),
h('div', { class: 'panel-body' },
preview,
h('p', { class: 'hint' }, 'THE MESSAGE IS OPAQUE BYTES. YOUR SHIP\'S PROGRAM DECIDES WHAT IT MEANS. IT ARRIVES IN THE UPLINK BUFFER (RAM 0-1023) BEFORE THE FIRST TICK OF THE NEXT DAILY RUN.'),
h('p', { class: 'hint' }, 'THE MESSAGE IS OPAQUE BYTES. YOUR SHIP\'S PROGRAM DECIDES WHAT IT MEANS. IT ARRIVES IN THE UPLINK BUFFER (RAM 0-1023) AT THE START OF EACH DAY.'),
),
),
);
+38 -7
View File
@@ -6,11 +6,12 @@ import (
"encoding/json"
"io/fs"
"net/http"
"os"
"sort"
"strings"
"wh/docs"
"wh/examples"
"halcyon/docs"
"halcyon/examples"
)
//go:embed static
@@ -18,15 +19,18 @@ var static embed.FS
// Handler serves the site. Mount it as the catch-all route; more specific API
// routes registered on the same mux take precedence.
func Handler() http.Handler {
//
// manualPath is the rendered PDF manual; it is opened on every request, so a
// new render is served as soon as it is renamed into place, without a restart.
func Handler(manualPath string) http.Handler {
sub, _ := fs.Sub(static, "static")
files := http.FileServerFS(sub)
mux := http.NewServeMux()
mux.Handle("GET /", files)
mux.HandleFunc("GET /manual.txt", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write(docs.Manual)
mux.HandleFunc("GET /manual.md", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Write(docs.Markdown)
})
mux.HandleFunc("GET /examples/index.json", func(w http.ResponseWriter, r *http.Request) {
entries, _ := fs.ReadDir(examples.FS, ".")
@@ -41,7 +45,34 @@ func Handler() http.Handler {
json.NewEncoder(w).Encode(names)
})
mux.Handle("GET /examples/", http.StripPrefix("/examples/", http.FileServerFS(examples.FS)))
return secure(mux)
top := http.NewServeMux()
top.HandleFunc("GET /manual.pdf", manualPDF(manualPath)) // not under the site's CSP: PDF viewers need their own resources
top.Handle("/", secure(mux))
return top
}
// manualPDF serves the manual rendered separately from the app.
func manualPDF(path string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
f, err := os.Open(path)
if err != nil {
http.Error(w, "The PDF manual has not been rendered yet (run scripts/render-manual.sh). "+
"The Markdown source is at /manual.md.", http.StatusNotFound)
return
}
defer f.Close()
st, err := f.Stat()
if err != nil {
http.Error(w, "cannot read the PDF manual", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", `inline; filename="halcyon-hc33-manual.pdf"`)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Cache-Control", "no-cache") // revalidate, so a fresh render shows up
http.ServeContent(w, r, "manual.pdf", st.ModTime(), f) // ranges and If-Modified-Since
}
}
// secure adds headers appropriate to a page that loads only its own assets.