- commit
- 4a2a019ec2cd1b0ebb0a2ca4ef14acca037b6c07
- parent
- 739d277d1107113fcddf08cf8565d6772a746dec
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 24 | +++++++++++++++++++++++- |
| M | tests/ | 29 | +++++++++++++++++++++++++++++ |
@@ -14,6 +14,9 @@ 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+// These glyphs depict an actor with a readable horizontal facing. Terrain,18+// 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']);1720 const FONT = "'Recursive', ui-monospace, monospace";1821 1922 function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); }@@ -38,6 +41,12 @@ function propertiesFor(object, state, game) {
3841 if (object.kind === 'word') return new Set(['PUSH']);3942 return new Set((state.rules || []).filter(rule => rule.subject === object.noun && PROPERTIES.has(rule.predicate)).map(rule => rule.predicate));4043 }44+function horizontalFacing(visual) {45+ const facing = visual?.facing;46+ if (typeof facing === 'string') return facing.toLowerCase() === 'left' ? 'left' : facing.toLowerCase() === 'right' ? 'right' : null;47+ const dx = Number.isFinite(facing?.x) ? facing.x : Number.isFinite(visual?.dx) ? visual.dx : null;48+ return dx < 0 ? 'left' : dx > 0 ? 'right' : null;49+}4150 function boardGeometry(ctx, state, options) {4251 const width = state.width || 0;4352 const height = state.height || 0;@@ -238,7 +247,20 @@ export function drawBoard(ctx, state, options = {}) {
238247 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();239248 }240249 ctx.font = `${Math.max(14, geometry.cell * 0.6)}px "Apple Color Emoji", "Segoe UI Emoji", sans-serif`;241- ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = colors.fg; ctx.fillText(glyph, cx, cy);250+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = colors.fg;251+ const facing = DIRECTIONAL_NOUNS.has(String(object.noun || '').toUpperCase()) ? horizontalFacing(visual) : null;252+ // The Apple Color Emoji cat faces left by default. Mirror right-facing253+ // actors; left-facing and non-directional glyphs keep their native form.254+ if (facing === 'right') {255+ ctx.save();256+ ctx.translate(cx, 0);257+ ctx.scale(-1, 1);258+ ctx.translate(-cx, 0);259+ ctx.fillText(glyph, cx, cy);260+ ctx.restore();261+ } else {262+ ctx.fillText(glyph, cx, cy);263+ }242264 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); }243265 if (scaleX !== 1 || scaleY !== 1) ctx.restore();244266 }@@ -74,6 +74,35 @@ test('drawBoard consumes juice positions and scales moving entities', () => {
7474 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '🐈'));7575 });7676 77+test('drawBoard keeps native left-facing actors and mirrors right-facing actors', () => {78+ const ctx = context();79+ const frame = {80+ positions: new Map([['cat', { x: 1, y: 1, facing: 'left' }]]),81+ sentences: [], effects: [], shake: { amplitude: 0 }, tint: null, wipe: null,82+ };83+ drawBoard(ctx, state, { x: 20, y: 10, cell: 50, palette: { CAT: '🐈' }, frame });84+ assert.equal(ctx.calls.some(call => call[0] === 'scale' && call[1] === -1 && call[2] === 1), false);85+ ctx.calls.length = 0;86+ frame.positions.set('cat', { x: 1, y: 1, facing: 'right' });87+ drawBoard(ctx, state, { x: 20, y: 10, cell: 50, palette: { CAT: '🐈' }, frame });88+ assert.ok(ctx.calls.some(call => call[0] === 'scale' && call[1] === -1 && call[2] === 1));89+});90+91+test('drawBoard leaves a non-directional KEKE paw glyph unchanged', () => {92+ const ctx = context();93+ const kekeState = {94+ ...state,95+ objects: state.objects.map(object => object.id === 'cat' ? { ...object, noun: 'KEKE' } : object),96+ };97+ drawBoard(ctx, kekeState, {98+ x: 20, y: 10, cell: 50,99+ palette: { KEKE: '🐾' },100+ frame: { positions: new Map([['cat', { x: 1, y: 1, facing: 'right' }]]), sentences: [], effects: [] },101+ });102+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '🐾'));103+ assert.equal(ctx.calls.some(call => call[0] === 'scale' && call[1] === -1 && call[2] === 1), false);104+});105+77106 test('drawBoard animates sentence bars from frozen cells and renders frame overlays', () => {78107 const ctx = context();79108 const frame = {