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>
| M | mockup/ | 84 | ++++++++++++++++++++++++------ |
| M | mockup/ | 50 | ++++++++++++++++-- |
@@ -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@@ -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 }