Si comincia svegliandosi sulla panchina dei vecchietti dopo una notte brava: gli occhi che si aprono a fatica, la piazza sfocata e doppia; e dal Barone il telefonino col sito di San Piero, che si apre a tutto schermo, e il Muro del Sanpierino vero coi messaggi del 2007, «Aggiorna» compreso (Andrea)

- Il risveglio (src/engine/risveglio.js): primo piano sopra la scena, con le battute mentre le palpebre si aprono e
  i vecchietti che danno la notizia del palio; «Salta» lo salta.
- Il telefonino del Barone (src/engine/telefono.js): sale dal basso come il libro, la home di sanpiero50037.it dalla
  Wayback Machine nello schermo, il mattone del muro apre il muro.
- Il muro (assets/muro, tools/muro.py): la pagina vera rifatta statica dal muro riesumato, coi 24 messaggi del dump
  (senza email né IP) e quelli del gioco, che arrivano con la storia; «Scrivi» spento.
- Il gioco sgancia i service worker di altre app rimasti sul suo indirizzo (la Bussola su localhost:8080).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
This commit is contained in:
2026-10-31 09:52:34 +01:00
co-authored by Claude Opus 5.5
parent 1ea78161d1
commit c5953f04c0
93 changed files with 1112 additions and 68 deletions
+35
View File
@@ -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).
+18 -2
View File
@@ -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}`;
+76
View File
@@ -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();
}
+81
View File
@@ -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;