clone

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

feat(render): preserve and animate directional actor facing

Dami committed 449765f
commit
449765f3c4581a05b8258867fa1b7a5aba252910
parent
45f15b60d66e933894fd9d28613bf97a83acbec0
author
Dami <dami.etoile@gmail.com>
7 files changed, +169 -24
Msrc/juice.js37+++++++++++++++++-
Msrc/main.js2+-
Msrc/render/board.js27+++++++++---
Msrc/scenes/title.js25++++++++++--
Mtests/board.test.js26+++++++++++-
Mtests/juice.test.js64++++++++++++++++++++++++++----
Mtests/title.test.js12+++++-
M src/juice.js +36 -1
@@ -20,6 +20,7 @@ const EFFECT_MS = 200;
2020 const TINT_MS = 140;2121 const WIPE_MS = 260;2222 const WIN_BEAT_MS = 260;23+const FACE_FLIP_MS = 70;2324 2425 const clamp = (value, min = 0, max = 1) => Math.max(min, Math.min(max, value));2526 const easeOut = value => 1 - ((1 - value) ** 3);
@@ -45,6 +46,7 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
4546 let tint = null;4647 let wipe = null;4748 let win = null;49+ const facingHistory = [];4850 let currentReducedMotion = Boolean(reducedMotion);4951 5052 const duration = value => currentReducedMotion ? 0 : value;
@@ -66,6 +68,7 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
6668 duration: 0,6769 pushed: false,6870 facing: null,71+ faceFlipStart: null,6972 });7073 return entities.get(id);7174 }
@@ -99,6 +102,7 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
99102 entities.clear();100103 rules.clear();101104 sentences.clear();105+ facingHistory.length = 0;102106 priorSentenceCells = new Map();103107 priorSentences = new Map();104108 globalShake = null;
@@ -168,7 +172,13 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
168172 entity.pushed = entry.pushedBy != null;169173 entity.dx = Math.sign(entity.x - entity.fromX);170174 entity.dy = Math.sign(entity.y - entity.fromY);171- entity.facing = facingFor(entity.dx, entity.dy) || entity.facing;175+ // The board uses facing as a character cue. Only a horizontal move made176+ // by the entity itself changes it; pushes and vertical moves preserve it.177+ if (entry.pushedBy == null && entity.dx !== 0) {178+ const nextFacing = facingFor(entity.dx, 0);179+ if (nextFacing !== entity.facing) entity.faceFlipStart = at;180+ entity.facing = nextFacing;181+ }172182 }173183 174184 function consumeRuleAdded(rule, at, holdUntil) {
@@ -275,6 +285,11 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
275285 snap(at);276286 blocked = null;277287 sentences.clear();288+ facingHistory.length = 0;289+ for (const entity of entities.values()) {290+ entity.facing = null;291+ entity.faceFlipStart = null;292+ }278293 rules.clear();279294 wipe = { start: at, duration: duration(WIPE_MS) };280295 tint = null;
@@ -283,7 +298,22 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
283298 }284299 285300 const moveDuration = duration(result.kind === 'undo' ? UNDO_MS : MOVE_MS);301+ const priorFacing = new Map();302+ if (result.kind === 'undo') {303+ const restoredFacing = facingHistory.pop();304+ if (restoredFacing) for (const [id, facing] of restoredFacing) priorFacing.set(id, facing);305+ } else if (result.kind === 'step' && result.moved?.length) {306+ for (const [id, entity] of entities) {307+ if (!String(id).startsWith('__spark__')) priorFacing.set(id, entity.facing);308+ }309+ facingHistory.push(priorFacing);310+ }286311 for (const entry of result.moved || []) consumeMovement(entry, at, moveDuration);312+ if (result.kind === 'undo') for (const [id, facing] of priorFacing) {313+ const entity = entityAt(id);314+ entity.facing = facing;315+ entity.faceFlipStart = null;316+ }287317 288318 if (result.kind === 'undo') tint = { start: at, duration: duration(TINT_MS) };289319 if (result.restored || result.kind === 'undo') {
@@ -365,6 +395,10 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
365395 const bounceProgress = entity.bounceDuration ? clamp((at - entity.bounceStart) / entity.bounceDuration) : 1;366396 const popScale = entity.popDuration ? 0.55 + 0.45 * easeBack(popProgress) : 1;367397 const bounceScale = entity.bounceDuration ? 1 + Math.sin(bounceProgress * Math.PI) * 0.14 : 1;398+ const faceFlipProgress = !currentReducedMotion && Number.isFinite(entity.faceFlipStart)399+ ? clamp((at - entity.faceFlipStart) / FACE_FLIP_MS)400+ : 1;401+ const glyphScaleX = currentReducedMotion ? 1 : 1 - 0.8 * Math.sin(Math.PI * faceFlipProgress);368402 return {369403 id,370404 x: x + shakeX,
@@ -374,6 +408,7 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
374408 moving: progress < 1,375409 pushed: entity.pushed,376410 facing: entity.facing,411+ glyphScaleX,377412 };378413 }379414
M src/main.js +1 -1
M src/render/board.js +20 -7
@@ -16,7 +16,7 @@ const PROPERTIES = new Set(['YOU', 'PUSH', 'STOP', 'WIN', 'SINK', 'DEFEAT']);
1616 const OPERATORS = new Set(['IS', 'AND']);1717 // These glyphs depict an actor with a readable horizontal facing. Terrain,1818 // flags, doors, and abstract symbols stay untouched when a frame has facing data.19-const DIRECTIONAL_NOUNS = new Set(['MIAU', 'CAT', 'DOG', 'FOX', 'RABBIT', 'MOUSE', 'ROBOT', 'GHOST', 'BIRD', 'FISH', 'MONSTER']);19+const DIRECTIONAL_NOUNS = new Set(['MIAU', 'KEKE', 'CAT', 'DOG', 'FOX', 'RABBIT', 'MOUSE', 'ROBOT', 'GHOST', 'BIRD', 'FISH', 'MONSTER']);2020 const FONT = "'Recursive', ui-monospace, monospace";2121 2222 function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); }
@@ -47,6 +47,12 @@ function horizontalFacing(visual) {
4747 const dx = Number.isFinite(facing?.x) ? facing.x : Number.isFinite(visual?.dx) ? visual.dx : null;4848 return dx < 0 ? 'left' : dx > 0 ? 'right' : null;4949 }50+function isEmojiGlyph(glyph) {51+ return Array.from(String(glyph || '')).some(character => {52+ const code = character.codePointAt(0);53+ return code > 0x1f000 || character === '\uFE0F';54+ });55+}5056 function boardGeometry(ctx, state, options) {5157 const width = state.width || 0;5258 const height = state.height || 0;
@@ -242,20 +248,27 @@ export function drawBoard(ctx, state, options = {}) {
242248 continue;243249 }244250 const props = propertiesFor(object, state, game);245- const glyph = options.palette?.[object.noun] || options.glyphs?.[object.noun] || object.noun.slice(0, 3);251+ const paletteEntry = options.palette?.[object.noun];252+ const paletteGlyph = paletteEntry && typeof paletteEntry === 'object' ? paletteEntry.glyph : paletteEntry;253+ const glyph = paletteGlyph || options.glyphs?.[object.noun] || object.noun.slice(0, 3);246254 if (props.has('YOU')) {247255 ctx.fillStyle = `${colors.YOU}66`; ctx.beginPath(); ctx.ellipse(cx, geometry.y + (object.y + 0.82) * geometry.cell, geometry.cell * 0.26, geometry.cell * 0.06, 0, 0, Math.PI * 2); ctx.fill();248256 }249257 ctx.font = `${Math.max(14, geometry.cell * 0.6)}px "Apple Color Emoji", "Segoe UI Emoji", sans-serif`;250258 ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = colors.fg;251- const facing = DIRECTIONAL_NOUNS.has(String(object.noun || '').toUpperCase()) ? horizontalFacing(visual) : null;259+ const nativeFacing = paletteEntry && typeof paletteEntry === 'object'260+ ? (paletteEntry.faces === 'left' || paletteEntry.faces === 'right' ? paletteEntry.faces : null)261+ : !paletteEntry && DIRECTIONAL_NOUNS.has(String(object.noun || '').toUpperCase()) ? 'left' : null;262+ const actualFacing = horizontalFacing(visual) || nativeFacing;263+ const mirror = nativeFacing && actualFacing && nativeFacing !== actualFacing && isEmojiGlyph(glyph);264+ const glyphScaleX = Number.isFinite(visual.glyphScaleX) ? visual.glyphScaleX : 1;252265 // The Apple Color Emoji cat faces left by default. Mirror right-facing253266 // actors; left-facing and non-directional glyphs keep their native form.254- if (facing === 'right') {267+ if (mirror || glyphScaleX !== 1) {255268 ctx.save();256- ctx.translate(cx, 0);257- ctx.scale(-1, 1);258- ctx.translate(-cx, 0);269+ ctx.translate(cx, cy);270+ ctx.scale((mirror ? -1 : 1) * glyphScaleX, 1);271+ ctx.translate(-cx, -cy);259272 ctx.fillText(glyph, cx, cy);260273 ctx.restore();261274 } else {
M src/scenes/title.js +21 -4
@@ -110,18 +110,32 @@ export function createTitleScene(options = {}) {
110110 const titleEpoch = options.startTime ?? (typeof performance === 'undefined' ? 0 : performance.now());111111 let solvedNotified = false;112112 let footerLayout = [];113+ let renderNow = titleEpoch;114+ let miauFlipStartedAt = null;115+ const facingHistory = [];113116 // Apple Color Emoji's native cat faces left. Keep the last accepted114117 // horizontal direction separately from the engine so blocked and idle115118 // inputs do not make the title character turn.116119 let miauFacing = 'left';117120 function updateFacing(event) {118121 if (!event?.accepted) return;119- if (event.kind === 'reset') { miauFacing = 'left'; return; }122+ if (event.kind === 'reset') { facingHistory.length = 0; miauFacing = 'left'; return; }123+ if (event.kind === 'undo') {124+ if (facingHistory.length) {125+ const priorFacing = facingHistory.pop();126+ if (priorFacing !== miauFacing) miauFlipStartedAt = renderNow;127+ miauFacing = priorFacing;128+ }129+ return;130+ }131+ if (event.kind !== 'step') return;132+ facingHistory.push(miauFacing);120133 const movement = event.moved?.find(entry => entry.id === 'title-miau');121134 if (!movement) return;122135 const dx = movement.to.x - movement.from.x;123- if (dx > 0) miauFacing = 'right';124- else if (dx < 0) miauFacing = 'left';136+ const nextFacing = dx > 0 ? 'right' : dx < 0 ? 'left' : miauFacing;137+ if (nextFacing !== miauFacing) miauFlipStartedAt = renderNow;138+ miauFacing = nextFacing;125139 }126140 function settle(event) {127141 updateFacing(event);
@@ -160,6 +174,7 @@ export function createTitleScene(options = {}) {
160174 const state = game.getState();161175 const reducedMotion = Boolean(drawOptions.reducedMotion ?? options.reducedMotion);162176 const now = drawOptions.now ?? (typeof performance === 'undefined' ? titleEpoch : performance.now());177+ renderNow = now;163178 const elapsed = Math.max(0, now - titleEpoch);164179 const header = drawHeader(ctx, width, height, { now: elapsed, reducedMotion });165180 const cell = drawOptions.cell ?? Math.min(84, Math.max(30, Math.floor(Math.min((width - 64) / state.width, (height - 330) / state.height))));
@@ -170,7 +185,9 @@ export function createTitleScene(options = {}) {
170185 const y = drawOptions.y ?? Math.floor(boardTop + Math.max(0, (boardBottom - boardTop - boardHeight) / 2));171186 const framePositions = drawOptions.frame?.positions instanceof Map ? new Map(drawOptions.frame.positions) : new Map();172187 const existingMiau = framePositions.get('title-miau') || {};173- framePositions.set('title-miau', { ...existingMiau, facing: miauFacing });188+ const flipProgress = miauFlipStartedAt == null ? 1 : Math.max(0, Math.min(1, (now - miauFlipStartedAt) / 70));189+ const flipScale = reducedMotion || flipProgress >= 1 ? 1 : 1 - 0.8 * Math.sin(Math.PI * flipProgress);190+ framePositions.set('title-miau', { ...existingMiau, facing: miauFacing, glyphScaleX: flipScale });174191 const frame = { ...(drawOptions.frame || {}), positions: framePositions };175192 drawBoard(ctx, state, { glyphs: { MIAU: '🐈', DOOR: '🚪', ...(drawOptions.glyphs || {}) }, ...drawOptions, frame, x, y, cell, game });176193 footerLayout = drawFooter(ctx, width, height);
M tests/board.test.js +24 -2
@@ -68,7 +68,7 @@ test('drawBoard consumes juice positions and scales moving entities', () => {
6868 positions: new Map([['cat', { x: 1.5, y: 1, scaleX: 1.12, scaleY: 0.94, moving: true }]]),6969 sentences: [], effects: [], shake: { amplitude: 2 }, tint: null, wipe: null,7070 };71- drawBoard(ctx, state, { x: 20, y: 10, cell: 50, palette: { CAT: '🐈' }, frame });71+ drawBoard(ctx, state, { x: 20, y: 10, cell: 50, glyphs: { CAT: '🐈' }, frame });7272 assert.ok(ctx.calls.some(call => call[0] === 'translate' && call[1] === 2 * 0.35));7373 assert.ok(ctx.calls.some(call => call[0] === 'scale' && call[1] === 1.12 && call[2] === 0.94));7474 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '🐈'));
@@ -80,14 +80,36 @@ test('drawBoard keeps native left-facing actors and mirrors right-facing actors'
8080 positions: new Map([['cat', { x: 1, y: 1, facing: 'left' }]]),8181 sentences: [], effects: [], shake: { amplitude: 0 }, tint: null, wipe: null,8282 };83- drawBoard(ctx, state, { x: 20, y: 10, cell: 50, palette: { CAT: '🐈' }, frame });83+ drawBoard(ctx, state, { x: 20, y: 10, cell: 50, glyphs: { CAT: '🐈' }, frame });8484 assert.equal(ctx.calls.some(call => call[0] === 'scale' && call[1] === -1 && call[2] === 1), false);8585 ctx.calls.length = 0;8686 frame.positions.set('cat', { x: 1, y: 1, facing: 'right' });87+ drawBoard(ctx, state, { x: 20, y: 10, cell: 50, glyphs: { CAT: '🐈' }, frame });88+ assert.ok(ctx.calls.some(call => call[0] === 'scale' && call[1] === -1 && call[2] === 1));89+});90+91+test('drawBoard only flips mod palette glyphs with explicit faces metadata', () => {92+ const ctx = context();93+ const frame = { positions: new Map([['cat', { x: 1, y: 1, facing: 'right' }]]), sentences: [], effects: [], shake: { amplitude: 0 }, tint: null, wipe: null };8794 drawBoard(ctx, state, { x: 20, y: 10, cell: 50, palette: { CAT: '🐈' }, frame });95+ assert.equal(ctx.calls.some(call => call[0] === 'scale' && call[1] === -1 && call[2] === 1), false);96+ ctx.calls.length = 0;97+ frame.positions.set('cat', { x: 1, y: 1, facing: 'left' });98+ drawBoard(ctx, state, { x: 20, y: 10, cell: 50, palette: { CAT: { glyph: '🐈', faces: 'right' } }, frame });8899 assert.ok(ctx.calls.some(call => call[0] === 'scale' && call[1] === -1 && call[2] === 1));89100 });90101 102+test('drawBoard compares movement facing with native mod facing and isolates glyph squash', () => {103+ const ctx = context();104+ const stateWithModActor = {105+ ...state,106+ objects: state.objects.map(object => object.id === 'cat' ? { ...object, noun: 'UNICORN' } : object),107+ };108+ const frame = { positions: new Map([['cat', { x: 1, y: 1, facing: 'left', glyphScaleX: 0.8 }]]), sentences: [], effects: [], shake: { amplitude: 0 }, tint: null, wipe: null };109+ drawBoard(ctx, stateWithModActor, { x: 20, y: 10, cell: 50, palette: { UNICORN: { glyph: '🦄', faces: 'right' } }, frame });110+ assert.ok(ctx.calls.some(call => call[0] === 'scale' && call[1] === -0.8 && call[2] === 1));111+});112+91113 test('drawBoard leaves a non-directional KEKE paw glyph unchanged', () => {92114 const ctx = context();93115 const kekeState = {
M tests/juice.test.js +56 -8
@@ -28,7 +28,7 @@ test('move and push tweens expose grid coordinates and push scale', () => {
2828 assert.equal(juice.frame(50).positions.get('miau').x, 1);2929 assert.equal(juice.frame(50).positions.get('rock').x, 2);3030 assert.equal(juice.frame(50).positions.get('miau').facing, 'right');31- assert.equal(juice.frame(50).positions.get('rock').facing, 'right');31+ assert.equal(juice.frame(50).positions.get('rock').facing, null);3232 });3333 3434 test('snapping before a level transition prevents reused ids from drawing at old cells', () => {
@@ -37,7 +37,9 @@ test('snapping before a level transition prevents reused ids from drawing at old
3737 juice.consume({ kind: 'step', accepted: true, moved: [{ id: 'player', from: { x: 0, y: 0 }, to: { x: 1, y: 0 }, pushedBy: null }], rulesAdded: [], rulesRemoved: [], destroyed: [] }, 0);3838 juice.snap(10);3939 juice.syncState(state([object('player', 0, 1)]), 10);40- assert.deepEqual(juice.frame(10).positions.get('player'), { id: 'player', x: 0, y: 1, scaleX: 1, scaleY: 1, moving: false, pushed: false, facing: 'right' });40+ const player = juice.frame(10).positions.get('player');41+ assert.equal(player.facing, 'right');42+ assert.ok(Math.abs(player.glyphScaleX - (1 - 0.8 * Math.sin(Math.PI * 10 / 70))) < 1e-9);4143 });4244 4345 test('resetLevel clears old sentences, effects, and win beats before seeding the next proof', () => {
@@ -51,24 +53,70 @@ test('resetLevel clears old sentences, effects, and win beats before seeding the
5153 assert.deepEqual(frame.effects, []);5254 assert.deepEqual(frame.rules, []);5355 assert.deepEqual(frame.sentences.map(sentence => ({ ids: sentence.ids, axis: sentence.axis })), [{ ids: ['a', 'b'], axis: 'v' }]);54- assert.deepEqual(frame.positions.get('a'), { id: 'a', x: 3, y: 1, scaleX: 1, scaleY: 1, moving: false, pushed: false, facing: null });56+ assert.equal(frame.positions.get('a').facing, null);57+ assert.ok(Math.abs(frame.positions.get('a').glyphScaleX - 1) < 1e-9);5558 });5659 57-test('facing follows travel direction, survives snap, and resets between levels', () => {60+test('facing follows horizontal self movement, preserves through pushes and vertical moves, and resets between levels', () => {5861 const juice = createJuice({ now: () => 0 });59- juice.syncState(state([object('player', 1, 1)]), 0);62+ juice.syncState(state([object('player', 1, 1), object('rock', 2, 1)]), 0);6063 juice.consume({6164 kind: 'step', accepted: true,62- moved: [{ id: 'player', from: { x: 1, y: 1 }, to: { x: 1, y: 0 }, pushedBy: null }],65+ moved: [{ id: 'player', from: { x: 1, y: 1 }, to: { x: 2, y: 1 }, pushedBy: null }],6366 rulesAdded: [], rulesRemoved: [], destroyed: [],6467 }, 0);65- assert.equal(juice.frame(45).positions.get('player').facing, 'up');68+ assert.equal(juice.frame(45).positions.get('player').facing, 'right');69+ juice.consume({70+ kind: 'step', accepted: true,71+ moved: [72+ { id: 'player', from: { x: 2, y: 1 }, to: { x: 2, y: 0 }, pushedBy: null },73+ { id: 'rock', from: { x: 2, y: 1 }, to: { x: 3, y: 1 }, pushedBy: 'player' },74+ ],75+ rulesAdded: [], rulesRemoved: [], destroyed: [],76+ }, 50);77+ assert.equal(juice.frame(95).positions.get('player').facing, 'right');78+ assert.equal(juice.frame(95).positions.get('rock').facing, null);6679 juice.snap(100);67- assert.equal(juice.frame(100).positions.get('player').facing, 'up');80+ assert.equal(juice.frame(100).positions.get('player').facing, 'right');6881 juice.resetLevel(state([object('player', 3, 3)]), 120);6982 assert.equal(juice.frame(120).positions.get('player').facing, null);7083 });7184 85+test('horizontal facing changes use an independent 70ms glyph flip', () => {86+ const juice = createJuice({ now: () => 0 });87+ juice.syncState(state([object('player', 0, 0)]), 0);88+ juice.consume({ kind: 'step', accepted: true, moved: [{ id: 'player', from: { x: 0, y: 0 }, to: { x: 1, y: 0 }, pushedBy: null }], rulesAdded: [], rulesRemoved: [], destroyed: [] }, 0);89+ assert.ok(Math.abs(juice.frame(35).positions.get('player').glyphScaleX - 0.2) < 1e-9);90+ assert.ok(Math.abs(juice.frame(70).positions.get('player').glyphScaleX - 1) < 1e-9);91+ const reduced = createJuice({ reducedMotion: true, now: () => 0 });92+ reduced.syncState(state([object('player', 0, 0)]), 0);93+ reduced.consume({ kind: 'step', accepted: true, moved: [{ id: 'player', from: { x: 0, y: 0 }, to: { x: 1, y: 0 }, pushedBy: null }], rulesAdded: [], rulesRemoved: [], destroyed: [] }, 0);94+ assert.equal(reduced.frame(35).positions.get('player').glyphScaleX, 1);95+});96+97+test('undo restores each entity previous facing', () => {98+ const juice = createJuice({ now: () => 0 });99+ juice.syncState(state([object('player', 0, 0)]), 0);100+ const move = (from, to, at) => juice.consume({ kind: 'step', accepted: true, moved: [{ id: 'player', from, to, pushedBy: null }], rulesAdded: [], rulesRemoved: [], destroyed: [] }, at);101+ move({ x: 0, y: 0 }, { x: 1, y: 0 }, 0);102+ move({ x: 1, y: 0 }, { x: 0, y: 0 }, 100);103+ assert.equal(juice.frame(100).positions.get('player').facing, 'left');104+ juice.consume({ kind: 'undo', accepted: true, moved: [{ id: 'player', from: { x: 0, y: 0 }, to: { x: 1, y: 0 }, pushedBy: null }], rulesAdded: [], rulesRemoved: [], destroyed: [], restored: true }, 200);105+ assert.equal(juice.frame(200).positions.get('player').facing, 'right');106+});107+108+test('reset clears facing immediately and undo restores nonmoving entities', () => {109+ const juice = createJuice({ now: () => 0 });110+ juice.syncState(state([object('player', 0, 0), object('rock', 2, 0)]), 0);111+ juice.consume({ kind: 'step', accepted: true, moved: [{ id: 'rock', from: { x: 2, y: 0 }, to: { x: 1, y: 0 }, pushedBy: null }], rulesAdded: [], rulesRemoved: [], destroyed: [] }, 0);112+ juice.consume({ kind: 'step', accepted: true, moved: [{ id: 'player', from: { x: 0, y: 0 }, to: { x: 1, y: 0 }, pushedBy: null }], rulesAdded: [], rulesRemoved: [], destroyed: [{ id: 'rock', noun: 'MIAU', at: { x: 1, y: 0 }, cause: 'sink' }] }, 100);113+ juice.consume({ kind: 'undo', accepted: true, moved: [{ id: 'player', from: { x: 1, y: 0 }, to: { x: 0, y: 0 }, pushedBy: null }], rulesAdded: [], rulesRemoved: [], destroyed: [], restored: true }, 200);114+ assert.equal(juice.frame(200).positions.get('rock').facing, 'left');115+ juice.consume({ kind: 'reset', accepted: true, moved: [], rulesAdded: [], rulesRemoved: [], destroyed: [] }, 300);116+ assert.equal(juice.frame(300).positions.get('player').facing, null);117+ assert.equal(juice.frame(300).positions.get('rock').facing, null);118+});119+72120 test('rule formation waits 45ms after movement lands', () => {73121 const juice = createJuice({ now: () => 0 });74122 const rule = { text: 'MIAU IS YOU', subject: 'MIAU', predicate: 'YOU', sourceIds: ['a', 'b', 'c'], axis: 'h' };
M tests/title.test.js +11 -1