clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

Render physical ledger motion in proof HUD

Dami committed 7f2383b
commit
7f2383b7e1ae517e3843b5b8f393a9e61b890202
parent
b73c92a9820cca5f844b6556b0c895f8b8a4f943
author
Dami <dami.etoile@gmail.com>
6 files changed, +136 -15
Msrc/juice.js4+-
Msrc/juice/ledger.js17++++++-
Msrc/render/stage-hud.js81++++++++++++++++++++++++++----
Msrc/render/stage-proof.js1+
Mtests/juice.test.js1+
Atests/stage-hud.test.js47+++++++++++++++++
M src/juice.js +3 -1
M src/juice/ledger.js +15 -2
M src/render/stage-hud.js +69 -12
@@ -403,16 +403,52 @@ export function drawSentenceTiles(
403403 return cursor;404404 }405405 406-export function drawLedger(ctx, rules, x, y, maxWidth) {406+function drawLedgerTile(ctx, text, x, y, role, progress = 1, exitProgress = 0) {407+ const stamp = Math.max(0, Math.min(1, Number(progress) || 0));408+ const exit = Math.max(0, Math.min(1, Number(exitProgress) || 0));409+ if (stamp <= 0 || exit >= 1) return;410+ const scale = 1 + 0.3 * (1 - stamp);411+ const opacity = 1 - exit;412+ const size = 10;413+ const width = Math.max(size * 1.6, ctx.measureText(String(text).toUpperCase()).width + size);414+ const centerX = x + width / 2;415+ const centerY = y + size * 0.75 + exit * 8;416+ ctx.save();417+ ctx.translate(centerX, centerY);418+ ctx.scale(scale, scale);419+ ctx.translate(-centerX, -centerY);420+ drawMiniTile(ctx, text, x, y + exit * 8, role, size, opacity);421+ ctx.restore();422+ return width;423+}424+425+function ledgerRole(value) {426+ const upper = String(value || '').toUpperCase();427+ return ['YOU', 'WIN', 'PUSH', 'STOP', 'TICK', 'SINK', 'DEFEAT', 'IS', 'AND'].includes(upper)428+ ? upper429+ : 'noun';430+}431+432+export function drawLedger(ctx, rules, x, y, maxWidth, options = {}) {407433 const lineHeight = 26;408434 const sentenceGap = 26;409435 const tileGap = 7;436+ const animated = Array.isArray(options.ledger) && options.ledger.some(437+ entry => Array.isArray(entry?.tokens) && entry.tokens.length > 0,438+ );439+ const entries = animated440+ ? options.ledger441+ : (rules || []).map(rule => ({442+ ids: [],443+ tokens: String(rule.text || rule).split(/\s+/).filter(Boolean),444+ stampProgress: [],445+ exitProgress: 0,446+ }));410447 let cursor = x;411448 let lineY = y;412- for (const rule of rules || []) {413- const words = String(rule.text || rule)414- .split(/\s+/)415- .filter(Boolean);449+ for (const entry of entries) {450+ const words = (entry.tokens || []).map(word => String(word || '').toUpperCase()).filter(Boolean);451+ if (!words.length) continue;416452 ctx.font = "800 10px Recursive, monospace";417453 const sentenceWidth =418454 words.reduce(
@@ -429,25 +465,46 @@ export function drawLedger(ctx, rules, x, y, maxWidth) {
429465 lineY += lineHeight;430466 cursor = x;431467 }432- for (const word of words) {468+ const strikeSegments = [];469+ let segmentStart = cursor;470+ let segmentRight = cursor;471+ let segmentY = lineY;472+ for (const [index, word] of words.entries()) {433473 ctx.font = "800 10px Recursive, monospace";434474 const tileWidth = Math.max(435475 16,436476 ctx.measureText(word.toUpperCase()).width + 10,437477 );438478 if (cursor !== x && cursor + tileWidth > x + maxWidth) {479+ strikeSegments.push({ x: segmentStart, right: segmentRight, y: segmentY });439480 lineY += lineHeight;440481 cursor = x;482+ segmentStart = cursor;483+ segmentRight = cursor;484+ segmentY = lineY;441485 }442486 const value = word.toUpperCase();443- const role = ["YOU", "WIN", "PUSH", "STOP", "TICK", "IS", "AND"].includes(444- value,445- )446- ? value447- : "noun";448- drawMiniTile(ctx, value, cursor, lineY, role, 10);487+ const stampProgress = entry.stampProgress?.[index] ?? 1;488+ const exitProgress = entry.exitProgress ?? 0;489+ drawLedgerTile(ctx, value, cursor, lineY, ledgerRole(value), stampProgress, exitProgress);490+ segmentRight = cursor + tileWidth;449491 cursor += tileWidth + tileGap;450492 }493+ strikeSegments.push({ x: segmentStart, right: segmentRight, y: segmentY });494+ if ((entry.strikeProgress || 0) > 0) {495+ for (const segment of strikeSegments) {496+ if (segment.right <= segment.x) continue;497+ ctx.save();498+ ctx.strokeStyle = "#FF4B3E";499+ ctx.globalAlpha = Math.min(1, entry.strikeProgress);500+ ctx.lineWidth = 2;501+ ctx.beginPath();502+ ctx.moveTo(segment.x, segment.y + 8);503+ ctx.lineTo(segment.right, segment.y + 8);504+ ctx.stroke();505+ ctx.restore();506+ }507+ }451508 cursor += sentenceGap;452509 }453510 lineY += lineHeight;
M src/render/stage-proof.js +1 -0
M tests/juice.test.js +1 -0
A tests/stage-hud.test.js +47 -0
@@ -0,0 +1,47 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { drawLedger } from '../src/render/stage-hud.js';4+5+function context() {6+ const calls = [];7+ return {8+ calls,9+ font: '',10+ save() { calls.push(['save']); },11+ restore() { calls.push(['restore']); },12+ translate(...args) { calls.push(['translate', ...args]); },13+ scale(...args) { calls.push(['scale', ...args]); },14+ beginPath() { calls.push(['beginPath']); },15+ moveTo(...args) { calls.push(['moveTo', ...args]); },16+ lineTo(...args) { calls.push(['lineTo', ...args]); },17+ roundRect(...args) { calls.push(['roundRect', ...args]); },18+ rect(...args) { calls.push(['rect', ...args]); },19+ stroke() { calls.push(['stroke']); },20+ fillRect(...args) { calls.push(['fillRect', ...args]); },21+ fillText(...args) { calls.push(['fillText', ...args]); },22+ measureText(text) { return { width: String(text).length * 6 }; },23+ };24+}25+26+test('drawLedger consumes physical frame entries with stamp, strike, and exit state', () => {27+ const ctx = context();28+ drawLedger(ctx, [{ text: 'MIAU IS YOU' }], 10, 20, 300, {29+ ledger: [{30+ key: 'a>b>c',31+ ids: ['a', 'b', 'c'],32+ tokens: ['MIAU', 'IS', 'YOU'],33+ stampProgress: [0.5, 0, 0],34+ strikeProgress: 0.5,35+ exitProgress: 0,36+ }],37+ });38+ assert.equal(ctx.calls.filter(call => call[0] === 'fillText').length, 4);39+ assert.ok(ctx.calls.some(call => call[0] === 'stroke'), 'strike should draw after the stamped tile');40+ assert.ok(ctx.calls.some(call => call[0] === 'scale' && call[1] > 1), 'stamped tile should land from scale 1.3');41+});42+43+test('drawLedger falls back to settled rules when no frame ledger exists', () => {44+ const ctx = context();45+ drawLedger(ctx, [{ text: 'MIAU IS YOU' }], 10, 20, 300);46+ assert.equal(ctx.calls.filter(call => call[0] === 'fillText').length, 6);47+});