- commit
- 7f2383b7e1ae517e3843b5b8f393a9e61b890202
- parent
- b73c92a9820cca5f844b6556b0c895f8b8a4f943
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 4 | +- |
| M | src/ | 17 | ++++++- |
| M | src/ | 81 | ++++++++++++++++++++++++++---- |
| M | src/ | 1 | + |
| M | tests/ | 1 | + |
| A | tests/ | 47 | +++++++++++++++++ |
@@ -514,7 +514,9 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
514514 rules: visibleRules,515515 sentences: visibleSentences,516516 ledger: [...ledger.values()]517- .map(entry => projectLedgerEntry(entry, at))517+ .map(entry => projectLedgerEntry(entry, at, currentReducedMotion518+ ? { stamp: 0, tileStagger: 0, strike: 0, exit: 0 }519+ : undefined))518520 .filter(entry => entry.visible),519521 effects: effects(at),520522 automata,@@ -17,6 +17,7 @@ export const LEDGER_TIMINGS = Object.freeze({
1717 1818 const stampProgress = (at, start, index, timings) => {1919 const elapsed = at - start - index * timings.tileStagger;20+ if (timings.stamp <= 0) return 1;2021 return elapsed <= 0 ? 0 : clamp(elapsed / timings.stamp);2122 };2223 @@ -30,8 +31,8 @@ const stampProgress = (at, start, index, timings) => {
3031 export function projectLedgerEntry(entry, at, timings = LEDGER_TIMINGS) {3132 const ids = [...(entry.ids || [])];3233 if (entry.phase === 'removing') {33- const strikeProgress = clamp((at - entry.start) / timings.strike);34- const exitProgress = clamp((at - (entry.start + timings.strike)) / timings.exit);34+ const strikeProgress = timings.strike <= 0 ? 1 : clamp((at - entry.start) / timings.strike);35+ const exitProgress = timings.exit <= 0 ? 1 : clamp((at - (entry.start + timings.strike)) / timings.exit);3536 return {3637 key: entry.key,3738 ids,@@ -43,6 +44,18 @@ export function projectLedgerEntry(entry, at, timings = LEDGER_TIMINGS) {
4344 visible: exitProgress < 1,4445 };4546 }47+ if (entry.phase === 'active') {48+ return {49+ key: entry.key,50+ ids,51+ tokens: [...(entry.tokens || [])],52+ phase: 'active',53+ stampProgress: ids.map(() => 1),54+ strikeProgress: 0,55+ exitProgress: 0,56+ visible: true,57+ };58+ }4659 const progresses = ids.map((_, index) => stampProgress(at, entry.start, index, timings));4760 const complete = progresses.every(progress => progress >= 1);4861 return {@@ -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;@@ -115,6 +115,7 @@ export function renderProofScene(ctx, state, view = {}) {
115115 x,116116 Math.min(y + state.height * cell + 46, height - 112),117117 width - x - 32,118+ { ledger: view.frame?.ledger },118119 );119120 const hint = String(state.hint || "").trim();120121 const automataY = automata.length@@ -272,6 +272,7 @@ test('physical ledger sentences stamp by tile and strike before exiting', () =>
272272 assert.ok(juice.frame(250).ledger[0].stampProgress[1] > 0);273273 274274 juice.syncState(formed, 300);275+ assert.deepEqual(juice.frame(900).ledger[0].stampProgress, [1, 1]);275276 juice.consume({276277 kind: 'step', accepted: true, moved: [], rulesAdded: [], rulesRemoved: [], destroyed: [],277278 }, 300, empty);@@ -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+});