clone

an offline sliding-block puzzle game in gleam and lustre, with an endless ladder of puzzles ranked by search

EXPERIMENT: compose the picture at 3x density

Dami committed cede175
The app has always been measured in a ~200-wide space, which on a 1984
Macintosh was the screen and on a 2026 phone is a decision -- it is why a
dot of the dither lands as a visible square rather than as grain. DENSITY
composes the same picture on a finer grid, so every screen resolves at
three times the dots per inch while the layout, the type and the touch
targets stay exactly where they were.

It is a transform, not a rewrite. Nothing that measures anything changes
units; the scene canvas is scaled and every drawing call arrives through
it, so the ~40 hard-coded offsets in the layout code keep meaning what they
meant. Only the places that touch real pixels know the number: the backing
stores, getImageData, the sprite blits, the tint rectangles and the wipe.

The rulings are deliberately not scaled with it. That is the experiment --
FINE at 3 is a third the physical dot it was. Scaling them alongside would
reproduce today's look at nine times the memory and no gain.

Set DENSITY back to 1 for the shipped look.
commit
cede1751399e6ef53b3b514cc324c5c8a237441b
parent
98a211636d86ab21331c23359edc4aa950e4cd2c
author
Dami <dami.etoile@gmail.com>
3 files changed, +78 -29
Mmockup/src/main.js4+-
Mmockup/src/render.js101++++++++++++++++++++++--------
Mmockup/sw.js2+-
M mockup/src/main.js +2 -2
M mockup/src/render.js +75 -26
@@ -52,6 +52,35 @@ const TONE_OF = [0, T.single, T.upright, T.recumbent, T.goal];
5252 let PIECE32 = [], PLATE32 = [];5353 export const PERIOD = 8; // slides are quantised to this5454 55+/* ------------------------------------------------------------------ density56+ *57+ * EXPERIMENT. Set back to 1 for the shipped look.58+ *59+ * How many buffer pixels there are per unit of the layout. The whole app is60+ * measured in a ~200-wide coordinate space and always has been, which on a 198461+ * Macintosh was the screen and on a 2026 phone is a decision: it is why a dot of62+ * the dither lands as a visible square rather than as grain. Raising this63+ * composes the same picture on a finer grid, so every screen resolves at this64+ * many times the dots per inch while the layout, the type and the touch targets65+ * stay exactly where they were.66+ *67+ * It is a transform, not a rewrite. Nothing that measures anything changes68+ * units; the scene canvas is scaled by this factor and every drawing call69+ * arrives through it, so the ~40 hard-coded offsets scattered through the70+ * layout code keep meaning what they meant. Only the handful of places that71+ * touch a canvas's real pixels -- the backing stores, getImageData, the sprite72+ * blits and the tint rectangles -- have to know the number at all.73+ *74+ * An integer, and it has to be: every rule in this app is one unit wide, and a75+ * fractional scale would put those on half pixels and hand the whole 1-bit76+ * picture back to the antialiaser.77+ *78+ * The rulings are deliberately *not* scaled with it. That is the experiment --79+ * FINE at 3 is a third the physical dot it was, which is the point. Scaling80+ * them alongside would reproduce today's look at more memory and no gain. */81+export const DENSITY = 3;82+const dpx = n => Math.round(n * DENSITY);83+5584 /* ------------------------------------------------------------------ canvas */5685 const cvs = document.getElementById('board'); // the dithered picture5786 const octx = cvs.getContext('2d');
@@ -163,10 +192,15 @@ function pack(rgb) { return (((255 << 24) | (rgb[2] << 16) | (rgb[1] << 8) | rgb
163192 * with a `screen` splits its ink between two plates on a rotated screen, which164193 * is that, done with the one pass the pipeline already had. */165194 const tints = [];195+/* Rectangles arrive in layout units, because that is what the drawing code196+ * speaks; this indexes the buffer, which is in real pixels. Everything below197+ * that crosses the two has to be converted, and the screens are read in real198+ * pixels too so an overprint stays in register with the shading under it. */199+const scaleRect = r => ({ x: dpx(r.x), y: dpx(r.y), w: dpx(r.w), h: dpx(r.h) });166200 function applyTints(dst, w, h) {167201 for (const t of tints) {168- const x0 = Math.max(0, Math.round(t.x)), x1 = Math.min(w, Math.round(t.x + t.w));169- const y0 = Math.max(0, Math.round(t.y)), y1 = Math.min(h, Math.round(t.y + t.h));202+ const x0 = Math.max(0, dpx(t.x)), x1 = Math.min(w, dpx(t.x + t.w));203+ const y0 = Math.max(0, dpx(t.y)), y1 = Math.min(h, dpx(t.y + t.h));170204 const s = t.screen;171205 for (let y = y0; y < y1; y++) {172206 const row = y * w;
@@ -225,9 +259,12 @@ export function layout() {
225259 offY: Math.round((ch - VH * S) / 2),226260 });227261 228- cvs.width = VW; cvs.height = VH;262+ // The backing stores carry the density; the CSS size does not, so the263+ // picture occupies exactly the space it always did and simply has more264+ // pixels in it.265+ cvs.width = dpx(VW); cvs.height = dpx(VH);229266 cvs.style.width = `${VW * S}px`; cvs.style.height = `${VH * S}px`;230- scene.width = VW; scene.height = VH;267+ scene.width = dpx(VW); scene.height = dpx(VH);231268 232269 typeScale = S * Math.min(3, window.devicePixelRatio || 1);233270 typeCvs.width = Math.round(VW * typeScale);
@@ -239,7 +276,7 @@ export function layout() {
239276 stage.width = `${VW * S}px`; stage.height = `${VH * S}px`;240277 document.documentElement.style.setProperty('--s', S);241278 242- outImg = octx.createImageData(VW, VH);279+ outImg = octx.createImageData(dpx(VW), dpx(VH));243280 sprites.clear();244281 245282 const barH = Math.max(14, Math.round(VH * 0.048));
@@ -542,9 +579,11 @@ function blockSprite(w, h, cls, opt) {
542579 if (hit) return hit;543580 544581 const sw = w + drop + 2, sh = h + drop + 2;582+ const pw = dpx(sw), ph = dpx(sh);545583 const cnv = document.createElement('canvas');546- cnv.width = sw; cnv.height = sh;584+ cnv.width = pw; cnv.height = ph;547585 const cx = cnv.getContext('2d', { willReadFrequently: true });586+ cx.setTransform(DENSITY, 0, 0, DENSITY, 0, 0);548587 549588 const outer = tints.splice(0); // the sprite has its own tint space550589 fill(cx, 0, 0, sw, sh, T.tray);
@@ -552,23 +591,26 @@ function blockSprite(w, h, cls, opt) {
552591 // asked for in another screen -- the atlas previews each row in its own --553592 // gets that screen's overprint too and not the frame's.554593 drawBlock(cx, 0, 0, w, h, cls, { ...opt, shading, ruling });555- const img = cx.getImageData(0, 0, sw, sh);594+ const img = cx.getImageData(0, 0, pw, ph);556595 const buf = new Uint32Array(img.data.buffer);557- shade(buf, buf, sw, sh, shading, INK32, PAPER32, ruling);558- applyTints(buf, sw, sh);596+ shade(buf, buf, pw, ph, shading, INK32, PAPER32, ruling);597+ applyTints(buf, pw, ph);559598 tints.length = 0; tints.push(...outer);560599 561600 // Keep only the block and its shadow; the rest was scaffolding for the562601 // threshold and would otherwise blank a neighbour.563- const bw = w - gap * 2, bh = h - gap * 2;564- for (let y = 0; y < sh; y++) {565- for (let x = 0; x < sw; x++) {566- const inBlock = x >= gap && x < gap + bw && y >= gap && y < gap + bh;567- const inShadow = x >= gap + drop && x < gap + drop + bw568- && y >= gap + drop && y < gap + drop + bh;569- if (!inBlock && !inShadow) buf[y * sw + x] &= 0x00FFFFFF; // clear alpha602+ // In real pixels, since it is masking the buffer rather than drawing.603+ const bw = dpx(w - gap * 2), bh = dpx(h - gap * 2);604+ const g = dpx(gap), d = dpx(drop);605+ for (let y = 0; y < ph; y++) {606+ for (let x = 0; x < pw; x++) {607+ const inBlock = x >= g && x < g + bw && y >= g && y < g + bh;608+ const inShadow = x >= g + d && x < g + d + bw609+ && y >= g + d && y < g + d + bh;610+ if (!inBlock && !inShadow) buf[y * pw + x] &= 0x00FFFFFF; // clear alpha570611 }571612 }613+ cx.setTransform(1, 0, 0, 1, 0, 0);572614 cx.putImageData(img, 0, 0);573615 574616 const sprite = { canvas: cnv, w: sw, h: sh };
@@ -706,7 +748,9 @@ export function renderFrame() {
706748 curField32 = pack(combo.ground.field);707749 syncGroundCss(combo.ground.field);708750 709- sctx.setTransform(1, 0, 0, 1, 0, 0);751+ // Every draw call below is in layout units and arrives through this, which752+ // is the whole of what density costs the drawing code.753+ sctx.setTransform(DENSITY, 0, 0, DENSITY, 0, 0);710754 sctx.clearRect(0, 0, VW, VH); // what nobody paints over becomes ground711755 712756 switch (App.screen) {
@@ -719,11 +763,12 @@ export function renderFrame() {
719763 case 'stats': drawStats(sctx); break;720764 }721765 722- const src = new Uint32Array(sctx.getImageData(0, 0, VW, VH).data.buffer);766+ const PW = dpx(VW), PH = dpx(VH); // the picture's real pixels767+ const src = new Uint32Array(sctx.getImageData(0, 0, PW, PH).data.buffer);723768 const dst = new Uint32Array(outImg.data.buffer);724- shade(src, dst, VW, VH, curShading, INK32, PAPER32, curRuling);725- applyTints(dst, VW, VH);726- paintGround(dst, VW, VH, curTile, curDot32, curField32);769+ shade(src, dst, PW, PH, curShading, INK32, PAPER32, curRuling);770+ applyTints(dst, PW, PH);771+ paintGround(dst, PW, PH, curTile, curDot32, curField32);727772 728773 // Struck inversions are applied after the threshold. They never coincide729774 // with a drag, so the carried sprite does not have to follow them. The
@@ -731,10 +776,10 @@ export function renderFrame() {
731776 // it, the backdrop sat perfectly still while the wipe crossed it, which read732777 // as the board flipping inside a window rather than the screen turning over.733778 if (fx.flash > 0 || fx.wipe >= 0) {734- for (let y = 0; y < VH; y++) {735- const row = y * VW;736- for (let x = 0; x < VW; x++) {737- const past = fx.wipe >= 0 && (fx.wipeAxis === 'x' ? x < fx.wipe : y < fx.wipe);779+ for (let y = 0; y < PH; y++) {780+ const row = y * PW;781+ for (let x = 0; x < PW; x++) {782+ const past = fx.wipe >= 0 && (fx.wipeAxis === 'x' ? x < dpx(fx.wipe) : y < dpx(fx.wipe));738783 if (fx.flash > 0 || past) {739784 const i = row + x; // piece colour is left alone740785 const v = dst[i];
@@ -747,7 +792,11 @@ export function renderFrame() {
747792 }748793 }749794 octx.putImageData(outImg, 0, 0);750- for (const b of blits) withClip(octx, b.clip, () => octx.drawImage(b.sprite.canvas, b.x, b.y));795+ // The sprites are already density-sized, so they blit at density coordinates.796+ for (const b of blits) {797+ withClip(octx, b.clip && scaleRect(b.clip), () =>798+ octx.drawImage(b.sprite.canvas, dpx(b.x), dpx(b.y)));799+ }751800 drawType();752801 }753802
M mockup/sw.js +1 -1