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.
-
+
Monkey Island è un marchio di Lucasfilm Ltd., citato solo nella dedica: questo gioco non è affiliato né approvato da Lucasfilm.
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'));