Le bocce al pallaio contro Mariano: si tiene premuto per la forza, tre bocce a testa, vince il più vicino al pallino; se vinci, «Hai vinto, ti ci porto!» a Brento Sanico (sfondo e Mariano di Codex dalle foto di Andrea)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
This commit is contained in:
2026-10-25 18:59:09 +01:00
co-authored by Claude Opus 5.5
parent 7f0584f0fc
commit c4bac08c02
29 changed files with 528 additions and 13 deletions
+70
View File
@@ -0,0 +1,70 @@
{
"image": "mariano.png",
"height": 120,
"frames": {
"stand": {
"x": 0,
"y": 0,
"w": 40,
"h": 120,
"ax": 17.8,
"ay": 120
},
"blink": {
"x": 42,
"y": 0,
"w": 40,
"h": 120,
"ax": 17.9,
"ay": 120
},
"talk": {
"x": 84,
"y": 0,
"w": 40,
"h": 120,
"ax": 17.9,
"ay": 120
},
"explain": {
"x": 126,
"y": 0,
"w": 60,
"h": 120,
"ax": 20.9,
"ay": 120
},
"throw": {
"x": 188,
"y": 0,
"w": 80,
"h": 102,
"ax": 51.5,
"ay": 102
},
"laugh": {
"x": 270,
"y": 0,
"w": 41,
"h": 119,
"ax": 16.9,
"ay": 119
},
"measure": {
"x": 313,
"y": 0,
"w": 72,
"h": 91,
"ax": 38.0,
"ay": 91
},
"cheer": {
"x": 387,
"y": 0,
"w": 63,
"h": 118,
"ax": 27.5,
"ay": 118
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+59 -3
View File
@@ -883,9 +883,9 @@
"file": "andrea-e94382adb6.mp3",
"sec": 5.76
},
"andrea|Senza bocce, e senza un vecchietto che misura col metro, non è una partita.": {
"file": "andrea-d6576896b2.mp3",
"sec": 5.36
"andrea|Una partita? Chiedo a Mariano, il campione.": {
"file": "andrea-609540460a.mp3",
"sec": 3.81
},
"andrea|Di là, dove guarda l'omino di ferro, c'è il campo da tennis della chiesa.": {
"file": "andrea-455ac4031a.mp3",
@@ -931,6 +931,10 @@
"file": "andrea-2de291f241.mp3",
"sec": 4.09
},
"andrea|Mariano, il campione del pallaio. Gli occhiali in testa, il sorriso di chi ha già vinto.": {
"file": "andrea-3e2f9b4424.mp3",
"sec": 6.42
},
"andrea|Giù a destra, oltre le vigne, c'è la casa del maestro Bacci. Mezzo paese ha imparato a leggere e scrivere da lui.": {
"file": "andrea-060513a152.mp3",
"sec": 8.65
@@ -1659,6 +1663,38 @@
"file": "arcu-5148dffe36.mp3",
"sec": 1.7
},
"andrea|Mariano! Facciamo una partita a bocce?": {
"file": "andrea-83b1337b4d.mp3",
"sec": 3.31
},
"andrea|La rivincita?": {
"file": "andrea-98e4366a56.mp3",
"sec": 0.83
},
"andrea|Allora, Brento Sanico?": {
"file": "andrea-2add39ff10.mp3",
"sec": 1.29
},
"andrea|Ciao Mariano.": {
"file": "andrea-76a4a385c7.mp3",
"sec": 0.89
},
"mariano|Con me? Al pallaio sono il campione.": {
"file": "mariano-e8bb19cfdf.mp3",
"sec": 3.31
},
"mariano|Facciamo così: se mi batti, ti porto a Brento Sanico.": {
"file": "mariano-0eeb942ca2.mp3",
"sec": 3.52
},
"mariano|Il campione non scappa. Tira!": {
"file": "mariano-bdf58ba1be.mp3",
"sec": 3.26
},
"mariano|Promesso: ti ci porto. Ma prima ritrova il palio, che senza festa non si parte.": {
"file": "mariano-1c9cd0c325.mp3",
"sec": 5.96
},
"andrea|Sono bloccato. Mi dai un consiglio?": {
"file": "andrea-42b02eb9ea.mp3",
"sec": 3.9
@@ -1735,6 +1771,26 @@
"file": "castello-dc8a873d86.mp3",
"sec": 4.36
},
"mariano|Aspetta, ci vuole il metro...": {
"file": "mariano-e8b4ebf3d9.mp3",
"sec": 1.62
},
"mariano|Hai vinto, ti ci porto!": {
"file": "mariano-f361945994.mp3",
"sec": 1.66
},
"mariano|Bocciato! Al pallaio comando io.": {
"file": "mariano-4b1eea73b2.mp3",
"sec": 3.38
},
"mariano|Quando vuoi la rivincita, sono qui.": {
"file": "mariano-e582e766cd.mp3",
"sec": 2.29
},
"mariano|Già stanco? Il pallaio ti aspetta.": {
"file": "mariano-39e27158b7.mp3",
"sec": 2.83
},
"andrea|Laggiù, appoggiato al muro, c'è un angolo di tela dipinta: una strada bianca, e un omino con la maglia rossa e le braccia alzate.": {
"file": "andrea-6484641c58.mp3",
"sec": 8.67
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+172
View File
@@ -0,0 +1,172 @@
// Le bocce al pallaio (primo piano `type: 'bocce'`): una mano da tre bocce a testa contro il campione.
// Tieni premuto e la forza va su e giù; lasci e la boccia parte. Vince la mano chi ha la boccia più vicina al pallino.
// La pista è un piano: `u` lungo la pista (0 dove si tira, 100 la sponda di fondo), `w` di traverso (-10…10).
// Niente fisica vera: attrito costante, urti semplici (chi colpisce cede quasi tutta la spinta), sponde che respingono.
export const LANE = 100, HALF = 10, FRICTION = 40, BALL = 1.25, JACK = 0.6, BALLS = 3;
export const SWING = 1.6; // secondi per un giro della forza (0 → 1 → 0)
const REST = 0.6; // pausa a bocce ferme prima del tiro seguente
// Numeri «a caso» ripetibili: lo stato del generatore sta nel primo piano, così le prove sono sempre uguali.
function random(c) {
c.seed = (c.seed * 1664525 + 1013904223) % 4294967296;
return c.seed / 4294967296;
}
const gauss = c => (random(c) + random(c) + random(c) - 1.5) * 1.15;
export function newBocce(seed = Date.now() % 4294967296) {
const c = { step: 'aim', t: 0, age: 0, seed, balls: [], turn: 0, power: 0, holding: false, holdTime: 0 };
c.jack = { u: 38 + random(c) * 22, w: (random(c) - 0.5) * 5, vu: 0, vw: 0, r: JACK, jack: true };
return c;
}
// Di chi è il tiro: Andrea ai tiri pari, il campione ai dispari.
export const thrower = c => (c.turn % 2 ? 'cpu' : 'player');
export const left = (c, who) => BALLS - c.balls.filter(b => b.who === who).length;
const moving = c => [c.jack, ...c.balls].some(b => Math.hypot(b.vu, b.vw) > 0.01);
const dist = (a, b) => Math.hypot(a.u - b.u, a.w - b.w);
// Una boccia lanciata verso un punto: parte da dove si tira con la velocità che basta ad arrivarci.
function launch(c, who, u, w) {
const d = Math.hypot(u, w), v = Math.sqrt(2 * FRICTION * d);
c.balls.push({ who, u: 0, w: 0, vu: v * u / d, vw: v * w / d, r: BALL });
c.step = 'rolling';
c.t = 0;
}
// Andrea: la forza decide quanto lontano; la direzione punta al pallino, con un po' di mano che trema.
export function press(c) {
if (c.step !== 'aim' || thrower(c) !== 'player' || c.holding) return false;
c.holding = true;
c.holdTime = 0;
c.power = 0;
return true;
}
export function release(c) {
if (!c.holding) return false;
c.holding = false;
launch(c, 'player', 8 + c.power * (LANE - 4), c.jack.w + gauss(c) * 0.9);
return true;
}
export const powerAt = holdTime => (1 - Math.cos((2 * Math.PI * holdTime) / SWING)) / 2;
function step(c, dt) {
const all = [c.jack, ...c.balls];
for (const b of all) {
const v = Math.hypot(b.vu, b.vw);
if (v <= 0.01) { b.vu = b.vw = 0; continue; }
const slow = Math.max(0, v - FRICTION * dt) / v;
b.vu *= slow; b.vw *= slow;
b.u += b.vu * dt; b.w += b.vw * dt;
// Le sponde: di lato e in fondo respingono, piano.
if (Math.abs(b.w) > HALF - b.r) { b.w = Math.sign(b.w) * (HALF - b.r); b.vw *= -0.4; }
if (b.u > LANE - b.r) { b.u = LANE - b.r; b.vu *= -0.3; }
}
// Gli urti: chi va più forte spinge l'altro lungo la congiungente e si tiene poco.
for (let i = 0; i < all.length; i++) for (let j = i + 1; j < all.length; j++) {
const a = all[i], b = all[j], d = dist(a, b), min = a.r + b.r;
if (d >= min || d === 0) continue;
const [hit, other] = Math.hypot(a.vu, a.vw) >= Math.hypot(b.vu, b.vw) ? [a, b] : [b, a];
const nu = (other.u - hit.u) / d, nw = (other.w - hit.w) / d, push = hit.vu * nu + hit.vw * nw;
if (push <= 0) continue;
const k = other.jack ? 1.1 : 0.85;
other.vu += nu * push * k; other.vw += nw * push * k;
hit.vu -= nu * push * 0.85; hit.vw -= nw * push * 0.85;
// Si staccano subito, così non restano incollate.
const back = (min - d) / 2;
hit.u -= nu * back; hit.w -= nw * back; other.u += nu * back; other.w += nw * back;
}
}
// Il punto: chi ha la boccia più vicina al pallino vince, e segna una boccia per ognuna più vicina della migliore
// dell'altro.
export function score(c) {
const by = who => c.balls.filter(b => b.who === who).map(b => dist(b, c.jack)).sort((a, b) => a - b);
const p = by('player'), q = by('cpu');
if (!p.length || !q.length) return { winner: null, points: 0 };
const winner = p[0] <= q[0] ? 'player' : 'cpu', [mine, theirs] = winner === 'player' ? [p, q] : [q, p];
return { winner, points: mine.filter(d => d < theirs[0]).length };
}
// L'età del primo piano (`age`) la conta il motore; qui solo il tempo del passo in corso.
export function update(c, dt) {
c.t += dt;
if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); }
if (c.step === 'rolling') {
for (let s = 0; s < 4; s++) step(c, dt / 4);
if (!moving(c) && c.t > 0.3) { c.step = 'settle'; c.t = 0; }
} else if (c.step === 'settle' && c.t >= REST) {
c.turn++;
c.t = 0;
c.step = c.turn >= BALLS * 2 ? 'result' : 'aim';
if (c.step === 'result') c.result = score(c);
} else if (c.step === 'aim' && thrower(c) === 'cpu' && c.t >= 1.1) {
// Il campione mira al pallino e sbaglia di poco, a volte di più.
launch(c, 'cpu', Math.min(LANE - 2, Math.max(10, c.jack.u + gauss(c) * 7)), c.jack.w + gauss(c) * 1.4);
}
}
// --- Il disegno: lo sfondo della pista e, sopra, bocce e pallino in prospettiva (misurata sullo sfondo di Codex).
const NEAR = { y: 360, x: 355, half: 339 }, FAR = { y: 187, x: 298, half: 37.5 };
const S_FAR = FAR.half / NEAR.half, Q = (1 / S_FAR - 1) / LANE, HORIZON = (FAR.y - NEAR.y * S_FAR) / (1 - S_FAR);
export function project(u, w) {
const s = 1 / (1 + Q * u), k = (s - S_FAR) / (1 - S_FAR);
const cx = FAR.x + (NEAR.x - FAR.x) * k;
return { x: cx + (w / HALF) * NEAR.half * s, y: HORIZON + (NEAR.y - HORIZON) * s, s };
}
const COLORS = { player: ['#1d3f8f', '#3f6fd1', '#a9c4ff'], cpu: ['#7a1f1a', '#c2392d', '#ffb0a3'], jack: ['#a8862a', '#f2d75c', '#fff6c4'] };
// Il disegno ha la sua misura (le bocce vere su una pista di 4 metri), più piccola di quella degli urti; lontano si
// ingrandisce un po' (fino a 2,6 volte in fondo), sennò in prospettiva esatta resterebbero puntini.
const LOOK = { player: 0.42, cpu: 0.42, jack: 0.2 };
function ball(ctx, b, kind) {
const { x, y, s } = project(b.u, b.w), r = Math.max(2, LOOK[kind] * (NEAR.half / HALF) * s * (1 + 1.6 * (1 - s)));
const [dark, mid, light] = COLORS[kind];
ctx.fillStyle = 'rgba(0,0,0,0.28)';
ctx.beginPath(); ctx.ellipse(x + r * 0.25, y + r * 0.15, r, r * 0.45, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = dark; ctx.beginPath(); ctx.arc(x, y - r, r, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = mid; ctx.beginPath(); ctx.arc(x - r * 0.15, y - r * 1.12, r * 0.72, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = light; ctx.fillRect(Math.round(x - r * 0.45), Math.round(y - r * 1.55), Math.max(1, Math.round(r * 0.35)), Math.max(1, Math.round(r * 0.3)));
}
const FONT = '"Press Start 2P", monospace';
function label(ctx, text, x, y, size = 7, color = '#ffd27a', align = 'center') {
ctx.font = `${size}px ${FONT}`;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
const w = ctx.measureText(text).width, x0 = align === 'center' ? x - w / 2 : align === 'right' ? x - w : x;
ctx.fillStyle = 'rgba(0,0,0,0.62)';
ctx.fillRect(Math.round(x0 - 6), Math.round(y - size), Math.round(w + 12), size * 2);
ctx.fillStyle = color;
ctx.fillText(text, Math.round(x), Math.round(y));
}
export function drawBocce(ctx, c, def, bg, time) {
ctx.save();
if (bg) ctx.drawImage(bg, 0, 0, 640, 360); else { ctx.fillStyle = '#c9b48a'; ctx.fillRect(0, 0, 640, 360); }
const pieces = [{ b: c.jack, kind: 'jack' }, ...c.balls.map(b => ({ b, kind: b.who }))].sort((a, b) => b.b.u - a.b.u);
for (const p of pieces) ball(ctx, p.b, p.kind);
// Il punteggio in basso a sinistra (in alto ci sono Pausa e il volume): le bocce che restano a ciascuno.
const dots = (who, y) => label(ctx, `${def.names[who]} ${'o'.repeat(left(c, who))}`, 16, y, 7, who === 'player' ? '#a9c4ff' : '#ffb0a3', 'left');
dots('player', 326);
dots('cpu', 344);
if (c.step === 'aim' && thrower(c) === 'player') {
// La forza: una barra che sale e scende finché si tiene premuto.
const x = 586, y = 320, h = 120;
ctx.fillStyle = 'rgba(0,0,0,0.6)'; ctx.fillRect(x - 3, y - h - 3, 20, h + 6);
ctx.fillStyle = '#3a2d42'; ctx.fillRect(x, y - h, 14, h);
const p = c.holding ? c.power : 0, grad = ['#7fe08a', '#ffd27a', '#ff7a5a'];
ctx.fillStyle = grad[Math.min(2, Math.floor(p * 3))]; ctx.fillRect(x, Math.round(y - h * p), 14, Math.round(h * p));
label(ctx, 'FORZA', x + 7, y + 14, 6);
if (!c.holding && c.age > 0.4) {
ctx.globalAlpha = 0.65 + 0.35 * Math.sin(time / 300);
label(ctx, 'Tieni premuto per caricare, lascia per tirare', 320, 62);
ctx.globalAlpha = 1;
}
} else if (c.step === 'aim') label(ctx, `Tira ${def.names.cpu}...`, 320, 62);
if (c.step === 'result' && c.result) {
const win = c.result.winner === 'player';
label(ctx, win ? `Punto per ${def.names.player}!` : `Punto per ${def.names.cpu}!`, 320, 150, 12, win ? '#7fe08a' : '#ffb0a3');
}
ctx.restore();
}
+23 -4
View File
@@ -1,6 +1,7 @@
// Il cuore dell'avventura, senza DOM: stato, comandi, coda delle azioni, dialoghi.
// La storia (scene, oggetti, dialoghi) arriva tutta dai dati: qui non c'è nessun enigma scritto a mano.
import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js';
import { newBocce, update as bocceUpdate, press as boccePress, release as bocceRelease } from './bocce.js';
export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give', 'drop'];
export const VERB_LABELS = { walk: 'Vai a', look: 'Guarda', take: 'Prendi', use: 'Usa', talk: 'Parla con', open: 'Apri', give: 'Dai', drop: 'Butta' };
@@ -229,7 +230,7 @@ export class Game {
closeupTap() {
const c = this.closeup;
if (!c || this.speech) return false;
if (this.story.closeups[c.id].type === 'torch') return false;
if (this.story.closeups[c.id].type) return false; // torcia e bocce 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 });
else return false;
@@ -247,11 +248,20 @@ export class Game {
if (c.step === 'done') this.queue.unshift(...def.complete);
return true;
}
// Le bocce: a mano finita, la battuta di chi ha vinto; uscendo prima, quella di chi lascia.
if (def?.type === 'bocce') {
this.queue.unshift(...(c.step === 'result' ? def[c.result?.winner === 'player' ? 'win' : 'lose'] : def.quit ?? []));
return true;
}
// Chi è arrivato all'ultima pagina, chiudendo, la commenta (`closing`).
if (c.step === 'end') this.queue.unshift(...(this.story.closeups?.[c.id]?.closing ?? []));
return true;
}
// Le bocce: si tiene premuto per caricare la forza e si lascia per tirare (mouse, dito o spazio).
closeupPress() { const c = this.closeup; return !!c && this.story.closeups[c.id].type === 'bocce' && !this.speech && boccePress(c); }
closeupRelease() { const c = this.closeup; return !!c && this.story.closeups[c.id].type === 'bocce' && bocceRelease(c); }
// Solo luce, nessun clic sugli oggetti dietro al primo piano. Il progresso è transitorio:
// uscire o ricaricare durante la ricerca permette di ripeterla, senza anticipare il finale.
closeupAim([x, y]) {
@@ -378,6 +388,12 @@ export class Game {
const c = this.closeup;
if (c) {
if (this.story.closeups[c.id].type === 'torch') { this.updateTorch(dt); return; }
if (this.story.closeups[c.id].type === 'bocce') {
bocceUpdate(c, dt);
// Il risultato resta a schermo un momento, poi si torna al pallaio per i commenti.
if (c.step === 'result' && c.t >= 2.4) this.closeupClose();
return;
}
c.t += dt;
if (c.step === 'turn' && c.t >= PAGE_TURN) Object.assign(c, { step: 'end', t: 0 });
if (c.step === 'end' && !c.lines && c.t >= CLOSEUP_PAUSE) c.lines = [...(this.story.closeups?.[c.id]?.comment ?? [])];
@@ -409,9 +425,12 @@ export class Game {
else if (e.node) this.dialogue.node = e.node;
else if (e.endDialogue) this.dialogue = null;
else if (e.end) s.ended = true;
else if (e.closeup) this.closeup = this.story.closeups[e.closeup].type === 'torch'
? { id: e.closeup, step: 'search', t: 0, age: 0, found: 0, dwell: 0, beam: [320, 180] }
: { id: e.closeup, step: 'cover', t: 0, age: 0 };
else if (e.closeup) {
const type = this.story.closeups[e.closeup].type;
this.closeup = type === 'torch' ? { id: e.closeup, step: 'search', t: 0, age: 0, found: 0, dwell: 0, beam: [320, 180] }
: type === 'bocce' ? { id: e.closeup, ...newBocce(Math.floor(Math.random() * 4294967296)) }
: { id: e.closeup, step: 'cover', t: 0, age: 0 };
}
else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e));
}
+6
View File
@@ -3,6 +3,7 @@
import { check, PAGE_TURN } from './game.js';
import { ambientEffects, ambientHash } from './ambient.js';
import { drawTorch } from './torch.js';
import { drawBocce } from './bocce.js';
import { versioned } from '../version.js';
export const WIDTH = 640, HEIGHT = 360;
@@ -669,6 +670,11 @@ function drawCloseup(ctx, game, time) {
drawTorch(ctx, c, def, backgrounds.get(def.picture));
return;
}
if (def.type === 'bocce') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawBocce(ctx, c, def, backgrounds.get(def.picture), time);
return;
}
ctx.save();
ctx.fillStyle = `rgba(8, 5, 12, ${Math.min(0.78, c.age / 0.35 * 0.78)})`;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
+8 -2
View File
@@ -397,6 +397,8 @@ canvas.addEventListener('pointerdown', e => {
// Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano.
if (game.closeup) {
press = { closeup: true };
// Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta).
if (!game.skip()) game.closeupPress();
game.closeupAim(scenePoint(e));
canvas.setPointerCapture(e.pointerId);
return;
@@ -406,7 +408,7 @@ canvas.addEventListener('pointerdown', e => {
});
canvas.addEventListener('pointerup', e => {
if (!press) return;
if (press.closeup) { press = null; if (running && !game.skip()) game.closeupTap(); return; }
if (press.closeup) { press = null; if (running && !game.closeupRelease() && !game.skip()) game.closeupTap(); return; }
clearTimeout(press.timer);
// Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità.
if (!press.long) {
@@ -529,6 +531,7 @@ window.addEventListener('keydown', e => {
return;
}
if (e.key === 'Escape') game.closeupClose();
else if ([' ', 'Enter'].includes(e.key) && story.closeups[game.closeup.id].type === 'bocce') { e.preventDefault(); if (!e.repeat && !game.skip()) game.closeupPress(); }
else if ([' ', 'Enter', 'ArrowRight'].includes(e.key)) { e.preventDefault(); if (!game.skip()) game.closeupTap(); }
else if (e.key === 'm' || e.key === 'M') toggleMute();
return;
@@ -546,6 +549,9 @@ window.addEventListener('keydown', e => {
if (game.dialogue && n >= 1 && n <= 9) game.choose(n - 1);
});
// Alle bocce, lasciando spazio o Invio parte il tiro.
window.addEventListener('keyup', e => { if (running && [' ', 'Enter'].includes(e.key)) game?.closeupRelease(); });
// --- Il primo piano: si chiude col pulsante; la pagina che gira fruscia (rumore filtrato, niente file audio).
$('closeup-close').addEventListener('click', () => game?.closeupClose());
let turning = null, reveal = null;
@@ -590,7 +596,7 @@ function updateUi() {
$('spots-button').hidden = !running || !!game.closeup;
$('notebook-button').hidden = !running || !story.notebook || !!game.closeup;
$('closeup-close').hidden = !running || !game.closeup;
$('closeup-close').textContent = torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : 'Chiudi';
$('closeup-close').textContent = torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : c && story.closeups[c.id].type === 'bocce' ? 'Lascia stare' : 'Chiudi';
$('panel').inert = !!c;
canvas.classList.toggle('torch', !!torch);
canvas.setAttribute('aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : "La scena dell'avventura");
+48 -2
View File
@@ -107,6 +107,22 @@ export const story = {
// Primi piani interattivi: libro dell'oratorio e ricerca con la torcia nella fessura.
// Le immagini (Codex, assets/closeups/) hanno l'etichetta e le pagine vuote: titolo e «FINE» li scrive il codice.
closeups: {
// Le bocce al pallaio contro Mariano, il campione (idea di Andrea, 25 ottobre): solo per gloria.
bocce: {
type: 'bocce',
picture: 'assets/closeups/pallaio.png',
names: { player: 'Andrea', cpu: 'Mariano' },
win: [
{ say: 'Aspetta, ci vuole il metro...', who: 'mariano', pose: 'measure' },
{ say: 'Hai vinto, ti ci porto!', who: 'mariano', pose: 'laugh' },
{ set: 'bocce_vinte' },
],
lose: [
{ say: 'Bocciato! Al pallaio comando io.', who: 'mariano', pose: 'cheer' },
{ say: 'Quando vuoi la rivincita, sono qui.', who: 'mariano' },
],
quit: [{ say: 'Già stanco? Il pallaio ti aspetta.', who: 'mariano', pose: 'laugh' }],
},
fessura: {
type: 'torch',
picture: 'assets/closeups/palio-2007.jpg',
@@ -155,6 +171,7 @@ export const story = {
arcu: { name: 'L\'Arcu', color: '#7fb2ff', sprite: 'assets/characters/arcu.json' },
tozzino: { name: 'Il Tozzino', color: '#c8f5b0', sprite: 'assets/characters/tozzino.json' },
barondeduke: { name: 'Il BaronDeDuke', color: '#e0c090', sprite: 'assets/characters/barondeduke.json' },
mariano: { name: 'Mariano', color: '#ffb0a3', sprite: 'assets/characters/mariano.json' },
},
items: {
squama: {
@@ -1424,7 +1441,9 @@ export const story = {
name: 'Il tennis della chiesa',
map: [66, 51],
background: 'assets/scenes/tennis-chiesa.png',
speakers: { tosca: [292, 196] },
speakers: { tosca: [292, 196], mariano: [500, 234] },
// Mariano, il campione del pallaio (sprite di Codex dalla foto di Andrea, 25 ottobre).
people: [{ who: 'mariano', at: [500, 290], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.4 }],
walk: [[0, 360], [0, 300], [200, 290], [420, 268], [640, 258], [640, 360]],
depth: [[258, 0.7], [360, 1.15]],
entry: [320, 350],
@@ -1433,7 +1452,7 @@ export const story = {
id: 'pallaio', name: 'il pallaio', box: [300, 248, 340, 40], at: [400, 300], face: 'up',
actions: {
look: [{ say: 'Il pallaio, dove si gioca a bocce. Qui un punto si discute più di un rigore.' }],
use: [{ say: 'Senza bocce, e senza un vecchietto che misura col metro, non è una partita.' }],
use: [{ say: 'Una partita? Chiedo a Mariano, il campione.' }, { dialogue: 'mariano' }],
},
},
{
@@ -1464,6 +1483,13 @@ export const story = {
{
id: 'al_cancello', name: 'dal cancello verde, verso il posticino della Carza', box: [470, 140, 120, 130], at: [515, 280], exit: { scene: 'carza', at: [440, 290], face: 'left' },
},
{
id: 'mariano', name: 'Mariano', box: [482, 238, 36, 52], at: [455, 300], face: 'right', default: 'talk',
actions: {
look: [{ say: 'Mariano, il campione del pallaio. Gli occhiali in testa, il sorriso di chi ha già vinto.' }],
talk: [{ dialogue: 'mariano' }],
},
},
],
},
// Fra la Fortezza e i cipressi: la stradina fra le vigne, la casa del maestro Bacci, e lassù il Trebbio.
@@ -2095,6 +2121,26 @@ export const story = {
},
},
},
// Mariano al pallaio: una mano a bocce, solo per gloria; se Andrea vince, la promessa di Brento Sanico.
mariano: {
nodes: {
start: {
options: [
{
text: 'Mariano! Facciamo una partita a bocce?', when: '!bocce_vinte', next: 'end',
then: [
{ say: 'Con me? Al pallaio sono il campione.', who: 'mariano', pose: 'explain' },
{ say: 'Facciamo così: se mi batti, ti porto a Brento Sanico.', who: 'mariano' },
{ closeup: 'bocce' },
],
},
{ text: 'La rivincita?', when: 'bocce_vinte', next: 'end', then: [{ say: 'Il campione non scappa. Tira!', who: 'mariano', pose: 'throw' }, { closeup: 'bocce' }] },
{ text: 'Allora, Brento Sanico?', when: 'bocce_vinte', then: [{ say: 'Promesso: ti ci porto. Ma prima ritrova il palio, che senza festa non si parte.', who: 'mariano', pose: 'explain' }] },
{ text: 'Ciao Mariano.', next: 'end' },
],
},
},
},
// Lo "psicologo": il castello ascolta e risponde con un indizio su quello che manca.
castello: {
nodes: {
+111
View File
@@ -0,0 +1,111 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { newBocce, press, release, update, powerAt, score, thrower, left, project, SWING, LANE, BALLS } from '../src/engine/bocce.js';
import { story } from '../src/story/story.js';
import { Game } from '../src/engine/game.js';
import { settle } from './helpers.mjs';
const run = (c, seconds, dt = 1 / 60) => { for (let t = 0; t < seconds; t += dt) update(c, dt); };
// Tiene premuto finché la forza porta la boccia a `u`, poi lascia (nella prima salita della barra).
function throwTo(c, u) {
assert.ok(press(c));
const want = (u - 8) / (LANE - 4);
while (c.power < want && c.holdTime < SWING / 2) update(c, 1 / 240);
assert.ok(release(c));
}
test('la forza sale e scende: a metà giro è piena, a giro finito torna a zero', () => {
assert.equal(powerAt(0), 0);
assert.ok(Math.abs(powerAt(SWING / 2) - 1) < 1e-9);
assert.ok(powerAt(SWING) < 1e-9);
});
test('una boccia va lontano quanto dice la forza, verso il pallino', () => {
const c = newBocce(7);
c.jack.u = 95; c.jack.w = 0; // il pallino lontano, così non lo tocca
throwTo(c, 50);
for (let i = 0; i < 600 && c.step === 'rolling'; i++) update(c, 1 / 60); // fino a boccia ferma, prima che tiri il campione
const b = c.balls[0];
assert.ok(Math.abs(b.u - 50) < 2.5, `si ferma a ${b.u.toFixed(1)}, non a 50`);
assert.ok(Math.abs(b.w) < 4, 'va più o meno dritta');
run(c, 1);
assert.equal(thrower(c), 'cpu', 'dopo la pausa, tira il campione');
});
test('una boccia che arriva addosso al pallino lo sposta', () => {
const c = newBocce(3);
c.jack.u = 40; c.jack.w = 0;
c.balls.push({ who: 'player', u: 30, w: 0, vu: 30, vw: 0, r: 1.25 });
c.step = 'rolling';
run(c, 3);
assert.ok(c.jack.u > 41, `il pallino resta a ${c.jack.u.toFixed(1)}`);
});
test('il punto: vince la boccia più vicina, e conta quelle più vicine della migliore avversaria', () => {
const c = newBocce(1);
c.jack.u = 60; c.jack.w = 0;
const put = (who, u) => c.balls.push({ who, u, w: 0, vu: 0, vw: 0, r: 1.25 });
put('player', 61); put('player', 63); put('player', 80);
put('cpu', 64); put('cpu', 70); put('cpu', 30);
assert.deepEqual(score(c), { winner: 'player', points: 2 });
c.balls[0].u = 75; c.balls[1].u = 76;
assert.equal(score(c).winner, 'cpu');
});
test('una mano intera: tre bocce a testa, a turno, poi il risultato', () => {
const c = newBocce(42);
for (let i = 0; i < 200 && c.step !== 'result'; i++) {
if (c.step === 'aim' && thrower(c) === 'player') throwTo(c, c.jack.u);
run(c, 0.5);
}
assert.equal(c.step, 'result');
assert.equal(left(c, 'player'), 0);
assert.equal(left(c, 'cpu'), 0);
assert.equal(c.balls.length, BALLS * 2);
assert.ok(['player', 'cpu'].includes(c.result.winner));
assert.equal(press(c), false, 'a mano finita non si tira più');
});
test('la prospettiva: lontano è più in alto e più piccolo, al centro della pista', () => {
const near = project(0, 0), far = project(LANE, 0);
assert.ok(far.y < near.y && far.s < near.s);
assert.ok(project(50, 5).x > project(50, -5).x);
});
test('al pallaio: Mariano sfida, la mano si gioca nel primo piano; vincere vale la gloria e la promessa', () => {
const g = new Game(story);
settle(g);
g.go('tennis_chiesa'); settle(g);
assert.ok(g.act('talk', 'mariano'));
settle(g);
g.choose(g.options().findIndex(o => o.text.startsWith('Mariano! Facciamo')));
const before = settle(g).map(s => s.text);
assert.ok(before.some(t => t.includes('Brento Sanico')), 'la posta la dice prima');
assert.equal(g.closeup?.id, 'bocce');
assert.equal(g.act('look', 'pallaio'), false, 'durante la partita la scena non risponde');
// Si gioca la mano (le bocce di Andrea mirano al pallino); a mano finita il risultato si forza a favore.
const c = g.closeup;
for (let i = 0; i < 400 && c.step !== 'result'; i++) {
if (c.step === 'aim' && thrower(c) === 'player') {
g.closeupPress();
while (c.power < (c.jack.u - 8) / (LANE - 4) && c.holdTime < SWING / 2) g.update(1 / 240);
g.closeupRelease();
}
g.update(0.25);
}
assert.equal(c.step, 'result');
c.result = { winner: 'player', points: 1 };
for (let i = 0; i < 20 && g.closeup; i++) g.update(0.25);
assert.equal(g.closeup, null, 'il risultato si chiude da solo');
const after = settle(g).map(s => `${s.who}: ${s.text}`);
assert.ok(after.includes('mariano: Hai vinto, ti ci porto!'));
assert.ok(g.state.flags.bocce_vinte);
// Vinto, si chiede la rivincita o Brento Sanico; lasciare la partita a metà non segna niente.
g.act('talk', 'mariano'); settle(g);
assert.ok(g.options().some(o => o.text === 'La rivincita?') && g.options().some(o => o.text === 'Allora, Brento Sanico?'));
g.choose(g.options().findIndex(o => o.text === 'La rivincita?'));
settle(g);
assert.equal(g.closeup?.id, 'bocce');
assert.ok(g.closeupClose());
assert.ok(settle(g).some(s => s.text.startsWith('Già stanco?')));
});
+28
View File
@@ -362,6 +362,34 @@ try {
await book.close();
}
// Le bocce al pallaio: col mouse tenuto premuto la forza sale, lasciando la boccia parte; la scena dietro è ferma.
{
const bocce = await open({ width: 1280, height: 860 });
await bocce.click('#splash');
await bocce.click('#new-game');
await bocce.click('#rione [data-rione="campomigliaio"]');
await bocce.click('#skip-intro');
await settle(bocce);
await bocce.evaluate(() => game.go('tennis_chiesa', [430, 310]));
await settle(bocce);
await clickScene(bocce, await hotspotCenter(bocce, 'mariano'));
await bocce.waitForFunction(() => game.dialogue && !game.busy, null, { timeout: 20000 });
await bocce.locator('#choices li', { hasText: 'Facciamo una partita' }).click();
await bocce.waitForFunction(() => game.closeup?.id === 'bocce' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 });
const r = await bocce.locator('#scene').boundingBox();
await bocce.mouse.move(r.x + r.width / 2, r.y + r.height / 2);
await bocce.mouse.down();
await bocce.waitForTimeout(500);
assert.ok(await bocce.evaluate(() => game.closeup.power > 0.2), 'tenendo premuto la forza sale');
await bocce.screenshot({ path: `${OUT}/03c-bocce-forza.png` });
await bocce.mouse.up();
assert.equal(await bocce.evaluate(() => game.closeup.balls.length), 1, 'lasciando, la boccia parte');
await bocce.waitForFunction(() => game.closeup.balls.length === 2, null, { timeout: 15000 });
await bocce.keyboard.press('Escape');
await bocce.waitForFunction(() => game.speech?.text.startsWith('Già stanco?'), null, { timeout: 5000 });
await bocce.close();
}
const phone = await open({ width: 390, height: 844 });
await phone.click('#splash'); // un clic salta il cartello
assert.ok(await phone.isVisible('#title'), 'il clic sul cartello porta al titolo');
+1 -1
View File
@@ -70,7 +70,7 @@ test('la storia è coerente: scene, oggetti, dialoghi e punti calpestabili esist
if (e.closeup) {
const c = story.closeups?.[e.closeup];
assert.ok(c, `${where}: primo piano ${e.closeup}`);
for (const src of c.type === 'torch' ? [c.picture] : [c.closed, c.open]) assert.ok(existsSync(new URL('../' + src, import.meta.url)), `${where}: manca ${src}`);
for (const src of c.picture ? [c.picture] : [c.closed, c.open]) assert.ok(existsSync(new URL('../' + src, import.meta.url)), `${where}: manca ${src}`);
}
if (e.who) assert.ok(story.characters[e.who], `${where}: personaggio ${e.who}`);
}
+2 -1
View File
@@ -9,5 +9,6 @@
"tosca": { "speaker": "Gitta Nikolina" },
"arcu": { "speaker": "Luis Moray" },
"tozzino": { "speaker": "Xavier Hayasaka", "filtro": "asetrate=24000*1.12,aresample=24000,atempo=0.92" },
"barondeduke": { "speaker": "Craig Gutsy" }
"barondeduke": { "speaker": "Craig Gutsy" },
"mariano": { "speaker": "Gilberto Mathias" }
}