Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
44 kB · 1289 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290/** * The camo editor. * * Everything happens in the browser. Nothing is uploaded while you work, an * imported file is read into memory and never sent anywhere, and the output is * a plain 84x72 PNG. * * Two things are on screen at once on purpose. The large pane is one unit * zoomed, for judging a pattern; the grid beside it is every sample unit at * the size a match actually draws them, for judging whether it survives. A * camo that looks good only in the large pane is a camo that will not read in * play, and the only way to notice that is to see both. * * Signing in is not required to design a camo or to download one. It is * required to save one to your own repository, and to borrow a palette from * your avatar, because both of those need an account. Someone who plays * MegaMek and has never heard of ATProto should be able to get a usable PNG * out of this page without an account at all. */
import { type Session } from "../api";import { camoCollection, failure, repoModule } from "./collection";import { makeSource, type CamoSource } from "./record";import type { StoredCamo } from "./repo";import { BACKGROUNDS, CAMO_H, CAMO_W, PREVIEW_SPRITES, applyCamo, fitToCamo, loadBackgrounds, loadSprites, over,} from "./mech";import { avatarUrl } from "../profile";import { GENERATORS, byId, type PatternParams } from "./patterns";import { PALETTE_GROUPS, PRESETS, fromHex, generatePalette, paletteFromImage, toHex, type Palette, type PaletteGroup,} from "./palette";import { randomSeed } from "./rng";import { randomCamo } from "./random";import { el } from "../dom";
type Imported = { image: HTMLImageElement; name: string; zoom: number; offsetX: number; offsetY: number;};
type State = { generator: string; params: PatternParams; palette: Palette; imported: Imported | null; sprite: string; background: string;};
// --- persistence -----------------------------------------------------------//// Signing in navigates away to the player's own provider and comes back to the// site root, so without this a player who clicks Save loses their camo, their// name for it, and their place on the page. The editor therefore writes itself// down before handing off to OAuth and picks itself back up on return.//// This is a scratchpad, not a storage format. The saved shape is versioned and// anything unreadable is discarded silently: generator ids and parameter// meanings change when this code changes, and a camo that came back subtly// wrong would be worse than one that came back missing. The rasterised image is// saved alongside the parameters for that reason - if the parameters no longer// mean anything, the picture still does.
const STORE_KEY = "lance.blue:camo:v2";const RESUME_KEY = "lance.blue:camo:resume";
type Saved = { v: 2; generator: string; params: PatternParams; palette: Palette; paletteTouched: boolean; name: string; sprite: string; background: string; /** The 84x72 PNG as a data URL. Always written; the fallback for everything. */ raster: string; /** Set when the camo came from a file rather than a generator. */ importedName: string | null;};
/** localStorage throws in private modes and when full; never let that matter. */function safeGet(key: string): string | null { try { return localStorage.getItem(key); } catch { return null; }}
function safeSet(key: string, value: string): void { try { localStorage.setItem(key, value); } catch { /* out of space or blocked - the editor still works, it just will not resume */ }}
function safeRemove(key: string): void { try { localStorage.removeItem(key); } catch { /* as above */ }}
function readSaved(): Saved | null { const raw = safeGet(STORE_KEY); if (!raw) return null; try { const parsed = JSON.parse(raw) as Saved; if (parsed.v !== 2 || typeof parsed.raster !== "string") return null; return parsed; } catch { return null; }}
/** * True when the last thing the player did here was start a sign-in. main.ts * uses it to send them back to the editor rather than the signed-in screen, * because the OAuth callback lands on the site root with no hash. */export function hasPendingCamo(): boolean { return safeGet(RESUME_KEY) === "1" && readSaved() !== null;}
/** Framing offsets run -1..1 about the centre, so the sign is the reading. */function signedPercent(v: number): string { return `${v > 0 ? "+" : ""}${Math.round(v * 100)}%`;}
/** A canvas whose backing store is the true pixel size, scaled up by CSS. */function pixelCanvas(w: number, h: number, zoom: number, className: string) { const canvas = el("canvas", { className, width: w, height: h }); canvas.style.width = `${w * zoom}px`; canvas.style.height = `${h * zoom}px`; return canvas;}
/** * No way out is returned here on purpose: the masthead is on every screen, so * leaving the editor is a nav click and the page needs no Back button of its * own. */export function camoEditor(): Node[] { // A camo carried through a sign-in wins over a fresh random one. Everything // else - a plain visit, a reload - still gets a new camo, which is the point // of rolling one on load. const resumed = hasPendingCamo() ? readSaved() : null; safeRemove(RESUME_KEY);
const state: State = resumed ? { generator: resumed.generator, params: resumed.params, palette: resumed.palette, imported: null, sprite: resumed.sprite, background: resumed.background, } : { ...randomCamo(), imported: null, sprite: "atlas", background: "battlespace", };
const camoCanvas = el("canvas", { className: "camo-swatch", width: CAMO_W, height: CAMO_H, }); const camoCtx = camoCanvas.getContext("2d")!; let camo: ImageData = new ImageData(CAMO_W, CAMO_H);
const stage = el("canvas", { className: "camo-stage", width: CAMO_W, height: CAMO_H, }); const stageCtx = stage.getContext("2d")!;
// Populated once the art decodes; every render is a no-op until then. const sprites = new Map<string, ImageData>(); const backgrounds = new Map<string, ImageData>();
const strip = el("div", { className: "camo-fleet" }); const stripCanvases = new Map<string, HTMLCanvasElement>(); const bgRow = el("div", { className: "camo-backgrounds" }); const bgCanvases = new Map<string, HTMLCanvasElement>();
// Two lines, and which one a message goes to is not a detail. The hint sits // under the Pattern label and describes the pattern on screen, so nothing // but choosing a pattern is allowed to write to it — a save, a sign-in or a // failed import left it describing something the grid below it disagreed // with. Everything that just happened goes to the message line instead, // under the camo and above the controls, where the buttons that caused it // are. const patternHint = el("p", { className: "small muted camo-pattern-hint" }); const status = el("p", { className: "small muted camo-message", role: "status", });
// --- rendering -----------------------------------------------------------
function renderCamo(): void { if (state.imported) { const { image, zoom, offsetX, offsetY } = state.imported; camo = fitToCamo( image, image.naturalWidth, image.naturalHeight, zoom, offsetX, offsetY, ); } else { const img = new ImageData(CAMO_W, CAMO_H); byId(state.generator).render(img, state.palette.colors, state.params); camo = img; } camoCtx.putImageData(camo, 0, 0); }
function renderPreviews(): void { const bg = backgrounds.get(state.background); const hero = sprites.get(state.sprite); if (hero) stageCtx.putImageData(over(bg, applyCamo(hero, camo)), 0, 0); for (const def of PREVIEW_SPRITES) { const canvas = stripCanvases.get(def.id); const base = sprites.get(def.id); if (!canvas || !base) continue; canvas .getContext("2d")! .putImageData(over(bg, applyCamo(base, camo)), 0, 0); } // The background chips show the current camo on the current unit, so the // choice is made on the thing being decided rather than on a swatch. for (const def of BACKGROUNDS) { const canvas = bgCanvases.get(def.id); const tile = backgrounds.get(def.id); if (!canvas || !hero || !tile) continue; canvas .getContext("2d")! .putImageData(over(tile, applyCamo(hero, camo)), 0, 0); } }
/** * Redraw the pattern grid: every generator, at the current palette and * settings. * * Twenty-one generators is twenty-one renders, and several of them run a * simulation for a few hundred steps, so this is an order of magnitude more * work than the previews above and does not belong on the same frame. It * runs on a trailing timer instead: a slider drag redraws the camo and its * units at full rate and the grid catches up once the hand stops. Half size * keeps even that catch-up to a quarter of the pixels. */ let thumbTimer = 0; let thumbsTimed = false; function renderThumbs(): void { const started = performance.now(); const img = new ImageData(THUMB_W, THUMB_H); for (const gen of GENERATORS) { const canvas = thumbCanvases.get(gen.id); if (!canvas) continue; gen.render(img, state.palette.colors, { ...state.params, seed: THUMB_SEED, }); canvas.getContext("2d")!.putImageData(img, 0, 0); } // Once per editor, not per redraw: the number is here to catch a generator // that turns out to be pathologically slow, and it does not change. if (!thumbsTimed) { thumbsTimed = true; console.debug( `camo: ${GENERATORS.length} pattern thumbnails in ${Math.round(performance.now() - started)}ms`, ); } }
function scheduleThumbs(): void { clearTimeout(thumbTimer); thumbTimer = window.setTimeout(renderThumbs, 120); }
// Some generators run a simulation for a few hundred steps. Coalesce into // one frame so dragging a slider does not queue up work it will discard. let pending = false; function refresh(): void { scheduleThumbs(); if (pending) return; pending = true; requestAnimationFrame(() => { pending = false; renderCamo(); renderPreviews(); }); }
// --- pattern picker ------------------------------------------------------
const patternGrid = el("div", { className: "camo-patterns" }); const patternButtons = new Map<string, HTMLButtonElement>();
function markPattern(): void { for (const [id, btn] of patternButtons) { const active = !state.imported && id === state.generator; btn.classList.toggle("active", active); btn.setAttribute("aria-pressed", String(active)); } // The hint's only writer, and this is the only thing that changes it: the // selection moved, so what is under the label follows it. Every path that // changes the pattern already comes through here. patternHint.textContent = state.imported ? "An imported picture, so there is no pattern being generated." : byId(state.generator).hint; }
// Every pattern draws itself, in the palette and at the settings currently // in play. Twenty-one names told a player nothing about what any of them // looked like, and "Cyclic automaton" is not a thing anyone can picture. // // Half size, because these are read as a set: at 42x36 all twenty-one fit // beside their names in the same seven rows the text buttons took, and a // pattern's character survives the halving even where its fine detail does // not. The seed is fixed rather than taken from `state.params` so that // rolling the shape does not reshuffle the whole grid under the player's // hand - only the controls they actually moved change what is drawn. const THUMB_W = CAMO_W / 2; const THUMB_H = CAMO_H / 2; const THUMB_SEED = 20260807; const thumbCanvases = new Map<string, HTMLCanvasElement>();
for (const gen of GENERATORS) { const btn = el("button", { type: "button", className: "camo-pattern", title: gen.hint, }); const thumb = pixelCanvas(THUMB_W, THUMB_H, 1, "camo-pattern-thumb"); thumbCanvases.set(gen.id, thumb); btn.append(thumb, el("span", { textContent: gen.name })); if (gen.emoji) { btn.append( el("span", { className: "camo-pattern-emoji", textContent: gen.emoji, ariaHidden: "true", }), ); } btn.addEventListener("click", () => { state.generator = gen.id; state.imported = null; // Pride with a woodland palette is just Bands. If the player has not // picked colours themselves yet, hand them a flag so the pattern means // what its name says; if they have, leave their choice alone. if (gen.id === "pride" && !paletteTouched) { const flags = PRESETS.filter((pal) => pal.flag); setPalette(flags[Math.floor(Math.random() * flags.length)]!, false); } markPattern(); renderShape(); refresh(); }); patternButtons.set(gen.id, btn); patternGrid.append(btn); }
// --- palette -------------------------------------------------------------
const swatchRow = el("div", { className: "camo-swatches" });
function renderSwatches(): void { swatchRow.replaceChildren(); state.palette.colors.forEach((color, i) => { const input = el("input", { type: "color", value: toHex(color), className: "camo-color", title: `Colour ${i + 1}`, }); input.addEventListener("input", () => { const next = state.palette.colors.slice(); next[i] = fromHex(input.value); // Set the palette directly rather than through setPalette: that // rebuilds the swatch row, which would replace this very <input> mid // drag and close the colour picker the user is still using. The row // only needs rebuilding when the palette is replaced wholesale. paletteTouched = true; state.palette = { name: "Custom", colors: next }; // Not a preset any more, so nothing in the grid should look chosen. markPreset(); refresh(); }); swatchRow.append(input); }); }
// True once the player has made a colour decision themselves. The palette // chosen at random on load does not count - that is the page's choice, not // theirs, and it is the thing Pride is allowed to overrule. let paletteTouched = resumed?.paletteTouched ?? false;
function setPalette(p: Palette, byUser = true): void { if (byUser) paletteTouched = true; state.palette = p; renderSwatches(); markPreset(); refresh(); }
// Presets, as a chip per group over a grid of swatch tiles. // // This was one flat <select>, which worked at sixteen presets and does not // at thirty-eight: a menu shows names, and a name is the least useful thing // about a colour scheme. "Free Rasalhague" tells a player nothing, four // bands of its actual colours tell them everything, and the pattern grid a // few lines up is already this exact shape - a grid of aria-pressed tiles - // so the idiom is borrowed rather than invented. const presetCats = el("div", { className: "camo-cats", role: "group", "aria-label": "Preset categories", }); const presetGrid = el("div", { className: "camo-presets" }); const catButtons = new Map<PaletteGroup, HTMLButtonElement>(); let openGroup: PaletteGroup = PALETTE_GROUPS[0]!;
function renderPresets(): void { presetGrid.replaceChildren(); for (const preset of PRESETS.filter((p) => p.group === openGroup)) { const btn = el("button", { type: "button", className: "camo-preset", // The palette's own name is the accessible name, so the tooltip is // free to carry the thing the tile cannot show. title: `${preset.name}: ${preset.colors.map(toHex).join(" ")}`, }); btn.setAttribute( "aria-pressed", String(state.palette.name === preset.name), ); const bands = el("span", { className: "camo-preset-bands" }); for (const color of preset.colors) { const band = el("span", { className: "camo-preset-band" }); band.style.background = toHex(color); bands.append(band); } btn.append( bands, el("span", { className: "camo-preset-name", textContent: preset.name }), ); btn.addEventListener("click", () => setPalette(preset)); presetGrid.append(btn); } }
/** The pressed tile follows the palette, however the palette got set. */ function markPreset(): void { for (const btn of presetGrid.children) { const name = btn.querySelector(".camo-preset-name")?.textContent; btn.setAttribute("aria-pressed", String(name === state.palette.name)); } }
for (const group of PALETTE_GROUPS) { const btn = el("button", { type: "button", className: "camo-cat", textContent: group, }); btn.setAttribute("aria-pressed", String(group === openGroup)); btn.addEventListener("click", () => { openGroup = group; for (const [name, other] of catButtons) { other.setAttribute("aria-pressed", String(name === group)); } renderPresets(); }); catButtons.set(group, btn); presetCats.append(btn); } renderPresets();
// One roll button per section, identically styled, each rolling only its own // section. The three of them together are what `randomCamo` does on load. function rollButton(label: string, onClick: () => void): HTMLButtonElement { const btn = el("button", { type: "button", className: "camo-roll", textContent: label, title: `New random ${label.replace("Random ", "")}`, }); btn.addEventListener("click", onClick); return btn; }
const randomPalette = rollButton("Random colours", () => setPalette(generatePalette(randomSeed())), );
const randomPattern = rollButton("Random pattern", () => { const pool = GENERATORS.filter( (g) => g.id !== state.generator && g.id !== "solid" && g.id !== "pride", ); const gen = pool[Math.floor(Math.random() * pool.length)]!; state.generator = gen.id; state.imported = null; markPattern(); renderShape(); refresh(); });
const avatarPalette = el("button", { type: "button", className: "secondary small-btn", textContent: "From my avatar", title: "Take a palette from the picture on your Atmosphere profile", }); avatarPalette.addEventListener("click", () => { // The connected account, not the site session: this screen has one notion // of "your account", and it is the one camo are saved to. if (!repoSession) { status.textContent = "Sign in to borrow the colours from your profile picture."; return; } avatarPalette.disabled = true; status.textContent = "Reading your profile picture…"; loadAvatarPalette(repoSession.did) .then((palette) => { setPalette(palette); status.textContent = "Palette taken from your avatar."; }) .catch((err: unknown) => { status.textContent = failure(err, "Could not read your avatar."); }) .finally(() => { avatarPalette.disabled = false; }); });
// --- shape controls ------------------------------------------------------
const shapeGroup = el("div", { className: "camo-group" });
/** * A labelled range with its value written beside it. * * The readout is not decoration. These sliders drive a picture rather than a * number, so the only way to come back to a setting you liked - or to tell * two nearly-identical drags apart - was to eyeball the handle. `format` * exists because the units differ: the pattern controls are fractions shown * as percentages, Angle is a turn shown in degrees, and Zoom is a multiple. */ function slider( label: string, value: number, onInput: (v: number) => void, min = "0", max = "1", format: (v: number) => string = (v) => `${Math.round(v * 100)}%`, ): HTMLElement { const input = el("input", { type: "range", min, max, step: "0.01", value: String(value), className: "camo-range", }); const readout = el("output", { className: "camo-value" }); // The readout is drawn, and it is also said — but by the slider rather than // by itself. <output> is a live region, so left alone it announced every // step of a drag, on top of the value the slider announces anyway. readout.setAttribute("aria-hidden", "true");
// One call to format, used twice: the readout a reader sees, and what the // slider says when a screen reader lands on it. Without the second the // announcement is the raw number, which is the one thing `format` exists // to stop anyone reading — 0.52 for a control showing 52%, and 0.25 for // one showing 90°. Formatting them separately is how they come to // disagree. const show = (v: number): void => { const text = format(v); readout.textContent = text; input.setAttribute("aria-valuetext", text); }; show(value);
input.addEventListener("input", () => { const v = Number(input.value); show(v); onInput(v); }); return el("label", { className: "camo-field" }, [ el("span", { textContent: label }), input, readout, ]); }
const randomShape = rollButton("Random shape", () => { // Rolls the seed and the sliders together. Seed alone gives a different // instance of the same look, which is not what "random" reads as. const rolled = randomCamo(); state.params = rolled.params; state.imported = null; markPattern(); renderShape(); refresh(); });
/** * The shape controls differ by source, which is the honest answer to "should * there be a zoom as well as a size". * * For a generated pattern there should not be. Size changes the frequency the * pattern is generated at, so it produces genuinely different shapes at the * full 84x72; a zoom could only magnify pixels that are already there and * would just look soft. Two controls that mostly overlap is worse than one * that works. * * For an imported image there should be, and it is the whole game: the file * has a subject, the centre of it is not always that subject, and a * cover-crop with no controls throws away the user's actual intent. */ function renderShape(): void { const imported = state.imported; shapeGroup.replaceChildren( el("div", { className: "camo-group-head" }, [ el("span", { className: "camo-label", textContent: imported ? "Framing" : "Shape", }), // Nothing to roll when the source is a file the user chose. ...(imported ? [] : [randomShape]), ]), ); if (imported) { shapeGroup.append( slider( "Zoom", imported.zoom, (v) => { imported.zoom = v; refresh(); }, "1", "6", (v) => `${v.toFixed(1)}x`, ), slider( "Across", imported.offsetX, (v) => { imported.offsetX = v; refresh(); }, "-1", "1", signedPercent, ), slider( "Down", imported.offsetY, (v) => { imported.offsetY = v; refresh(); }, "-1", "1", signedPercent, ), el("p", { className: "hint", textContent: `Framing ${imported.name} into ${CAMO_W}x${CAMO_H}.`, }), ); return; } shapeGroup.append( slider("Size", state.params.scale, (v) => { state.params = { ...state.params, scale: v }; refresh(); }), slider("Edges", state.params.contrast, (v) => { state.params = { ...state.params, contrast: v }; refresh(); }), slider("Detail", state.params.detail, (v) => { state.params = { ...state.params, detail: v }; refresh(); }), slider( "Angle", state.params.angle, (v) => { state.params = { ...state.params, angle: v }; refresh(); }, "0", "1", // The parameter is a fraction of a turn; nobody reads a rotation that // way. 0.25 is a quarter turn to the code and 90 degrees to a person. (v) => `${Math.round(v * 360)}°`, ), ); }
// --- import, download, save ----------------------------------------------
const fileInput = el("input", { type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", className: "camo-file", }); fileInput.addEventListener("change", () => { const file = fileInput.files?.[0]; if (!file) return; const url = URL.createObjectURL(file); // Emptied as soon as the file is in hand, because a file input only fires // change when the picker hands back something different from what it holds. // Import a picture, roll a new camo over it, then import the same picture // again and the browser sees no change: no event, no import, and nothing // on screen to say why. The File is a Blob of its own and outlives this. fileInput.value = ""; const img = new Image(); img.onload = () => { // Held in memory and never sent anywhere. The object URL is revoked as // soon as the pixels are decoded. state.imported = { image: img, name: file.name, zoom: 1, offsetX: 0, offsetY: 0, }; URL.revokeObjectURL(url); markPattern(); renderShape(); status.textContent = `Imported ${file.name}. Nothing was uploaded.`; refresh(); }; img.onerror = () => { URL.revokeObjectURL(url); status.textContent = "That file could not be read as an image."; }; img.src = url; });
const importBtn = el("button", { type: "button", className: "secondary small-btn", textContent: "Import", title: "Use an image from this device", }); importBtn.addEventListener("click", () => fileInput.click());
// Loaded on the click that opens it, like the sign-in and report dialogs: // the catalogue and its thumbnails are worth nothing to someone who never // opens the picker, and this page already has plenty to fetch. const megamekBtn = el("button", { type: "button", className: "secondary small-btn", textContent: "Import camo from MegaMek…", title: "Use one of the camos shipped with MegaMek", }); megamekBtn.addEventListener("click", () => { megamekBtn.disabled = true; void import("./megamek") .then(({ openMegaMekPicker }) => openMegaMekPicker({ onPick: (url, name) => { void adoptImage(url, name) .then(() => { status.textContent = `Using MegaMek's ${name}. Nothing was uploaded.`; }) .catch(() => { status.textContent = "That camo could not be loaded."; }); }, onError: (message) => { status.textContent = message; }, }), ) .catch((err: unknown) => { console.error("camo: the MegaMek camo picker did not load", err); status.textContent = "MegaMek's camos could not be loaded."; }) .finally(() => { megamekBtn.disabled = false; }); });
const download = el("button", { type: "button", className: "secondary small-btn", textContent: "Download", title: "Save the 84x72 PNG to this device", }); download.addEventListener("click", () => { camoCanvas.toBlob((blob) => { if (!blob) return; const url = URL.createObjectURL(blob); const a = el("a", { href: url, download: "camo.png" }); a.click(); URL.revokeObjectURL(url); }, "image/png"); });
// A saved camo is one of several a player owns and picks between at launch, // so it needs a name to be picked by. Required for saving only - downloading // a nameless PNG is fine, because a file already has a filename. const nameInput = el("input", { type: "text", className: "camo-name", placeholder: "Name this camo", maxLength: 64, required: true, "aria-label": "Camo name", }); const nameError = el("p", { className: "error", role: "alert" }); const nameField = el("label", { className: "camo-namefield" }, [ nameInput, nameError, ]); nameInput.addEventListener("input", () => { nameError.textContent = ""; });
// --- saving --------------------------------------------------------------- // // The camo open in the editor, when it came from the player's repository. // Null means the next save writes a new record; anything else means Save // changes that one and "Save as new" is the way to branch off it.
let editing: StoredCamo | null = null;
const save = el("button", { type: "button", className: "small-btn", textContent: "Save", title: "Save to your own account", }); const saveAsNew = el("button", { type: "button", className: "secondary small-btn", textContent: "Save as new", title: "Keep the original and save this as another camo", hidden: true, });
function markEditing(camo: StoredCamo | null): void { editing = camo; saveAsNew.hidden = camo === null; save.textContent = camo ? "Save changes" : "Save"; collection.setOpen(camo); }
/** The 84x72 PNG on screen, which is what every preview was drawn from. */ function currentPng(): Promise<Blob> { return new Promise((resolve, reject) => { camoCanvas.toBlob((blob) => { if (blob) resolve(blob); else reject(new Error("The camo could not be read for saving.")); }, "image/png"); }); }
/** * An imported camo has no settings to write: there is no generator to re-run * and the picture is the whole of it. */ function currentSource(): CamoSource | null { if (state.imported) return null; return makeSource(state.generator, state.params, state.palette); }
function doSave(asNew: boolean): void { const name = nameInput.value.trim(); if (!name) { nameError.textContent = "Give the camo a name before saving it."; nameInput.focus(); return; } if (!repoSession) { // Pressing Save is asking to save, so put the one thing in the way in // front of them rather than a line of text about it. The dialog writes // the camo down before the redirect, so it survives the round trip. collection.signIn(); return; } const target = asNew ? null : editing; save.disabled = true; saveAsNew.disabled = true; status.textContent = target ? `Saving changes to “${name}”…` : "Saving…"; void (async () => { try { const png = await currentPng(); const source = currentSource(); const repo = await repoModule(); const saved = target ? await repo.update(target, { name, png, source }) : await repo.create(name, png, source); markEditing(saved); status.textContent = target ? `Saved changes to “${name}”.` : `Saved “${name}” to your account.`; await collection.reload(); } catch (e: unknown) { status.textContent = failure(e, "The camo could not be saved."); } finally { save.disabled = false; saveAsNew.disabled = false; } })(); }
save.addEventListener("click", () => doSave(false)); saveAsNew.addEventListener("click", () => doSave(true));
/** * Put a stored camo back on the workbench. * * With settings, the controls go back where they were and the camo is * redrawn from them — an edit changes the pattern, not just the name. With * none, the stored picture is adopted the way an imported file is: it can be * renamed, previewed and re-saved, but there is nothing to move. */ async function openStored(camo: StoredCamo): Promise<void> { if (!camo.rkey) { // The camo that was open has just been deleted. markEditing(null); return; } nameInput.value = camo.name; nameError.textContent = ""; if (camo.source) { state.generator = camo.source.generator; state.params = camo.source.params; state.imported = null; paletteTouched = true; setPalette(camo.source.palette, false); markPattern(); renderShape(); refresh(); markEditing(camo); status.textContent = `Editing “${camo.name}”.`; return; } if (!camo.imageUrl) { status.textContent = "That camo's picture could not be found."; return; } try { await adoptImage(camo.imageUrl, camo.name); markEditing(camo); status.textContent = `Editing “${camo.name}”. It was imported, so there are no settings to move.`; } catch (e: unknown) { status.textContent = failure(e, "That camo could not be opened."); } }
let repoSession: Session | null = null;
const collection = camoCollection({ onOpen: (camo) => void openStored(camo), onStatus: (message) => { status.textContent = message; }, onSession: (next) => { repoSession = next; if (!next) markEditing(null); }, onBeforeConnect: persist, });
/** * A whole new camo: pattern, colours and shape rolled together, which is the * same `randomCamo` a fresh page load uses. "Random shape" above is the same * roll with only the shape taken out of it. * * Unlike the roll on load, this one counts as a colour decision: the player * asked for it, so Pride will not overrule the palette it hands out. * * The unit and terrain are left where they are. Those are how the camo is * being looked at rather than part of it, and moving them under the player * would only make the change harder to judge. */ function rollEverything(): void { const rolled = randomCamo(); state.generator = rolled.generator; state.params = rolled.params; state.imported = null; setPalette(rolled.palette); markPattern(); renderShape(); refresh(); }
const randomize = el("button", { type: "button", className: "camo-randomize", textContent: "Randomize", title: "Roll a whole new camo: pattern, colours and shape", }); randomize.addEventListener("click", rollEverything);
/** Everything the player can see, written down so a sign-in can restore it. */ function persist(): void { safeSet( STORE_KEY, JSON.stringify({ v: 2, generator: state.generator, params: state.params, palette: state.palette, paletteTouched, name: nameInput.value, sprite: state.sprite, background: state.background, raster: camoCanvas.toDataURL("image/png"), importedName: state.imported?.name ?? null, } satisfies Saved), ); safeSet(RESUME_KEY, "1"); }
/** * Adopt a rasterised camo as if it had been imported, so an imported camo * survives the sign-in round trip: there is no generator to re-run, only the * saved picture. */ function adoptImage(url: string, name: string): Promise<void> { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { state.imported = { image: img, name, zoom: 1, offsetX: 0, offsetY: 0 }; markPattern(); renderShape(); refresh(); resolve(); }; img.onerror = () => reject(new Error(`could not load ${name}`)); img.src = url; }); }
// --- fleet and backgrounds ------------------------------------------------
for (const def of PREVIEW_SPRITES) { const canvas = pixelCanvas(CAMO_W, CAMO_H, 1, "camo-unit"); stripCanvases.set(def.id, canvas); const btn = el("button", { type: "button", className: "camo-unit-btn", title: def.detail, }); btn.append( canvas, el("span", { className: "camo-unit-name", textContent: def.name }), el("span", { className: "camo-unit-kind", textContent: def.kind }), ); btn.addEventListener("click", () => { state.sprite = def.id; for (const other of strip.querySelectorAll(".camo-unit-btn")) { const active = other === btn; other.classList.toggle("active", active); other.setAttribute("aria-pressed", String(active)); } renderPreviews(); }); const spriteActive = def.id === state.sprite; btn.classList.toggle("active", spriteActive); btn.setAttribute("aria-pressed", String(spriteActive)); strip.append(btn); }
for (const def of BACKGROUNDS) { const canvas = pixelCanvas(CAMO_W, CAMO_H, 1, "camo-bg"); bgCanvases.set(def.id, canvas); const btn = el("button", { type: "button", className: "camo-bg-btn", title: `Preview against ${def.name.toLowerCase()}`, }); btn.append( canvas, el("span", { className: "camo-unit-kind", textContent: def.name }), ); btn.addEventListener("click", () => { state.background = def.id; for (const other of bgRow.querySelectorAll(".camo-bg-btn")) { const active = other === btn; other.classList.toggle("active", active); other.setAttribute("aria-pressed", String(active)); } renderPreviews(); }); const bgActive = def.id === state.background; btn.classList.toggle("active", bgActive); btn.setAttribute("aria-pressed", String(bgActive)); bgRow.append(btn); }
// --- start ---------------------------------------------------------------
renderSwatches(); renderShape(); markPattern(); if (resumed) nameInput.value = resumed.name;
const LOADING_ART = "Loading unit art…"; status.textContent = LOADING_ART; Promise.all([loadSprites(), loadBackgrounds()]) .then(([units, tiles]) => { for (const [id, data] of units) sprites.set(id, data); for (const [id, data] of tiles) backgrounds.set(id, data); // Take the loading line back down, unless the player has done something // in the meantime and the line is already saying something newer. if (status.textContent === LOADING_ART) status.textContent = ""; refresh(); }) .catch(() => { status.textContent = "Unit art failed to load; the camo itself still works."; }); refresh();
// A resumed camo that came from a file has no generator to re-run, so the // saved raster is re-adopted as the import. if (resumed?.importedName) { void adoptImage(resumed.raster, resumed.importedName).then(() => { status.textContent = `Picked up where you left off with ${resumed.importedName}.`; }); } else if (resumed) { status.textContent = "Picked up where you left off."; }
// Picks up a grant this browser already has, and finishes one the player is // being redirected back from. Nothing is downloaded for a visitor who has // never connected an account: the module behind this resolves to nothing to // do and the OAuth client is never loaded. void collection.start().then((connected) => { if (connected && resumed) { status.textContent = `Picked up where you left off, saving to ${connected.handle ? `@${connected.handle}` : connected.did}.`; } });
return [ el("section", { className: "card camo-editor" }, [ // Camo, then the large unit, then the grid. The swatch and the large // view are the same 252x216 block, so putting them next to each other // lines their top and bottom edges up and reads as cause then effect. el("div", { className: "camo-stage-row" }, [ el("div", { className: "camo-aside" }, [ camoCanvas, nameField, el("div", { className: "camo-actions" }, [ importBtn, download, save, saveAsNew, fileInput, ]), // Its own row. This column is one camo wide, and a fourth button // with a five-word label in the row above wrapped to three lines and // pushed Download and Save out of line with it. el("div", { className: "camo-actions" }, [megamekBtn]), randomize, ]), el("div", { className: "camo-stage-col" }, [ el("div", { className: "camo-stage-wrap" }, [stage]), bgRow, ]), strip, ]),
// Full width under the camo and its units, above the controls: what the // editor has to say about what just happened, next to the buttons that // did it and clear of the pattern grid. status,
el("div", { className: "camo-controls" }, [ el("div", { className: "camo-group" }, [ el("div", { className: "camo-group-head" }, [ el("span", { className: "camo-label", textContent: "Pattern" }), randomPattern, ]), patternHint, patternGrid, ]), // Colours above Shape rather than beside it: both are short, and // stacking them keeps the pattern grid a full-height column instead of // three stubby ones. el("div", { className: "camo-column" }, [ el("div", { className: "camo-group" }, [ el("div", { className: "camo-group-head" }, [ el("span", { className: "camo-label", textContent: "Colours" }), randomPalette, ]), swatchRow, presetCats, presetGrid, el("div", { className: "camo-actions" }, [avatarPalette]), ]), shapeGroup, ]), ]),
// Full width, under both columns: it is the only block about camo other // than the one being drawn, and a list picked from by name wants the // room more than a third column of controls would. collection.node,
el("p", { className: "small muted camo-credit" }, [ "Unit art and terrain from MegaMek, used under CC BY-NC-SA 4.0.", ]), ]), ];}
/** * Pull the signed-in account's avatar and reduce it to a palette. * * The picture is read from the account's own PDS rather than from the Bluesky * appview (profile.ts says why that matters everywhere). It matters twice as * much here: the appview hands back a cdn.bsky.app URL, and that CDN sends no * `Access-Control-Allow-Origin` at all — so the image never loads with * `crossOrigin`, and without it the canvas is tainted and getImageData throws. * Either way the colours would be unreadable. * * Still client-side, and still no extra authentication: a profile record and * its blob are public. */async function loadAvatarPalette(did: string): Promise<Palette> { const url = await avatarUrl(did); if (!url) throw new Error("Your profile has no picture to read.");
// The original upload, at whatever size it was uploaded — there is no // resized copy on a PDS. It is drawn straight down to 64x64 below. const image = await new Promise<HTMLImageElement>((resolve, reject) => { const img = new Image(); img.crossOrigin = "anonymous"; img.onload = () => resolve(img); img.onerror = () => reject(new Error("Your avatar could not be loaded.")); img.src = url; });
const canvas = document.createElement("canvas"); canvas.width = 64; canvas.height = 64; const ctx = canvas.getContext("2d")!; ctx.drawImage(image, 0, 0, 64, 64); let data: ImageData; try { data = ctx.getImageData(0, 0, 64, 64); } catch { throw new Error("Your avatar's host does not allow reading its colours."); } return paletteFromImage(data.data, 4, 1);}