1// The pixel pipeline.2//3// This is the platform gap the design record allows: a one-bit picture is a4// whole-image operation — error diffusion pays its error forward across the5// entire buffer — and there is no declarative way to say that. Everything that6// is a *decision* was made in Gleam and arrives in the frame; nothing here7// knows what a theme is, what a family is, or which rung is being played.8//9// The order of the passes is load-bearing and is the mockup's:10//11// 1. compose the scene in greyscale, at DENSITY buffer pixels per unit12// 2. threshold it — Atkinson diffusion, or a screen with true area coverage13// 3. reverse the goal block's mark, inside the sprite, before there is colour14// 4. reprint the tinted regions, splitting a comic's ink across two plates15// 5. paint the ground into every pixel nobody drew over16// 6. apply whichever struck inversion is in force17// 7. blit the carried sprites, then the type, at full device resolution1819import {20 Fill,21 Block,22 Label,23 Trophy,24 GoldLine,25 DevLogo,26 Exit,27 Clip,28 Unclip,29 Display,30 Center,31 End,32} from "./scene.mjs";33import { Ok } from "../gleam.mjs";3435// ---------------------------------------------------------------- elements3637let boardCanvas = null;38let boardCtx = null;39let typeCanvas = null;40let typeCtx = null;41let sceneCanvas = null;42let sceneCtx = null;43let outImage = null;44let outWidth = 0;45let outHeight = 0;4647// A context that exists only to measure type. Kept apart from the type canvas48// so a measurement never disturbs a transform mid-frame.49let ruler = null;5051function elements() {52 if (boardCanvas && document.body.contains(boardCanvas)) return true;53 boardCanvas = document.getElementById("board");54 typeCanvas = document.getElementById("type");55 if (!boardCanvas || !typeCanvas) return false;56 boardCtx = boardCanvas.getContext("2d");57 typeCtx = typeCanvas.getContext("2d");58 if (!sceneCanvas) {59 sceneCanvas = document.createElement("canvas");60 sceneCtx = sceneCanvas.getContext("2d", { willReadFrequently: true });61 ruler = document.createElement("canvas").getContext("2d");62 }63 return true;64}6566export function devicePixelRatio() {67 return window.devicePixelRatio || 1;68}6970export function measureBox() {71 const body = document.body;72 const style = getComputedStyle(body);73 const n = (value) => parseFloat(value) || 0;74 const x = n(style.paddingLeft);75 const y = n(style.paddingTop);76 return [77 x,78 y,79 Math.max(160, Math.round(body.clientWidth - x - n(style.paddingRight))),80 Math.max(160, Math.round(body.clientHeight - y - n(style.paddingBottom))),81 ];82}8384// ------------------------------------------------------------------ colour85//86// Unsigned, and it matters. `255 << 24` is negative in JS, and a value read87// back out of a Uint32Array never is — so a signed constant compares equal to88// nothing, and every "is this pixel ink?" test silently fails.8990function pack(rgb) {91 return ((255 << 24) | (rgb.b << 16) | (rgb.g << 8) | rgb.r) >>> 0;92}9394function css(rgb) {95 return `rgb(${rgb.r},${rgb.g},${rgb.b})`;96}9798function grey(value) {99 const v = value | 0;100 return `rgb(${v},${v},${v})`;101}102103function toArray(list) {104 return list instanceof Array ? list : Array.from(list);105}106107// ------------------------------------------------------------------ screens108//109// A screen is tiled at its ruling once and kept. A cell of 3 or 6 does not110// divide 8, so the scaled tile is 8*cell square and indexed by modulo rather111// than by a mask — which is why it is worth building once instead of computing112// two divisions per pixel.113114const tiles = new Map();115116function tiled(key, table, cell) {117 const cached = tiles.get(key);118 if (cached) return cached;119 const period = 8 * cell;120 const m = new Uint8Array(period * period);121 for (let y = 0; y < period; y++) {122 for (let x = 0; x < period; x++) {123 m[y * period + x] = table[(((y / cell) | 0) & 7) * 8 + (((x / cell) | 0) & 7)];124 }125 }126 const built = { period, m };127 tiles.set(key, built);128 return built;129}130131function screenOf(screen) {132 if (screen.diffuse) return null;133 return tiled(screen.key, toArray(screen.table), screen.cell);134}135136function overprintOf(screen) {137 const table = toArray(screen.overprint);138 if (!table.length) return null;139 return tiled(`${screen.key}|over`, table, screen.cell);140}141142// -------------------------------------------------------------------- area143//144// Choose where to cut a group of same-toned pixels so the inked proportion is145// as close to the tone as the screen's own levels allow.146//147// A block is shaded alone in its own sprite, so its fill samples whatever148// window of the tiled screen happens to lie under it. That is fine when the149// window is many periods across and quietly wrong when it is not: the realised150// coverage becomes a property of the slice rather than of the tone, and at the151// extreme a 1x1 piece under a coarse screen came out entirely bare paper and152// disappeared off the board.153//154// The fix is not to scale the screen to the block. A press does not print the155// small object at a finer ruling than the big one. Instead the coverage is made156// true by construction: pixels of a given tone are grouped, the group is cut at157// whichever threshold lands closest to the intended proportion of ink, and the158// cut always falls *between* threshold levels rather than through one — so a159// band stays a band and a dot stays round.160161function cutFor(levels, counts, total, targetInk) {162 let best = 256;163 let bestError = Infinity;164 let run = 0;165 for (let i = levels.length - 1; i >= 0; i--) {166 run += counts[i];167 const error = Math.abs(run / total - targetInk);168 if (error < bestError) {169 bestError = error;170 best = levels[i];171 }172 }173 // Nothing inked would lose the shape entirely; everything inked would lose174 // the tone. Both are worse than being one level out.175 if (targetInk > 0 && best === 256) best = levels[levels.length - 1];176 if (targetInk < 1 && best === levels[0] && levels.length > 1) best = levels[1];177 return best;178}179180const groups = new Map();181let diffusionBuffer = new Float32Array(0);182183/// Shade a greyscale source to two colours.184///185/// A source pixel with zero alpha is one nothing was drawn over. It is left as186/// zero and takes no part in diffusion — it neither receives error nor pays any187/// out, because it is not part of the picture. Ground is painted into those188/// holes afterwards. Packed colours always carry full alpha, so zero can never189/// collide with a real one.190///191/// `phaseX`/`phaseY` are where this buffer sits on the sheet, in real pixels. A192/// ruled screen is a pure function of position, so passing that offset in is193/// exactly equivalent to ruling one screen across the whole picture and cutting194/// this window out of it — the same thing a press does, and at no cost, because195/// the field never has to exist. Diffusion takes no offset and needs none: its196/// texture is aperiodic, so there is no phase for it to be out of.197///198/// The scene arrives here through `getImageData`, and a browser is allowed to199/// lie about what it reads back: Brave perturbs a scattering of channel values200/// by one, by default, so that a canvas cannot be used as a fingerprint. That is201/// invisible in a photograph and catastrophic in front of a threshold — a202/// transparent pixel whose alpha reads 1 stops being ground and becomes ink, and203/// paper that reads 254 stops being paper and gets dithered. It printed as204/// single white specks scattered over the whole picture, re-rolled on every205/// repaint, which is what a scroll or a moving block made visible.206///207/// So nothing is compared for equality. The scene only ever composes 0, 64, 128,208/// 192 and 255, and a band of eight is nowhere near any of them.209const CLEAR = 8;210const SOLID = 247;211212function shade(src, dst, w, h, screen, ink32, paper32, phaseX = 0, phaseY = 0) {213 if (screen.diffuse) return diffuse(src, dst, w, h, ink32, paper32);214215 const tile = screenOf(screen);216 const { period, m } = tile;217 const n = w * h;218 const ox = ((phaseX % period) + period) % period;219 const oy = ((phaseY % period) + period) % period;220 const at = (i) => m[((((i / w) | 0) + oy) % period) * period + (((i % w) + ox) % period)];221222 // Bucket by tone. The two extremes are the overwhelming majority of any frame223 // — the whole scene outside the block sprites is composed at 0 and 255 — and224 // they resolve without ever touching the screen.225 for (const group of groups.values()) group.length = 0;226 for (let i = 0; i < n; i++) {227 if (src[i] >>> 24 <= CLEAR) {228 dst[i] = 0;229 continue;230 }231 const value = src[i] & 0xff;232 if (value <= CLEAR) {233 dst[i] = ink32;234 continue;235 }236 if (value >= SOLID) {237 dst[i] = paper32;238 continue;239 }240 let group = groups.get(value);241 if (!group) groups.set(value, (group = []));242 group.push(i);243 }244245 for (const [tone, indices] of groups) {246 const total = indices.length;247 if (!total) continue;248249 const seen = new Map();250 for (let k = 0; k < total; k++) {251 const threshold = at(indices[k]);252 seen.set(threshold, (seen.get(threshold) || 0) + 1);253 }254 const levels = [...seen.keys()].sort((a, b) => a - b);255 const counts = levels.map((level) => seen.get(level));256 const cut = cutFor(levels, counts, total, 1 - tone / 255);257258 for (let k = 0; k < total; k++) {259 const i = indices[k];260 dst[i] = at(i) >= cut ? ink32 : paper32;261 }262 }263}264265// Only six eighths of the error is ever paid out. The missing quarter is the266// Atkinson signature: highlights run clean to white and shadows to solid black267// instead of being speckled, so the midtones carry all the texture.268function diffuse(src, dst, w, h, ink32, paper32) {269 const n = w * h;270 if (diffusionBuffer.length < n) diffusionBuffer = new Float32Array(n);271 const buf = diffusionBuffer;272 // Snapped to the extremes on the way in, for the reason `shade` gives above.273 // Diffusion is the path that cares most: a flat field of true 255 pays no274 // error forward at all, and one pixel of 254 in the middle of it starts a275 // trail of error that has to decay back out again.276 for (let i = 0; i < n; i++) {277 const value = src[i] & 0xff;278 buf[i] = value >= SOLID ? 255 : value <= CLEAR ? 0 : value;279 }280281 for (let y = 0; y < h; y++) {282 const row = y * w;283 const hasNext = y + 1 < h;284 const hasNext2 = y + 2 < h;285 for (let x = 0; x < w; x++) {286 const i = row + x;287 if (src[i] >>> 24 <= CLEAR) {288 dst[i] = 0;289 continue;290 }291 const old = buf[i];292 const on = old >= 128;293 dst[i] = on ? paper32 : ink32;294 const e = (old - (on ? 255 : 0)) / 8;295 if (e === 0) continue;296 if (x + 1 < w) buf[i + 1] += e;297 if (x + 2 < w) buf[i + 2] += e;298 if (hasNext) {299 if (x > 0) buf[i + w - 1] += e;300 buf[i + w] += e;301 if (x + 1 < w) buf[i + w + 1] += e;302 }303 if (hasNext2) buf[i + 2 * w] += e;304 }305 }306}307308// ------------------------------------------------------------------- ground309//310// Every pixel the threshold left as zero is one it was told to skip, which is311// to say one no screen drew over.312313function paintGround(dst, w, h, tile, dot32, field32) {314 for (let y = 0; y < h; y++) {315 const row = y * w;316 const tileRow = (y & 7) << 3;317 for (let x = 0; x < w; x++) {318 const i = row + x;319 if (dst[i] === 0) dst[i] = tile[tileRow + (x & 7)] ? dot32 : field32;320 }321 }322}323324// ------------------------------------------------------------------ sprites325//326// Every block is shaded on its own and then blitted.327//328// Diffusion is global by nature: error runs off one shape and into whatever is329// drawn to its right and below, so a block's texture depended on its330// neighbours and a 50% bar could develop a streaked patch where the error331// happened to pile up. Shading each block alone against bare paper makes its332// texture a pure function of its size, tone and colour — identical every time,333// in every position, and free after the first one because they cache.334//335// It also means nothing boils while a block slides, which is what this336// mechanism was originally built for.337338const sprites = new Map();339340export function clearSprites() {341 sprites.clear();342}343344function blockSprite(op, env) {345 const style = op.style;346 const screen = style.screen;347 const period = screen.diffuse ? 8 : 8 * screen.cell;348 const dpx = (n) => Math.round(n * env.density);349 const phaseX = ((dpx(op.rest_x) % period) + period) % period;350 const phaseY = ((dpx(op.rest_y) % period) + period) % period;351 const key =352 `${op.w}x${op.h}|${op.class}|${style.gap}|${style.drop}|` +353 `${style.thick ? 1 : 0}|${style.device ? 1 : 0}|${style.shadow ? 1 : 0}|` +354 `${style.tone}|${screen.key}|${phaseX},${phaseY}|${env.palette}`;355 const cached = sprites.get(key);356 if (cached) return cached;357358 const sw = op.w + style.drop + 2;359 const sh = op.h + style.drop + 2;360 const pw = dpx(sw);361 const ph = dpx(sh);362 const canvas = document.createElement("canvas");363 canvas.width = pw;364 canvas.height = ph;365 const ctx = canvas.getContext("2d", { willReadFrequently: true });366 ctx.setTransform(env.density, 0, 0, env.density, 0, 0);367368 const gap = style.gap;369 const drop = style.drop;370 const bx = gap;371 const by = gap;372 const bw = op.w - gap * 2;373 const bh = op.h - gap * 2;374375 // Bare paper under everything: scaffolding for the threshold, masked off376 // again below so a sprite's margin cannot blank its neighbour.377 ctx.fillStyle = grey(255);378 ctx.fillRect(0, 0, sw, sh);379380 // Flat, with a hard offset shadow. An extruded 2.5D side was tried and it381 // smears: the darkened faces of adjacent blocks merge into one muddy band382 // down the right of the board. A solid black drop, which is what QuickDraw383 // did, separates them at a glance and costs two pixels.384 if (style.shadow) {385 ctx.fillStyle = grey(0);386 ctx.fillRect(bx + drop, by + drop, bw, bh);387 }388 ctx.fillStyle = grey(style.tone);389 ctx.fillRect(bx, by, bw, bh);390391 // A plain ink outline: the fills are told apart by hue now, so the outline no392 // longer has to invert to keep the silhouette.393 ctx.fillStyle = grey(0);394 ctx.fillRect(bx, by, bw, 1);395 ctx.fillRect(bx, by + bh - 1, bw, 1);396 ctx.fillRect(bx, by, 1, bh);397 ctx.fillRect(bx + bw - 1, by, 1, bh);398 if (style.thick) {399 ctx.fillRect(bx + 1, by + 1, bw - 2, 1);400 ctx.fillRect(bx + 1, by + bh - 2, bw - 2, 1);401 ctx.fillRect(bx + 1, by + 1, 1, bh - 2);402 ctx.fillRect(bx + bw - 2, by + 1, 1, bh - 2);403 }404405 const image = ctx.getImageData(0, 0, pw, ph);406 const buf = new Uint32Array(image.data.buffer);407 shade(buf, buf, pw, ph, screen, env.ink32, env.paper32, phaseX, phaseY);408409 // The mark on the goal block, before there is a colour.410 //411 // It was a tone once — a denser patch laid down before the threshold and put412 // through the screen with everything else. That is a lovely idea and it is at413 // the mercy of the screen it is made of: it needs room inside itself for a414 // density to differ, so it thinned out at the coarse rulings, and under a415 // flat threshold it could not appear at all.416 //417 // So the disc inverts the picture instead of contributing to it. Every pixel418 // inside it is swapped, ink for paper, after the threshold and before the419 // colour — so the mark is exactly the negative of whatever screen the rung420 // drew, in register with it, and the tint pass that follows reprints the421 // reversed dots for free. An inversion cannot clash with the pattern, for the422 // same reason the old inlay could not: it is made of it. But it also cannot423 // run out of room.424 if (style.device) {425 const cx = dpx(bx + bw / 2);426 const cy = dpx(by + bh / 2);427 const r = dpx(Math.max(3, Math.round(Math.min(bw, bh) * 0.26)));428 // Plotted span by span: a filled arc is anti-aliased, and half-covered429 // pixels are not a thing a one-bit picture has anywhere to put.430 for (let dy = -r; dy <= r; dy++) {431 const y = cy + dy;432 if (y < 0 || y >= ph) continue;433 const half = Math.floor(Math.sqrt(r * r - dy * dy));434 const x0 = Math.max(0, cx - half);435 const x1 = Math.min(pw - 1, cx + half);436 const row = y * pw;437 for (let x = x0; x <= x1; x++) {438 const pixel = buf[row + x];439 if (pixel === env.ink32) buf[row + x] = env.paper32;440 else if (pixel === env.paper32) buf[row + x] = env.ink32;441 }442 }443 }444445 // The block's own colour, reprinted over the ink. A tint may carry a second446 // plate: a colour comic was never one ink, each was screened separately and447 // printed at its own angle, and the colour you saw was two of them448 // interleaved and very slightly out of register.449 if (style.ink instanceof Ok) {450 const colour = pack(style.ink[0]);451 const second = style.plate instanceof Ok ? pack(style.plate[0]) : 0;452 const over = second ? overprintOf(screen) : null;453 const x0 = dpx(bx + 1);454 const x1 = Math.min(pw, dpx(bx + bw - 1));455 const y0 = dpx(by + 1);456 const y1 = Math.min(ph, dpx(by + bh - 1));457 const ox = over ? ((phaseX % over.period) + over.period) % over.period : 0;458 const oy = over ? ((phaseY % over.period) + over.period) % over.period : 0;459 for (let y = y0; y < y1; y++) {460 const row = y * pw;461 const sr = over ? ((y + oy) % over.period) * over.period : 0;462 for (let x = x0; x < x1; x++) {463 if (buf[row + x] !== env.ink32) continue;464 buf[row + x] = over465 ? over.m[sr + ((x + ox) % over.period)] >= 128466 ? second467 : colour468 : colour;469 }470 }471 }472473 // Keep only the block and its shadow; the rest was scaffolding and would474 // otherwise blank a neighbour.475 const mbw = dpx(bw);476 const mbh = dpx(bh);477 const mg = dpx(gap);478 const md = dpx(drop);479 for (let y = 0; y < ph; y++) {480 for (let x = 0; x < pw; x++) {481 const inBlock = x >= mg && x < mg + mbw && y >= mg && y < mg + mbh;482 const inShadow =483 style.shadow &&484 x >= mg + md &&485 x < mg + md + mbw &&486 y >= mg + md &&487 y < mg + md + mbh;488 if (!inBlock && !inShadow) buf[y * pw + x] &= 0x00ffffff;489 }490 }491492 ctx.setTransform(1, 0, 0, 1, 0, 0);493 ctx.putImageData(image, 0, 0);494 const sprite = { canvas, w: sw, h: sh };495 sprites.set(key, sprite);496 return sprite;497}498499// --------------------------------------------------------------------- type500501function faceName(face) {502 return face instanceof Display ? "Tiny5" : "DMMono";503}504505function measure(ctx, text, size, face, track) {506 ctx.font = `${size}px ${face}`;507 let width = -track;508 for (const ch of text) width += ctx.measureText(ch).width + track;509 return width;510}511512function fitSize(text, size, face, track, maxWidth) {513 let s = Math.max(2, Math.round(size / 2) * 2);514 if (!maxWidth) return s;515 while (s > 4 && measure(ruler, text, s, face, track) > maxWidth) s -= 2;516 return s;517}518519function paintTracked(ctx, text, x, y, track, align) {520 let width = -track;521 for (const ch of text) width += ctx.measureText(ch).width + track;522 let cx = align === "center" ? Math.round(x - width / 2) : align === "right" ? Math.round(x - width) : x;523 for (const ch of text) {524 ctx.fillText(ch, cx, y);525 cx += ctx.measureText(ch).width + track;526 }527}528529function withClip(ctx, clip, scale, paint) {530 if (!clip) {531 paint();532 return;533 }534 ctx.save();535 ctx.beginPath();536 ctx.rect(clip.x * scale, clip.y * scale, clip.w * scale, clip.h * scale);537 ctx.clip();538 paint();539 ctx.restore();540}541542// ------------------------------------------------------------------- marks543544function paintGoldLine(ctx, decal, gold) {545 ctx.save();546 ctx.strokeStyle = gold;547 ctx.lineWidth = 1.2;548 ctx.setLineDash([3, 2]);549 ctx.beginPath();550 ctx.moveTo(decal.x1, decal.y1);551 ctx.lineTo(decal.x2, decal.y2);552 ctx.stroke();553 ctx.restore();554}555556function paintTrophy(ctx, cx, cy, s, inkCss, gold) {557 const w = s;558 const h = s * 1.1;559 ctx.lineJoin = "round";560 ctx.strokeStyle = inkCss;561 ctx.fillStyle = gold;562 ctx.lineWidth = Math.max(0.7, s * 0.09);563564 for (const side of [-1, 1]) {565 ctx.beginPath();566 ctx.moveTo(cx + side * w * 0.36, cy - h * 0.34);567 ctx.quadraticCurveTo(cx + side * w * 0.72, cy - h * 0.22, cx + side * w * 0.34, cy - h * 0.02);568 ctx.stroke();569 }570 ctx.beginPath();571 ctx.moveTo(cx - w * 0.4, cy - h * 0.44);572 ctx.lineTo(cx + w * 0.4, cy - h * 0.44);573 ctx.lineTo(cx + w * 0.28, cy + h * 0.04);574 ctx.quadraticCurveTo(cx, cy + h * 0.3, cx - w * 0.28, cy + h * 0.04);575 ctx.closePath();576 ctx.fill();577 ctx.stroke();578579 ctx.beginPath();580 ctx.rect(cx - w * 0.09, cy + h * 0.16, w * 0.18, h * 0.2);581 ctx.fill();582 ctx.stroke();583 ctx.beginPath();584 ctx.rect(cx - w * 0.34, cy + h * 0.34, w * 0.68, h * 0.14);585 ctx.fill();586 ctx.stroke();587}588589// A delta in a ring, drawn from geometry rather than carried as an asset: it is590// a circle and three lines, and an SVG would have to be rasterised and591// recoloured per theme to end up here anyway. The numbers are the seed592// drawing's, transcribed rather than eyeballed — a 24-unit box with a 9-radius593// circle stroked at 2, so every coordinate is that drawing divided by its own594// outer diameter of 20 and multiplied back up by the ring's.595//596// The delta is stroked and not filled, which is the whole character of the597// mark. At this weight on a shape this size the outline very nearly fills598// itself, so what is left of the middle is a slot rather than a hole — and the599// short inner stroke, the one that makes it a delta rather than a triangle,600// cuts that slot off-centre.601// The arrowhead under the aperture, drawn as a stack of whole-unit runs rather602// than as a polygon. Everything else on this board is made of square pixels, and603// a smooth hypotenuse beside them reads as a picture from a different machine.604function paintExit(ctx, decal) {605 ctx.fillStyle = css(decal.ink);606 const rows = (decal.w + 1) / 2;607 for (let i = 0; i < rows; i++) {608 ctx.fillRect(decal.x + i, decal.y + i, decal.w - 2 * i, 1);609 }610}611612function paintDevLogo(ctx, decal, inkCss) {613 const r = decal.size / 2;614 ctx.save();615 // Load-bearing now that the delta is a stroke: three heavy segments meeting616 // at sharp angles are a spike at each corner under a mitre.617 ctx.lineJoin = "round";618 ctx.lineCap = "round";619 ctx.lineWidth = Math.max(1, decal.size * 0.1);620621 ctx.strokeStyle = inkCss;622 ctx.beginPath();623 ctx.arc(decal.x, decal.y, r - ctx.lineWidth / 2, 0, Math.PI * 2);624 ctx.stroke();625626 ctx.strokeStyle = css(decal.ink);627 ctx.beginPath();628 ctx.moveTo(decal.x, decal.y - r * 0.5);629 ctx.lineTo(decal.x + r * 0.4, decal.y + r * 0.3);630 ctx.lineTo(decal.x - r * 0.4, decal.y + r * 0.3);631 ctx.closePath();632 ctx.stroke();633634 ctx.beginPath();635 ctx.moveTo(decal.x + r * 0.3, decal.y + r * 0.3);636 ctx.lineTo(decal.x - r * 0.05, decal.y - r * 0.4);637 ctx.stroke();638 ctx.restore();639}640641// ------------------------------------------------------------------- frame642643let lastGeometry = "";644645function resize(frame) {646 const density = frame.density;647 const pw = Math.round(frame.vw * density);648 const ph = Math.round(frame.vh * density);649 const typeScale = frame.scale * devicePixelRatio();650 const geometry = `${pw}x${ph}|${frame.scale}|${frame.off_x},${frame.off_y}`;651 if (geometry === lastGeometry) return;652 lastGeometry = geometry;653654 boardCanvas.width = pw;655 boardCanvas.height = ph;656 boardCanvas.style.width = `${frame.vw * frame.scale}px`;657 boardCanvas.style.height = `${frame.vh * frame.scale}px`;658 sceneCanvas.width = pw;659 sceneCanvas.height = ph;660661 // Type is not shaded and is not upscaled from the buffer, so it wants the662 // device grid itself rather than a capped multiple of the upscale.663 typeCanvas.width = Math.round(frame.vw * typeScale);664 typeCanvas.height = Math.round(frame.vh * typeScale);665 typeCanvas.style.width = `${frame.vw * frame.scale}px`;666 typeCanvas.style.height = `${frame.vh * frame.scale}px`;667668 // The stage's own box and the canvases' CSS size belong to the view: they are669 // laid out from the same numbers this frame was composed at, and two writers670 // for one fact is how they end up disagreeing. What is owned here is the671 // backing store, which no declarative attribute can set without clearing the672 // picture every render.673 outImage = boardCtx.createImageData(pw, ph);674 outWidth = pw;675 outHeight = ph;676 sprites.clear();677}678679// ------------------------------------------------------------------- input680//681// A tap arrives in CSS pixels and the picture is drawn in layout units, so the682// conversion is the upscale the last frame was painted at. Reading it off the683// canvas's own box rather than off a remembered number means a resize that has684// not yet produced a frame still lands in the right place.685686let lastFrameWidth = 0;687688function toVirtual(event) {689 const rect = boardCanvas.getBoundingClientRect();690 const scale = rect.width > 0 && lastFrameWidth > 0 ? rect.width / lastFrameWidth : 1;691 return [(event.clientX - rect.left) / scale, (event.clientY - rect.top) / scale];692}693694export function watchPointer(dispatch, down, move, up, cancel) {695 // The canvas is the view's to create, so this waits for it rather than696 // assuming a mount order.697 const attach = () => {698 if (!elements()) {699 requestAnimationFrame(attach);700 return;701 }702 boardCanvas.addEventListener("pointerdown", (event) => {703 // Capture on the way down, so a drag that leaves the canvas is still a704 // drag rather than a released block.705 boardCanvas.setPointerCapture(event.pointerId);706 const [x, y] = toVirtual(event);707 dispatch(down(x, y));708 });709 boardCanvas.addEventListener("pointermove", (event) => {710 const [x, y] = toVirtual(event);711 dispatch(move(x, y));712 });713 boardCanvas.addEventListener("pointerup", (event) => {714 const [x, y] = toVirtual(event);715 dispatch(up(x, y));716 });717 boardCanvas.addEventListener("pointercancel", () => dispatch(cancel));718 };719 attach();720}721722export function paint(frame) {723 if (!elements()) return;724 lastFrameWidth = frame.vw;725 resize(frame);726727 const density = frame.density;728 const ink32 = pack(frame.ink);729 const paper32 = pack(frame.paper);730 const inkCss = css(frame.ink);731 const paperCss = css(frame.paper);732 const goldCss = css(frame.gold);733 const dot32 = pack(frame.dot);734 const field32 = pack(frame.field);735 const env = {736 density,737 ink32,738 paper32,739 palette: `${inkCss}|${paperCss}`,740 };741742 // Every draw call below is in layout units and arrives through this, which is743 // the whole of what density costs the drawing code.744 sceneCtx.setTransform(density, 0, 0, density, 0, 0);745 // What nobody paints over becomes ground.746 sceneCtx.clearRect(0, 0, frame.vw, frame.vh);747748 const blits = [];749 const overlays = [];750 let clip = null;751752 for (const op of frame.ops) {753 if (op instanceof Fill) {754 sceneCtx.fillStyle = grey(op.tone);755 sceneCtx.fillRect(op.x, op.y, op.w, op.h);756 } else if (op instanceof Block) {757 blits.push({ sprite: blockSprite(op, env), x: op.x, y: op.y, clip });758 } else if (op instanceof Clip) {759 clip = op.rect;760 } else if (op instanceof Unclip) {761 clip = null;762 } else {763 overlays.push({ op, clip });764 }765 }766767 const src = new Uint32Array(768 sceneCtx.getImageData(0, 0, outWidth, outHeight).data.buffer,769 );770 const dst = new Uint32Array(outImage.data.buffer);771 shade(src, dst, outWidth, outHeight, frame.screen, ink32, paper32);772 paintGround(dst, outWidth, outHeight, toArray(frame.tile), dot32, field32);773774 // Struck inversions are applied after the threshold. They never coincide with775 // a drag, so the carried sprites do not have to follow them. The ground776 // inverts on its own pair rather than on ink and paper — left out of it, the777 // backdrop sat perfectly still while the wipe crossed it, which read as the778 // board flipping inside a window rather than the screen turning over.779 if (frame.flash || frame.wipe >= 0) {780 const edge = Math.round(frame.wipe * density);781 for (let y = 0; y < outHeight; y++) {782 const row = y * outWidth;783 for (let x = 0; x < outWidth; x++) {784 if (!frame.flash && !(frame.wipe >= 0 && x < edge)) continue;785 const i = row + x;786 const v = dst[i];787 // Piece colour is left alone.788 if (v === paper32) dst[i] = ink32;789 else if (v === ink32) dst[i] = paper32;790 else if (v === field32) dst[i] = dot32;791 else if (v === dot32) dst[i] = field32;792 }793 }794 }795796 boardCtx.putImageData(outImage, 0, 0);797798 // The sprites are already density-sized, so they blit at density coordinates.799 for (const blit of blits) {800 withClip(boardCtx, blit.clip, density, () =>801 boardCtx.drawImage(802 blit.sprite.canvas,803 Math.round(blit.x * density),804 Math.round(blit.y * density),805 ),806 );807 }808809 paintOverlays(frame, overlays, inkCss, paperCss, goldCss);810}811812function paintOverlays(frame, overlays, inkCss, paperCss, goldCss) {813 const typeScale = frame.scale * devicePixelRatio();814 typeCtx.setTransform(1, 0, 0, 1, 0, 0);815 typeCtx.clearRect(0, 0, typeCanvas.width, typeCanvas.height);816 typeCtx.setTransform(typeScale, 0, 0, typeScale, 0, 0);817 typeCtx.textBaseline = "alphabetic";818819 for (const { op, clip } of overlays) {820 if (op instanceof Label) {821 const face = faceName(op.face);822 const size = fitSize(op.text, op.size, face, op.track, op.max_w);823 typeCtx.font = `${size}px ${face}`;824 typeCtx.globalAlpha = op.alpha;825 // A struck inversion has to take the type with it or the screen tears.826 typeCtx.fillStyle = frame.flash ? paperCss : inkCss;827 const align =828 op.align instanceof Center ? "center" : op.align instanceof End ? "right" : "left";829 withClip(typeCtx, clip, 1, () =>830 paintTracked(typeCtx, op.text, op.x, op.y, op.track, align),831 );832 typeCtx.globalAlpha = 1;833 } else if (op instanceof Trophy) {834 withClip(typeCtx, clip, 1, () => paintTrophy(typeCtx, op.x, op.y, op.size, inkCss, goldCss));835 } else if (op instanceof GoldLine) {836 withClip(typeCtx, clip, 1, () => paintGoldLine(typeCtx, op, goldCss));837 } else if (op instanceof DevLogo) {838 withClip(typeCtx, clip, 1, () => paintDevLogo(typeCtx, op, inkCss));839 } else if (op instanceof Exit) {840 withClip(typeCtx, clip, 1, () => paintExit(typeCtx, op));841 }842 }843 typeCtx.globalAlpha = 1;844}