diff --git a/nginx.conf b/nginx.conf
index 677f393..37eed9e 100644
--- a/nginx.conf
+++ b/nginx.conf
@@ -17,11 +17,12 @@ server {
# I moduli ES richiedono il MIME type corretto.
types {
- text/html html;
+ text/html html htm;
text/css css;
application/javascript js mjs;
application/json json map;
image/png png;
+ image/gif gif;
image/webp webp;
audio/mpeg mp3;
image/svg+xml svg;
@@ -39,7 +40,7 @@ server {
location ~* \.(css|js|mjs|json)$ {
add_header Cache-Control "no-cache";
}
- location ~* \.(png|webp|jpg|mp3|mp4|woff2|ico)$ {
+ location ~* \.(png|gif|webp|jpg|mp3|mp4|woff2|ico)$ {
add_header Cache-Control $media_cache;
}
location = /index.html {
diff --git a/package.json b/package.json
index b6928aa..0e052b7 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,8 @@
"browser:tressette": "node tests/tressette.browser.mjs",
"browser:scopa": "node tests/scopa.browser.mjs",
"browser:briscola": "node tests/briscola.browser.mjs",
+ "browser:telefono": "node tests/telefono.browser.mjs",
+ "browser:risveglio": "node tests/risveglio.browser.mjs",
"browser:effects": "node tests/ambient.browser.mjs",
"serve": "python3 -m http.server 8000"
},
diff --git a/src/engine/game.js b/src/engine/game.js
index 6223891..1186c2a 100644
--- a/src/engine/game.js
+++ b/src/engine/game.js
@@ -14,6 +14,8 @@ import * as micro from './micro.js';
import * as cerbottana from './cerbottana.js';
import { newCorsa, updateCorsa } from './corsa.js';
import { newIncanto, updateIncanto } from './incanto.js';
+import { newRisveglio, updateRisveglio } from './risveglio.js';
+import { newTelefono, updateTelefono, tapTelefono } from './telefono.js';
import * as condor from './condor.js';
import * as campana from './campana.js';
import * as poker from './poker.js';
@@ -312,6 +314,8 @@ export class Game {
this.queue.unshift(...(this.story.closeups[c.id].tap ?? []));
return true;
}
+ // Il telefonino del Barone: un tocco apre il sito, e sul sito il link apre il muro.
+ if (this.story.closeups[c.id].type === 'telefono') return tapTelefono(c, this.story.closeups[c.id]);
if (this.story.closeups[c.id].type) return false; // torcia e minigiochi non si sfogliano
if (c.step === 'cover') Object.assign(c, { step: 'title', t: 0 });
else if (c.step === 'title') Object.assign(c, { step: 'turn', t: 0 });
@@ -352,6 +356,14 @@ export class Game {
this.queue.unshift(...(def.complete ?? []));
return true;
}
+ // Il telefonino: chi ha visto il muro, chiudendo, lo commenta.
+ if (def?.type === 'telefono') { if (c.step === 'wall') this.queue.unshift(...(def.closing ?? [])); return true; }
+ // Il risveglio, saltato a metà (Esc), finisce comunque: le battute rimaste e poi quelle di dopo.
+ if (def?.type === 'risveglio') {
+ const left = (def.lines ?? []).slice(c.fired).map(({ at, ...e }) => e);
+ this.queue.unshift(...left, ...(def.complete ?? []));
+ return true;
+ }
// I minigiochi: a partita finita (risultato deciso, anche se il conteggio si sta ancora mostrando) la battuta
// di chi ha vinto; uscendo prima, quella di chi lascia.
if (MINIGAMES[def?.type]) {
@@ -364,6 +376,13 @@ export class Game {
return true;
}
+ // Il muro del telefonino (una pagina vera, assets/muro): «Home» torna alla home del sito.
+ closeupHome() {
+ const c = this.closeup;
+ if (!c || this.story.closeups[c.id].type !== 'telefono' || c.step !== 'wall') return false;
+ Object.assign(c, { step: 'site', t: 0 });
+ return true;
+ }
// Bocce e biliardo: si tiene premuto per caricare la forza e si lascia per tirare (mouse, dito o spazio).
minigame() { const c = this.closeup; return c ? MINIGAMES[this.story.closeups[c.id].type] ?? null : null; }
closeupPress(pointerId) { const m = this.minigame(); return !!m && !this.speech && !!m.press?.(this.closeup, pointerId); }
@@ -377,6 +396,8 @@ export class Game {
const c = this.closeup;
const m = this.minigame();
if (m) return !!m.aim?.(c, [x, y]);
+ // Sul sito del telefonino conta dove si tocca (il link del muro).
+ if (c && this.story.closeups[c.id].type === 'telefono') { c.aim = [x, y]; return true; }
if (!c || this.story.closeups[c.id].type !== 'torch' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
c.beam = [Math.max(70, Math.min(570, x)), Math.max(65, Math.min(295, y))];
return true;
@@ -527,6 +548,18 @@ export class Game {
if (now === 'end') this.closeupClose();
return;
}
+ if (this.story.closeups[c.id].type === 'telefono') {
+ updateTelefono(c, dt);
+ if (c.step === 'wall' && !c.lines && c.t >= CLOSEUP_PAUSE) c.lines = [...(this.story.closeups[c.id].comment ?? [])];
+ if (c.lines?.length) this.run(c.lines.shift());
+ return;
+ }
+ if (this.story.closeups[c.id].type === 'risveglio') {
+ const { due, end } = updateRisveglio(c, this.story.closeups[c.id], dt);
+ for (const e of due) this.run(e);
+ if (end) this.closeupClose();
+ return;
+ }
const m = this.minigame();
if (m) {
m.update(c, dt);
@@ -571,6 +604,8 @@ export class Game {
: type === 'viaggio' ? { id: e.closeup, t: 0, age: 0, events: [], typed: 0 }
: type === 'corsa' ? newCorsa(e.closeup)
: type === 'incanto' ? newIncanto(e.closeup)
+ : type === 'risveglio' ? newRisveglio(e.closeup)
+ : type === 'telefono' ? newTelefono(e.closeup)
: type === 'poster' ? { id: e.closeup, step: 'poster', t: 0, age: 0 }
// `level`: «Alla bona», «Seria» o «A fassi male», scelto nel dialogo con l'avversario.
// `decks`: il mazzo da usare quando il suo flag è acceso (briscola: le napoletane al posto delle toscane).
diff --git a/src/engine/render.js b/src/engine/render.js
index b40d4cf..1ca7f93 100644
--- a/src/engine/render.js
+++ b/src/engine/render.js
@@ -17,6 +17,8 @@ import { drawCampana } from './campana-render.js';
import { drawPoker } from './poker-render.js';
import { drawViaggio } from './viaggio.js';
import { drawIncanto } from './incanto.js';
+import { drawRisveglio } from './risveglio.js';
+import { drawTelefono, telefonoHint } from './telefono.js';
import { drawBriscola } from './briscola-render.js';
import { drawTressette } from './tressette-render.js';
import { drawScopa } from './scopa-render.js';
@@ -178,7 +180,9 @@ export function render(ctx, game, { hover, spots, debug, time = performance.now(
const andrea = sprites.get('andrea');
const drawAndreaNow = () => andrea ? drawSprite(ctx, andrea, game.pose(), s.x, s.y, game.scaleAt(s.y), s.facing)
: drawAndrea(ctx, s.x, s.y, game.scaleAt(s.y), s.facing, game.walking ? game.stride / 6 : null);
- const queue = [{ y: s.y, draw: drawAndreaNow }];
+ // Durante il risveglio si guarda dagli occhi di Andrea: lui non si vede.
+ const pov = game.closeup && game.story.closeups?.[game.closeup.id]?.type === 'risveglio';
+ const queue = pov ? [] : [{ y: s.y, draw: drawAndreaNow }];
for (const p of game.people()) {
const sheet = sprites.get(p.who);
if (sheet) queue.push({ y: p.at[1], draw: () => drawSprite(ctx, sheet, game.personPose(p), ...p.at, game.scaleAt(p.at[1]), p.facing ?? 1, p.size) });
@@ -919,6 +923,15 @@ function drawCloseup(ctx, game, time) {
}
// L'incantesimo va sopra la scena (con Andrea dentro), già disegnata.
if (def.type === 'incanto') { drawIncanto(ctx, c); return; }
+ // Il risveglio, visto da dentro gli occhi: anche lui sopra la scena già disegnata.
+ if (def.type === 'risveglio') { drawRisveglio(ctx, c); return; }
+ // Il telefonino del Barone: sale come il libro, e il sito nello schermo si allarga a tutta la scena.
+ if (def.type === 'telefono') {
+ const pic = src => { if (!backgrounds.has(src)) loadBackground(src); return backgrounds.get(src); };
+ drawTelefono(ctx, c, def, { phone: pic(def.phone), site: pic(def.site) }, time);
+ closeupHint(ctx, game.speech ? null : telefonoHint(c), time);
+ return;
+ }
// Il manifesto ingrandito: la scena si scurisce e lui viene avanti, poi l'invito a toccarlo.
if (def.type === 'poster') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
@@ -1043,7 +1056,10 @@ function drawCloseup(ctx, game, time) {
ctx.restore();
// Il suggerimento in alto, finché c'è qualcosa da sfogliare.
const hint = c.step === 'cover' ? 'Clic o tocco: apri il libro' : c.step === 'title' ? 'Clic o tocco: gira la pagina' : null;
- if (hint && !game.speech && c.age > 0.5) {
+ closeupHint(ctx, !game.speech && c.age > 0.5 ? hint : null, time);
+}
+function closeupHint(ctx, hint, time) {
+ if (hint) {
ctx.save();
ctx.globalAlpha = 0.65 + 0.35 * Math.sin(time / 300);
ctx.font = `7px ${FONT}`;
diff --git a/src/engine/risveglio.js b/src/engine/risveglio.js
new file mode 100644
index 0000000..032b44f
--- /dev/null
+++ b/src/engine/risveglio.js
@@ -0,0 +1,76 @@
+// Il risveglio (story.closeups, type 'risveglio'; Andrea, 31 ottobre: «si ritrova aprendo piano piano gli occhi su
+// una panchina, come svegliato da una notte brava, con effetto annebbiato»). Sopra la scena di adesso, vista da
+// dentro gli occhi: le palpebre si aprono a fatica, ricascano, si riaprono; tutto è sfocato e doppio, e ondeggia.
+// Le battute (`lines`, ognuna col suo `at` in secondi) partono mentre gli occhi si aprono; mentre Andrea parla il
+// tempo del risveglio si ferma (la battuta va letta), l'ondeggiare no. Unità: i pixel della scena 640×360.
+
+// Quanto sono aperti gli occhi (0 chiusi, 1 aperti) nel tempo: tre tentativi, l'ultimo è quello buono.
+const OPEN = [[0, 0], [1.4, 0], [2.1, 0.22], [2.5, 0.04], [2.9, 0.04], [3.8, 0.5], [4.15, 0.12], [4.45, 0.2], [5.6, 0.85], [5.85, 0.55], [6.9, 1]];
+export const END = 8.2;
+const clamp01 = k => Math.max(0, Math.min(1, k));
+const smooth = k => k * k * (3 - 2 * k);
+
+export function openness(t) {
+ for (let i = 1; i < OPEN.length; i++) {
+ const [t1, o1] = OPEN[i];
+ if (t <= t1) { const [t0, o0] = OPEN[i - 1]; return o0 + (o1 - o0) * smooth(clamp01((t - t0) / (t1 - t0))); }
+ }
+ return 1;
+}
+// La nebbia: sfocatura e vista doppia, che si diradano fino a sparire alla fine.
+export const haze = t => 1 - smooth(clamp01((t - 1.4) / (END - 1.6)));
+
+export function newRisveglio(id) { return { id, t: 0, age: 0, fired: 0 }; }
+
+// Il tempo che passa: torna le battute da dire adesso, e 'end' alla fine.
+export function updateRisveglio(c, def, dt) {
+ c.t += dt;
+ const lines = def.lines ?? [], due = [];
+ while (c.fired < lines.length && c.t >= lines[c.fired].at) { const { at, ...e } = lines[c.fired++]; due.push(e); }
+ return { due, end: c.t >= END && c.fired >= lines.length };
+}
+
+let scratch = null;
+export function drawRisveglio(ctx, c) {
+ const t = c.t, h = haze(t), open = openness(t), W = 640, H = 360;
+ ctx.save();
+ // La scena annebbiata: sfocata, e una seconda copia spostata che ondeggia (la vista doppia).
+ if (h > 0.01 && typeof document !== 'undefined') {
+ if (!scratch) { scratch = document.createElement('canvas'); scratch.width = W; scratch.height = H; }
+ const s = scratch.getContext('2d');
+ s.clearRect(0, 0, W, H);
+ s.drawImage(ctx.canvas, 0, 0, W, H);
+ const sway = 14 * h, dx = Math.sin(c.age * 1.3) * sway, dy = Math.sin(c.age * 0.9 + 1) * sway * 0.35;
+ ctx.filter = `blur(${(5 * h).toFixed(2)}px)`;
+ ctx.drawImage(scratch, 0, 0);
+ ctx.globalAlpha = 0.45 * h;
+ ctx.drawImage(scratch, dx, dy);
+ ctx.globalAlpha = 1;
+ ctx.filter = 'none';
+ // La luce del mattino che abbaglia.
+ ctx.fillStyle = `rgba(255,244,214,${0.22 * h})`;
+ ctx.fillRect(0, 0, W, H);
+ }
+ // Gli angoli scuri, che si aprono con gli occhi.
+ const v = ctx.createRadialGradient(W / 2, H / 2, 60 + 220 * open, W / 2, H / 2, 420);
+ v.addColorStop(0, 'rgba(10,2,4,0)');
+ v.addColorStop(1, `rgba(10,2,4,${0.85 * (1 - open * 0.75) * Math.max(h, 0.3)})`);
+ ctx.fillStyle = v;
+ ctx.fillRect(0, 0, W, H);
+ // Le palpebre: sopra e sotto, col bordo curvo e morbido; chiuse, si vede il rosso scuro della luce che passa.
+ if (open < 0.999) {
+ const gap = 200 * open, mid = H / 2, bulge = 70 + 60 * open;
+ ctx.filter = 'blur(6px)';
+ ctx.fillStyle = 'rgb(22,4,6)';
+ ctx.beginPath();
+ ctx.moveTo(-20, -20); ctx.lineTo(W + 20, -20); ctx.lineTo(W + 20, mid - gap + bulge);
+ ctx.quadraticCurveTo(W / 2, mid - gap - bulge, -20, mid - gap + bulge);
+ ctx.closePath(); ctx.fill();
+ ctx.beginPath();
+ ctx.moveTo(-20, H + 20); ctx.lineTo(W + 20, H + 20); ctx.lineTo(W + 20, mid + gap - bulge);
+ ctx.quadraticCurveTo(W / 2, mid + gap + bulge, -20, mid + gap - bulge);
+ ctx.closePath(); ctx.fill();
+ ctx.filter = 'none';
+ }
+ ctx.restore();
+}
diff --git a/src/engine/telefono.js b/src/engine/telefono.js
new file mode 100644
index 0000000..ed353e4
--- /dev/null
+++ b/src/engine/telefono.js
@@ -0,0 +1,81 @@
+// Il telefonino del Barone (story.closeups, type 'telefono'; Andrea, 31 ottobre: «prenda il cellulare, nello stile
+// di come si sfoglia la storia infinita, e quando si clicca viene a tutto schermo il sito di San Piero»).
+// Come il libro: la scena si scurisce e le mani alzano il telefonino dal basso; nello schermo c'è il sito
+// (`site`, un'immagine 16:9 della home di sanpiero50037.it). Un tocco: lo schermo si allarga a tutta la scena.
+// Sul sito si tocca il link (`link`, in pixel dell'immagine del sito) e si apre il muro: una pagina vera
+// (assets/muro, la mette main.js sopra la scena), che qui sotto lascia solo il fondo. A muro aperto partono le
+// battute `comment`, e chiudendo `closing`. Unità: i pixel della scena 640×360.
+
+export const RISE = 0.45, ZOOM = 0.55;
+const W = 640, H = 360;
+const clamp01 = k => Math.max(0, Math.min(1, k));
+const ease = k => (1 - Math.cos(Math.PI * clamp01(k))) / 2;
+
+export function newTelefono(id) { return { id, step: 'phone', t: 0, age: 0, aim: null }; }
+
+// Il tempo: lo zoom finisce sul sito; a muro aperto, dopo un attimo, le battute (una alla volta, come il libro).
+export function updateTelefono(c, dt) {
+ c.t += dt;
+ if (c.step === 'zoom' && c.t >= ZOOM) Object.assign(c, { step: 'site', t: 0 });
+}
+
+// Dove sta il link del muro sulla scena, a sito aperto: il riquadro `link` riportato da immagine a scena.
+export function linkBox(def, site) {
+ const [x, y, w, h] = def.link, kx = W / site[0], ky = H / site[1];
+ return [x * kx, y * ky, w * kx, h * ky];
+}
+const inside = ([x, y, w, h], [px, py]) => px >= x && px <= x + w && py >= y && py <= y + h;
+
+// Un tocco: sul telefonino apre il sito; sul sito, solo sul link, apre il muro. Torna true se è successo qualcosa.
+export function tapTelefono(c, def) {
+ if (c.step === 'phone') { if (c.age < RISE) return false; Object.assign(c, { step: 'zoom', t: 0 }); return true; }
+ if (c.step === 'site' && c.aim && inside(linkBox(def, def.siteSize), c.aim)) { Object.assign(c, { step: 'wall', t: 0 }); return true; }
+ return false;
+}
+
+// Il riquadro dello schermo sulla scena, col telefonino che sale dal basso.
+function phoneRect(def, img, age) {
+ const rise = 1 - clamp01(age / RISE), k = def.phoneScale ?? 1;
+ const w = img.width * k, h = img.height * k, x = Math.round((W - w) / 2), y = Math.round(H - h + 140 * rise * rise);
+ return { x, y, w, h, k };
+}
+
+export function drawTelefono(ctx, c, def, { phone, site }, time) {
+ ctx.save();
+ ctx.fillStyle = `rgba(8, 5, 12, ${Math.min(0.78, c.age / 0.35 * 0.78)})`;
+ ctx.fillRect(0, 0, W, H);
+ if (!phone || !site) { ctx.restore(); return; }
+ const p = phoneRect(def, phone, c.age), [sx, sy, sw, sh] = def.screen;
+ const screen = [p.x + sx * p.k, p.y + sy * p.k, sw * p.k, sh * p.k];
+ const z = c.step === 'phone' ? 0 : c.step === 'zoom' ? ease(c.t / ZOOM) : 1;
+ // Il telefonino, finché lo schermo non ha preso tutta la scena; poi sfuma.
+ if (z < 1) {
+ ctx.globalAlpha = 1 - z;
+ ctx.imageSmoothingEnabled = false;
+ ctx.drawImage(phone, p.x, p.y, p.w, p.h);
+ ctx.globalAlpha = 1;
+ }
+ // Il sito: dentro lo schermo, poi sempre più grande fino a tutta la scena. Lo schermo ha le sue proporzioni:
+ // il sito ci sta intero, centrato, sul fondo del sito.
+ const r = screen.map((v, i) => v + ([0, 0, W, H][i] - v) * z);
+ const fit = Math.min(r[2] / site.width, r[3] / site.height), dw = site.width * fit, dh = site.height * fit;
+ ctx.fillStyle = def.paper ?? '#f7e0c0';
+ ctx.fillRect(r[0], r[1], r[2], r[3]);
+ ctx.imageSmoothingEnabled = true;
+ ctx.drawImage(site, r[0] + (r[2] - dw) / 2, r[1] + (r[3] - dh) / 2, dw, dh);
+ // Sotto il muro (la pagina vera) resta il fondo del sito.
+ if (c.step === 'wall') { ctx.fillStyle = def.paper ?? '#f7e0c0'; ctx.fillRect(0, 0, W, H); }
+ // Il link da toccare: un riquadro che pulsa intorno al mattone del muro.
+ if (c.step === 'site') {
+ const [x, y, w, h] = linkBox(def, def.siteSize), pulse = 0.5 + 0.5 * Math.sin(time / 260);
+ ctx.strokeStyle = `rgba(255, 210, 90, ${0.5 + 0.5 * pulse})`;
+ ctx.lineWidth = 2 + pulse;
+ ctx.strokeRect(Math.round(x) - 2, Math.round(y) - 2, Math.round(w) + 4, Math.round(h) + 4);
+ }
+ ctx.imageSmoothingEnabled = false;
+ ctx.restore();
+}
+
+// Il suggerimento in alto, finché c'è qualcosa da toccare.
+export const telefonoHint = c => c.step === 'phone' && c.age > RISE ? 'Clic o tocco: guarda il sito'
+ : c.step === 'site' ? 'Clic o tocco sul Muro del Sanpierino' : null;
diff --git a/src/main.js b/src/main.js
index 3641c25..7738de0 100644
--- a/src/main.js
+++ b/src/main.js
@@ -65,6 +65,10 @@ $('effects-volume').addEventListener('input', e => { ambience.configure({ volume
showSound();
window.music = music; // per i test nel browser
+// Il gioco non usa service worker: se ne è rimasto uno di un'altra app passata su questo indirizzo (la Bussola su
+// localhost:8080, 31 ottobre), si sgancia, sennò intercetta le pagine aperte dal gioco (il muro nel telefonino).
+navigator.serviceWorker?.getRegistrations().then(list => list.forEach(r => r.unregister())).catch(() => {});
+
fetch(versioned('assets/voices/index.json')).then(r => r.ok ? r.json() : {}).then(index => { voices.index = index; }).catch(() => {});
window.voices = voices;
let voiced = null;
@@ -75,7 +79,7 @@ function start(state, rione = null) {
game = new Game(story, { state, onSave: data => storage && writeSave(storage, data), voiceTime: (who, text) => voices.seconds(who, text) });
command = new Command(game);
if (!state && rione) game.state.flags['rione:' + rione] = true;
- if (!state) game.go(story.start.scene, story.start.at);
+ if (!state) game.go(story.start.scene, story.start.at, story.start.face);
$('title').hidden = true;
$('ending').hidden = true;
running = true;
@@ -1094,7 +1098,14 @@ function updateUi() {
setProp($('spots-button'), 'hidden', !running || !!game.closeup);
setProp($('notebook-button'), 'hidden', !running || !story.notebook || !!game.closeup);
setProp($('closeup-close'), 'hidden', !running || !game.closeup);
- setText($('closeup-close'), torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : mg ? c.step === 'result' ? mg.back : 'Lascia stare' : 'Chiudi');
+ // Il Muro del Sanpierino: a muro aperto sul telefonino del Barone, la pagina vera sopra la scena. Ogni volta che si
+ // riapre si ricarica (i messaggi nuovi della storia).
+ const muro = $('muro'), wall = running && c && story.closeups[c.id]?.type === 'telefono' && c.step === 'wall';
+ if (wall && muro.hidden) muro.src = versioned('assets/muro/index.html');
+ if (!wall && !muro.hidden) muro.src = 'about:blank';
+ setProp(muro, 'hidden', !wall);
+ if (wall) muro.style.setProperty('--muro-scale', String(canvas.clientWidth / 1067));
+ setText($('closeup-close'), torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : mg ? c.step === 'result' ? mg.back : 'Lascia stare' : c && story.closeups[c.id]?.type === 'risveglio' ? 'Salta' : 'Chiudi');
setProp($('panel'), 'inert', !!c);
canvas.classList.toggle('torch', !!torch);
canvas.classList.toggle('bocce', !!mg);
diff --git a/src/story/story.js b/src/story/story.js
index d50093a..2d26f96 100644
--- a/src/story/story.js
+++ b/src/story/story.js
@@ -64,7 +64,9 @@ export const story = {
title: 'La Leggenda del Regolo',
// La mappa (pulsante «Mappa»): ogni scena con `map: [x, y]` (percentuali della pianta) ci compare quando è
// stata visitata; le strade sono le uscite fra scene. Un posto nuovo entra con la sua riga `map`.
- start: { scene: 'piazza', at: [250, 320] },
+ // Si comincia sulla panchina dei vecchietti, dopo una notte brava (Andrea, 31 ottobre): il risveglio è il primo
+ // piano `risveglio`, che parte entrando in piazza la prima volta.
+ start: { scene: 'piazza', at: [96, 328], face: 'left' },
// Le fasi del giorno (idea di Andrea, 24 ottobre): seguono la storia, non l'orologio. Il primo capitolo comincia
// di pomeriggio; con le impronte del Regolo viene il tramonto, e quando Andrea ha la torcia è sera. Sulle scene
// all'aperto un velo di colore (`tint`); luci ed effetti possono valere solo in certe fasi (`phase`).
@@ -120,7 +122,8 @@ export const story = {
cardetole: 'Io corro per Cardetole, i verdi: gioco in casa.',
campomigliaio: 'Io corro per Campomigliaio, i blu.',
} },
- 'E stamattina, in piazza, è successo qualcosa...',
+ // [DA ANDREA: la riga che porta al risveglio sulla panchina]
+ 'Ieri sera s\'è fatto tardi. Molto tardi...',
],
// Colonna sonora di Andrea (fatta con Suno): titolo, paese, ricordi (fine e Trebbio).
// splash: la musichetta del cartello d'apertura (tools/jingle.py), suonata una volta sola.
@@ -340,6 +343,54 @@ export const story = {
{ closeup: 'ingorgo' },
],
},
+ // Il Muro del Sanpierino sul telefonino del Barone (Andrea, 31 ottobre): sale come il libro, nello schermo la
+ // home di sanpiero50037.it (la Wayback Machine, 31 dicembre 2007, senza la data in alto); un tocco la porta a
+ // tutto schermo, e il mattone «Nuovo muro del Sanpierino» apre il muro: la pagina vera (assets/muro, fatta da
+ // tools/muro.py dal muro riesumato in ~/Workspaces/misc/spswall), coi messaggi veri del 2007 (Andrea: «quelli
+ // veri»; niente email, «Scrivi» spento) e sopra quelli del gioco qui sotto, ognuno con la sua condizione:
+ // «Aggiorna» ricarica, e la storia che va avanti ne fa comparire di nuovi. [DA ANDREA: messaggi e battute]
+ muro_sanpierino: {
+ type: 'telefono',
+ phone: 'assets/closeups/telefonino-barone.png', // Codex, prompts/closeups/telefonino-barone.txt
+ screen: [120, 25, 106, 142], // lo schermo, in pixel del telefonino
+ site: 'assets/closeups/sanpiero50037-home.png', siteSize: [1067, 600],
+ link: [48, 42, 302, 99], // il mattone del muro, in pixel del sito
+ paper: '#f7e1c0',
+ messages: [
+ { nick: 'Uno di Cardetole', date: '2007-05-26 00:55:12', text: 'Quest\'anno il palio è nostro!!! [bouncer]\nGli altri rioni si preparino a piangere. [14_bigmouth]' },
+ { nick: 'Baron De Duke', date: '2007-05-26 01:40:37', text: 'Chi ha trovato un paio di mutande, le riporti al circolino.\nSenza fare domande. [15_weeping]' },
+ { nick: 'Il Regolo', date: '2007-05-26 03:12:08', text: 'IL PALIO ME LO SONO RIPRESO IO. [22_yikes]\nChi lo rivuole, venga a prenderselo. Se ha coraggio.' },
+ // Quelli che arrivano con la storia: si vedono riaprendo il muro, o con «Aggiorna».
+ { when: 'indizio_regolo', nick: 'Uno che passava', date: '2007-05-26 18:20:44', text: 'Ma avete visto le impronte sotto la Fortezza??? Grandi come una pizza!!! [24_shocked]' },
+ { when: 'casetta_aperta', nick: 'Il Regolo', date: '2007-05-26 21:03:51', text: 'Ho sentito che qualcuno ha trovato una torcia. Io ho sempre la luna. [28_spin]' },
+ ],
+ comment: [
+ { say: '«Il palio me lo sono ripreso io». Firmato: il Regolo. Alle tre di notte.' },
+ { say: 'E sotto ci sono io, con le mutande. Nessuno ha risposto.', who: 'barondeduke', pose: 'facepalm' },
+ ],
+ closing: [
+ { say: 'Barone, il sito è tuo: non puoi vedere chi l\'ha scritto?' },
+ { say: 'Potrei. Ma ci vuole il computer di casa. Il telefonino, più di così, non fa.', who: 'barondeduke', pose: 'shrug' },
+ ],
+ },
+ // L'inizio della partita: Andrea apre gli occhi sulla panchina dei vecchietti, dopo una notte brava (Andrea,
+ // 31 ottobre). Le battute `lines` arrivano mentre gli occhi si aprono; `complete` a occhi aperti.
+ // [DA ANDREA: le battute sono provvisorie]
+ risveglio: {
+ type: 'risveglio',
+ lines: [
+ { at: 0.8, say: 'Mmmh... che botta.' },
+ { at: 3.0, say: 'Ma... dove sono?' },
+ { at: 5.9, say: 'Una panchina. In piazza. Coi vecchietti.' },
+ ],
+ complete: [
+ { say: 'Oh, s\'è svegliato il bello addormentato! Bella nottata, eh?', who: 'vecchietta', pose: 'laugh' },
+ { say: 'Ma l\'hai saputo? Il palio è sparito!', who: 'vecchietto', pose: 'point' },
+ { say: 'Il palio? Il quadro che hanno dipinto i ragazzi delle medie?' },
+ { say: 'Lo tenevano a scuola: stamani la Gianna ha trovato il posto vuoto.', who: 'vecchietta', pose: 'explain' },
+ { say: 'La festa è domani: se nessuno lo ritrova, salta tutto. Tocca a me.' },
+ ],
+ },
// L'incantesimo dell'Ingorgo Sonoro, dal manifesto in piazza: luci, cassa, lampo, ed è la notte della festa.
ingorgo: {
type: 'incanto',
@@ -735,7 +786,7 @@ export const story = {
id: 'verso_calimara', name: 'Via Calimara, verso la farmacia', box: [180, 334, 380, 26], at: [360, 354], exit: { scene: 'via_calimara', at: [365, 212], face: 'down' },
},
],
- enter: [{ if: '!intro', then: [{ set: 'intro' }, { say: 'Il palio è sparito! Il quadro che hanno dipinto i ragazzi delle medie.' }, { say: 'Lo tenevano a scuola: stamani la Gianna ha trovato il posto vuoto.' }, { say: 'La festa è domani: se nessuno lo ritrova, salta tutto. Tocca a me.' }] }],
+ enter: [{ if: '!intro', then: [{ set: 'intro' }, { closeup: 'risveglio' }] }],
},
fortezza: {
name: 'Sotto le mura della Fortezza',
@@ -2084,15 +2135,7 @@ export const story = {
},
actions: {
look: [{ say: 'Il BaronDeDuke, col cappello da cowboy. Ha l\'aria di uno che aspetta qualcuno, e che spera non lo veda nessuno.' }],
- talk: [
- { say: 'Barone! Che ci fai qui?' },
- { say: 'Aspetto uno che mi deve riportare le mutande.', who: 'barondeduke' },
- { say: 'Le... mutande?' },
- { say: 'Le ho perse a poker, ieri sera.', who: 'barondeduke', pose: 'facepalm' },
- { say: 'Avevo due donne e due jack. Entra la terza donna, e penso: «Male male si divide».', who: 'barondeduke', pose: 'cards' },
- { say: 'E invece?' },
- { say: 'L\'altro aveva colore.', who: 'barondeduke', pose: 'shrug' },
- ],
+ talk: [{ dialogue: 'barondeduke' }],
},
},
],
@@ -3899,6 +3942,28 @@ export const story = {
},
},
},
+ // Il BaronDeDuke nella discesa verso la polisportiva: le mutande perse a poker, e il Muro del Sanpierino sul
+ // telefonino (Andrea, 31 ottobre: lui è il webmaster di sanpiero50037.it).
+ barondeduke: {
+ nodes: {
+ start: { options: [
+ { text: 'Barone! Che ci fai qui?', then: [
+ { say: 'Aspetto uno che mi deve riportare le mutande.', who: 'barondeduke' },
+ { say: 'Le... mutande?' },
+ { say: 'Le ho perse a poker, ieri sera.', who: 'barondeduke', pose: 'facepalm' },
+ { say: 'Avevo due donne e due jack. Entra la terza donna, e penso: «Male male si divide».', who: 'barondeduke', pose: 'cards' },
+ { say: 'E invece?' },
+ { say: 'L\'altro aveva colore.', who: 'barondeduke', pose: 'shrug' },
+ ] },
+ // [DA ANDREA: le battute sono provvisorie]
+ { text: 'Hai visto cosa c\'era sul muro del Sanpierino?', next: 'end', then: [
+ { say: 'Il muro? Aspetta, ce l\'ho qui sul telefonino.', who: 'barondeduke', pose: 'explain' },
+ { closeup: 'muro_sanpierino' },
+ ] },
+ { text: 'Ci vediamo, Barone.', next: 'end' },
+ ] },
+ },
+ },
// L'Arcu davanti al circolino (battute di Andrea, 25 ottobre). Lavora alla Casa dell'Armadio: «Armando» è apposta.
arcu: {
nodes: {
diff --git a/style.css b/style.css
index 38e5d0c..e5c7ef3 100644
--- a/style.css
+++ b/style.css
@@ -81,6 +81,12 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
font-size: clamp(10.08px, 1.66vw, 18.72px); font-weight: bold; line-height: 1.2; text-align: center;
text-shadow: 2px 0 #000, -2px 0 #000, 0 2px #000, 0 -2px #000, 2px 2px #000, -2px -2px #000; }
#speech:empty { display: none; }
+#speech { z-index: 3; }
+/* Il Muro del Sanpierino sul telefonino del Barone: la pagina vera (assets/muro) larga 1067 pixel come la home del
+ sito, rimpicciolita sulla scena da main.js (--muro-scale). */
+#muro { position: absolute; left: 0; top: 0; width: 1067px; height: 600px; border: 0; z-index: 1; background: #f7e1c0;
+ transform: scale(var(--muro-scale, 0.6)); transform-origin: 0 0; }
+#muro[hidden] { display: none; }
/* Il primo piano del libro si chiude da qui (o con Esc). */
#closeup-close { position: absolute; right: 10px; bottom: 10px; z-index: 2; font: bold clamp(10px, 1.5vw, 15px) var(--font);
padding: .45em 1.2em; border-radius: 6px; border: 0; cursor: pointer; color: var(--bg); background: var(--accent); }
diff --git a/tests/helpers.mjs b/tests/helpers.mjs
index be892da..a296e1f 100644
--- a/tests/helpers.mjs
+++ b/tests/helpers.mjs
@@ -7,7 +7,8 @@ export function settle(game, { maxSteps = 20000, explore = false } = {}) {
game.skip();
}
// Un primo piano aperto aspetta il giocatore (sfogliare, chiudere): qui ci si ferma e decide il test.
- if (game.closeup && !game.speech && game.reach <= 0) {
+ // Il risveglio dell'inizio no: va avanti da solo, come una battuta.
+ if (game.closeup && !game.speech && game.reach <= 0 && game.story.closeups[game.closeup.id].type !== 'risveglio') {
if (!explore || game.story.closeups[game.closeup.id].type !== 'torch') return said;
exploreTorch(game);
}
diff --git a/tests/risveglio.browser.mjs b/tests/risveglio.browser.mjs
new file mode 100644
index 0000000..a12d9f1
--- /dev/null
+++ b/tests/risveglio.browser.mjs
@@ -0,0 +1,30 @@
+// Il risveglio sulla panchina nel browser vero: Nuova partita, l'introduzione, gli occhi che si aprono annebbiati.
+// npm run browser:risveglio — schermate in /tmp/regolo-risveglio.
+import { chromium } from 'playwright';
+import { mkdirSync } from 'node:fs';
+import assert from 'node:assert/strict';
+import { staticServer } from './helpers.mjs';
+
+const out = process.env.OUT ?? '/tmp/regolo-risveglio';
+mkdirSync(out, { recursive: true });
+const server = await staticServer();
+let browser;
+const errors = [];
+try {
+ browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
+ const page = await browser.newPage({ viewport: { width: 1280, height: 860 } });
+ page.on('pageerror', e => { errors.push(e.message); console.log('ERR', e.message); });
+ await page.goto(`http://127.0.0.1:${server.port}/`);
+ await page.click('#splash'); await page.click('#new-game'); await page.click('#rione [data-rione="campomigliaio"]'); await page.click('#skip-intro');
+ await page.waitForFunction(() => game.closeup?.id === 'risveglio', null, { timeout: 10000 });
+ await page.waitForFunction(() => document.getElementById('closeup-close').textContent === 'Salta', null, { timeout: 5000 });
+ // Le battute si saltano solo per far correre il tempo fino ai momenti da fotografare.
+ for (const [t, name] of [[0.5, '0-buio'], [2.1, '1-primo-tentativo'], [3.8, '2-secondo'], [5.6, '3-quasi'], [7.2, '4-aperti']]) {
+ await page.waitForFunction(at => { if (game.speech && game.closeup) game.skip(); return !game.closeup || game.closeup.t >= at; }, t, { timeout: 30000, polling: 50 });
+ await page.screenshot({ path: `${out}/${name}.png` });
+ }
+ await page.waitForFunction(() => !game.closeup && game.state.flags.intro, null, { timeout: 15000 });
+ await page.screenshot({ path: `${out}/5-vecchietti.png` });
+ assert.deepEqual(errors, []);
+ console.log(`ok: schermate in ${out}`);
+} finally { await browser?.close(); server.kill(); }
diff --git a/tests/risveglio.test.mjs b/tests/risveglio.test.mjs
new file mode 100644
index 0000000..632d4c7
--- /dev/null
+++ b/tests/risveglio.test.mjs
@@ -0,0 +1,43 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { openness, haze, END } from '../src/engine/risveglio.js';
+import { Game } from '../src/engine/game.js';
+import { story } from '../src/story/story.js';
+import { settle } from './helpers.mjs';
+
+test('il risveglio: gli occhi chiusi, due tentativi che ricascano, poi aperti; la nebbia si dirada', () => {
+ assert.equal(openness(0), 0);
+ assert.ok(openness(2.1) > 0.15 && openness(2.7) < 0.1, 'il primo tentativo ricasca');
+ assert.ok(openness(3.8) > openness(4.15), 'il secondo pure');
+ assert.equal(openness(END), 1);
+ assert.equal(haze(0), 1);
+ assert.equal(haze(END), 0);
+});
+
+test('la partita comincia sulla panchina: le battute a occhi semichiusi, poi i vecchietti col palio sparito', () => {
+ const g = new Game(story);
+ g.go(story.start.scene, story.start.at, story.start.face);
+ g.update(0);
+ assert.equal(g.closeup?.id, 'risveglio');
+ const said = [];
+ for (let i = 0; i < 2000 && (g.closeup || g.speech || g.queue.length); i++) {
+ if (g.speech) { said.push([g.speech.who, g.closeup ? openness(g.closeup.t) : 1]); g.skip(); }
+ g.update(0.05);
+ }
+ assert.deepEqual(said.slice(0, 3).map(s => s[0]), ['andrea', 'andrea', 'andrea']);
+ assert.ok(said[0][1] < 0.05, 'la prima battuta a occhi chiusi');
+ assert.ok(said.some(([who]) => who === 'vecchietto'), 'il vecchietto dà la notizia');
+ assert.ok(g.state.flags.intro && !g.closeup);
+ // Rientrando in piazza non si risveglia più.
+ g.go('via_cipriani'); settle(g); g.go('piazza'); g.update(0);
+ assert.equal(g.closeup, null);
+});
+
+test('saltando il risveglio (Esc) le battute arrivano lo stesso', () => {
+ const g = new Game(story);
+ g.go(story.start.scene, story.start.at);
+ g.update(0);
+ assert.ok(g.closeupClose());
+ const said = settle(g).map(s => s.text);
+ assert.ok(said.includes('Mmmh... che botta.') && said.some(t => t.includes('Il palio è sparito')));
+});
diff --git a/tests/story.test.mjs b/tests/story.test.mjs
index b089cab..50d768e 100644
--- a/tests/story.test.mjs
+++ b/tests/story.test.mjs
@@ -545,11 +545,33 @@ test('gli amici in giro: l\'Arcu al circolino, il Tozzino al cancello del tennis
ask('Ci si becca da i\' Trappola, ai\' Barrettino!');
assert.equal(g.dialogue, null);
assert.ok(talk('carza', 'tozzino').includes('tozzino: E chi sennò? Ha chiamato ora: s\'è fermato a Pratolino a mettere le catene. A breve arriva.'));
- const baron = talk('polisportiva', 'barondeduke');
+ talk('polisportiva', 'barondeduke');
+ const baron = ask('Barone! Che ci fai qui?');
assert.ok(baron.includes('barondeduke: Avevo due donne e due jack. Entra la terza donna, e penso: «Male male si divide».'));
assert.equal(baron.at(-1), 'barondeduke: L\'altro aveva colore.');
});
+test('il Muro del Sanpierino sul telefonino del Barone: sale, il sito a tutto schermo, il link apre il muro', async () => {
+ const { RISE, ZOOM, linkBox } = await import('../src/engine/telefono.js');
+ const g = new Game(story);
+ settle(g); g.go('polisportiva'); settle(g); g.act('talk', 'barondeduke'); settle(g);
+ g.choose(g.options().findIndex(o => o.text.includes('muro del Sanpierino'))); settle(g);
+ assert.equal(g.closeup?.id, 'muro_sanpierino');
+ const def = story.closeups.muro_sanpierino;
+ assert.ok(!g.closeupTap(), 'mentre sale, un tocco non conta');
+ g.update(RISE); assert.ok(g.closeupTap()); assert.equal(g.closeup.step, 'zoom');
+ for (let i = 0; i < 20; i++) g.update(ZOOM / 10);
+ assert.equal(g.closeup.step, 'site');
+ g.closeupAim([600, 340]); assert.ok(!g.closeupTap(), 'fuori dal link non succede niente');
+ const [x, y, w, h] = linkBox(def, def.siteSize);
+ g.closeupAim([x + w / 2, y + h / 2]); assert.ok(g.closeupTap()); assert.equal(g.closeup.step, 'wall');
+ const said = [];
+ for (let i = 0; i < 400 && (g.speech || !g.closeup.lines || g.closeup.lines.length); i++) { if (g.speech) { said.push(g.speech.text); g.skip(); } g.update(0.05); }
+ assert.ok(said[0].includes('Firmato: il Regolo'));
+ g.closeupClose();
+ assert.ok(settle(g).some(s => s.text.includes('il sito è tuo')), 'chiudendo, il commento');
+});
+
test('i tentativi sbagliati hanno una risposta loro, non quella generica', () => {
const g = new Game(story);
settle(g);
diff --git a/tests/telefono.browser.mjs b/tests/telefono.browser.mjs
new file mode 100644
index 0000000..3199e71
--- /dev/null
+++ b/tests/telefono.browser.mjs
@@ -0,0 +1,57 @@
+// Il Muro del Sanpierino sul telefonino del Barone, nel browser vero: il telefonino che sale, il sito a tutto
+// schermo, il clic sul mattone del muro. npm run browser:telefono — schermate in /tmp/regolo-telefono.
+import { chromium } from 'playwright';
+import { mkdirSync } from 'node:fs';
+import assert from 'node:assert/strict';
+import { staticServer } from './helpers.mjs';
+
+const out = process.env.OUT ?? '/tmp/regolo-telefono';
+mkdirSync(out, { recursive: true });
+const server = await staticServer();
+let browser;
+const errors = [];
+try {
+ browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
+ const page = await browser.newPage({ viewport: { width: 1280, height: 860 } });
+ page.on('pageerror', e => { errors.push(e.message); console.log('ERR', e.message); });
+ await page.goto(`http://127.0.0.1:${server.port}/`);
+ await page.click('#splash'); await page.click('#new-game'); await page.click('#rione [data-rione="campomigliaio"]'); await page.click('#skip-intro');
+ await page.evaluate(async () => {
+ const { settle } = await import('/tests/helpers.mjs');
+ settle(game); game.go('polisportiva'); settle(game);
+ game.act('talk', 'barondeduke'); settle(game);
+ game.choose(game.options().findIndex(o => o.text.includes('muro del Sanpierino'))); settle(game);
+ });
+ await page.waitForFunction(() => game.closeup?.id === 'muro_sanpierino' && game.closeup.age > 1.2, null, { timeout: 15000 });
+ await page.screenshot({ path: `${out}/1-telefonino.png` });
+ const box = await page.locator('#scene').boundingBox(), k = box.width / 640;
+ const click = ([x, y]) => page.mouse.click(box.x + x * k, box.y + y * k);
+ await click([320, 180]);
+ await page.waitForFunction(() => game.closeup.step === 'site', null, { timeout: 5000 });
+ await page.waitForTimeout(300);
+ await page.screenshot({ path: `${out}/2-sito.png` });
+ const link = await page.evaluate(async () => { const t = await import('/src/engine/telefono.js'); const d = story => story.closeups.muro_sanpierino; const def = d(game.story); const [x, y, w, h] = t.linkBox(def, def.siteSize); return [x + w / 2, y + h / 2]; });
+ await click(link);
+ await page.waitForFunction(() => game.closeup.step === 'wall', null, { timeout: 8000 });
+ // Il muro è la pagina vera: i messaggi del gioco in cima, sotto quelli del 2007; «Scrivi» spento, niente email.
+ const frame = page.frameLocator('#muro');
+ await frame.locator('.name').first().waitFor({ timeout: 10000 });
+ assert.match(await frame.locator('.name').first().textContent(), /Il Regolo/);
+ assert.ok((await frame.locator('.name').allTextContents()).some(t => t.includes('Kiara')), 'i messaggi veri del 2007');
+ assert.match(await frame.locator('.pagerbutton', { hasText: 'SCRIVI' }).first().getAttribute('class'), /disabled/);
+ assert.equal(await frame.locator('a[href^="mailto:"]').count(), 0);
+ await page.waitForTimeout(500);
+ await page.screenshot({ path: `${out}/3-muro.png` });
+ // La storia va avanti: «Aggiorna» fa comparire il messaggio nuovo.
+ await page.evaluate(() => { game.speech = null; game.closeup.lines = []; game.state.flags.indizio_regolo = true; });
+ await frame.locator('.pagerbutton', { hasText: 'Aggiorna' }).first().click();
+ await page.waitForTimeout(800);
+ assert.match(await frame.locator('.name').first().textContent(), /Uno che passava/);
+ await page.screenshot({ path: `${out}/4-aggiornato.png` });
+ // «Home» torna alla home del sito sul telefonino.
+ await frame.locator('.pagerbutton', { hasText: 'Home' }).first().click();
+ await page.waitForFunction(() => game.closeup.step === 'site', null, { timeout: 5000 });
+ await page.waitForSelector('#muro', { state: 'hidden', timeout: 3000 });
+ assert.deepEqual(errors, []);
+ console.log(`ok: schermate in ${out}`);
+} finally { await browser?.close(); server.kill(); }
diff --git a/tests/voices.test.mjs b/tests/voices.test.mjs
index 3db55c5..a7081ff 100644
--- a/tests/voices.test.mjs
+++ b/tests/voices.test.mjs
@@ -13,7 +13,8 @@ const voci = JSON.parse(readFileSync(new URL('../tools/voci.json', import.meta.u
test('le battute si trovano ovunque: introduzione, scene, oggetti, dialoghi e rami if', () => {
const lines = voiceLines(story), has = (who, part) => lines.some(l => l.who === who && l.text.includes(part));
assert.ok(has('andrea', 'Fine maggio 2007'), 'introduzione');
- assert.ok(has('andrea', 'Il palio è sparito!'), 'ingresso in una scena, dentro un if');
+ assert.ok(has('vecchietto', 'Il palio è sparito!'), 'primo piano del risveglio, a occhi aperti');
+ assert.ok(has('andrea', 'che botta'), 'primo piano del risveglio, mentre gli occhi si aprono');
assert.ok(has('vecchietta', 'cadde dal cielo'), 'dialogo, con chi parla');
assert.ok(has('castello', 'vicino alle sue impronte'), 'ramo else annidato');
assert.ok(has('andrea', 'pagina sola'), 'azione di un oggetto');
@@ -61,15 +62,16 @@ test('il castello e il leone hanno voci diverse da tutti gli altri', () => {
});
test('il fumetto resta a schermo almeno quanto la voce', () => {
- const g = new Game(story, { voiceTime: (who, text) => (text.startsWith('Il palio') ? 9 : undefined) });
+ // La prima battuta della partita è quella del risveglio, mentre gli occhi si aprono.
+ const g = new Game(story, { voiceTime: (who, text) => (text.startsWith('Mmmh') ? 9 : undefined) });
g.go(story.start.scene, story.start.at);
- g.update(0.05);
- assert.ok(g.speech.text.startsWith('Il palio'));
+ for (let i = 0; i < 40 && !g.speech; i++) g.update(0.05);
+ assert.ok(g.speech.text.startsWith('Mmmh'));
assert.ok(g.speech.left > 9, 'la voce lunga allunga il fumetto');
settle(g);
const quiet = new Game(story);
quiet.go(story.start.scene, story.start.at);
- quiet.update(0.05);
+ for (let i = 0; i < 40 && !quiet.speech; i++) quiet.update(0.05);
assert.ok(quiet.speech.left < 9, 'senza voce vale la durata di lettura');
});
diff --git a/tools/muro.py b/tools/muro.py
new file mode 100644
index 0000000..5337b04
--- /dev/null
+++ b/tools/muro.py
@@ -0,0 +1,80 @@
+#!/usr/bin/env python3
+"""Il Muro del Sanpierino dentro il gioco (Andrea, 31 ottobre): dal dump del muro vero riesumato
+(~/Workspaces/misc/spswall) prepara assets/muro/ per la pagina statica del telefonino del Barone.
+
+- messaggi.json: i messaggi veri (soprannome, data, testo), con gli accenti rimessi a posto. Niente email né IP.
+ Solo quelli del dump (i primi 24, novembre 2007): i 654 ritrovati nella Wayback Machine restano nel muro riesumato
+ (spswall/archivio/importa.py), fuori dal gioco (Andrea, 31 ottobre).
+- img/: solo gli smile usati e le icone della pagina; muro.css è il CSS originale; le due pagine di testo, com'erano.
+
+ python3 tools/muro.py [percorso del dump] [cartella www del muro]
+"""
+import json, re, shutil, sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+SPS = Path.home() / 'Workspaces/misc/spswall'
+DUMP = Path(sys.argv[1]) if len(sys.argv) > 1 else SPS / 'db/01-spswall.sql'
+WWW = Path(sys.argv[2]) if len(sys.argv) > 2 else SPS / 'www'
+OUT = ROOT / 'assets/muro'
+# Le icone della pagina, oltre agli smile.
+PAGE = ['logo_trasparente_obliquo.gif', 'spacer.gif', 'pagefirst.gif', 'pageback.gif', 'postmessage.gif', 'refresh.gif',
+ 'home.gif', 'pageahead.gif', 'pagelast.gif', 'logomanzolo.jpg']
+
+
+def rows(sql, table):
+ """Le righe di INSERT INTO `table` del dump di phpMyAdmin, come liste di valori."""
+ m = re.search(r'INSERT INTO `%s` \(([^)]*)\) VALUES\s*(.*?);\n' % table, sql, re.S)
+ cols = [c.strip(' `') for c in m.group(1).split(',')]
+ body, out, row, val, i, quoted = m.group(2), [], [], '', 0, False
+ while i < len(body):
+ ch = body[i]
+ if quoted:
+ if ch == '\\': val += {'n': '\n', 'r': '\r', 't': '\t', '0': ''}.get(body[i + 1], body[i + 1]); i += 2; continue
+ if ch == "'" and body[i + 1:i + 2] == "'": val += "'"; i += 2; continue
+ if ch == "'": quoted = False
+ else: val += ch
+ elif ch == "'": quoted = True
+ elif ch == '(': row, val = [], ''
+ elif ch == ',': row.append(val.strip()); val = ''
+ elif ch == ')': row.append(val.strip()); out.append(dict(zip(cols, row)))
+ elif not ch.isspace(): val += ch
+ i += 1
+ return out
+
+
+def fix(text):
+ """Il muro scriveva UTF-8 su una connessione latin1: nel dump gli accenti sono «Ã¨». Si rimettono a posto."""
+ for enc in ('latin1', 'cp1252'):
+ try: return text.encode(enc).decode('utf8')
+ except (UnicodeEncodeError, UnicodeDecodeError): pass
+ return text
+
+
+def main():
+ sql = DUMP.read_text('utf8')
+ smiles = {r['codsml']: r['linksml'] for r in rows(sql, 'tpsmiles')}
+ # Le immagini prese da fuori (http://...) non si caricano: il vecchio dominio oggi può essere di chiunque.
+ outside = lambda t: re.sub(r'
![]()
]*src="?http[^>]*>', '', t, flags=re.I)
+ messages = [{'nick': fix(r['nikute']), 'date': r['datmsg'], 'text': outside(fix(r['desmsg']))} for r in rows(sql, 'tpmsgute')]
+ messages.sort(key=lambda m: m['date'], reverse=True)
+ # Gli smile usati: dai messaggi veri e da quelli del gioco (story.closeups.muro_sanpierino.messages).
+ ours = (ROOT / 'src/story/story.js').read_text('utf8')
+ used = {code: link for code, link in smiles.items() if code in ours or any(code in m['text'] or code in m['nick'] for m in messages)}
+ shutil.rmtree(OUT / 'img', ignore_errors=True)
+ (OUT / 'img').mkdir(parents=True)
+ pictures = {n for m in messages for n in re.findall(r'src="\./img/([^"]+)"', m['text'] + m['nick'])}
+ for name in PAGE + sorted(set(used.values()) | pictures):
+ shutil.copy(WWW / 'imgwall' / name, OUT / 'img' / name)
+ shutil.copy(WWW / 'class/thewall.css', OUT / 'muro.css')
+ # Le due pagine di testo, com'erano; in fondo un link per tornare al muro (nel telefonino non c'è il «Indietro»).
+ for page in ('Responsabilita.htm', 'guida_al_muro_del_sanpierino.htm'):
+ html = (WWW / page).read_bytes()
+ back = b'
« Torna al Muro
'
+ (OUT / page).write_bytes(re.sub(rb'