- commit
- 261d15564b167bf0b0cb8c63c3eec20db516de23
- parent
- 01058b5fd5f8431f5f8e2153d0ebfbc444d05f3b
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 28 | +++++++++++++++++++--------- |
| M | tests/ | 23 | +++++++++++++++++++++++ |
@@ -1,4 +1,5 @@
11 import { drawBoard } from '../render/board.js';2+import { createGame } from '../engine.js';23 34 const FONT = 'Recursive, ui-monospace, monospace';45 const COLORS = Object.freeze({@@ -26,7 +27,7 @@ function fit(ctx, value, maxWidth, size = 16) {
2627 /** Canvas-only Gift controller. It never installs a pack itself. */2728 export function createGiftScene({ pack = null, collision = false, reducedMotion = false, onAction } = {}) {2829 let preview = pack ? clone(pack) : null;29- let collides = Boolean(collision);30+ let collides = collision ? (typeof collision === 'object' ? clone(collision) : { id: '', name: 'this pack' }) : null;3031 let mode = 'prompt';3132 let selected = 0;3233 let open = Boolean(pack);@@ -34,7 +35,7 @@ export function createGiftScene({ pack = null, collision = false, reducedMotion
3435 const primaryChoices = () => ['keep', 'later'];3536 const collisionChoices = () => ['replace', 'copy', 'later'];3637 const choices = () => mode === 'collision' ? collisionChoices() : primaryChoices();37- function setPack(value, options = {}) { preview = value ? clone(value) : null; collides = Boolean(options.collision ?? collision); mode = 'prompt'; selected = 0; open = Boolean(value); buttons = []; return api; }38+ function setPack(value, options = {}) { const collisionValue = options.collision ?? collision; preview = value ? clone(value) : null; collides = collisionValue ? (typeof collisionValue === 'object' ? clone(collisionValue) : { id: '', name: 'this pack' }) : null; mode = 'prompt'; selected = 0; open = Boolean(value); buttons = []; return api; }3839 function show(value = preview, options = {}) { return setPack(value, options); }3940 function hide() { open = false; buttons = []; return api; }4041 function choose(action = choices()[selected]) {@@ -60,22 +61,31 @@ export function createGiftScene({ pack = null, collision = false, reducedMotion
6061 const title = packName(preview); const author = packAuthor(preview); const levels = levelList(preview); const selectedAction = choices()[selected];6162 ctx.save(); ctx.fillStyle = 'rgba(0,0,0,.78)'; ctx.fillRect(0, 0, width, height);6263 ctx.fillStyle = COLORS.lip; rounded(ctx, left + 8, top + 8, cardWidth, cardHeight, 5); ctx.fill(); ctx.fillStyle = COLORS.board; rounded(ctx, left, top, cardWidth, cardHeight, 5); ctx.fill(); ctx.strokeStyle = COLORS.edge; ctx.lineWidth = 1; rounded(ctx, left, top, cardWidth, cardHeight, 5); ctx.stroke();63- text(ctx, mode === 'collision' ? 'PACK ALREADY HERE' : 'A GIFT FOR YOU', left + 32, top + 38, { size: 12, weight: 800, color: COLORS.accent });64+ text(ctx, mode === 'collision' ? 'ALREADY HERE' : 'A PACK FOR YOU', left + 32, top + 38, { size: 12, weight: 800, color: COLORS.accent });6465 text(ctx, title, left + 32, top + 82, { size: fit(ctx, title, cardWidth - 64, 34), weight: 850 });65- if (author) text(ctx, `BY ${author}`, left + 34, top + 112, { size: 11, weight: 700, color: COLORS.muted });66- text(ctx, `${levels.length} ${levels.length === 1 ? 'PROOF' : 'PROOFS'}`, left + cardWidth - 32, top + 112, { size: 12, weight: 800, align: 'right' });66+ if (mode === 'collision') text(ctx, `You have ${collides?.name || title}. Replace it, or keep both as a copy?`, left + 32, top + 112, { size: fit(ctx, `You have ${collides?.name || title}. Replace it, or keep both as a copy?`, cardWidth - 64, 13), weight: 650, color: COLORS.muted });67+ else text(ctx, `${levels.length} ${levels.length === 1 ? 'proof' : 'proofs'}${author ? ` | by ${author}` : ''}`, left + 34, top + 112, { size: 11, weight: 700, color: COLORS.muted });6768 const first = levels[0]; const previewWidth = Math.min(cardWidth * 0.64, 450); const previewHeight = Math.min(cardHeight * 0.48, 260);68- if (first && Number.isInteger(first.width) && Number.isInteger(first.height)) { const cell = Math.max(18, Math.floor(Math.min(previewWidth / first.width, previewHeight / first.height))); drawBoard(ctx, first, { x: left + 32, y: top + 140, cell, availableWidth: cell * first.width, availableHeight: cell * first.height, palette: preview.palette || {} }); } else text(ctx, 'NO PREVIEW LEVEL', left + 32, top + 205, { size: 13, weight: 800, color: COLORS.muted });69+ if (first && Number.isInteger(first.width) && Number.isInteger(first.height)) {70+ const cell = Math.max(18, Math.floor(Math.min(previewWidth / first.width, previewHeight / first.height)));71+ // The raw pack level has no derived rules. Use the engine snapshot so72+ // active words, sentence bars, YOU markers, and the lost-control overlay73+ // all match how the proof will actually play.74+ const previewGame = createGame(first);75+ const previewState = previewGame.getState();76+ drawBoard(ctx, previewState, { x: left + 32, y: top + 140, cell, availableWidth: cell * first.width, availableHeight: cell * first.height, palette: preview.palette || {}, game: previewGame });77+ } else text(ctx, 'NO PREVIEW LEVEL', left + 32, top + 205, { size: 13, weight: 800, color: COLORS.muted });6978 if (first?.title) text(ctx, first.title, left + 32, top + cardHeight - 92, { size: 13, weight: 800 });70- const labels = mode === 'collision' ? [['replace', 'REPLACE'], ['copy', 'KEEP BOTH AS A COPY'], ['later', 'NOT NOW']] : [['keep', 'KEEP AND PLAY'], ['later', 'NOT NOW']];79+ const labels = mode === 'collision' ? [['replace', 'REPLACE'], ['copy', 'KEEP BOTH AS A COPY'], ['later', 'CANCEL']] : [['keep', 'KEEP AND PLAY'], ['later', 'NOT NOW']];7180 buttons = []; const buttonHeight = 42; const buttonGap = 10; const buttonWidth = Math.min(260, cardWidth - 64); const buttonLeft = left + cardWidth - buttonWidth - 32; const buttonTop = top + cardHeight - 190;7281 labels.forEach(([action, label], index) => { const y = buttonTop + index * (buttonHeight + buttonGap); const active = selectedAction === action; buttons.push({ action, index, x: buttonLeft, y, width: buttonWidth, height: buttonHeight }); ctx.save(); ctx.fillStyle = COLORS.lip; rounded(ctx, buttonLeft + 4, y + 4, buttonWidth, buttonHeight, 4); ctx.fill(); ctx.fillStyle = active ? (action === 'replace' ? COLORS.danger : COLORS.accent) : COLORS.board; rounded(ctx, buttonLeft, y, buttonWidth, buttonHeight, 4); ctx.fill(); ctx.strokeStyle = active ? COLORS.fg : COLORS.edge; ctx.lineWidth = active ? 2 : 1; rounded(ctx, buttonLeft, y, buttonWidth, buttonHeight, 4); ctx.stroke(); text(ctx, label, buttonLeft + buttonWidth / 2, y + buttonHeight / 2, { size: 12, weight: 850, color: active ? COLORS.ink : COLORS.fg, align: 'center' }); ctx.restore(); });73- text(ctx, reducedMotion ? 'ARROWS CHOOSE · ENTER SELECTS · ESC CLOSES' : '↑ ↓ CHOOSE · ENTER SELECTS · ESC CLOSES', left + cardWidth / 2, top + cardHeight - 24, { size: 10, weight: 700, color: COLORS.muted, align: 'center' }); ctx.restore();82+ const hints = [['↑ ↓', 'choose'], ['ENTER', 'select'], ['ESC', 'not now']]; let hintX = left + cardWidth / 2 - 108;83+ hints.forEach(([key, label]) => { ctx.save(); ctx.font = `800 10px ${FONT}`; const width = Math.max(29, ctx.measureText(key).width + 12); ctx.fillStyle = COLORS.board; rounded(ctx, hintX, top + cardHeight - 35, width, 20, 3); ctx.fill(); ctx.strokeStyle = COLORS.edge; ctx.lineWidth = 1; rounded(ctx, hintX, top + cardHeight - 35, width, 20, 3); ctx.stroke(); text(ctx, key, hintX + width / 2, top + cardHeight - 25, { size: 9, weight: 850, color: COLORS.fg, align: 'center' }); text(ctx, label, hintX + width + 6, top + cardHeight - 25, { size: 10, weight: 700, color: COLORS.muted }); hintX += width + 6 + ctx.measureText(label).width + 16; }); ctx.restore();7484 return { x: left, y: top, width: cardWidth, height: cardHeight, selected: selectedAction, now };7585 }7686 const api = { show, hide, setPack, choose, input, hit, draw, get open() { return open; }, get mode() { return mode; }, get selected() { return choices()[selected]; }, get pack() { return preview ? clone(preview) : null; }, get collision() { return collides; } };7787 return Object.freeze(api);7888 }7989 80-export const giftScene = Object.freeze({ id: 'gift', eyebrow: 'A GIFT FOR YOU', title: 'A garden pack has arrived', prompt: 'KEEP AND PLAY', help: 'ENTER KEEP · ESC NOT NOW', create: createGiftScene });90+export const giftScene = Object.freeze({ id: 'gift', eyebrow: 'A PACK FOR YOU', title: 'A pack of proofs has arrived', prompt: 'KEEP AND PLAY', help: 'ENTER KEEP ESC NOT NOW', create: createGiftScene });8191 export default createGiftScene;@@ -37,6 +37,29 @@ test('collision requires an explicit replace or copy choice', () => {
3737 assert.deepEqual(actions.map(action => action.action), ['replace', 'copy']);3838 });3939 40+test('collision metadata carries the installed pack name for the duplicate card', () => {41+ const scene = createGiftScene({ pack, collision: { id: 'word-garden', name: 'Word Garden' } });42+ assert.deepEqual(scene.collision, { id: 'word-garden', name: 'Word Garden' });43+ assert.equal(scene.input('confirm').action, 'collision');44+ assert.equal(scene.mode, 'collision');45+});46+47+test('first-level preview derives active rules through the engine', () => {48+ const labels = [];49+ const scene = createGiftScene({ pack: { ...pack, levels: [{ id: 'intro', title: 'Read the proof', width: 3, height: 2, objects: [50+ { id: 'miau-word', kind: 'word', text: 'MIAU', x: 0, y: 0 },51+ { id: 'is-word', kind: 'word', text: 'IS', x: 1, y: 0 },52+ { id: 'you-word', kind: 'word', text: 'YOU', x: 2, y: 0 },53+ { id: 'miau', kind: 'object', noun: 'MIAU', x: 0, y: 1 },54+ ] }] } });55+ const ctx = {56+ canvas: { width: 800, height: 600 }, save() {}, restore() {}, beginPath() {}, roundRect() {}, rect() {}, fill() {}, stroke() {}, arc() {}, fillRect() {}, moveTo() {}, lineTo() {}, closePath() {}, clip() {}, ellipse() {},57+ fillText(value) { labels.push(String(value)); }, measureText(value) { return { width: String(value).length * 8 }; },58+ };59+ scene.draw(ctx, 0, { width: 800, height: 600 });60+ assert.equal(labels.includes('NOTHING'), false);61+});62+4063 test('escape dismisses without installing', () => {4164 const actions = [];4265 const scene = createGiftScene({ pack, onAction: action => actions.push(action) });