/** * 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(); const backgrounds = new Map(); const strip = el("div", { className: "camo-fleet" }); const stripCanvases = new Map(); const bgRow = el("div", { className: "camo-backgrounds" }); const bgCanvases = new Map(); // 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(); 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(); 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 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