Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64dc8e18e7 | ||
|
|
b262977117 |
+1
-1
@@ -101,7 +101,7 @@
|
||||
<h2>Ringraziamenti</h2>
|
||||
<p class="dedication credits-dedication">Al Lele, che mi ha fatto conoscere Monkey Island.</p>
|
||||
<dl id="credits-list"></dl>
|
||||
<div id="credits-reel" aria-hidden="true"><div class="track"></div></div>
|
||||
<div id="credits-reel"><button class="arrow prev" type="button" aria-label="Foto precedente">‹</button><div class="track" aria-label="Le immagini del gioco: toccane una per vederla grande"></div><button class="arrow next" type="button" aria-label="Foto successiva">›</button></div>
|
||||
<p class="legal">Monkey Island è un marchio di Lucasfilm Ltd., citato solo nella dedica: questo gioco non è affiliato né approvato da Lucasfilm.</p>
|
||||
<div class="buttons"><button id="close-credits">Chiudi</button></div>
|
||||
<figure id="face" hidden><img alt=""><figcaption></figcaption></figure>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "leggenda-del-regolo",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
+65
-18
@@ -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; }
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
|
||||
export const VERSION = '0.1.19';
|
||||
export const VERSION = '0.1.20';
|
||||
|
||||
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica,
|
||||
// mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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');
|
||||
|
||||
+15
-1
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user