- commit
- a6a5f160bcd52560628edbf438256b4895ad5729
- parent
- ca0e3e67554a6deb15d987b4391d99fc28b96e6d
- author
- Dami <dami.etoile@gmail.com>
| M | mods/ | 2 | +- |
| M | src/ | 11 | ++++----- |
| M | src/ | 36 | ++++++++----------------------- |
| A | src/ | 13 | +++++++++++ |
| M | src/ | 10 | ++++++--- |
| M | src/ | 8 | ++++--- |
| M | src/ | 3 | ++- |
| M | tests/ | 22 | ++++++++++++++++--- |
| M | tests/ | 35 | ++++++++++++++++++++++++++++- |
| A | tests/ | 17 | ++++++++++++++ |
| M | tests/ | 17 | ++++++++++++--- |
| M | tests/ | 2 | +- |
@@ -179,12 +179,11 @@ export function createJuice({ reducedMotion = false, now = () => performance.now
179179 entity.pushed = entry.pushedBy != null;180180 entity.dx = Math.sign(entity.x - entity.fromX);181181 entity.dy = Math.sign(entity.y - entity.fromY);182- // A pushed actor can also move itself later in the same settled turn.183- // Prefer that self movement for facing, even when the merged path began184- // with a push; pure pushes and vertical self moves preserve facing.185- const selfDx = entry.selfDirection?.dx ?? (entry.pushedBy == null ? entity.dx : 0);186- if (selfDx !== 0) {187- const nextFacing = facingFor(selfDx, 0);182+ // Face actual horizontal travel, including a push. If a pushed actor183+ // then moves itself, the self-movement direction is the final intent.184+ const travelDx = entry.selfDirection?.dx || entity.dx;185+ if (travelDx !== 0) {186+ const nextFacing = facingFor(travelDx, 0);188187 if (nextFacing !== entity.facing) entity.faceFlipStart = at;189188 entity.facing = nextFacing;190189 }@@ -1,3 +1,5 @@
1+import { nativeHorizontalFacing } from './facing.js';2+13 /**24 * Ink and signal board renderer.35 *@@ -35,22 +37,6 @@ const PROPERTIES = new Set([
3537 "TICK",3638 ]);3739 const OPERATORS = new Set(["IS", "AND"]);38-// These glyphs depict an actor with a readable horizontal facing. Terrain,39-// flags, doors, and abstract symbols stay untouched when a frame has facing data.40-const DIRECTIONAL_NOUNS = new Set([41- "MIAU",42- "FENN",43- "CAT",44- "DOG",45- "FOX",46- "RABBIT",47- "MOUSE",48- "ROBOT",49- "GHOST",50- "BIRD",51- "FISH",52- "MONSTER",53-]);5440 const FONT = "'Recursive', ui-monospace, monospace";5541 5642 function clamp(value, min, max) {@@ -612,15 +598,11 @@ export function drawBoard(ctx, state, options = {}) {
612598 ctx.textAlign = "center";613599 ctx.textBaseline = "middle";614600 ctx.fillStyle = colors.fg;615- const nativeFacing =616- paletteEntry && typeof paletteEntry === "object"617- ? paletteEntry.faces === "left" || paletteEntry.faces === "right"618- ? paletteEntry.faces619- : null620- : !paletteEntry &&621- DIRECTIONAL_NOUNS.has(String(object.noun || "").toUpperCase())622- ? "left"623- : null;601+ const nativeFacing = nativeHorizontalFacing(602+ object.noun,603+ glyph,604+ paletteEntry,605+ );624606 const actualFacing = horizontalFacing(visual) || nativeFacing;625607 const mirror =626608 nativeFacing &&@@ -630,8 +612,8 @@ export function drawBoard(ctx, state, options = {}) {
630612 const glyphScaleX = Number.isFinite(visual.glyphScaleX)631613 ? visual.glyphScaleX632614 : 1;633- // The Apple Color Emoji cat faces left by default. Mirror right-facing634- // actors; left-facing and non-directional glyphs keep their native form.615+ // Mirror the emoji from its declared native direction; keep the shadow,616+ // cell geometry, and text unchanged.635617 if (mirror || glyphScaleX !== 1) {636618 ctx.save();637619 ctx.translate(cx, cy);@@ -0,0 +1,13 @@
1+// Native horizontal pose is a property of a rendered glyph. Keep this small:2+// other emoji are directional only when a mod declares their native face.3+export const BUILTIN_FACES = Object.freeze({4+ MIAU: Object.freeze({ glyph: '🐈', direction: 'left' }),5+ CAT: Object.freeze({ glyph: '🐈', direction: 'left' }),6+});7+8+export function nativeHorizontalFacing(noun, glyph, paletteEntry = null) {9+ const declared = paletteEntry && typeof paletteEntry === 'object' ? paletteEntry.faces : null;10+ if (declared === 'left' || declared === 'right') return declared;11+ const builtIn = BUILTIN_FACES[String(noun || '').toUpperCase()];12+ return builtIn?.glyph === glyph ? builtIn.direction : null;13+}@@ -1,3 +1,5 @@
1+import { nativeHorizontalFacing } from './facing.js';2+13 /**24 * Pure canvas renderer for the INDEX map.35 *@@ -103,15 +105,17 @@ function drawNode(ctx, node, center, geometry, current, colors, options) {
103105 ctx.strokeStyle = signal; ctx.lineWidth = Math.max(2, geometry.cell * 0.045); ctx.stroke();104106 ctx.font = `${Math.max(18, geometry.cell * 0.42)}px "Apple Color Emoji", "Segoe UI Emoji", sans-serif`;105107 ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = colors.fg;106- if (options.catFacing === 'right') {108+ const catGlyph = options.cat || '🐈';109+ const nativeFacing = nativeHorizontalFacing('MIAU', catGlyph);110+ if (nativeFacing && options.catFacing && options.catFacing !== nativeFacing) {107111 ctx.save();108112 ctx.translate(center.x, 0);109113 ctx.scale(-1, 1);110114 ctx.translate(-center.x, 0);111- ctx.fillText(options.cat || '🐈', center.x, center.y);115+ ctx.fillText(catGlyph, center.x, center.y);112116 ctx.restore();113117 } else {114- ctx.fillText(options.cat || '🐈', center.x, center.y);118+ ctx.fillText(catGlyph, center.x, center.y);115119 }116120 } else {117121 roundRect(ctx, left + geometry.cell * 0.04, top + geometry.cell * 0.04, tileWidth, tileHeight, geometry.cell * 0.025);@@ -1,5 +1,6 @@
11 import { createGame } from '../engine.js';22 import { drawBoard } from '../render/board.js';3+import { nativeHorizontalFacing } from '../render/facing.js';34 45 /**56 * Playable title scene.@@ -113,13 +114,14 @@ export function createTitleScene(options = {}) {
113114 let renderNow = titleEpoch;114115 let miauFlipStartedAt = null;115116 const facingHistory = [];116- // Apple Color Emoji's native cat faces left. Keep the last accepted117+ // The built-in cat's declared native pose faces left. Keep the last accepted117118 // horizontal direction separately from the engine so blocked and idle118119 // inputs do not make the title character turn.119- let miauFacing = 'left';120+ const nativeMiauFacing = nativeHorizontalFacing('MIAU', '🐈');121+ let miauFacing = nativeMiauFacing;120122 function updateFacing(event) {121123 if (!event?.accepted) return;122- if (event.kind === 'reset') { facingHistory.length = 0; miauFacing = 'left'; return; }124+ if (event.kind === 'reset') { facingHistory.length = 0; miauFacing = nativeMiauFacing; return; }123125 if (event.kind === 'undo') {124126 if (facingHistory.length) {125127 const priorFacing = facingHistory.pop();@@ -5,6 +5,7 @@ import { pauseScene } from "./scenes/pause.js";
55 import { winScene } from "./scenes/win.js";66 import { drawBoard } from "./render/board.js";77 import { drawIndex } from "./render/index.js";8+import { nativeHorizontalFacing } from './render/facing.js';89 import { createGiftScene } from "./scenes/gift.js";910 import { createWorkshopScene } from "./scenes/workshop.js";1011 @@ -47,7 +48,7 @@ export function createStage(options = {}) {
4748 let indexNavigator = null;4849 let indexGeometry = null;4950 let indexLockedNode = null;50- let indexFacing = "left";51+ let indexFacing = nativeHorizontalFacing('MIAU', '🐈');5152 let title = options.title || null;5253 let gift = options.gift || null;5354 let workshop = options.workshop || null;@@ -88,17 +88,33 @@ test('drawBoard keeps native left-facing actors and mirrors right-facing actors'
8888 assert.ok(ctx.calls.some(call => call[0] === 'scale' && call[1] === -1 && call[2] === 1));8989 });9090 91-test('drawBoard only flips mod palette glyphs with explicit faces metadata', () => {91+test('drawBoard keeps custom plain glyphs neutral and flips explicit mod faces', () => {9292 const ctx = context();9393 const frame = { positions: new Map([['cat', { x: 1, y: 1, facing: 'right' }]]), sentences: [], effects: [], shake: { amplitude: 0 }, tint: null, wipe: null };94- drawBoard(ctx, state, { x: 20, y: 10, cell: 50, palette: { CAT: '🐈' }, frame });94+ const custom = { ...state, objects: state.objects.map(object => object.id === 'cat' ? { ...object, noun: 'UNICORN' } : object) };95+ drawBoard(ctx, custom, { x: 20, y: 10, cell: 50, palette: { UNICORN: '🦄' }, frame });9596 assert.equal(ctx.calls.some(call => call[0] === 'scale' && call[1] === -1 && call[2] === 1), false);9697 ctx.calls.length = 0;9798 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 });99+ drawBoard(ctx, custom, { x: 20, y: 10, cell: 50, palette: { UNICORN: { glyph: '🦄', faces: 'right' } }, frame });99100 assert.ok(ctx.calls.some(call => call[0] === 'scale' && call[1] === -1 && call[2] === 1));100101 });101102 103+test('drawBoard faces MIAU forward when a bundled pack repeats the cat as a string', () => {104+ const ctx = context();105+ const miauState = {106+ ...state,107+ objects: state.objects.map(object => object.id === 'cat' ? { ...object, noun: 'MIAU' } : object),108+ };109+ const frame = { positions: new Map([['cat', { x: 1, y: 1, facing: 'right' }]]), sentences: [], effects: [] };110+ drawBoard(ctx, miauState, { x: 20, y: 10, cell: 50, palette: { MIAU: '🐈' }, frame });111+ assert.ok(ctx.calls.some(call => call[0] === 'scale' && call[1] === -1 && call[2] === 1));112+ ctx.calls.length = 0;113+ frame.positions.set('cat', { x: 1, y: 1, facing: 'left' });114+ drawBoard(ctx, miauState, { x: 20, y: 10, cell: 50, palette: { MIAU: '🐈' }, frame });115+ assert.equal(ctx.calls.some(call => call[0] === 'scale' && call[1] === -1 && call[2] === 1), false);116+});117+102118 test('drawBoard compares movement facing with native mod facing and isolates glyph squash', () => {103119 const ctx = context();104120 const stateWithModActor = {@@ -86,6 +86,26 @@ try {
8686 await screenshot('automata-desktop-initial.png');8787 check('desktop proof starts with FENN at (4,3), phase 0');8888 89+ // This pack deliberately repeats MIAU's 🐈 as a plain palette string.90+ // A successful right step must still use the cat's native-left face data.91+ await page.keyboard.press('ArrowRight');92+ await page.waitForFunction(() => JSON.parse(window.render_game_to_text()).moves === 1, { timeout });93+ current = await state();94+ assert.deepEqual(95+ { x: current.objects.find(object => object.kind === 'object' && object.noun === 'MIAU').x,96+ y: current.objects.find(object => object.kind === 'object' && object.noun === 'MIAU').y },97+ { x: 1, y: 3 },98+ );99+ await screenshot('automata-desktop-miau-right.png');100+ await page.keyboard.press('ArrowLeft');101+ await page.waitForFunction(() => JSON.parse(window.render_game_to_text()).moves === 2, { timeout });102+ await screenshot('automata-desktop-miau-left.png');103+ await page.keyboard.press('z');104+ await page.waitForFunction(() => JSON.parse(window.render_game_to_text()).moves === 1, { timeout });105+ await page.keyboard.press('z');106+ await page.waitForFunction(() => JSON.parse(window.render_game_to_text()).moves === 0, { timeout });107+ check('desktop MIAU turns right then left with a plain-string cat palette, and Undo restores both turns');108+89109 await page.keyboard.press('Space');90110 await page.waitForFunction(() => JSON.parse(window.render_game_to_text()).moves === 1, { timeout });91111 current = await state();@@ -128,6 +148,19 @@ try {
128148 assert.deepEqual({ x: initialMobileFenn.x, y: initialMobileFenn.y }, { x: 4, y: 3 });129149 await mobilePage.waitForTimeout(750);130150 await mobilePage.screenshot({ path: `${screenshots}/automata-mobile-initial.png` });151+ await mobilePage.keyboard.press('ArrowRight');152+ await mobilePage.waitForFunction(() => JSON.parse(window.render_game_to_text()).moves === 1, { timeout });153+ await mobilePage.waitForTimeout(750);154+ await mobilePage.screenshot({ path: `${screenshots}/automata-mobile-miau-right.png` });155+ await mobilePage.keyboard.press('ArrowLeft');156+ await mobilePage.waitForFunction(() => JSON.parse(window.render_game_to_text()).moves === 2, { timeout });157+ await mobilePage.waitForTimeout(750);158+ await mobilePage.screenshot({ path: `${screenshots}/automata-mobile-miau-left.png` });159+ await mobilePage.keyboard.press('z');160+ await mobilePage.waitForFunction(() => JSON.parse(window.render_game_to_text()).moves === 1, { timeout });161+ await mobilePage.keyboard.press('z');162+ await mobilePage.waitForFunction(() => JSON.parse(window.render_game_to_text()).moves === 0, { timeout });163+ check('mobile MIAU right and left frames and Undo complete');131164 await mobilePage.mouse.click(195, 770);132165 await mobilePage.waitForFunction(() => JSON.parse(window.render_game_to_text()).moves === 1, { timeout });133166 mobileState = await mobilePage.evaluate(() => JSON.parse(window.render_game_to_text()));@@ -143,7 +176,7 @@ try {
143176 assert.deepEqual(consoleErrors, [], 'desktop browser should have no console/page errors');144177 check('desktop and mobile complete without console errors');145178 process.stdout.write(`shareUrl=${shareUrl}\n`);146- process.stdout.write(`screenshots=${screenshots}/automata-desktop-initial.png,${screenshots}/automata-desktop-wait.png,${screenshots}/automata-mobile-initial.png,${screenshots}/automata-mobile-wait.png\n`);179+ process.stdout.write(`screenshots=${screenshots}/automata-desktop-miau-right.png,${screenshots}/automata-desktop-miau-left.png,${screenshots}/automata-mobile-miau-right.png,${screenshots}/automata-mobile-miau-left.png\n`);147180 } finally {148181 await context.close();149182 await browser.close();@@ -0,0 +1,17 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { nativeHorizontalFacing } from '../src/render/facing.js';4+5+test('native face follows the built-in cat glyph through plain palette strings', () => {6+ assert.equal(nativeHorizontalFacing('MIAU', '🐈'), 'left');7+ assert.equal(nativeHorizontalFacing('MIAU', '🐈', '🐈'), 'left');8+ assert.equal(nativeHorizontalFacing('CAT', '🐈', '🐈'), 'left');9+ assert.equal(nativeHorizontalFacing('MIAU', '🐱', '🐱'), null);10+ assert.equal(nativeHorizontalFacing('FENN', '🦊', '🦊'), null);11+});12+13+test('a mod may declare the native face of another profile glyph', () => {14+ assert.equal(nativeHorizontalFacing('UNICORN', '🦄', { glyph: '🦄', faces: 'right' }), 'right');15+ assert.equal(nativeHorizontalFacing('UNICORN', '🦄', '🦄'), null);16+ assert.equal(nativeHorizontalFacing('MIAU', '🐈', { glyph: '🐈', faces: 'right' }), 'right');17+});@@ -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, null);31+ assert.equal(juice.frame(50).positions.get('rock').facing, 'right');3232 });3333 3434 test('snapping before a level transition prevents reused ids from drawing at old cells', () => {@@ -57,7 +57,7 @@ test('resetLevel clears old sentences, effects, and win beats before seeding the
5757 assert.ok(Math.abs(frame.positions.get('a').glyphScaleX - 1) < 1e-9);5858 });5959 60-test('facing follows horizontal self movement, preserves through pushes and vertical moves, and resets between levels', () => {60+test('facing follows horizontal movement and preserves through vertical moves and level changes', () => {6161 const juice = createJuice({ now: () => 0 });6262 juice.syncState(state([object('player', 1, 1), object('rock', 2, 1)]), 0);6363 juice.consume({@@ -75,7 +75,7 @@ test('facing follows horizontal self movement, preserves through pushes and vert
7575 rulesAdded: [], rulesRemoved: [], destroyed: [],7676 }, 50);7777 assert.equal(juice.frame(95).positions.get('player').facing, 'right');78- assert.equal(juice.frame(95).positions.get('rock').facing, null);78+ assert.equal(juice.frame(95).positions.get('rock').facing, 'right');7979 juice.snap(100);8080 assert.equal(juice.frame(100).positions.get('player').facing, 'right');8181 juice.resetLevel(state([object('player', 3, 3)]), 120);@@ -105,6 +105,17 @@ test('undo restores each entity previous facing', () => {
105105 assert.equal(juice.frame(200).positions.get('player').facing, 'right');106106 });107107 108+test('a pushed MIAU faces its actual horizontal travel and Undo restores its prior pose', () => {109+ const juice = createJuice({ now: () => 0 });110+ juice.syncState(state([object('miau', 1, 1)]), 0);111+ juice.consume({ kind: 'step', accepted: true, moved: [{ id: 'miau', from: { x: 1, y: 1 }, to: { x: 2, y: 1 }, pushedBy: 'rock' }], rulesAdded: [], rulesRemoved: [], destroyed: [] }, 0);112+ assert.equal(juice.frame(90).positions.get('miau').facing, 'right');113+ juice.consume({ kind: 'blocked', accepted: false, blocked: { id: 'miau', reason: 'bounds' }, moved: [] }, 100);114+ assert.equal(juice.frame(100).positions.get('miau').facing, 'right');115+ juice.consume({ kind: 'undo', accepted: true, moved: [{ id: 'miau', from: { x: 2, y: 1 }, to: { x: 1, y: 1 } }], rulesAdded: [], rulesRemoved: [], destroyed: [] }, 200);116+ assert.equal(juice.frame(200).positions.get('miau').facing, null);117+});118+108119 test('reset clears facing immediately and undo restores nonmoving entities', () => {109120 const juice = createJuice({ now: () => 0 });110121 juice.syncState(state([object('player', 0, 0), object('rock', 2, 0)]), 0);@@ -22,7 +22,7 @@ test('loads the minimal playable fox automata proof', () => {
2222 assert.equal(mod.levels[0].automata[0].property, 'TICK');2323 assert.equal(mod.levels[0].automata[0].subject, undefined);2424 assert.deepEqual(mod.levels[0].automata[0].pattern[0], { action: 'move', dx: -1, dy: 0, cue: '🦊', sound: 'tick' });25- assert.equal(mod.palette.FENN.glyph, '🦊');25+ assert.equal(mod.palette.FENN, '🦊');2626 assert.deepEqual(validateMod(mod), []);2727 });2828