diff --git a/assets/characters/mariano.json b/assets/characters/mariano.json new file mode 100644 index 0000000..62bd79e --- /dev/null +++ b/assets/characters/mariano.json @@ -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 + } + } +} diff --git a/assets/characters/mariano.png b/assets/characters/mariano.png new file mode 100644 index 0000000..7cb45b0 Binary files /dev/null and b/assets/characters/mariano.png differ diff --git a/assets/closeups/pallaio.png b/assets/closeups/pallaio.png new file mode 100644 index 0000000..55b7b71 Binary files /dev/null and b/assets/closeups/pallaio.png differ diff --git a/assets/voices/andrea-2add39ff10.mp3 b/assets/voices/andrea-2add39ff10.mp3 new file mode 100644 index 0000000..0e813a2 Binary files /dev/null and b/assets/voices/andrea-2add39ff10.mp3 differ diff --git a/assets/voices/andrea-3e2f9b4424.mp3 b/assets/voices/andrea-3e2f9b4424.mp3 new file mode 100644 index 0000000..6d2907b Binary files /dev/null and b/assets/voices/andrea-3e2f9b4424.mp3 differ diff --git a/assets/voices/andrea-609540460a.mp3 b/assets/voices/andrea-609540460a.mp3 new file mode 100644 index 0000000..aff477c Binary files /dev/null and b/assets/voices/andrea-609540460a.mp3 differ diff --git a/assets/voices/andrea-76a4a385c7.mp3 b/assets/voices/andrea-76a4a385c7.mp3 new file mode 100644 index 0000000..441b7c8 Binary files /dev/null and b/assets/voices/andrea-76a4a385c7.mp3 differ diff --git a/assets/voices/andrea-83b1337b4d.mp3 b/assets/voices/andrea-83b1337b4d.mp3 new file mode 100644 index 0000000..3baa972 Binary files /dev/null and b/assets/voices/andrea-83b1337b4d.mp3 differ diff --git a/assets/voices/andrea-98e4366a56.mp3 b/assets/voices/andrea-98e4366a56.mp3 new file mode 100644 index 0000000..3f95e07 Binary files /dev/null and b/assets/voices/andrea-98e4366a56.mp3 differ diff --git a/assets/voices/andrea-d6576896b2.mp3 b/assets/voices/andrea-d6576896b2.mp3 deleted file mode 100644 index 593fafb..0000000 Binary files a/assets/voices/andrea-d6576896b2.mp3 and /dev/null differ diff --git a/assets/voices/index.json b/assets/voices/index.json index 3dd4c92..b26e75c 100644 --- a/assets/voices/index.json +++ b/assets/voices/index.json @@ -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 diff --git a/assets/voices/mariano-0eeb942ca2.mp3 b/assets/voices/mariano-0eeb942ca2.mp3 new file mode 100644 index 0000000..bd12707 Binary files /dev/null and b/assets/voices/mariano-0eeb942ca2.mp3 differ diff --git a/assets/voices/mariano-1c9cd0c325.mp3 b/assets/voices/mariano-1c9cd0c325.mp3 new file mode 100644 index 0000000..f16b5ac Binary files /dev/null and b/assets/voices/mariano-1c9cd0c325.mp3 differ diff --git a/assets/voices/mariano-39e27158b7.mp3 b/assets/voices/mariano-39e27158b7.mp3 new file mode 100644 index 0000000..487374f Binary files /dev/null and b/assets/voices/mariano-39e27158b7.mp3 differ diff --git a/assets/voices/mariano-4b1eea73b2.mp3 b/assets/voices/mariano-4b1eea73b2.mp3 new file mode 100644 index 0000000..60654c4 Binary files /dev/null and b/assets/voices/mariano-4b1eea73b2.mp3 differ diff --git a/assets/voices/mariano-bdf58ba1be.mp3 b/assets/voices/mariano-bdf58ba1be.mp3 new file mode 100644 index 0000000..f750487 Binary files /dev/null and b/assets/voices/mariano-bdf58ba1be.mp3 differ diff --git a/assets/voices/mariano-e582e766cd.mp3 b/assets/voices/mariano-e582e766cd.mp3 new file mode 100644 index 0000000..3dd61bd Binary files /dev/null and b/assets/voices/mariano-e582e766cd.mp3 differ diff --git a/assets/voices/mariano-e8b4ebf3d9.mp3 b/assets/voices/mariano-e8b4ebf3d9.mp3 new file mode 100644 index 0000000..1be3db3 Binary files /dev/null and b/assets/voices/mariano-e8b4ebf3d9.mp3 differ diff --git a/assets/voices/mariano-e8bb19cfdf.mp3 b/assets/voices/mariano-e8bb19cfdf.mp3 new file mode 100644 index 0000000..e38cd11 Binary files /dev/null and b/assets/voices/mariano-e8bb19cfdf.mp3 differ diff --git a/assets/voices/mariano-f361945994.mp3 b/assets/voices/mariano-f361945994.mp3 new file mode 100644 index 0000000..a7de7db Binary files /dev/null and b/assets/voices/mariano-f361945994.mp3 differ diff --git a/src/engine/bocce.js b/src/engine/bocce.js new file mode 100644 index 0000000..45049f3 --- /dev/null +++ b/src/engine/bocce.js @@ -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(); +} diff --git a/src/engine/game.js b/src/engine/game.js index 259e6d7..f47f94e 100644 --- a/src/engine/game.js +++ b/src/engine/game.js @@ -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)); } diff --git a/src/engine/render.js b/src/engine/render.js index 0cb642e..ce662d9 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -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); diff --git a/src/main.js b/src/main.js index 92e9c91..3b13f58 100644 --- a/src/main.js +++ b/src/main.js @@ -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"); diff --git a/src/story/story.js b/src/story/story.js index bea75d7..45d9e29 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -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: { diff --git a/tests/bocce.test.mjs b/tests/bocce.test.mjs new file mode 100644 index 0000000..a449e18 --- /dev/null +++ b/tests/bocce.test.mjs @@ -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?'))); +}); diff --git a/tests/browser.mjs b/tests/browser.mjs index 361b9ae..30698ad 100644 --- a/tests/browser.mjs +++ b/tests/browser.mjs @@ -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'); diff --git a/tests/story.test.mjs b/tests/story.test.mjs index 6bc2c26..b35a0df 100644 --- a/tests/story.test.mjs +++ b/tests/story.test.mjs @@ -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}`); } diff --git a/tools/voci.json b/tools/voci.json index 9d22d31..e10cde1 100644 --- a/tools/voci.json +++ b/tools/voci.json @@ -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" } }