250 lines
11 KiB
JavaScript
250 lines
11 KiB
JavaScript
// 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, customDialog, download, pad } from './dom.js';
|
|
import { createEditor } from './editor.js';
|
|
|
|
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) {
|
|
// The unsaved working copy survives reloads, per account and per browser.
|
|
const draftKey = `halcyon.editor.${ctx.state.me?.name ?? ''}`;
|
|
|
|
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: '',
|
|
onInput: (v) => {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(() => writeJSON(draftKey, { slot: current.slot, text: v }), 150);
|
|
renderStatus();
|
|
},
|
|
onCursor: ({ line, col }) => { cursor.textContent = `LN ${line} COL ${col}`; },
|
|
});
|
|
|
|
// ---- console -----------------------------------------------------------
|
|
const output = h('div', { class: 'console', role: 'log', 'aria-live': 'polite' },
|
|
h('p', { class: 'dim' }, 'READY. ASSEMBLE TO CHECK YOUR PROGRAM.'));
|
|
const say = (...nodes) => clear(output).append(...nodes);
|
|
|
|
let assembled = null;
|
|
async function assemble() {
|
|
try {
|
|
const r = await api.assemble(editor.value);
|
|
editor.setError(null);
|
|
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' })),
|
|
);
|
|
output.querySelector('.fill')?.style.setProperty('width', `${pct}%`);
|
|
return assembled;
|
|
} catch (e) {
|
|
assembled = null;
|
|
const m = /^line (\d+):/.exec(e.message);
|
|
if (m) editor.setError(Number(m[1]));
|
|
say(h('p', { class: 'bad' }, `ERROR: ${e.message}`));
|
|
return null;
|
|
}
|
|
}
|
|
const bytesOrNull = async () => {
|
|
const r = await assemble();
|
|
if (r && r.size === 0) { toast('NOTHING TO ASSEMBLE', 'err'); return null; }
|
|
return r;
|
|
};
|
|
|
|
// ---- slots -------------------------------------------------------------
|
|
const btn = (label, onclick, opts = {}) =>
|
|
h('button', { class: `btn ${opts.cls ?? ''}`, type: 'button', onclick, disabled: opts.disabled, title: opts.title }, label);
|
|
|
|
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'); }
|
|
}),
|
|
btn('DOWNLOAD .S', () => download(`slot-${current.slot}.s`, editor.value, 'text/plain')),
|
|
);
|
|
|
|
// ---- reference ---------------------------------------------------------
|
|
const card = h('pre', { class: 'refcard' }, 'LOADING...');
|
|
const ref = h('details', { class: 'panel ref', onToggle: async (e) => {
|
|
if (!e.target.open || card.dataset.loaded) return;
|
|
card.dataset.loaded = '1';
|
|
try {
|
|
// 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.pdf', target: '_blank', rel: 'noopener' }, 'OPEN THE FULL MANUAL (PDF)')),
|
|
);
|
|
|
|
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' },
|
|
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('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;
|
|
}
|