clone

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

One screen ruled across the picture, not one per block

Dami committed ff953b0
Dithering each block alone is right about the texture and was wrong
about the phase. Every sprite indexed its screen from its own corner, so
whether two identical blocks a cell apart agreed came down to the
screen's period dividing the cell in buffer pixels -- which is
arithmetic luck. At a 32-unit cell that is 96 and every period divides
it, which is why this looked fine on the phone it was reviewed on. At 24
units it is 72: POSTER lands eight pixels out and BILLBOARD twenty-four,
half a period, on any 360-wide screen.

The fix is not a bigger buffer. A ruled screen is a pure function of
position, so passing a window its own offset on the sheet is exactly
equivalent to ruling one screen across the whole picture and cutting
each block out of it -- and the field never has to exist. Diffusion
takes no offset and needs none: its texture is aperiodic, so there is no
phase for it to be out of. The sprite cache gains the phase as a key,
and because a cell is a whole number of periods that is one phase per
class rather than one per square. Cold frames went 6.2ms to 4.4ms at the
coarse end; warm frames are unchanged, because warm frames never built a
sprite.

The whole-number part is now guaranteed rather than hoped for.
fitRuling already stepped a ruling down when a block was too small to
carry it, which is the same kind of rule; it steps down until the period
divides the cell as well. A 32-unit cell keeps the whole ladder and a
24-unit cell stops at COARSE -- the coarse end unavailable there rather
than available and out of register, which is the better failure. A
screen that does not hold register is not a coarser screen, it is a
misprint. Previews pass no grid and are unconstrained: nothing on an
atlas row moves.

