From b26297711725b18edb38c25f40b890c93f00b51f Mon Sep 17 00:00:00 2001 From: Andrea Manzi Date: Tue, 27 Oct 2026 22:57:01 +0100 Subject: [PATCH] Il carosello dei Ringraziamenti si ferma, si sfoglia e ingrandisce le foto Tutti gli sfondi del paese (game.reelPictures, uno per scena, col nome del posto) e ogni ritratto una volta sola (prima tre si ripetevano). Scorre da solo ma si ferma sotto il mouse, si trascina col mouse o col dito, si spinge con la rotella, con le frecce ai lati e con quelle della tastiera; un tocco su una foto la mostra grande col nome (il tocco si legge al rilascio: con la cattura del puntatore il clic non arriva all'immagine). Test della fila e prova nel browser. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GiECdBYE8csKd4tcFvwntT --- index.html | 2 +- src/engine/game.js | 16 +++++++++ src/main.js | 83 ++++++++++++++++++++++++++++++++++---------- style.css | 18 +++++++--- tests/browser.mjs | 31 +++++++++++++++++ tests/story.test.mjs | 16 ++++++++- 6 files changed, 141 insertions(+), 25 deletions(-) diff --git a/index.html b/index.html index af13ceb..91e5fed 100644 --- a/index.html +++ b/index.html @@ -101,7 +101,7 @@

Ringraziamenti

Al Lele, che mi ha fatto conoscere Monkey Island.

- +
diff --git a/src/engine/game.js b/src/engine/game.js index d490747..a8812a8 100644 --- a/src/engine/game.js +++ b/src/engine/game.js @@ -70,6 +70,22 @@ export function shuffledCredits(credits, random = Math.random) { }); } +// Le immagini del carosello dei Ringraziamenti: tutti gli sfondi del paese (uno per scena, senza doppioni) col nome +// del posto, e un ritratto dei crediti ogni quattro sfondi; i ritratti che non trovano posto chiudono la fila. +export function reelPictures(story) { + const seen = new Set(), scenes = []; + for (const s of Object.values(story.scenes)) { + if (!s.background || seen.has(s.background)) continue; + seen.add(s.background); + scenes.push({ src: s.background, name: s.name, kind: 'scene' }); + } + const faces = (story.credits ?? []).flatMap(c => c.names).filter(n => n.face).map(facePicture); + let next = 0; + const placed = scenes.flatMap((p, i) => i % 4 === 3 && next < faces.length ? [p, faces[next++]] : [p]); + return [...placed, ...faces.slice(next)]; +} +export const facePicture = n => ({ src: n.face, name: n.name, kind: n.face.endsWith('palio.png') ? 'palio' : 'face' }); + // La ricerca della mappa: ogni parola scritta deve stare nel nome del posto, senza badare a maiuscole, accenti e // apostrofi («camp» trova Campomigliaio, «de medici» trova Via de' Medici). const plain = text => text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, ' ').trim(); diff --git a/src/main.js b/src/main.js index 9ccbb3c..3231bec 100644 --- a/src/main.js +++ b/src/main.js @@ -1,6 +1,6 @@ // Avvio nel browser: collega motore, disegno e interfaccia HTML (verbi, inventario, dialoghi, fumetti). import { story } from './story/story.js'; -import { Game, VERBS, VERB_LABELS, checkSave, introText, shuffledCredits, matchPlace } from './engine/game.js'; +import { Game, VERBS, VERB_LABELS, checkSave, introText, shuffledCredits, matchPlace, reelPictures, facePicture } from './engine/game.js'; import { Command } from './engine/command.js'; import { render, loadAssets, hasBackground, WIDTH, HEIGHT } from './engine/render.js'; import { readSave, writeSave, clearSave, readAudio, writeAudio, saveFile, saveFileName, readSaveFile } from './engine/storage.js'; @@ -125,7 +125,7 @@ function openCredits() { if (typeof n === 'string') return Object.assign(document.createElement('span'), { textContent: n }); // Chi ha il ritratto: il nome è un pulsante che lo mostra. const b = Object.assign(document.createElement('button'), { type: 'button', className: 'credit-face', textContent: n.name }); - b.addEventListener('click', () => showFace(n)); + b.addEventListener('click', () => showFace(facePicture(n))); return b; })); return [dt, dd]; @@ -133,22 +133,64 @@ function openCredits() { fillReel(); $('credits').hidden = false; rollCredits(); + rollReel(); } -// Sotto i nomi, un carosello che scorre da solo: gli sfondi del paese e i ritratti, due giri uguali in fila per non -// vedere mai la cucitura. +// Sotto i nomi, il carosello: tutti gli sfondi del paese e i ritratti (game.reelPictures), due giri uguali in fila per +// non vedere mai la cucitura. Scorre da solo; si ferma sotto il mouse, si trascina, si spinge con la rotella, le +// frecce ai lati o quelle della tastiera, e toccando una foto la si vede grande (Andrea, 27 ottobre). +const reelTrack = $('credits-reel').querySelector('.track'); +let reeling = 0, reelWait = 0, reelDrag = null, reelDragged = false, reelTap = null; +const reelPics = new WeakMap(); function fillReel() { - const track = $('credits-reel').querySelector('.track'); - if (track.children.length) return; - const pictures = [...new Set(Object.values(story.scenes).map(s => s.background).filter(Boolean))]; - const faces = (story.credits ?? []).flatMap(c => c.names).filter(n => n.face).map(n => n.face); - // I ritratti sparsi fra gli sfondi, uno ogni quattro. - const all = pictures.flatMap((p, i) => i % 4 === 3 && faces.length ? [p, faces[(i >> 2) % faces.length]] : [p]); - for (const src of [...all, ...all]) { - const img = Object.assign(document.createElement('img'), { src, alt: '', loading: 'lazy', decoding: 'async' }); - if (src.includes('/credits/')) img.className = src.endsWith('palio.png') ? 'palio' : 'face'; - track.append(img); + if (reelTrack.children.length) return; + const all = reelPictures(story); + for (const p of [...all, ...all]) { + const img = Object.assign(document.createElement('img'), { src: versioned(p.src), alt: p.name, title: p.name, className: p.kind, loading: 'lazy', decoding: 'async', draggable: false }); + reelPics.set(img, p); + reelTrack.append(img); } } +function rollReel() { + cancelAnimationFrame(reeling); + reelTrack.scrollLeft = 0; + reelWait = 0; + if (matchMedia('(prefers-reduced-motion: reduce)').matches) return; + let last = performance.now(), pos = 0; + const step = now => { + if ($('credits').hidden) return; + const dt = now - last; last = now; + const half = reelTrack.scrollWidth / 2; + if (reelTrack.matches(':hover') || reelDrag || !$('face').hidden || (reelWait -= dt) > 0) pos = reelTrack.scrollLeft; + else { pos += dt * 0.05; if (pos >= half) pos -= half; reelTrack.scrollLeft = pos; } + reeling = requestAnimationFrame(step); + }; + reeling = requestAnimationFrame(step); +} +// Di una foto alla volta, con le frecce. +function nudgeReel(dir) { + reelWait = 4000; + reelTrack.scrollBy({ left: dir * ((reelTrack.firstElementChild?.offsetWidth ?? 300) + 10), behavior: 'smooth' }); +} +for (const b of $('credits-reel').querySelectorAll('.arrow')) b.addEventListener('click', () => nudgeReel(b.classList.contains('next') ? 1 : -1)); +// Col dito si trascina da sé (touch-action: pan-x); col mouse lo facciamo noi. Un tocco fermo su una foto la mostra +// grande: si guarda al rilascio, perché con la cattura del puntatore il clic non arriva più all'immagine. +reelTrack.addEventListener('pointerdown', e => { + reelDragged = false; + reelTap = reelPics.get(e.target) ?? null; + if (e.pointerType !== 'mouse') { reelWait = 4000; return; } + reelDrag = { x: e.clientX, left: reelTrack.scrollLeft, id: e.pointerId }; + reelTrack.setPointerCapture(e.pointerId); +}); +reelTrack.addEventListener('pointermove', e => { + if (!reelDrag) return; + const dx = e.clientX - reelDrag.x; + if (Math.abs(dx) > 6) reelDragged = true; + reelTrack.scrollLeft = reelDrag.left - dx; +}); +const endReelDrag = () => { if (reelDrag && reelDragged) reelWait = 4000; reelDrag = null; }; +reelTrack.addEventListener('pointerup', () => { if (!reelDragged && reelTap) showFace(reelTap); reelTap = null; endReelDrag(); }); +reelTrack.addEventListener('pointercancel', () => { reelTap = null; endReelDrag(); }); +reelTrack.addEventListener('wheel', e => { reelWait = 4000; if (e.deltaY && !e.deltaX) { reelTrack.scrollLeft += e.deltaY; e.preventDefault(); } }, { passive: false }); // I nomi salgono piano da soli; si fermano se ci si passa sopra o si tocca, e ricominciano dall'alto in fondo. let rolling = 0, rollWait = 0; $('credits-list').addEventListener('pointerdown', () => { rollWait = 4000; }); @@ -170,9 +212,10 @@ function rollCredits() { }; rolling = requestAnimationFrame(step); } -function showFace({ name, face }) { - $('face').querySelector('img').src = versioned(face); - $('face').querySelector('img').classList.toggle('palio', face.endsWith('palio.png')); +function showFace({ name, src, kind }) { + const img = $('face').querySelector('img'); + img.src = versioned(src); + img.className = kind; $('face').querySelector('figcaption').textContent = name; $('face').hidden = false; } @@ -631,7 +674,11 @@ window.addEventListener('keydown', e => { } if (debugKey(e)) return; if (!$('tutorial').hidden) { if (e.key === 'Escape') closeTutorial(); return; } - if (!$('credits').hidden) { if (e.key === 'Escape') { if (!$('face').hidden) $('face').hidden = true; else $('credits').hidden = true; } return; } + if (!$('credits').hidden) { + if (e.key === 'Escape') { if (!$('face').hidden) $('face').hidden = true; else $('credits').hidden = true; } + else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { if ($('face').hidden) nudgeReel(e.key === 'ArrowRight' ? 1 : -1); } + return; + } if (!$('pause').hidden) { if (e.key === 'Escape') closePause(); return; } if (!$('notebook').hidden) { if (['Escape', 'n', 'N'].includes(e.key)) $('notebook').hidden = true; return; } if (!$('map').hidden) { if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') $('map').hidden = true; return; } diff --git a/style.css b/style.css index d4cc190..71b93d8 100644 --- a/style.css +++ b/style.css @@ -259,10 +259,18 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins #credits dt { text-align: right; color: var(--dim); font-size: clamp(7.2px, 1.01vw, 10.08px); letter-spacing: .12em; text-transform: uppercase; } #credits dd span { display: block; } /* I nomi col ritratto: si toccano e compare il volto a pixel sopra i Ringraziamenti. */ -#credits-reel { flex: 0 0 auto; height: clamp(56px, 24vh, 200px); width: 100%; overflow: hidden; margin: .5em 0 .3em; +#credits-reel { flex: 0 0 auto; position: relative; height: clamp(56px, 24vh, 200px); width: 100%; margin: .5em 0 .3em; } +/* La fila scorre da sola (main.js) ed è anche un normale scorrimento orizzontale: dito, rotella, trascinamento col mouse. */ +#credits-reel .track { display: flex; gap: 10px; height: 100%; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; touch-action: pan-x; cursor: grab; -webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); } -#credits-reel .track { display: flex; gap: 10px; height: 100%; width: max-content; animation: reel 240s linear infinite; } -#credits-reel img { flex: none; height: 100%; aspect-ratio: 16 / 9; object-fit: cover; image-rendering: pixelated; border-radius: 6px; border: 2px solid #3a2d42; } +#credits-reel .track::-webkit-scrollbar { display: none; } +#credits-reel .track:active { cursor: grabbing; } +#credits-reel img { flex: none; height: 100%; aspect-ratio: 16 / 9; object-fit: cover; image-rendering: pixelated; border-radius: 6px; border: 2px solid #3a2d42; cursor: pointer; user-select: none; -webkit-user-drag: none; } +#credits-reel img:hover { border-color: #fff; } +#credits-reel .arrow { position: absolute; top: 50%; transform: translateY(-50%); z-index: 1; width: 1.8em; height: 2.4em; padding: 0; border: 1px solid var(--accent); border-radius: 6px; + background: rgba(15, 10, 20, .8); color: var(--accent); font: inherit; font-size: clamp(12px, 1.8vw, 20px); line-height: 1; cursor: pointer; opacity: .85; } +#credits-reel .arrow:hover, #credits-reel .arrow:focus-visible { opacity: 1; color: #fff; } +#credits-reel .arrow.prev { left: .3em; } #credits-reel .arrow.next { right: .3em; } #credits-reel img.face { aspect-ratio: 1; border-color: var(--accent); } #credits-reel img.palio { aspect-ratio: auto; border: 0; } /* La fine: il quadro del palio accanto alle parole (sotto, sugli schermi stretti). */ @@ -275,14 +283,14 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins #ending figure.palio figcaption { font-size: clamp(6.5px, .85vw, 9px); color: var(--dim); max-width: 22em; } #ending .ending-row > div { max-width: 26em; text-align: center; } @media (max-aspect-ratio: 1/1) { #ending .ending-row { flex-direction: column; } #ending figure.palio img { height: 30vh; } } -@keyframes reel { to { transform: translateX(-50%); } } -@media (prefers-reduced-motion: reduce) { #credits-reel .track { animation: none; } } #credits .credit-face { all: unset; display: block; cursor: pointer; color: var(--accent); text-decoration: underline dotted; text-underline-offset: .25em; } #credits .credit-face:hover, #credits .credit-face:focus-visible { color: #fff; } #face { position: absolute; inset: 0; margin: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .8em; background: rgba(15, 10, 20, .92); cursor: pointer; z-index: 1; } #face[hidden] { display: none; } #face img { height: min(60vmin, 384px); width: auto; max-width: 90%; image-rendering: pixelated; border: 3px solid var(--accent); border-radius: 8px; } +/* Uno sfondo ingrandito: largo quanto può restando 16:9 dentro lo schermo. */ +#face img.scene { height: auto; width: min(92%, 128vh, 1024px); } #face figcaption { color: var(--ink); font-size: clamp(9px, 1.4vw, 14px); text-align: center; padding: 0 1em; } #credits dd { margin: 0; text-align: left; color: var(--ink); font-size: clamp(8px, 1.15vw, 12px); line-height: 1.6; font-weight: bold; } #credits .legal { color: var(--dim); font-size: clamp(7.2px, 0.86vw, 9.36px); opacity: .8; } diff --git a/tests/browser.mjs b/tests/browser.mjs index 84531e4..8ba57e3 100644 --- a/tests/browser.mjs +++ b/tests/browser.mjs @@ -6,6 +6,8 @@ import { staticServer } from './helpers.mjs'; import { mkdirSync, readFileSync } from 'node:fs'; import assert from 'node:assert/strict'; import { solution, route } from '../src/story/solution.js'; +import { story } from '../src/story/story.js'; +import { reelPictures } from '../src/engine/game.js'; import { checkMultimedia } from './multimedia.browser.mjs'; const OUT = process.env.OUT ?? '/tmp/regolo-shots'; @@ -120,6 +122,35 @@ try { await page.screenshot({ path: `${OUT}/01d-ritratto.png` }); await page.click('#face'); assert.ok(await page.isHidden('#face')); + // Il carosello: tutte le immagini (due giri), scorre da solo, si ferma sotto il mouse, le frecce lo spingono di una + // foto, un tocco su una foto la mostra grande col nome del posto. + { + const track = page.locator('#credits-reel .track'); + assert.equal(await track.locator('img').count(), reelPictures(story).length * 2, 'ogni sfondo e ogni ritratto, due giri'); + await page.mouse.move(5, 5); + const x0 = await track.evaluate(t => t.scrollLeft); + await page.waitForTimeout(600); + const x1 = await track.evaluate(t => t.scrollLeft); + assert.ok(x1 > x0, `il carosello scorre da solo (${x0} → ${x1})`); + await track.hover({ position: { x: 20, y: 20 } }); + const x2 = await track.evaluate(t => t.scrollLeft); + await page.waitForTimeout(500); + assert.equal(await track.evaluate(t => t.scrollLeft), x2, 'sotto il mouse si ferma'); + await page.click('#credits-reel .arrow.next'); + await page.waitForTimeout(800); + const x3 = await track.evaluate(t => t.scrollLeft); + assert.ok(x3 - x2 > 150, `la freccia spinge di una foto (${x2} → ${x3})`); + const third = track.locator('img').nth(2); + const name = await third.getAttribute('alt'); + await third.click(); + assert.ok(await page.isVisible('#face img.scene'), 'la foto si vede grande'); + assert.equal(await page.textContent('#face figcaption'), name); + await page.waitForFunction(() => document.querySelector('#face img').naturalWidth > 0, null, { timeout: 5000 }); + await page.screenshot({ path: `${OUT}/01e-carosello-zoom.png` }); + await page.keyboard.press('Escape'); + assert.ok(await page.isHidden('#face'), 'Esc chiude la foto grande, non i crediti'); + assert.ok(await page.isVisible('#credits')); + } const onTop = await page.evaluate(() => { const r = document.getElementById('close-credits').getBoundingClientRect(); return document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.id === 'close-credits'; }); assert.ok(onTop, 'i crediti stanno sopra il titolo, non dietro'); diff --git a/tests/story.test.mjs b/tests/story.test.mjs index e0271ad..5aeb400 100644 --- a/tests/story.test.mjs +++ b/tests/story.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { backgroundOrder } from '../src/engine/render.js'; import { story } from '../src/story/story.js'; import { solution } from '../src/story/solution.js'; -import { Game, VERBS, checkSave, introText, shuffledCredits, matchPlace } from '../src/engine/game.js'; +import { Game, VERBS, checkSave, introText, shuffledCredits, matchPlace, reelPictures } from '../src/engine/game.js'; import { inside } from '../src/engine/geometry.js'; import { existsSync, readFileSync } from 'node:fs'; import { settle, exploreTorch } from './helpers.mjs'; @@ -144,6 +144,20 @@ test('le fasi del giorno seguono la storia: pomeriggio, poi tramonto, poi sera, assert.deepEqual(seen, ids); }); +test('il carosello dei Ringraziamenti: ogni sfondo una volta sola col nome del posto, tutti i ritratti, mai due ritratti vicini', () => { + const reel = reelPictures(story); + const backgrounds = new Set(Object.values(story.scenes).map(s => s.background).filter(Boolean)); + assert.deepEqual(reel.filter(p => p.kind === 'scene').map(p => p.src).sort(), [...backgrounds].sort(), 'tutti gli sfondi, senza doppioni'); + for (const p of reel) { assert.ok(p.name, `${p.src} senza nome`); assert.ok(existsSync(new URL('../' + p.src, import.meta.url)), `manca ${p.src}`); } + const faces = story.credits.flatMap(c => c.names).filter(n => n.face); + assert.deepEqual(reel.filter(p => p.kind !== 'scene').map(p => p.src).sort(), faces.map(n => n.face).sort(), 'tutti i ritratti, una volta'); + assert.ok(reel.some(p => p.kind === 'palio'), 'il palio ha la sua cornice'); + reel.forEach((p, i) => { if (i && p.kind !== 'scene') assert.equal(reel[i - 1].kind, 'scene', `due ritratti di fila: ${reel[i - 1].name}, ${p.name}`); }); + // Poche scene: i ritratti che non trovano posto chiudono comunque la fila. + const few = reelPictures({ scenes: { a: { name: 'A', background: 'x.png' } }, credits: story.credits }); + assert.equal(few.length, 1 + faces.length); +}); + test('la ricerca della mappa: pezzi di parola, senza maiuscole, accenti e apostrofi', () => { assert.ok(matchPlace('Campomigliaio', 'camp')); assert.ok(matchPlace('Via de\' Medici', 'de medici'));