- commit
- 01058b5fd5f8431f5f8e2153d0ebfbc444d05f3b
- parent
- bae5dd768fa1e3ad7d6dcc104634747ccdf7a40a
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 102 | +++++++++++++++++++++++------- |
| M | tests/ | 31 | +++++++++ |
@@ -14,6 +14,7 @@ export const BOARD_COLORS = Object.freeze({
1414 1515 const PROPERTIES = new Set(['YOU', 'PUSH', 'STOP', 'WIN', 'SINK', 'DEFEAT']);1616 const OPERATORS = new Set(['IS', 'AND']);17+const FONT = "'Recursive', ui-monospace, monospace";1718 1819 function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); }1920 function role(text) { return OPERATORS.has(text) ? 'operator' : PROPERTIES.has(text) ? 'property' : 'noun'; }@@ -22,7 +23,7 @@ function roundRect(ctx, x, y, width, height, radius) {
2223 if (typeof ctx.roundRect === 'function') ctx.roundRect(x, y, width, height, radius);2324 else ctx.rect(x, y, width, height);2425 }25-function fitFont(ctx, text, maxWidth, baseSize, weight = 800, family = 'ui-monospace, monospace') {26+function fitFont(ctx, text, maxWidth, baseSize, weight = 800, family = FONT) {2627 let size = baseSize;2728 if (typeof ctx.measureText !== 'function') return `${weight} ${size}px ${family}`;2829 while (size > baseSize * 0.55) {@@ -91,10 +92,10 @@ function drawTile(ctx, text, cx, cy, cell, active, colors) {
9192 ctx.restore();9293 }9394 94-function drawWall(ctx, object, geometry, walls, colors) {95+function drawWall(ctx, object, geometry, walls, colors, visual = object) {9596 const { x, y, cell } = geometry;96- const left = x + object.x * cell;97- const top = y + object.y * cell;97+ const left = x + visual.x * cell;98+ const top = y + visual.y * cell;9899 ctx.fillStyle = colors.wall; ctx.fillRect(left, top, cell, cell);99100 ctx.save(); ctx.beginPath(); ctx.rect(left, top, cell, cell); ctx.clip();100101 ctx.strokeStyle = 'rgba(242,238,230,.08)'; ctx.lineWidth = 2;@@ -104,35 +105,74 @@ function drawWall(ctx, object, geometry, walls, colors) {
104105 ctx.restore();105106 }106107 107-function drawSentenceBars(ctx, state, geometry, colors, objectById) {108+function drawSentenceBars(ctx, state, geometry, colors, objectById, frame = null, visualOf = object => object) {108109 const { x, y, cell } = geometry;109- const sentences = Array.isArray(state.sentences) ? state.sentences : [];110+ const sentences = frame && Array.isArray(frame.sentences) && frame.sentences.length111+ ? frame.sentences112+ : (Array.isArray(state.sentences) ? state.sentences : []);110113 for (const sentence of sentences) {111114 const objects = sentence.ids.map(id => objectById.get(id)).filter(Boolean);112115 const words = objects.filter(object => object.kind === 'word');113- if (!words.length) continue;114- const predicate = words[words.length - 1]?.text;116+ const cells = sentence.frozenCells?.length117+ ? sentence.frozenCells.map(cell => ({ x: cell.x, y: cell.y }))118+ : words.map(word => { const visual = visualOf(word); return { x: visual.x, y: visual.y }; });119+ if (!cells.length) continue;120+ const predicate = sentence.predicate || words[words.length - 1]?.text;121+ const progress = Number.isFinite(sentence.progress) ? Math.max(0, Math.min(1, sentence.progress)) : 1;115122 ctx.fillStyle = colors[predicate] || colors.ink;116123 if (sentence.axis === 'v') {117- const px = Math.min(...words.map(word => word.x));118- const y0 = Math.min(...words.map(word => word.y));119- const y1 = Math.max(...words.map(word => word.y));120- ctx.fillRect(x + px * cell + cell * 0.045, y + y0 * cell + cell * 0.16, Math.max(3, cell * 0.04), (y1 - y0 + 0.68) * cell);124+ const px = Math.min(...cells.map(cellPosition => cellPosition.x));125+ const y0 = Math.min(...cells.map(cellPosition => cellPosition.y));126+ const y1 = Math.max(...cells.map(cellPosition => cellPosition.y));127+ ctx.fillRect(x + px * cell + cell * 0.045, y + y0 * cell + cell * 0.16, Math.max(3, cell * 0.04), (y1 - y0 + 0.68) * cell * progress);121128 } else {122- const py = Math.min(...words.map(word => word.y));123- const x0 = Math.min(...words.map(word => word.x));124- const x1 = Math.max(...words.map(word => word.x));125- roundRect(ctx, x + x0 * cell + cell * 0.08, y + (py + 0.935) * cell, (x1 - x0 + 0.84) * cell, Math.max(3, cell * 0.04), cell * 0.02);129+ const py = Math.min(...cells.map(cellPosition => cellPosition.y));130+ const x0 = Math.min(...cells.map(cellPosition => cellPosition.x));131+ const x1 = Math.max(...cells.map(cellPosition => cellPosition.x));132+ roundRect(ctx, x + x0 * cell + cell * 0.13, y + (py + 0.935) * cell, (x1 - x0 + 0.78) * cell * progress, Math.max(3, cell * 0.04), cell * 0.02);126133 ctx.fill();127134 }128135 }129136 }130137 138+function drawFrameEffects(ctx, frame, geometry, colors) {139+ if (!frame) return;140+ const { x, y, cell, boardWidth, boardHeight } = geometry;141+ const effects = Array.isArray(frame.effects) ? frame.effects : [];142+ for (const effect of effects) {143+ const cellX = effect.cell?.x ?? effect.x;144+ const cellY = effect.cell?.y ?? effect.y;145+ if (!Number.isFinite(cellX) || !Number.isFinite(cellY)) continue;146+ const progress = Math.max(0, Math.min(1, Number(effect.progress) || 0));147+ const cx = x + (cellX + 0.5) * cell;148+ const cy = y + (cellY + 0.5) * cell;149+ const color = colors[effect.color] || colors.fg;150+ ctx.save(); ctx.globalAlpha = 1 - progress; ctx.fillStyle = color; ctx.strokeStyle = color;151+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';152+ if (effect.type === 'transform') {153+ ctx.font = `800 ${Math.max(12, cell * 0.22)}px ${FONT}`;154+ ctx.fillText(effect.text || '', cx, cy - cell * progress * 0.3);155+ } else {156+ ctx.font = `800 ${Math.max(15, cell * 0.34)}px ${FONT}`;157+ ctx.fillText(effect.text || (effect.type === 'sink' ? 'SINK' : 'DEFEAT'), cx, cy - cell * progress * 0.22);158+ }159+ ctx.restore();160+ }161+ if (frame.tint?.progress > 0) {162+ ctx.save(); ctx.globalAlpha = Math.max(0, Math.min(1, frame.tint.progress)) * 0.18; ctx.fillStyle = '#6CA9D8';163+ ctx.fillRect(x, y, boardWidth, boardHeight); ctx.restore();164+ }165+ if (frame.wipe?.progress != null && frame.wipe.progress < 1) {166+ ctx.save(); ctx.globalAlpha = 1 - Math.max(0, Math.min(1, frame.wipe.progress)); ctx.fillStyle = colors.stage;167+ ctx.fillRect(x, y, boardWidth, boardHeight); ctx.restore();168+ }169+}170+131171 function drawLost(ctx, geometry, colors) {132172 const { x, y, cell, boardWidth } = geometry;133173 const words = ['NOTHING', 'IS', 'YOU'];134174 const height = Math.max(18, cell * 0.34);135- const widths = words.map(text => { ctx.font = `800 ${height * 0.44}px ui-monospace, monospace`; return Math.max(height, ctx.measureText(text).width + height * 0.6); });175+ const widths = words.map(text => { ctx.font = `800 ${height * 0.44}px ${FONT}`; return Math.max(height, ctx.measureText(text).width + height * 0.6); });136176 const gap = 6;137177 const total = widths.reduce((sum, width) => sum + width, 0) + gap * 2;138178 let left = x + (boardWidth - total) / 2;@@ -144,7 +184,7 @@ function drawLost(ctx, geometry, colors) {
144184 ctx.fillStyle = word === 'IS' ? colors.fg : colors.ink; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';145185 ctx.fillText(word, left + width / 2, cy); left += width + gap;146186 });147- ctx.fillStyle = colors.YOU; ctx.font = `700 ${Math.max(11, cell * 0.15)}px ui-monospace, monospace`; ctx.textAlign = 'center';187+ ctx.fillStyle = colors.YOU; ctx.font = `700 ${Math.max(11, cell * 0.15)}px ${FONT}`; ctx.textAlign = 'center';148188 ctx.fillText('Z TO UNDO | R TO RESTART', x + boardWidth / 2, cy + height * 1.55);149189 }150190 @@ -153,6 +193,14 @@ export function drawBoard(ctx, state, options = {}) {
153193 if (!ctx || !state || !Number.isInteger(state.width) || !Number.isInteger(state.height)) throw new TypeError('drawBoard(ctx, state): state needs integer width and height');154194 const colors = { ...BOARD_COLORS, ...(options.colors || {}) };155195 const geometry = boardGeometry(ctx, state, options);196+ const frame = options.frame || null;197+ const framePositions = frame?.positions instanceof Map ? frame.positions : null;198+ const visualOf = object => {199+ const visual = framePositions?.get(object.id);200+ return visual ? { ...object, ...visual } : object;201+ };202+ const shake = Number(frame?.shake?.amplitude) || 0;203+ if (frame && shake) { ctx.save(); ctx.translate(shake * 0.35, -shake * 0.2); }156204 drawSlab(ctx, geometry, colors);157205 const objects = Array.isArray(state.objects) ? state.objects : [];158206 const objectById = new Map(objects.map(object => [object.id, object]));@@ -170,12 +218,17 @@ export function drawBoard(ctx, state, options = {}) {
170218 const key = `${object.x},${object.y}`;171219 const index = stackIndices.get(key) || 0; stackIndices.set(key, index + 1);172220 const count = stacks.get(key) || 1;173- if (object.kind === 'object' && (options.wallNouns || ['WALL']).includes(object.noun)) { drawWall(ctx, object, geometry, walls, colors); continue; }221+ const visual = visualOf(object);222+ if (object.kind === 'object' && (options.wallNouns || ['WALL']).includes(object.noun)) { drawWall(ctx, object, geometry, walls, colors, visual); continue; }174223 const offset = count > 1 ? index * 0.09 : 0;175- const cx = geometry.x + (object.x + 0.5 + offset) * geometry.cell;176- const cy = geometry.y + (object.y + 0.5 - offset) * geometry.cell;224+ const cx = geometry.x + (visual.x + 0.5 + offset) * geometry.cell;225+ const cy = geometry.y + (visual.y + 0.5 - offset) * geometry.cell;226+ const scaleX = Number.isFinite(visual.scaleX) ? visual.scaleX : 1;227+ const scaleY = Number.isFinite(visual.scaleY) ? visual.scaleY : 1;228+ if (scaleX !== 1 || scaleY !== 1) { ctx.save(); ctx.translate(cx, cy); ctx.scale(scaleX, scaleY); ctx.translate(-cx, -cy); }177229 if (object.kind === 'word') {178230 ctx.save(); if (count > 1) ctx.rotate((index % 2 ? 1 : -1) * 0.07); drawTile(ctx, object.text, cx, cy, geometry.cell, activeIds.has(object.id), colors); ctx.restore();231+ if (scaleX !== 1 || scaleY !== 1) ctx.restore();179232 continue;180233 }181234 const props = propertiesFor(object, state, game);@@ -185,11 +238,14 @@ export function drawBoard(ctx, state, options = {}) {
185238 }186239 ctx.font = `${Math.max(14, geometry.cell * 0.6)}px "Apple Color Emoji", "Segoe UI Emoji", sans-serif`;187240 ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = colors.fg; ctx.fillText(glyph, cx, cy);188- if (count > 2 && index === 0) { ctx.font = `800 ${Math.max(10, geometry.cell * 0.16)}px ui-monospace, monospace`; ctx.fillText(`×${count}`, geometry.x + (object.x + 0.86) * geometry.cell, geometry.y + (object.y + 0.86) * geometry.cell); }241+ if (count > 2 && index === 0) { ctx.font = `800 ${Math.max(10, geometry.cell * 0.16)}px ${FONT}`; ctx.fillText(`×${count}`, geometry.x + (object.x + 0.86) * geometry.cell, geometry.y + (object.y + 0.86) * geometry.cell); }242+ if (scaleX !== 1 || scaleY !== 1) ctx.restore();189243 }190- drawSentenceBars(ctx, state, geometry, colors, objectById);244+ drawSentenceBars(ctx, state, geometry, colors, objectById, frame, visualOf);245+ drawFrameEffects(ctx, frame, geometry, colors);191246 const hasYou = objects.some(object => propertiesFor(object, state, game).has('YOU'));192247 if (!hasYou) { ctx.save(); ctx.globalAlpha = 0.68; ctx.fillStyle = colors.ink; ctx.fillRect(geometry.x, geometry.y, geometry.boardWidth, geometry.boardHeight); ctx.restore(); drawLost(ctx, geometry, colors); }248+ if (frame && shake) ctx.restore();193249 return geometry;194250 }195251 @@ -7,6 +7,7 @@ function context() {
77 const ctx = {88 canvas: { width: 320, height: 240 }, calls,99 save() { calls.push(['save']); }, restore() { calls.push(['restore']); },10+ translate(...args) { calls.push(['translate', ...args]); }, scale(...args) { calls.push(['scale', ...args]); },1011 beginPath() { calls.push(['beginPath']); }, closePath() { calls.push(['closePath']); }, moveTo(...args) { calls.push(['moveTo', ...args]); }, lineTo(...args) { calls.push(['lineTo', ...args]); },1112 rect(...args) { calls.push(['rect', ...args]); }, roundRect(...args) { calls.push(['roundRect', ...args]); },1213 fill() { calls.push(['fill']); }, stroke() { calls.push(['stroke']); }, clip() { calls.push(['clip']); },@@ -44,6 +45,8 @@ test('drawBoard draws sentence bars from physical sentence IDs and merges walls
4445 drawBoard(ctx, state, { x: 20, y: 10, cell: 50 });4546 const horizontalBars = ctx.calls.filter(call => call[0] === 'roundRect' && call[1] >= 20 && call[2] > 50);4647 assert.ok(horizontalBars.length >= 1);48+ assert.equal(horizontalBars[0][1], 26.5);49+ assert.equal(horizontalBars[0][3], 139);4750 assert.ok(ctx.calls.some(call => call[0] === 'fillRect' && call[1] === 170 && call[2] === 110 && call[3] === 50 && call[4] === 50));4851 });4952 @@ -55,3 +58,31 @@ test('drawBoard dims a no-YOU board and shows recovery copy', () => {
5558 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'NOTHING'));5659 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'Z TO UNDO | R TO RESTART'));5760 });61+62+test('drawBoard consumes juice positions and scales moving entities', () => {63+ const ctx = context();64+ const frame = {65+ positions: new Map([['cat', { x: 1.5, y: 1, scaleX: 1.12, scaleY: 0.94, moving: true }]]),66+ sentences: [], effects: [], shake: { amplitude: 2 }, tint: null, wipe: null,67+ };68+ drawBoard(ctx, state, { x: 20, y: 10, cell: 50, palette: { CAT: '🐈' }, frame });69+ assert.ok(ctx.calls.some(call => call[0] === 'translate' && call[1] === 2 * 0.35));70+ assert.ok(ctx.calls.some(call => call[0] === 'scale' && call[1] === 1.12 && call[2] === 0.94));71+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '🐈'));72+});73+74+test('drawBoard animates sentence bars from frozen cells and renders frame overlays', () => {75+ const ctx = context();76+ const frame = {77+ positions: new Map(),78+ sentences: [{ ids: ['miau', 'is', 'you'], axis: 'h', progress: 0.5, frozenCells: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 2, y: 0 }] }],79+ effects: [{ type: 'sink', text: 'MIAU', cell: { x: 1, y: 1 }, color: 'SINK', progress: 0.25 }],80+ shake: { amplitude: 0 }, tint: { progress: 0.5, color: 'cool' }, wipe: { progress: 0.5 },81+ };82+ drawBoard(ctx, state, { x: 20, y: 10, cell: 50, frame });83+ const animatedBar = ctx.calls.find(call => call[0] === 'roundRect' && call[1] === 26.5 && call[2] === 56.75);84+ assert.ok(animatedBar, 'expected the sentence bar to use the frozen cells');85+ assert.equal(animatedBar[3], 69.5);86+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'MIAU'));87+ assert.ok(ctx.calls.some(call => call[0] === 'fillRect' && call[1] === 20 && call[2] === 10 && call[3] === 200 && call[4] === 150));88+});