And the block is cut at the phase of the square it *rests* on, never the
one it is being dragged through. The ink belongs to the block, so a
piece under a thumb carries its own across the board; a move is one
cell, which is a whole number of periods, so it lands on the pattern it
left with nothing to snap. Taking the phase from the animated position
instead would have made the ink a function of how the piece got there,
which is the same thing as making it depend on whether it was animated
at all.
commit
ff953b0dd805da72b95c137b4052f9cec229c474
parent
a69b1798f115ce5864e0115698381f671b700806
author
Dami <dami.etoile@gmail.com>
2 files changed, +111 -23
Mmockup/src/render.js84++++++++++++++++++++++++------
Mmockup/src/shading.js50++++++++++++++++--
M mockup/src/render.js +67 -17
@@ -28,8 +28,11 @@ import { FAMILIES } from './families.js';
2828 import { grade } from './difficulty.js';2929 import { famStats } from './store.js';3030 import { depthForRung, levelProgress, atCeiling, puzzleFor } from './progress.js';31-import { shade, effectiveRuling, fitRuling, PATTERNS, screenOf } from './shading.js';32-import { THEMES, themeById, comboFor, houseCombo, logoInk, dayOfYear } from './theme.js';31+import { shade, effectiveRuling, fitRuling, PATTERNS, RULINGS, screenOf } from './shading.js';32+import {33+ THEMES, themeById, comboFor, houseCombo, logoInk, dayOfYear, resolveTheme,34+} from './theme.js';35+import { prefersDark, prefersStill } from './prefs.js';3336 import { GROUND_TILES, paintGround } from './ground.js';3437 3538 /* Tones are coverage of paper, 0..255.
@@ -174,7 +177,8 @@ export function resolveCombo() {
174177 // house lattice snapped the backdrop the instant the puzzle was solved, which175178 // read as the reward taking the world away.176179 const onBoard = BOARD_SCREENS.has(App.screen) && App.fam;177- const c = onBoard ? comboFor(save.palette, App.fam.id, App.rung) : houseCombo(save.palette);180+ const pal = activePalette();181+ const c = onBoard ? comboFor(pal, App.fam.id, App.rung) : houseCombo(pal);178182 return applyPins(c);179183 }180184
@@ -189,7 +193,7 @@ function applyPins(c) {
189193 190194 /** The look a given rung will be played at -- what the atlas previews. */191195 export function lookFor(famId, rung) {192- return applyPins(comboFor(save.palette, famId, rung));196+ return applyPins(comboFor(activePalette(), famId, rung));193197 }194198 195199 /* The picture buffer is upscaled by a whole number, so on anything wider than a
@@ -233,17 +237,23 @@ const tints = [];
233237 * that crosses the two has to be converted, and the screens are read in real234238 * pixels too so an overprint stays in register with the shading under it. */235239 const scaleRect = r => ({ x: dpx(r.x), y: dpx(r.y), w: dpx(r.w), h: dpx(r.h) });236-function applyTints(dst, w, h) {240+/* `phaseX/phaseY` are this buffer's position on the sheet, exactly as they are241+ * for the threshold -- the overprint is a second screen at a rotated angle and242+ * has to stay in register with the first one, so it is offset by the same243+ * amount or a comic block prints its two plates against each other. */244+function applyTints(dst, w, h, phaseX = 0, phaseY = 0) {237245 for (const t of tints) {238246 const x0 = Math.max(0, dpx(t.x)), x1 = Math.min(w, dpx(t.x + t.w));239247 const y0 = Math.max(0, dpx(t.y)), y1 = Math.min(h, dpx(t.y + t.h));240248 const s = t.screen;249+ const ox = s ? ((phaseX % s.period) + s.period) % s.period : 0;250+ const oy = s ? ((phaseY % s.period) + s.period) % s.period : 0;241251 for (let y = y0; y < y1; y++) {242252 const row = y * w;243- const sr = s ? (y % s.period) * s.period : 0;253+ const sr = s ? ((y + oy) % s.period) * s.period : 0;244254 for (let x = x0; x < x1; x++) {245255 if (dst[row + x] !== INK32) continue;246- dst[row + x] = s ? (s.m[sr + (x % s.period)] >= 128 ? t.c2 : t.c) : t.c;256+ dst[row + x] = s ? (s.m[sr + ((x + ox) % s.period)] >= 128 ? t.c2 : t.c) : t.c;247257 }248258 }249259 }
@@ -296,9 +306,19 @@ const plateTwo = rgb => {
296306 return rgb.map(v => (v === mx ? 255 : Math.round(v * 0.55)));297307 };298308 309+/* The theme actually in force.310+ *311+ * AUTO is stored as AUTO and resolved here rather than being written down as312+ * whichever theme the system wanted at the moment it was chosen -- otherwise a313+ * phone that goes dark at sunset finds the app remembered a decision the person314+ * never made. Everything downstream asks for this rather than for save.palette,315+ * including the seeded look: the pools belong to the theme, and AUTO is not316+ * one. */317+export const activePalette = () => resolveTheme(save.palette, prefersDark());318+299319 export function setTheme(id) {300- const t = themeById(id); // falls back rather than throwing301- save.palette = THEMES[id] ? id : 'macintosh';320+ save.palette = id === 'auto' || THEMES[id] ? id : 'macintosh';321+ const t = themeById(activePalette()); // falls back rather than throwing302322 INK32 = pack(t.ink); PAPER32 = pack(t.paper);303323 PIECE32 = t.pieces.map(c => (c ? pack(c) : 0));304324 PLATE32 = t.pieces.map(c => (c ? pack(plateTwo(c)) : 0));
@@ -873,14 +893,34 @@ function drawBlock(c, x, y, w, h, cls, opt = {}) {
873893 * without one sprite's paper margin erasing the next one's shadow. */874894 const sprites = new Map();875895 const blits = [];896+/* `opt.rx` and `opt.ry` are where this block *rests* on the picture, in layout897+ * units -- not where it is being dragged through, which is the whole point.898+ *899+ * The screen is ruled across the picture rather than restarted at each block900+ * (see shade), so a sprite has to be cut at the phase of the place it belongs901+ * to. Taking that from the animated position instead would make the ink a902+ * function of how the piece got there: the pattern would crawl under a sliding903+ * block, and a board drawn without animation would not match the same board904+ * drawn with it. Resting position keeps it a function of *where*, which is what905+ * makes the reduced-motion path the same picture rather than a second one.906+ *907+ * A move of one cell changes that phase by the cell's own width, which fitRuling908+ * guarantees is a whole number of periods -- so the pattern a block sits on is909+ * the same before and after, and the landing has nothing to snap. */876910 function blockSprite(w, h, cls, opt) {877911 const { gap, drop } = blockGeom(w, opt);878912 // Normally the frame's own screen, but a caller may ask for another: the879913 // atlas previews each row in the screen that row's rung will be played in.880914 const shading = opt.shading || curShading;881915 const ruling = opt.ruling || curRuling;916+ // Reduced modulo the period here, where the ruling is known, so the cache does917+ // not gain a key per pixel of the board -- there are only ever as many phases918+ // as the period has room for, and usually exactly one.919+ const period = (RULINGS[ruling] || RULINGS.fine).cell * 8;920+ const phaseX = ((dpx(opt.rx || 0) % period) + period) % period;921+ const phaseY = ((dpx(opt.ry || 0) % period) + period) % period;882922 const key = `${w}x${h}|${cls}|${gap}|${drop}|${opt.thick ? 1 : 0}|${opt.device ? 1 : 0}`883- + `|${shading}@${ruling}`;923+ + `|${shading}@${ruling}|${phaseX},${phaseY}`;884924 const hit = sprites.get(key);885925 if (hit) return hit;886926
@@ -900,9 +940,9 @@ function blockSprite(w, h, cls, opt) {
900940 drawBlock(cx, 0, 0, w, h, cls, { ...opt, shading, ruling });901941 const img = cx.getImageData(0, 0, pw, ph);902942 const buf = new Uint32Array(img.data.buffer);903- shade(buf, buf, pw, ph, shading, INK32, PAPER32, ruling);943+ shade(buf, buf, pw, ph, shading, INK32, PAPER32, ruling, phaseX, phaseY);904944 applyInverts(buf, pw, ph); // the mark, before there is a colour905- applyTints(buf, pw, ph);945+ applyTints(buf, pw, ph, phaseX, phaseY);906946 tints.length = 0; tints.push(...outer);907947 inverts.length = 0;908948
@@ -1045,9 +1085,12 @@ function paintTrophy(c, cx, cy, s) {
10451085 c.fill(); c.stroke();10461086 }10471087 1048-function placeBlock(x, y, w, h, cls, opt) {1088+/* Drawn at (x, y); *cut* at the phase of (rx, ry), which is where the block1089+ * rests on the grid. They are the same everywhere except under a thumb or1090+ * mid-slide, and keeping them apart is what stops the ink crawling. */1091+function placeBlock(x, y, w, h, cls, opt, rx = x, ry = y) {10491092 blits.push({1050- sprite: blockSprite(w, h, cls, opt),1093+ sprite: blockSprite(w, h, cls, { ...opt, rx, ry }),10511094 x: Math.round(x), y: Math.round(y), clip: activeClip,10521095 });10531096 }
@@ -1074,7 +1117,11 @@ export function renderFrame() {
10741117 // Stepped down to what the smallest piece on this board can carry: one10751118 // ruling for the whole board, measured in the buffer pixels the screen is10761119 // actually cut in, against a 1x1's fill less its gap and outline.1077- curRuling = fitRuling(combo.ruling, dpx((LY.cell || 16) - 6));1120+ // Stepped down to what the smallest piece can carry *and* to what stays in1121+ // register with the grid it is printed on: the screen runs across the whole1122+ // picture now, so a cell that is not a whole number of periods wide would put1123+ // every piece on a different part of the pattern from its neighbour.1124+ curRuling = fitRuling(combo.ruling, dpx((LY.cell || 16) - 6), dpx(LY.cell || 16));10781125 curTile = GROUND_TILES[combo.backdrop] || GROUND_TILES.lattice;10791126 curDot32 = pack(combo.ground.dot);10801127 curField32 = pack(combo.ground.field);
@@ -1355,7 +1402,7 @@ function drawAtlas(c) {
13551402 // list below is measured from barH, which has not moved, and PUZZLES at half13561403 // the bar height still clears the buttons on the right with room to spare.13571404 const logo = Math.round(LY.barH * 0.62);1358- devLogo(8 + logo / 2, LY.barH / 2, logo, css(logoInk(save.palette, dayOfYear())));1405+ devLogo(8 + logo / 2, LY.barH / 2, logo, css(logoInk(activePalette(), dayOfYear())));13591406 label('PUZZLES', 8 + logo + 6, LY.barH - Math.round(LY.barH * 0.28),13601407 { size: LY.barH * 0.5, track: LY.barH * 0.08 });13611408 }
@@ -1436,8 +1483,11 @@ function drawPlay(c) {
14361483 for (const p of App.pieces) {14371484 const o = pieceOffset(p);14381485 const jx = App.shakePiece === p ? shake : 0;1486+ // The offset moves the block; it does not move the screen the block is cut1487+ // from. A piece under a thumb carries its own ink across the board.14391488 placeBlock(bx + p.x * cell + o.dx + jx, by + p.y * cell + o.dy,1440- p.w * cell, p.h * cell, p.c, blockOpt(p));1489+ p.w * cell, p.h * cell, p.c, blockOpt(p),1490+ bx + p.x * cell, by + p.y * cell);14411491 }14421492 14431493 // Centred on the plate it is printed on, not on the picture: beside a panel
M mockup/src/shading.js +44 -6
@@ -219,15 +219,38 @@ export function effectiveRuling(pattern, ruling) {
219219 * printed at one ruling. Choosing per piece would have let a 1x1 and the 2x2220220 * beside it come out at different frequencies, which is the one thing a press221221 * genuinely never does.222+ *223+ * The second requirement is register, and it is the same kind of rule. The224+ * screen is ruled across the whole picture rather than restarted at each block,225+ * so a block landing one cell over lands on a different part of it -- unless the226+ * cell is a whole number of periods wide, in which case it lands on exactly the227+ * pattern it left. `gridPx` is that cell, in buffer pixels, and a ruling whose228+ * period does not divide it is stepped down until one does.229+ *230+ * Which is a real constraint and worth stating plainly: at three buffer pixels231+ * per unit a 32-unit cell is 96 wide and every ruling divides it, so a phone232+ * gets the whole ladder. A 24-unit cell is 72 and only FINE and COARSE do, so233+ * the coarse end of the ladder is unavailable there rather than being available234+ * and out of register. That is the better failure. A screen that does not hold235+ * register is not a coarser screen, it is a misprint.236+ *237+ * Pass nothing and the check is skipped, which is what the previews want: an238+ * atlas row is a picture of a board rather than a board, and nothing on it239+ * moves.222240 */223-export function fitRuling(ruling, fillPx) {241+export function fitRuling(ruling, fillPx, gridPx = 0) {224242 let out = ruling;225- while (RULINGS[out] && RULINGS[out].cell * 12 > fillPx) { // 8 cells x 1.5243+ for (;;) {244+ const r = RULINGS[out];245+ if (!r) return out;246+ const period = r.cell * 8;247+ const tooCoarse = r.cell * 12 > fillPx; // 8 cells x 1.5248+ const offRegister = gridPx > 0 && gridPx % period !== 0;249+ if (!tooCoarse && !offRegister) return out;226250 const i = RULING_NAMES.indexOf(out);227251 if (i <= 0) return 'fine';228252 out = RULING_NAMES[i - 1];229253 }230- return out;231254 }232255 233256 /* Screens are tiled at their ruling once and kept. A cell of 3 or 6 does not
@@ -308,13 +331,28 @@ const groups = new Map();
308331 * painted into those holes afterwards (see ground.js). Packed colours always309332 * carry full alpha, so zero can never collide with a real one.310333 */311-export function shade(src, dst, w, h, pattern, ink32, paper32, ruling) {334+/* `phaseX` and `phaseY` are where this buffer sits on the sheet, in real pixels.335+ *336+ * A ruled screen is a pure function of position, so passing that offset in is337+ * exactly equivalent to ruling one screen across the whole picture and cutting338+ * this window out of it -- the same thing a press does, and at no cost, because339+ * the field never has to exist. Without it every buffer starts the pattern at340+ * its own corner, which is why two identical blocks a cell apart could sit half341+ * a period out of register with each other.342+ *343+ * Diffusion takes no offset and needs none: its texture is aperiodic, so there344+ * is no phase for it to be out of. */345+export function shade(src, dst, w, h, pattern, ink32, paper32, ruling, phaseX = 0, phaseY = 0) {312346 const p = PATTERNS[pattern] || PATTERNS.atkinson;313347 if (p.diffuse) return diffuse(src, dst, w, h, ink32, paper32);314348 315349 const screen = screenOf(pattern, ruling);316350 const period = screen.period, m = screen.m;317351 const n = w * h;352+ // Kept non-negative so the modulo below never sees one.353+ const ox = ((phaseX % period) + period) % period;354+ const oy = ((phaseY % period) + period) % period;355+ const at = i => m[((((i / w) | 0) + oy) % period) * period + (((i % w) + ox) % period)];318356 319357 // Bucket by tone. The two extremes are the overwhelming majority of any frame320358 // -- the whole scene outside the block sprites is composed at 0 and 255 -- and
@@ -339,7 +377,7 @@ export function shade(src, dst, w, h, pattern, ink32, paper32, ruling) {
339377 const seen = new Map();340378 for (let k = 0; k < total; k++) {341379 const i = idx[k];342- const t = m[(((i / w) | 0) % period) * period + ((i % w) % period)];380+ const t = at(i);343381 seen.set(t, (seen.get(t) || 0) + 1);344382 }345383 const levels = [...seen.keys()].sort((a, b) => a - b);
@@ -348,7 +386,7 @@ export function shade(src, dst, w, h, pattern, ink32, paper32, ruling) {
348386 349387 for (let k = 0; k < total; k++) {350388 const i = idx[k];351- const t = m[(((i / w) | 0) % period) * period + ((i % w) % period)];389+ const t = at(i);352390 dst[i] = t >= cut ? ink32 : paper32;353391 }354392 }