- commit
- 248a839c9ce510e2353da66a755e2fb88dc70a07
- parent
- e405226a6545762a5afbc09b4215d0b9c55684b6
- author
- Dami <dami.etoile@gmail.com>
| A | src/ | 81 | ++++++++++++++++++++++++++++++ |
| A | tests/ | 47 | +++++++++++++++++ |
@@ -0,0 +1,81 @@
1+import { drawBoard } from '../render/board.js';2+3+const FONT = 'Recursive, ui-monospace, monospace';4+const COLORS = Object.freeze({5+ stage: '#0F0E0D', board: '#191816', edge: '#2E2B28', lip: '#000000',6+ tile: '#F2EEE6', ink: '#0F0E0D', fg: '#F2EEE6', muted: '#8E877C',7+ YOU: '#FF4B3E', WIN: '#2EE6A8', accent: '#FFB020', danger: '#FF4FA3',8+});9+10+const clone = value => typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));11+const manifestOf = pack => pack?.manifest || pack || {};12+const levelList = pack => Array.isArray(pack?.levels) ? pack.levels : [];13+const packName = pack => manifestOf(pack).name || pack?.name || 'Untitled pack';14+const packAuthor = pack => manifestOf(pack).author || pack?.author || '';15+const rounded = (ctx, x, y, width, height, radius = 4) => { ctx.beginPath(); if (typeof ctx.roundRect === 'function') ctx.roundRect(x, y, width, height, radius); else ctx.rect(x, y, width, height); };16+function text(ctx, value, x, y, options = {}) {17+ const { size = 16, weight = 700, color = COLORS.fg, align = 'left', alpha = 1, baseline = 'middle' } = options;18+ ctx.save(); ctx.globalAlpha *= alpha; ctx.font = `${weight} ${size}px ${FONT}`; ctx.fillStyle = color; ctx.textAlign = align; ctx.textBaseline = baseline; ctx.fillText(String(value), x, y); ctx.restore();19+}20+function fit(ctx, value, maxWidth, size = 16) {21+ let current = size;22+ while (current > 9) { ctx.font = `800 ${current}px ${FONT}`; if (ctx.measureText(value).width <= maxWidth) break; current -= 1; }23+ return current;24+}25+26+/** Canvas-only Gift controller. It never installs a pack itself. */27+export function createGiftScene({ pack = null, collision = false, reducedMotion = false, onAction } = {}) {28+ let preview = pack ? clone(pack) : null;29+ let collides = Boolean(collision);30+ let mode = 'prompt';31+ let selected = 0;32+ let open = Boolean(pack);33+ let buttons = [];34+ const primaryChoices = () => ['keep', 'later'];35+ const collisionChoices = () => ['replace', 'copy', 'later'];36+ 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 show(value = preview, options = {}) { return setPack(value, options); }39+ function hide() { open = false; buttons = []; return api; }40+ function choose(action = choices()[selected]) {41+ if (!open || !preview) return { ok: false, action: null };42+ if (action === 'keep' && collides) { mode = 'collision'; selected = 0; return { ok: true, action: 'collision' }; }43+ const result = { ok: true, action, pack: clone(preview), install: action === 'keep' ? {} : action === 'replace' ? { replace: true } : action === 'copy' ? { keepBoth: true } : null };44+ hide(); onAction?.(result); return result;45+ }46+ function move(direction) { const values = choices(); if (direction === 'up' || direction === 'left') selected = (selected - 1 + values.length) % values.length; if (direction === 'down' || direction === 'right') selected = (selected + 1) % values.length; return values[selected]; }47+ function input(action) {48+ if (!open) return { ok: false, action: null };49+ if (action === 'up' || action === 'down' || action === 'left' || action === 'right') return { ok: true, selected: move(action) };50+ if (action === 'confirm' || action === 'enter') return choose();51+ if (action === 'back' || action === 'escape' || action === 'cancel') return choose('later');52+ return { ok: false, action: null };53+ }54+ function hit(x, y) { const button = buttons.find(item => x >= item.x && x <= item.x + item.width && y >= item.y && y <= item.y + item.height); if (!button) return { ok: false, action: null }; selected = button.index; return choose(button.action); }55+ function draw(ctx, now = 0, options = {}) {56+ if (!open || !preview || !ctx) return null;57+ const width = options.width || ctx.canvas?.width || 800;58+ const height = options.height || ctx.canvas?.height || 600;59+ const cardWidth = Math.min(720, width - 48); const cardHeight = Math.min(520, height - 48); const left = (width - cardWidth) / 2; const top = (height - cardHeight) / 2;60+ const title = packName(preview); const author = packAuthor(preview); const levels = levelList(preview); const selectedAction = choices()[selected];61+ ctx.save(); ctx.fillStyle = 'rgba(0,0,0,.78)'; ctx.fillRect(0, 0, width, height);62+ 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, 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' });67+ 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?.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']];71+ 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;72+ 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();74+ return { x: left, y: top, width: cardWidth, height: cardHeight, selected: selectedAction, now };75+ }76+ 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; } };77+ return Object.freeze(api);78+}79+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 });81+export default createGiftScene;@@ -0,0 +1,47 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { createGiftScene, giftScene } from '../src/scenes/gift.js';4+5+const pack = {6+ manifest: { id: 'word-garden', name: 'Word Garden', author: 'A. Designer' },7+ palette: { MIAU: '🐈' },8+ levels: [{ id: 'intro', title: 'Read the board', width: 3, height: 2, objects: [] }],9+};10+11+test('gift scene exposes preview metadata and explicit keep/later actions', () => {12+ const actions = [];13+ const scene = createGiftScene({ pack, onAction: action => actions.push(action) });14+ assert.equal(scene.open, true);15+ assert.equal(scene.mode, 'prompt');16+ assert.equal(scene.pack.manifest.name, 'Word Garden');17+ assert.equal(scene.selected, 'keep');18+ assert.equal(scene.input('confirm').action, 'keep');19+ assert.deepEqual(actions[0].install, {});20+ assert.equal(scene.open, false);21+ assert.equal(giftScene.id, 'gift');22+});23+24+test('collision requires an explicit replace or copy choice', () => {25+ const actions = [];26+ const scene = createGiftScene({ pack, collision: true, onAction: action => actions.push(action) });27+ assert.equal(scene.input('confirm').action, 'collision');28+ assert.equal(scene.mode, 'collision');29+ assert.equal(scene.selected, 'replace');30+ assert.deepEqual(scene.input('confirm').install, { replace: true });31+32+ scene.show(pack, { collision: true });33+ scene.input('confirm');34+ scene.input('down');35+ assert.equal(scene.selected, 'copy');36+ assert.deepEqual(scene.input('confirm').install, { keepBoth: true });37+ assert.deepEqual(actions.map(action => action.action), ['replace', 'copy']);38+});39+40+test('escape dismisses without installing', () => {41+ const actions = [];42+ const scene = createGiftScene({ pack, onAction: action => actions.push(action) });43+ assert.equal(scene.input('escape').action, 'later');44+ assert.equal(scene.open, false);45+ assert.equal(actions.length, 1);46+ assert.equal(actions[0].install, null);47+});