diff --git a/assets/closeups/smarronata-bruciatore.png b/assets/closeups/smarronata-bruciatore.png new file mode 100644 index 0000000..ac69800 Binary files /dev/null and b/assets/closeups/smarronata-bruciatore.png differ diff --git a/assets/voices/andrea-1308ef4494.mp3 b/assets/voices/andrea-1308ef4494.mp3 new file mode 100644 index 0000000..54ed9c8 Binary files /dev/null and b/assets/voices/andrea-1308ef4494.mp3 differ diff --git a/assets/voices/andrea-145f20bff9.mp3 b/assets/voices/andrea-145f20bff9.mp3 new file mode 100644 index 0000000..1e66be6 Binary files /dev/null and b/assets/voices/andrea-145f20bff9.mp3 differ diff --git a/assets/voices/andrea-367ddde0fe.mp3 b/assets/voices/andrea-367ddde0fe.mp3 new file mode 100644 index 0000000..dc1626b Binary files /dev/null and b/assets/voices/andrea-367ddde0fe.mp3 differ diff --git a/assets/voices/andrea-675499a046.mp3 b/assets/voices/andrea-675499a046.mp3 deleted file mode 100644 index 61f2065..0000000 Binary files a/assets/voices/andrea-675499a046.mp3 and /dev/null differ diff --git a/assets/voices/andrea-6f3339acb0.mp3 b/assets/voices/andrea-6f3339acb0.mp3 new file mode 100644 index 0000000..dea96ba Binary files /dev/null and b/assets/voices/andrea-6f3339acb0.mp3 differ diff --git a/assets/voices/andrea-8febc84325.mp3 b/assets/voices/andrea-8febc84325.mp3 new file mode 100644 index 0000000..12ae915 Binary files /dev/null and b/assets/voices/andrea-8febc84325.mp3 differ diff --git a/assets/voices/andrea-ade8043e82.mp3 b/assets/voices/andrea-ade8043e82.mp3 new file mode 100644 index 0000000..e4cf69d Binary files /dev/null and b/assets/voices/andrea-ade8043e82.mp3 differ diff --git a/assets/voices/index.json b/assets/voices/index.json index 8849322..539dc18 100644 --- a/assets/voices/index.json +++ b/assets/voices/index.json @@ -1939,9 +1939,9 @@ "file": "andrea-2603f4db73.mp3", "sec": 5.93 }, - "andrea|Si gira piano, sennò le bruciate si bruciano davvero.": { - "file": "andrea-675499a046.mp3", - "sec": 3.07 + "andrea|Posso girare io la manovella?": { + "file": "andrea-8febc84325.mp3", + "sec": 1.75 }, "andrea|Una bruciata calda, presa al volo. Scotta! Ma è buonissima.": { "file": "andrea-ebba87b32c.mp3", @@ -1971,18 +1971,6 @@ "file": "andrea-bfd09556ea.mp3", "sec": 5.63 }, - "andrea|Che Iddio ve ne renda merito!! A voi e agli alpini!": { - "file": "andrea-6933735b75.mp3", - "sec": 3.95 - }, - "volontari|E a te, figliolo. To', un cartoccio: son belle calde.": { - "file": "volontari-e8705fe901.mp3", - "sec": 5.14 - }, - "andrea|Scottano! Ma son buonissime.": { - "file": "andrea-ff81f048f5.mp3", - "sec": 2.79 - }, "andrea|La fanfara degli alpini. Quando sfilavano per il paese suonando, s'affacciava tutto San Piero.": { "file": "andrea-a372a71849.mp3", "sec": 6.3 @@ -2647,6 +2635,54 @@ "file": "vecchietto-c8eae2e59e.mp3", "sec": 4.88 }, + "andrea|Che Iddio ve ne renda merito!! A voi e agli alpini!": { + "file": "andrea-6933735b75.mp3", + "sec": 3.95 + }, + "andrea|Vi do una mano alla manovella?": { + "file": "andrea-1308ef4494.mp3", + "sec": 1.53 + }, + "andrea|Buon lavoro!": { + "file": "andrea-ade8043e82.mp3", + "sec": 0.71 + }, + "volontari|E a te, figliolo. To', un cartoccio: son belle calde.": { + "file": "volontari-e8705fe901.mp3", + "sec": 5.14 + }, + "andrea|Scottano! Ma son buonissime.": { + "file": "andrea-ff81f048f5.mp3", + "sec": 2.79 + }, + "volontari|Su, giovanotto, che noi s'ha le braccia stanche!": { + "file": "volontari-98d33dbfbd.mp3", + "sec": 2.43 + }, + "volontari|Si gira sempre uguale: piano piano sotto si bruciano, a furia volan nel fuoco. Quando son cotte, nel sacco.": { + "file": "volontari-7db485651b.mp3", + "sec": 8.77 + }, + "volontari|Come te la senti?": { + "file": "volontari-a104846cbf.mp3", + "sec": 0.88 + }, + "andrea|No, dopo.": { + "file": "andrea-78ad61a67a.mp3", + "sec": 1.22 + }, + "volontari|Alla bona: ti si guarda noi, e se si bruciano un si dice a nessuno.": { + "file": "volontari-5761a9776b.mp3", + "sec": 4.82 + }, + "volontari|Seria: la gente è in fila, e il cartoccio lo vole bello pieno.": { + "file": "volontari-dad04d4161.mp3", + "sec": 3.99 + }, + "volontari|A fassi male? Allora ci si mette anche il fuoco forte.": { + "file": "volontari-839aa118ab.mp3", + "sec": 4.1 + }, "andrea|Barone! Che ci fai qui?": { "file": "andrea-83abae1419.mp3", "sec": 2.91 @@ -2987,10 +3023,6 @@ "file": "andrea-9cc0991d39.mp3", "sec": 3.32 }, - "andrea|No, dopo.": { - "file": "andrea-78ad61a67a.mp3", - "sec": 1.22 - }, "andrea|C'è posto per uno?": { "file": "andrea-f7481e5519.mp3", "sec": 1.81 @@ -3619,6 +3651,26 @@ "file": "andrea-356a1afe8d.mp3", "sec": 5.91 }, + "andrea|Bruciate coi fiocchi! Mezza Smarronata le mangia grazie a me.": { + "file": "andrea-145f20bff9.mp3", + "sec": 5.11 + }, + "volontari|Bravo, figliolo! Se ti va, l'anno prossimo la manovella è tua.": { + "file": "volontari-067aa2d701.mp3", + "sec": 4.42 + }, + "andrea|Un po' crude, un po' carbone... Le mangio io, va'.": { + "file": "andrea-6f3339acb0.mp3", + "sec": 4.06 + }, + "volontari|Ci vuole il polso, figliolo. Il polso e la pazienza.": { + "file": "volontari-3bb4fe1dc0.mp3", + "sec": 4.28 + }, + "andrea|Vi rendo la manovella. Mi fumano anche gli occhi.": { + "file": "andrea-367ddde0fe.mp3", + "sec": 3.61 + }, "andrea|Il manifesto dell'Ingorgo Sonoro dell'anno scorso. Da piazza fino a Cambray Digny, una console in ogni angolo.": { "file": "andrea-0c26e34815.mp3", "sec": 7.53 diff --git a/assets/voices/volontari-067aa2d701.mp3 b/assets/voices/volontari-067aa2d701.mp3 new file mode 100644 index 0000000..2f2118e Binary files /dev/null and b/assets/voices/volontari-067aa2d701.mp3 differ diff --git a/assets/voices/volontari-3bb4fe1dc0.mp3 b/assets/voices/volontari-3bb4fe1dc0.mp3 new file mode 100644 index 0000000..4477060 Binary files /dev/null and b/assets/voices/volontari-3bb4fe1dc0.mp3 differ diff --git a/assets/voices/volontari-5761a9776b.mp3 b/assets/voices/volontari-5761a9776b.mp3 new file mode 100644 index 0000000..77a4130 Binary files /dev/null and b/assets/voices/volontari-5761a9776b.mp3 differ diff --git a/assets/voices/volontari-7db485651b.mp3 b/assets/voices/volontari-7db485651b.mp3 new file mode 100644 index 0000000..3f815f7 Binary files /dev/null and b/assets/voices/volontari-7db485651b.mp3 differ diff --git a/assets/voices/volontari-839aa118ab.mp3 b/assets/voices/volontari-839aa118ab.mp3 new file mode 100644 index 0000000..c35c58f Binary files /dev/null and b/assets/voices/volontari-839aa118ab.mp3 differ diff --git a/assets/voices/volontari-98d33dbfbd.mp3 b/assets/voices/volontari-98d33dbfbd.mp3 new file mode 100644 index 0000000..a38adca Binary files /dev/null and b/assets/voices/volontari-98d33dbfbd.mp3 differ diff --git a/assets/voices/volontari-a104846cbf.mp3 b/assets/voices/volontari-a104846cbf.mp3 new file mode 100644 index 0000000..63e5b0f Binary files /dev/null and b/assets/voices/volontari-a104846cbf.mp3 differ diff --git a/assets/voices/volontari-dad04d4161.mp3 b/assets/voices/volontari-dad04d4161.mp3 new file mode 100644 index 0000000..9674c91 Binary files /dev/null and b/assets/voices/volontari-dad04d4161.mp3 differ diff --git a/docs/STATO.md b/docs/STATO.md index b00237a..37868f3 100644 --- a/docs/STATO.md +++ b/docs/STATO.md @@ -41,7 +41,15 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto, merito!! A voi e agli alpini!»), la fanfara degli alpini, gli alpini della polenta e del vino, la gente coi cartocci (anche nel tendone), le coppie del liscio che girano (`flourish` continuo). La musica di Andrea (Suno): il liscio fuori, più forte vicino alle coppie (`stages`), la mazurca nel tendone. - [DA ANDREA] le battute; un minigioco delle bruciate (girare il cilindro sul fuoco). + [DA ANDREA] le battute. +- **Il minigioco delle bruciate (31 ottobre)**: dai volontari della Misericordia («Vi do una mano alla manovella?»), o + usando il cilindro, si sceglie il livello e si gira la manovella a mano (`src/engine/bruciate.js`, disegno in + `bruciate-render.js`, sfondo di Codex `assets/closeups/smarronata-bruciatore.png`). Il gesto: tenendo premuto si + fanno giri, grandi o piccoli e dovunque (conta quanto gira la direzione del dito); Spazio, frecce: una spinta a + colpo. Troppo piano le castagne di sotto si carbonizzano e quelle di sopra restano crude, troppo forte volano nel + fuoco; quando son cotte, «Rovescia nel sacco». Contano le buone su 18 (alla bona 10, seria 15, a fassi male 16); + vincendo, il flag `bruciate_fatte`. Test in `bruciate.test.mjs`, prova `npm run browser:bruciate`. + [DA ANDREA] le battute dei volontari; un premio (un cartoccio da portare via?). - **Dedica allo zio Mario (31 ottobre)**: nei Ringraziamenti, sotto quella al Lele e più piccola: «Al mio caro zio Mario, che mi ha fatto vivere tante avventure più belle della fantasia.» - **Il Taglia (31 ottobre)**: tolte le battute sulla sua altezza (Baldoria: non gli fa piacere). diff --git a/index.html b/index.html index f7dbc7f..af6bfaa 100644 --- a/index.html +++ b/index.html @@ -295,6 +295,14 @@ sinistra per la forza. I salti: la lancetta dell'equilibrio attraversa la barra; premi (o Spazio, o Salta) quando è nel verde. Troppo presto metti giù l'altro piede, troppo tardi pesti la riga.
+Le bruciate della Smarronata, coi volontari della Misericordia: le castagne sono nel cilindro a rete sopra il + fuoco e si gira la manovella. Tieni premuto e fai dei giri, grandi o piccoli, dove vuoi, col mouse o col dito: + il cilindro gira con te. Oppure Spazio o le frecce: una spinta a ogni colpo.
+In alto, GIRO: tienilo nel verde. Troppo piano le castagne non si rimescolano, quelle di sotto si carbonizzano e + quelle di sopra restano crude; troppo forte volano fuori, nel fuoco. COTTURA: quando è nel verde, Rovescia nel + sacco (o Invio). Contano le bruciate buone: alla bona ne bastano 10 su 18, seria 15, a fassi male 16.
+La cerbottana, con lo stucco verde di Pierin di Frilli: dodici pallini contro il muro della piazza. Il bersaglio di gesso vale 10, 5 o 2 punti secondo il cerchio, ogni lattina sul muretto 3, il barattolo appeso che dondola (Seria diff --git a/package.json b/package.json index 563468e..a65a01c 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "browser:cerbottana": "node tests/cerbottana.browser.mjs", "browser:condor": "node tests/condor.browser.mjs", "browser:campana": "node tests/campana.browser.mjs", + "browser:bruciate": "node tests/bruciate.browser.mjs", "browser:ingorgo": "node tests/ingorgo.browser.mjs", "browser:tressette": "node tests/tressette.browser.mjs", "browser:scopa": "node tests/scopa.browser.mjs", diff --git a/src/engine/bruciate-render.js b/src/engine/bruciate-render.js new file mode 100644 index 0000000..5eac3e8 --- /dev/null +++ b/src/engine/bruciate-render.js @@ -0,0 +1,152 @@ +// Le bruciate in primo piano (bruciate.js): il cilindro a rete sopra il fuoco, visto di fronte, con le castagne dentro +// che ricascano, la manovella a destra, le fiamme che lo lambiscono; in basso il giro della manovella e la cottura. +// Niente filtri e niente passate trasparenti a tutto schermo (vedi risveglio.js): solo forme piccole. +import { DRUM, CRANK, BAR, N, COOKED, BURNT, POUR, MAX_SPEED, inside, doneness } from './bruciate.js'; +import { text, panel } from './bocce-render.js'; +import { ambientHash as hash } from './ambient.js'; + +const SACK = { x: 604, y: 268 }, FIRE = 252, RODS = 18, RINGS = 9; +const lerp = (a, b, k) => a + (b - a) * k; +const mix = (p, q, k) => `rgb(${p.map((v, i) => Math.round(lerp(v, q[i], k))).join(',')})`; +// Il colore della buccia: lucida e rossiccia da cruda, scura da cotta, nera da carbone. +const RAW = [168, 96, 50], DONE = [118, 64, 30], COAL = [26, 20, 18]; +const shell = cook => cook < COOKED ? mix(RAW, DONE, Math.max(0, cook) / COOKED) : mix(DONE, COAL, Math.min(1, (cook - COOKED) / (BURNT - COOKED))); + +// Dove sta una castagna sullo schermo, e quanto è davanti (per l'ordine e la luce). +function place(k) { + return { x: lerp(DRUM.x0 + 8, DRUM.x1 - 8, k.x), y: DRUM.y + Math.sin(k.a) * DRUM.r * k.r, z: Math.cos(k.a) * k.r }; +} +function chestnut(ctx, x, y, cook, z = 0) { + // Il bordino, perché si stacchino dal fondo del cilindro (chiaro su quelle carbone). + ctx.fillStyle = cook > BURNT ? '#6a5a4e' : '#1e120a'; + ctx.beginPath(); ctx.ellipse(Math.round(x), Math.round(y), 7, 6, 0, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = shell(cook); + ctx.beginPath(); ctx.ellipse(Math.round(x), Math.round(y), 6, 5, 0, 0, Math.PI * 2); ctx.fill(); + // La punta chiara e, da cotte, l'incisione aperta col giallo della castagna. + ctx.fillStyle = cook > BURNT ? '#2a2420' : '#d9b48a'; + ctx.fillRect(Math.round(x) - 1, Math.round(y) - 4, 2, 1); + if (cook >= COOKED * 0.8 && cook <= BURNT) { ctx.fillStyle = '#e8c070'; ctx.fillRect(Math.round(x) - 2, Math.round(y) - 1, 4, 1); } + // Il riflesso del fuoco, più forte su quelle davanti. + if (cook < BURNT) { ctx.fillStyle = `rgba(255,190,120,${0.25 + 0.3 * Math.max(0, z)})`; ctx.fillRect(Math.round(x) + 1, Math.round(y) - 2, 2, 1); } +} +// Le fiamme che lambiscono il cilindro: lingue basse e storte, ognuna col suo passo, che ogni tanto si allungano. +function flames(ctx, time, front) { + for (let i = 0; i < 17; i++) { + if ((i % 3 === 0) !== front) continue; + const r = hash(i * 7.3), x = DRUM.x0 + 6 + i * (DRUM.x1 - DRUM.x0 - 12) / 16 + 6 * (hash(i * 1.9) - 0.5); + const lick = Math.max(0, Math.sin(time * (1.3 + r) + i * 2.1)) ** 4, h = front ? 5 + 6 * r + 2 * Math.sin(time * (6 + r * 5) + i) + 7 * lick : 18 + 16 * r + 7 * Math.sin(time * (6 + r * 5) + i) + 22 * lick; + const w = 5 + 5 * hash(i * 3.1), sway = 2.5 * Math.sin(time * (3 + r) + i * 1.7); + for (const [k, color] of [[1, '#b8301a'], [0.7, '#ea7a1a'], [0.4, '#ffd04a']]) { + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(x - w * k, FIRE); + ctx.quadraticCurveTo(x - w * k * 0.2 + sway, FIRE - h * k * 0.6, x + sway * 2, FIRE - h * k); + ctx.quadraticCurveTo(x + w * k * 0.5 + sway, FIRE - h * k * 0.45, x + w * k, FIRE); + ctx.fill(); + } + } +} +// Il cilindro appeso: la sbarra sulle due forcelle e i due ferri che lo reggono per l'asse. +function hanger(ctx) { + ctx.fillStyle = '#2a2420'; ctx.fillRect(150, BAR - 3, 336, 6); + ctx.fillStyle = '#6a5a4a'; ctx.fillRect(150, BAR - 3, 336, 1); + for (const x of [DRUM.x0 - 12, DRUM.x1 + 12]) { + ctx.fillStyle = '#2a2420'; ctx.fillRect(x - 2, BAR, 4, DRUM.y - BAR + 4); + ctx.fillStyle = '#5a4a3e'; ctx.fillRect(x - 2, BAR, 1, DRUM.y - BAR + 4); + ctx.fillStyle = '#2a2420'; ctx.beginPath(); ctx.arc(x, DRUM.y, 5, 0, Math.PI * 2); ctx.fill(); + } +} +// La rete: gli anelli e i tondini che girano col cilindro (quelli davanti più chiari). +function mesh(ctx, c, front) { + const { x0, x1, y, r } = DRUM; + for (let j = 0; j < RODS; j++) { + const a = c.drum + j * Math.PI * 2 / RODS, z = Math.cos(a); + if (front !== z > 0) continue; + ctx.fillStyle = front ? '#9a9088' : '#3a302a'; + ctx.fillRect(x0, Math.round(y + Math.sin(a) * r), x1 - x0, 1); + } + ctx.strokeStyle = front ? '#8a8078' : '#3a302a'; ctx.lineWidth = 1; + for (let i = 0; i <= RINGS; i++) { + const x = Math.round(lerp(x0, x1, i / RINGS)) + 0.5, end = i === 0 || i === RINGS; + ctx.beginPath(); ctx.ellipse(x, y, end ? 9 : 4, r, 0, front ? -Math.PI / 2 : Math.PI / 2, front ? Math.PI / 2 : Math.PI * 1.5); ctx.stroke(); + } +} +function crank(ctx, c, time) { + const { x, y, rx, ry } = CRANK, a = c.drum; + // L'asse, da una forcella all'altra e fino alla manovella. + ctx.fillStyle = '#2a2420'; ctx.fillRect(DRUM.x0 - 16, y - 2, x - DRUM.x0 + 18, 4); + ctx.fillStyle = '#6a625a'; ctx.fillRect(DRUM.x0 - 16, y - 2, x - DRUM.x0 + 18, 1); + // Il giro da fare: un'ellisse tratteggiata che pulsa, finché il cilindro è fermo. + if (c.step === 'turn' && Math.abs(c.speed) < 0.5) { + ctx.strokeStyle = `rgba(255,224,140,${0.5 + 0.4 * Math.sin(time * 5)})`; ctx.setLineDash([3, 3]); + ctx.beginPath(); ctx.ellipse(x, y, rx + 6, ry + 6, 0, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); + text(ctx, 'GIRA!', x, y + ry + 18, 6, '#ffe08c', 'center'); + } + const hx = x + Math.cos(a) * rx, hy = y + Math.sin(a) * ry; + ctx.strokeStyle = '#5a5450'; ctx.lineWidth = 4; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(hx, hy); ctx.stroke(); + ctx.strokeStyle = '#b0a698'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, y - 1); ctx.lineTo(hx, hy - 1); ctx.stroke(); + ctx.fillStyle = '#4a4440'; ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fill(); + // L'impugnatura di legno, consumata dalle mani. + ctx.fillStyle = '#3a2414'; ctx.fillRect(Math.round(hx) - 4, Math.round(hy) - 8, 9, 16); + ctx.fillStyle = '#a0703c'; ctx.fillRect(Math.round(hx) - 3, Math.round(hy) - 7, 7, 14); + ctx.fillStyle = '#d6a062'; ctx.fillRect(Math.round(hx) - 2, Math.round(hy) - 6, 2, 12); +} +// In basso a sinistra (in alto ci sono i cursori del volume): il giro della manovella (rosso troppo piano, verde giusto, rosso troppo forte) e la cottura. +function gauges(ctx, c) { + panel(ctx, 10, 296, 196, 54); + const bar = (y, label, k, zones, mark) => { + text(ctx, label, 18, y, 6, '#efd49e'); + const x0 = 82, w = 116; + for (const [a, b, color] of zones) { ctx.fillStyle = color; ctx.fillRect(x0 + Math.round(a * w), y - 3, Math.max(1, Math.round((b - a) * w)), 7); } + ctx.fillStyle = mark; ctx.fillRect(x0 + Math.round(Math.max(0, Math.min(1, k)) * w) - 1, y - 6, 3, 13); + }; + const s = c.skill, lo = s.lo / MAX_SPEED, hi = s.hi / MAX_SPEED; + bar(312, 'GIRO', Math.abs(c.speed) / MAX_SPEED, [[0, lo, '#8a3a2a'], [lo, hi, '#4f8a3a'], [hi, 1, '#b3402a']], '#fff4c8'); + const top = BURNT * 1.15; + bar(334, 'COTTURA', doneness(c) / top, [[0, COOKED / top, '#b07a4a'], [COOKED / top, BURNT / top, '#6a8a3a'], [BURNT / top, 1, '#2a2420']], '#fff4c8'); +} +function result(ctx, c) { + const r = c.result; + panel(ctx, 170, 120, 300, 104); + text(ctx, r.winner === 'player' ? 'BRUCIATE COI FIOCCHI!' : 'BRUCIATE COSÌ COSÌ', 320, 140, 8, '#efd49e', 'center'); + const rows = [['Buone', r.good, '#9fd27a'], ['Crude', r.raw, '#e0b07a'], ['Carbonizzate', r.burnt, '#c0b0a0'], ['Nel fuoco', r.spilled, '#f08a5a']]; + rows.forEach(([label, n, color], i) => { text(ctx, label, 200, 162 + i * 13, 6, color); text(ctx, `${n}`, 440, 162 + i * 13, 6, color, 'right'); }); + text(ctx, `Ne servivano ${r.goal} su ${N}`, 320, 216, 5, '#d7ccb4', 'center'); +} + +export function drawBruciate(ctx, c, def, bg) { + const time = c.visual.time; + ctx.save(); + if (bg) ctx.drawImage(bg, 0, 0, 640, 360); + else { ctx.fillStyle = '#6a5a40'; ctx.fillRect(0, 0, 640, 360); } + hanger(ctx); + // Il fondo del cilindro, scuro, col bagliore della brace sotto; poi le fiamme dietro e la rete di dietro. + ctx.fillStyle = 'rgba(74,46,30,0.72)'; ctx.fillRect(DRUM.x0, DRUM.y - DRUM.r, DRUM.x1 - DRUM.x0, DRUM.r * 2); + ctx.fillStyle = 'rgba(240,120,40,0.18)'; ctx.fillRect(DRUM.x0, DRUM.y + DRUM.r * 0.3, DRUM.x1 - DRUM.x0, DRUM.r * 0.7); + flames(ctx, time, false); + mesh(ctx, c, false); + // Le castagne: da dietro a davanti. Rovesciandole, scivolano tutte verso il sacco. + const pour = c.step === 'pour' ? Math.min(1, c.t / POUR) : c.step === 'result' ? 1 : 0; + const ks = (pour ? c.chestnuts.filter(k => !k.out) : inside(c)).map(k => ({ k, ...place(k) })).sort((p, q) => p.z - q.z); + for (const p of ks) { + if (pour) { + const d = Math.min(1, Math.max(0, pour * 1.6 - p.k.x * 0.6)), x = lerp(p.x, SACK.x, d), y = lerp(p.y, SACK.y, d) - Math.sin(d * Math.PI) * 40; + if (d < 1) chestnut(ctx, x, y, p.k.cook, 1); + } else chestnut(ctx, p.x, p.y, p.k.cook, p.z); + } + mesh(ctx, c, true); + flames(ctx, time, true); + crank(ctx, c, time); + // Quelle che volano nel fuoco, e lo sbuffo quando ci cascano. + for (const f of c.flying) { + const k = Math.min(1, f.t / 0.6), x = f.x + f.dir * 60 * k, y = lerp(f.y, FIRE - 6, k) - Math.sin(k * Math.PI) * 50; + if (f.t < 0.6) chestnut(ctx, x, y, 0.5); + else for (let i = 0; i < 4; i++) { + const s = (f.t - 0.6) / 0.6; + ctx.fillStyle = `rgba(200,195,185,${0.6 * (1 - s)})`; + ctx.beginPath(); ctx.arc(x + (i - 1.5) * 5 * (1 + s), FIRE - 10 - s * 30 - i * 3, 4 + s * 6, 0, Math.PI * 2); ctx.fill(); + } + } + if (c.step === 'result') result(ctx, c); else gauges(ctx, c); + ctx.restore(); +} diff --git a/src/engine/bruciate.js b/src/engine/bruciate.js new file mode 100644 index 0000000..8403b7c --- /dev/null +++ b/src/engine/bruciate.js @@ -0,0 +1,162 @@ +// Le bruciate della Smarronata (Andrea, 31 ottobre: «a manovella, a mano, fallo così»): coi volontari della +// Misericordia si gira la manovella del cilindro a rete sopra il fuoco. Il gesto: tenendo premuto si fanno dei giri, +// grandi o piccoli, dove si vuole (conta quanto gira la direzione del dito, non dove sta), come la manovella vera. +// Le castagne stanno in fondo al cilindro: girando, la rete le alza e loro ricascano e si rimescolano. Troppo piano, +// quelle di sotto restano sulla brace e si carbonizzano mentre quelle di sopra restano crude; troppo forte, la rete +// le porta in cima e qualcuna salta fuori nel fuoco («mica si fa la centrifuga!»). Quando paiono cotte si rovesciano +// nel sacco: contano quelle buone, né crude né bruciate. Per chi non può fare il gesto: Spazio, le frecce o il +// pulsante Gira danno una spinta a ogni colpo. Unità: i pixel del primo piano 640×360, visto di fronte (sfondo di +// Codex: assets/closeups/smarronata-bruciatore.png). + +// Il cilindro (fra le due forcelle dello sfondo) e la manovella, a destra: un giro visto di sbieco, un'ellisse. +export const DRUM = { x0: 182, x1: 456, y: 190, r: 46 }, CRANK = { x: 506, y: 190, rx: 14, ry: 30 }, BAR = 92; +export const N = 18, BOTTOM = Math.PI / 2; +// Tempi in secondi; velocità in radianti al secondo. +export const MIN_POUR = 4, POUR = 1.4, SPILL = 0.45, PUSH = 2.6, FRICTION = 1.4, LINE = 2.4, MAX_SPEED = 22; +// Quando una castagna è cotta e quando è carbonizzata. +export const COOKED = 0.85, BURNT = 1.6; +// I livelli: sotto `lo` il cilindro gira troppo piano (le castagne non si rimescolano abbastanza), sopra `hi` le +// castagne volano fuori; `time`, in quanti secondi cuociono girando bene; `goal`, quante buone ci vogliono. +export const LEVELS = { + bona: { lo: 1.6, hi: 10, time: 20, goal: 10 }, + seria: { lo: 2.2, hi: 8, time: 17, goal: 15 }, + male: { lo: 2.8, hi: 6.5, time: 14, goal: 16 }, +}; +const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); +const wrap = a => Math.atan2(Math.sin(a), Math.cos(a)); +function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; } +const between = (c, lo, hi) => lo + (hi - lo) * random(c); + +// Dove ricasca una castagna: di nuovo in fondo, a un'altezza a caso nel mucchio, con la sua voglia di farsi alzare. +function drop(c, k, side = 1) { Object.assign(k, { a: BOTTOM - side * between(c, 0, 0.55), r: between(c, 0.5, 0.93), lift: between(c, 0.55, 1.5) }); } + +export function newBruciate(seed = Date.now() >>> 0, def = {}) { + const level = LEVELS[def.level] ? def.level : 'seria'; + const c = { step: 'turn', t: 0, seed, level, skill: LEVELS[level], current: 'player', power: 0, drum: 0, speed: 0, + pending: 0, grab: null, cursor: null, spin: 0, chestnuts: [], flying: [], line: null, lineT: 0, events: [], visual: { time: 0 } }; + for (let i = 0; i < N; i++) { const k = { x: between(c, 0.06, 0.94), cook: between(c, 0, 0.08), out: false }; drop(c, k, random(c) < 0.5 ? 1 : -1); c.chestnuts.push(k); } + return c; +} +export const inside = c => c.chestnuts.filter(k => !k.out); +// Quanto stanno a contatto con la rete sopra la brace: le castagne in fondo e di fuori; quelle di sopra quasi niente. +export function contact(k) { return Math.max(0, Math.cos(k.a - BOTTOM)) ** 3 * clamp((k.r - 0.62) / 0.3, 0, 1); } +// La cottura media delle castagne ancora nel cilindro, da 0 (crude) in su (1: cotte). +export const doneness = c => { const ks = inside(c); return ks.length ? ks.reduce((s, k) => s + k.cook, 0) / ks.length : 0; }; +export const tally = c => { + const t = { good: 0, raw: 0, burnt: 0, spilled: 0 }; + for (const k of c.chestnuts) t[k.out ? 'spilled' : k.cook < COOKED ? 'raw' : k.cook > BURNT ? 'burnt' : 'good']++; + return t; +}; +function say(c, line) { c.line = line; c.lineT = LINE; } + +export function update(c, dt) { + if (!Number.isFinite(dt) || dt <= 0) return; + dt = Math.min(dt, 0.25); c.t += dt; c.visual.time += dt; + if (c.lineT > 0 && (c.lineT -= dt) <= 0) c.line = null; + for (const f of c.flying) f.t += dt; + c.flying = c.flying.filter(f => f.t < 1.2); + if (c.step === 'pour') { if (c.t >= POUR) finish(c); return; } + if (c.step !== 'turn') return; + // La velocità: tenendo la manovella è quella del gesto (smussata); lasciata, il cilindro rallenta da sé. + if (c.grab) c.speed += (c.pending / dt - c.speed) * (1 - Math.exp(-dt * 10)); + else c.speed *= Math.exp(-FRICTION * dt); + c.pending = 0; + c.speed = clamp(c.speed, -MAX_SPEED, MAX_SPEED); + const w = c.speed, fast = Math.abs(w) > c.skill.hi, side = Math.sign(w) || 1, before = c.drum; + c.drum += w * dt; + // Il cigolio della manovella, a ogni mezzo giro. + if (Math.floor(before / Math.PI) !== Math.floor(c.drum / Math.PI)) c.events.push({ kind: 'bruciate-manovella', power: clamp(Math.abs(w) / 8, 0.3, 1) }); + const rate = 1 / (c.skill.time * 0.45); + for (const k of inside(c)) { + // La rete se le porta dietro finché non ricascano in fondo; troppo forte le porta fino in cima e oltre. + k.a += w * dt * k.r; + if (!fast && (k.a - BOTTOM) * side > k.lift) drop(c, k, side); + const before = k.cook; + k.cook += dt * rate * (0.15 + 1.9 * contact(k)); + if (before < COOKED && k.cook >= COOKED) c.events.push({ kind: 'bruciate-scoppio', power: 0.5 + 0.5 * random(c) }); + } + // La centrifuga: ogni tanto ne vola fuori una, nel fuoco. + if (fast) { + c.spin += dt; + if (c.spin >= SPILL) { + c.spin = 0; + const k = inside(c).sort((p, q) => Math.cos(p.a - BOTTOM) - Math.cos(q.a - BOTTOM))[0]; + if (k) { k.out = true; c.flying.push({ x: DRUM.x0 + k.x * (DRUM.x1 - DRUM.x0), y: DRUM.y + Math.sin(k.a) * DRUM.r * k.r, dir: side, t: 0 }); c.events.push({ kind: 'bruciate-cade', power: 0.8 }); } + say(c, random(c) < 0.5 ? 'Oh! Mica si fa la centrifuga!' : 'Piano, che le si butta nel fuoco!'); + } + } else c.spin = Math.max(0, c.spin - dt); + if (!c.line && Math.abs(w) < c.skill.lo && c.t > 3 && inside(c).some(k => k.cook > BURNT - 0.3)) say(c, 'Gira, che sotto si bruciano!'); + // Tutte nel fuoco o tutte carbone: si rovescia comunque. + if (!inside(c).length || inside(c).every(k => k.cook > BURNT)) pour(c); +} +function finish(c) { + const t = tally(c); + c.step = 'result'; c.speed = 0; + c.result = { winner: t.good >= c.skill.goal ? 'player' : 'cpu', ...t, goal: c.skill.goal }; +} +export function pour(c) { + if (c.step !== 'turn') return false; + Object.assign(c, { step: 'pour', t: 0, grab: null }); + c.events.push({ kind: 'bruciate-sacco', power: 0.8 }); + return true; +} + +// --- Comandi. Il gesto: la direzione del movimento, a ogni tratto, quanto ha girato rispetto al tratto prima. +const STROKE = 3; +export function aimScreen(c, [x, y]) { + if (!Number.isFinite(x) || !Number.isFinite(y)) return false; + const g = c.grab; + if (g && c.step === 'turn') { + const [px, py] = g.last ?? [x, y], dx = x - px, dy = y - py; + if (Math.hypot(dx, dy) >= STROKE) { + const heading = Math.atan2(dy, dx); + if (g.heading !== null) c.pending += clamp(wrap(heading - g.heading), -1.2, 1.2); + g.heading = heading; g.last = [x, y]; + } else if (!g.last) g.last = [x, y]; + } + c.cursor = [x, y]; + return true; +} +export function press(c, pointerId) { + if (c.step !== 'turn' || c.grab) return false; + c.grab = { pointerId, heading: null, last: c.cursor }; + return true; +} +export function release(c, pointerId) { + if (!c.grab || c.grab.pointerId !== pointerId) return false; + c.grab = null; return true; +} +export const cancel = (c, pointerId) => (pointerId === undefined || c.grab?.pointerId === pointerId) && release(c, c.grab?.pointerId); +// Una spinta alla manovella (Spazio, frecce): per chi non fa il gesto. +export function push(c) { + if (c.step !== 'turn' || c.grab) return false; + c.speed = (Math.sign(c.speed) || 1) * (Math.abs(c.speed) + PUSH); + return true; +} +export function key(c, k, down) { + if (k === 'Enter') { if (down) shoot(c); return true; } + if (![' ', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(k)) return false; + if (down) push(c); + return true; +} +// Il pulsante: rovescia nel sacco (dopo i primi secondi). +export const canThrow = c => c.step === 'turn' && c.t >= MIN_POUR; +export const canAdjust = () => false; +export const aimFree = () => true; +export const shoot = c => canThrow(c) && pour(c); +export const button = c => c.step === 'result' ? 'Finita' : c.step === 'pour' ? 'Nel sacco…' : 'Rovescia nel sacco'; +export function speedWord(c) { + const w = Math.abs(c.speed); + return w < 0.3 ? 'ferma' : w < c.skill.lo ? 'troppo piano' : w > c.skill.hi ? 'troppo forte' : 'giusta'; +} +export function status(c) { + if (c.step === 'result') { + const r = c.result, extra = [r.raw && `${r.raw} crude`, r.burnt && `${r.burnt} carbonizzate`, r.spilled && `${r.spilled} nel fuoco`].filter(Boolean).join(', '); + return `${r.good} bruciate buone su ${N}${extra ? ` (${extra})` : ''}. ${r.winner === 'player' ? 'I volontari approvano!' : `Ne servivano ${r.goal}.`}`; + } + if (c.step === 'pour') return 'Nel sacco di iuta, a sudare...'; + const cooked = `Cottura ${Math.round(doneness(c) * 100)}%.`; + if (c.line) return `${c.line} ${cooked}`; + const hint = c.t < 3 && Math.abs(c.speed) < 0.3 ? 'Tieni premuto e fai dei giri per girare la manovella.' : `Manovella ${speedWord(c)}.`; + return `${hint} ${cooked} Quando son cotte: Rovescia nel sacco.`; +} diff --git a/src/engine/game.js b/src/engine/game.js index b79f5eb..277989b 100644 --- a/src/engine/game.js +++ b/src/engine/game.js @@ -18,6 +18,7 @@ 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 bruciate from './bruciate.js'; import * as poker from './poker.js'; // I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta. @@ -34,6 +35,7 @@ export const MINIGAMES = { cerbottana: { create: (seed, level) => cerbottana.newCerbottana(seed, { level }), update: cerbottana.update, press: cerbottana.press, release: cerbottana.release, cancel: cerbottana.cancel, aim: cerbottana.aimScreen }, condor: { create: (seed, level) => condor.newCondor(seed, { level }), update: condor.update, press: condor.press, release: condor.release, cancel: condor.cancel, aim: condor.aimScreen }, campana: { create: (seed, level) => campana.newCampana(seed, { level }), update: campana.update, press: campana.press, release: campana.release, cancel: campana.cancel, aim: campana.aimScreen }, + bruciate: { create: (seed, level) => bruciate.newBruciate(seed, { level }), update: bruciate.update, press: bruciate.press, release: bruciate.release, cancel: bruciate.cancel, aim: bruciate.aimScreen }, micro: { create: (seed, level) => micro.newMicro(seed, { level }), update: micro.update, press: micro.press, release: micro.release, cancel: micro.cancel, aim: micro.aimScreen }, gara: { create: (seed, level, deck, def) => gara.newGara(seed, { level, riders: def.riders }), update: gara.update, press: gara.press, release: gara.release, cancel: gara.cancel, aim: gara.aimScreen }, }; diff --git a/src/engine/render.js b/src/engine/render.js index e827da9..17dfb9f 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -14,6 +14,7 @@ import { drawCerbottana } from './cerbottana-render.js'; import { drawCorsa } from './corsa.js'; import { drawCondor } from './condor-render.js'; import { drawCampana } from './campana-render.js'; +import { drawBruciate } from './bruciate-render.js'; import { drawPoker } from './poker-render.js'; import { drawViaggio } from './viaggio.js'; import { drawIncanto } from './incanto.js'; @@ -1020,6 +1021,11 @@ function drawCloseup(ctx, game, time) { drawCampana(ctx, c, def, backgrounds.get(def.picture)); return; } + if (def.type === 'bruciate') { + if (!backgrounds.has(def.picture)) loadBackground(def.picture); + drawBruciate(ctx, c, def, backgrounds.get(def.picture)); + return; + } if (def.type === 'micro') { if (!backgrounds.has(def.picture)) loadBackground(def.picture); drawMicro(ctx, c, def, backgrounds.get(def.picture), src => { if (!backgrounds.has(src)) loadBackground(src); return backgrounds.get(src); }); diff --git a/src/engine/soundscape.js b/src/engine/soundscape.js index 61de0f8..200fb85 100644 --- a/src/engine/soundscape.js +++ b/src/engine/soundscape.js @@ -156,6 +156,11 @@ export class Soundscape { else if (kind === 'campana-sasso') { this.tone(1900, 1100, 0.04, 0.05 * strength + 0.02, 0, pan); this.tone(1500, 900, 0.03, 0.03, 0.06, pan); this.noiseSound(0.04, 3000, 0.03, { pan }); } else if (kind === 'campana-salto') { this.tone(110, 60, 0.07, 0.07, 0, pan); this.noiseSound(0.05, 500, 0.04, { pan }); } else if (kind === 'campana-riga') { this.tone(300, 180, 0.12, 0.05, 0, pan); this.tone(220, 120, 0.16, 0.05, 0.1, pan); } + // Le bruciate: il cigolio della manovella, la castagna che scoppietta cuocendo, quella che casca nel fuoco, il sacco. + else if (kind === 'bruciate-manovella') { this.tone(420, 380, 0.08, 0.025 * strength, 0, pan); this.noiseSound(0.06, 900, 0.02, { pan }); } + else if (kind === 'bruciate-scoppio') { this.noiseSound(0.04, 2600, 0.05 * strength, { pan }); this.tone(900, 500, 0.03, 0.03, 0, pan); } + else if (kind === 'bruciate-cade') { this.noiseSound(0.5, 3200, 0.09, { pan }); this.tone(200, 90, 0.12, 0.05, 0, pan); } + else if (kind === 'bruciate-sacco') this.noiseSound(0.7, 1100, 0.1, { paper: true, pan }); else if (kind === 'cerbottana-tosse') { this.noiseSound(0.18, 900, 0.08, { pan }); this.tone(260, 140, 0.12, 0.06, 0.25, pan); this.tone(240, 120, 0.1, 0.05, 0.3, pan); } // Le macchinine: il dito che spinge, le ruote sull'asfalto, l'urto di plastica, fuori pista, il traguardo. else if (kind === 'micro-spinta') { this.tone(1200, 500, 0.04, 0.04 + strength * 0.04, 0, pan); this.noiseSound(0.04, 2600, 0.04, { pan }); } diff --git a/src/main.js b/src/main.js index 27f0f7f..0dc512c 100644 --- a/src/main.js +++ b/src/main.js @@ -15,6 +15,7 @@ import * as micro from './engine/micro.js'; import * as cerbottana from './engine/cerbottana.js'; import * as condor from './engine/condor.js'; import * as campana from './engine/campana.js'; +import * as bruciate from './engine/bruciate.js'; import * as poker from './engine/poker.js'; import { cardAt as pokerAt } from './engine/poker-render.js'; import * as basket from './engine/basket.js'; @@ -617,6 +618,7 @@ const MINI = { cerbottana: { ...cerbottana, back: 'Torna in piazza', label: 'La cerbottana: muovi il mouse o tocca il muro per mirare, tieni premuto per prendere fiato e lascia per sparare; oppure regola il fiato e premi Spara. Frecce per spostare il mirino.' }, condor: { ...condor, back: 'Torna al campone', label: 'La Condor: muovi il mouse o tocca il campo per mirare, tieni premuto per trattenere il fiato e lascia per sparare; oppure premi Spara. Frecce per spostare il mirino.' }, campana: { ...campana, back: 'Torna al campone', label: 'La campana: muovi il mouse o tocca la campana per mirare, tieni premuto per la forza e lascia per tirare il sassolino; oppure regola la forza e premi Tira (frecce su e giù per mirare, destra e sinistra per la forza). Ai salti premi, o Spazio, quando la lancetta è nel verde.' }, + bruciate: { ...bruciate, back: 'Torna alla festa', label: 'Le bruciate: tieni premuto e fai dei giri, col mouse o col dito, per girare la manovella; oppure Spazio o le frecce, una spinta a ogni colpo. Né troppo piano né troppo forte; quando son cotte, Rovescia nel sacco (o Invio).' }, micro: { ...micro, back: 'Torna dal Chiaru', label: 'La pista col gesso: muovi il mouse o tocca il parcheggio per mirare, tieni premuto per caricare la spinta e lascia; oppure regola la forza e premi Spingi. Frecce o pulsanti Mira per girare.' }, basket: { ...basket, back: 'Torna in strada', label: 'Fai due tiri: muovi il mouse o tocca per mirare, tieni premuto per caricare la forza, lascia per tirare.' }, // La gara: niente mira né forza, il pulsante alterna gas e freno (chi non può tenere premuto). @@ -1046,7 +1048,7 @@ function updateUi() { } } if (mg && !cards) { - setAttr($('bocce-controls'), 'aria-label', { rinquarto: 'Comandi dei rinquarti', biliardo: 'Comandi del biliardo', basket: 'Comandi del basket', pesca: 'Comandi della pesca', gara: 'Comandi della gara', micro: 'Comandi delle macchinine', cerbottana: 'Comandi della cerbottana', condor: 'Comandi della Condor', campana: 'Comandi della campana' }[def.type] ?? 'Comandi delle bocce'); + setAttr($('bocce-controls'), 'aria-label', { rinquarto: 'Comandi dei rinquarti', biliardo: 'Comandi del biliardo', basket: 'Comandi del basket', pesca: 'Comandi della pesca', gara: 'Comandi della gara', micro: 'Comandi delle macchinine', cerbottana: 'Comandi della cerbottana', condor: 'Comandi della Condor', campana: 'Comandi della campana', bruciate: 'Comandi delle bruciate' }[def.type] ?? 'Comandi delle bocce'); setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true); const line = mg.status(c, def.names); if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line; @@ -1060,8 +1062,8 @@ function updateUi() { // Ai rinquarti la forza è sempre piena: il cursore non serve. Nella guida semplice del GP (alla bona e seria) si // usa solo il gas: niente sensibilità, sterzo, freno né riquadro dello swipe. const easy = def.type === 'gara' && gara.simple(c); - setProp(powerLabel, 'hidden', def.type === 'rinquarto' || def.type === 'condor' || easy); - for (const id of ['bocce-left', 'bocce-right']) setProp($(id), 'hidden', easy || def.type === 'campana'); + setProp(powerLabel, 'hidden', def.type === 'rinquarto' || def.type === 'condor' || def.type === 'bruciate' || easy); + for (const id of ['bocce-left', 'bocce-right']) setProp($(id), 'hidden', easy || def.type === 'campana' || def.type === 'bruciate'); const powerName = def.type === 'gara' ? 'Sensibilità ' : def.type === 'cerbottana' ? 'Fiato ' : 'Forza '; if (powerLabel.firstChild.nodeValue !== powerName) powerLabel.firstChild.nodeValue = powerName; setAttr($('bocce-power'), 'aria-label', def.type === 'gara' ? 'Sensibilità dello sterzo col dito' : 'Forza del lancio'); @@ -1083,6 +1085,7 @@ function updateUi() { setProp($('cerbottana-help'), 'hidden', def.type !== 'cerbottana'); setProp($('condor-help'), 'hidden', def.type !== 'condor'); setProp($('campana-help'), 'hidden', def.type !== 'campana'); + setProp($('bruciate-help'), 'hidden', def.type !== 'bruciate'); setProp($('basket-help'), 'hidden', def.type !== 'basket'); setProp($('pesca-help'), 'hidden', def.type !== 'pesca'); setProp($('gara-help'), 'hidden', def.type !== 'gara'); diff --git a/src/story/story.js b/src/story/story.js index ab1ff90..7c41987 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -360,6 +360,17 @@ export const story = { draw: [], quit: [{ say: 'Il gesso lo lascio lì. Ci giocherà qualche bambino, che è più in allenamento di me.' }], }, + // Le bruciate della Smarronata, 1993: la manovella del cilindro sul fuoco, coi volontari della Misericordia + // (Andrea, 31 ottobre: «a manovella, a mano»). [DA ANDREA: le battute sono provvisorie] + bruciate: { + type: 'bruciate', + picture: 'assets/closeups/smarronata-bruciatore.png', + names: { player: 'Andrea', cpu: 'i volontari' }, + win: [{ say: 'Bruciate coi fiocchi! Mezza Smarronata le mangia grazie a me.' }, { say: 'Bravo, figliolo! Se ti va, l\'anno prossimo la manovella è tua.', who: 'volontari', pose: 'give' }, { set: 'bruciate_fatte' }], + lose: [{ say: 'Un po\' crude, un po\' carbone... Le mangio io, va\'.' }, { say: 'Ci vuole il polso, figliolo. Il polso e la pazienza.', who: 'volontari', pose: 'explain' }], + draw: [], + quit: [{ say: 'Vi rendo la manovella. Mi fumano anche gli occhi.' }], + }, // Il manifesto dell'Ingorgo, ingrandito (Andrea, 30 ottobre: «se mi avvicino compaia ingrandito, e poi parta // l'Ingorgo se ci clicco»). [DA ANDREA: il manifesto vero; questo è di Codex, provvisorio] manifesto_ingorgo: { @@ -3734,7 +3745,8 @@ export const story = { id: 'bruciatore', name: 'il cilindro delle bruciate', box: [192, 188, 68, 54], at: [226, 262], face: 'up', actions: { look: [{ say: 'Il cilindro delle bruciate: si gira la manovella sopra il fuoco, e le castagne ci ballano dentro.' }], - use: [{ say: 'Si gira piano, sennò le bruciate si bruciano davvero.' }], + // Il minigioco delle bruciate: la manovella si gira coi volontari (Andrea, 31 ottobre). + use: [{ say: 'Posso girare io la manovella?' }, { dialogue: 'volontari' }], take: [{ say: 'Una bruciata calda, presa al volo. Scotta! Ma è buonissima.' }], }, }, @@ -3763,11 +3775,7 @@ export const story = { id: 'volontari', name: 'i volontari della Misericordia', box: [130, 148, 97, 114], at: [200, 272], face: 'left', default: 'talk', actions: { look: [{ say: 'I volontari della Misericordia, alle bruciate: uno rimescola, l\'altro riempie i cartocci.' }], - talk: [ - { say: 'Che Iddio ve ne renda merito!! A voi e agli alpini!' }, - { say: 'E a te, figliolo. To\', un cartoccio: son belle calde.', who: 'volontari', pose: 'give' }, - { say: 'Scottano! Ma son buonissime.' }, - ], + talk: [{ dialogue: 'volontari' }], }, }, { @@ -4237,6 +4245,30 @@ 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). + // I volontari della Misericordia alle bruciate della Smarronata, 1993: il ringraziamento di Andrea e la manovella + // (il minigioco delle bruciate, Andrea, 31 ottobre). [DA ANDREA: le battute sono provvisorie] + volontari: { + nodes: { + start: { options: [ + { text: 'Che Iddio ve ne renda merito!! A voi e agli alpini!', then: [ + { say: 'E a te, figliolo. To\', un cartoccio: son belle calde.', who: 'volontari', pose: 'give' }, + { say: 'Scottano! Ma son buonissime.' }, + ] }, + { text: 'Vi do una mano alla manovella?', next: 'livello', then: [ + { say: 'Su, giovanotto, che noi s\'ha le braccia stanche!', who: 'volontari', pose: 'talk' }, + { say: 'Si gira sempre uguale: piano piano sotto si bruciano, a furia volan nel fuoco. Quando son cotte, nel sacco.', who: 'volontari', pose: 'explain' }, + { say: 'Come te la senti?', who: 'volontari', pose: 'talk' }, + ] }, + { text: 'Buon lavoro!', next: 'end' }, + ] }, + livello: { options: [ + { text: 'Alla bona.', next: 'end', then: [{ say: 'Alla bona: ti si guarda noi, e se si bruciano un si dice a nessuno.', who: 'volontari', pose: 'talk' }, { closeup: 'bruciate', level: 'bona' }] }, + { text: 'Seria.', next: 'end', then: [{ say: 'Seria: la gente è in fila, e il cartoccio lo vole bello pieno.', who: 'volontari', pose: 'explain' }, { closeup: 'bruciate', level: 'seria' }] }, + { text: 'A fassi male.', next: 'end', then: [{ say: 'A fassi male? Allora ci si mette anche il fuoco forte.', who: 'volontari', pose: 'talk' }, { closeup: 'bruciate', level: 'male' }] }, + { text: 'No, dopo.', next: 'start' }, + ] }, + }, + }, barondeduke: { nodes: { start: { options: [ diff --git a/style.css b/style.css index 037bd4c..53e14bc 100644 --- a/style.css +++ b/style.css @@ -62,16 +62,16 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h /* La guida semplice del GP: il Gas si tiene premuto col pollice, più alto. */ #bocce-throw.hold { min-height: 72px; font-size: 14px; } .bocce-adjust button:disabled { opacity: .4; cursor: default; } -#bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-help, #condor-help, #campana-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; } -#bocce-help summary, #biliardo-help summary, #basket-help summary, #pesca-help summary, #gara-help summary, #rinquarto-help summary, #micro-help summary, #cerbottana-help, #condor-help summary, #campana-help summary { cursor: pointer; padding: 6px 0; } -#bocce-help p, #biliardo-help p, #basket-help p, #pesca-help p, #gara-help p, #rinquarto-help p, #micro-help p, #cerbottana-help, #condor-help p, #campana-help p { max-width: 76em; line-height: 1.7; } +#bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-help, #condor-help, #campana-help, #bruciate-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; } +#bocce-help summary, #biliardo-help summary, #basket-help summary, #pesca-help summary, #gara-help summary, #rinquarto-help summary, #micro-help summary, #cerbottana-help, #condor-help summary, #campana-help summary, #bruciate-help summary { cursor: pointer; padding: 6px 0; } +#bocce-help p, #biliardo-help p, #basket-help p, #pesca-help p, #gara-help p, #rinquarto-help p, #micro-help p, #cerbottana-help, #condor-help p, #campana-help p, #bruciate-help p { max-width: 76em; line-height: 1.7; } @media (max-width: 600px), (orientation: landscape) and (max-height: 520px) { #bocce-controls { padding: 8px; font-size: 10px; overflow-y: auto; min-height: 0; } .bocce-adjust { flex-wrap: wrap; gap: 6px; } .bocce-adjust label { order: -1; flex-basis: 100%; max-width: none; } .bocce-adjust button { flex: 1; font-size: 9px; padding: 6px; } #bocce-throw { flex-basis: 100%; } - #bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-help, #condor-help, #campana-help { font-size: 9px; } + #bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-help, #condor-help, #campana-help, #bruciate-help { font-size: 9px; } } .effects-control { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; font-size: clamp(9px, 1.2vw, 13px); } #effects-volume { width: 130px; accent-color: var(--accent); } diff --git a/tests/bruciate.browser.mjs b/tests/bruciate.browser.mjs new file mode 100644 index 0000000..8d78cd9 --- /dev/null +++ b/tests/bruciate.browser.mjs @@ -0,0 +1,67 @@ +// Le bruciate nel browser vero: dai volontari della Smarronata, la manovella girata col mouse e col dito, il sacco. +// npm run browser:bruciate — schermate in /tmp/regolo-bruciate. +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-bruciate'; +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'] }); + for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) { + const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile }); + page.on('pageerror', e => errors.push(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('smarronata_1993'); settle(game); + game.act('talk', 'volontari'); settle(game); + game.choose(game.options().findIndex(o => o.text === 'Vi do una mano alla manovella?')); settle(game); + game.choose(game.options().findIndex(o => o.text === 'Alla bona.')); settle(game); + }); + await page.waitForSelector('#bocce-controls:not([hidden])'); + await page.waitForFunction(async () => (await import('/src/engine/render.js')).hasBackground('assets/closeups/smarronata-bruciatore.png')); + assert.equal(await page.locator('#bocce-throw').textContent(), 'Rovescia nel sacco'); + assert.ok(await page.locator('#bruciate-help').isVisible()); + assert.ok(!(await page.locator('#bocce-left').isVisible()), 'niente pulsanti Mira'); + assert.ok(!(await page.locator('#bocce-power').isVisible()), 'niente forza'); + await page.screenshot({ path: `${out}/${name}-ferma.png` }); + const box = await page.locator('#scene').boundingBox(), P = (x, y) => [box.x + x / 640 * box.width, box.y + y / 360 * box.height]; + // I giri attorno alla manovella: col mouse tenendo premuto, col dito trascinando (eventi di puntatore veri). + const loops = async (turns, steps = 16) => { + const at = i => { const a = i / steps * Math.PI * 2; return P(506 + Math.cos(a) * 40, 190 + Math.sin(a) * 40); }; + if (mobile) { + const cdp = await page.context().newCDPSession(page), touch = (type, [x, y]) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints: type === 'touchEnd' ? [] : [{ x, y }] }); + await touch('touchStart', at(0)); + for (let i = 1; i <= turns * steps; i++) await touch('touchMove', at(i)); + await touch('touchEnd', at(0)); + } else { + await page.mouse.move(...at(0)); await page.mouse.down(); + for (let i = 1; i <= turns * steps; i++) await page.mouse.move(...at(i)); + await page.mouse.up(); + } + }; + await loops(3); + const turned = await page.evaluate(() => Math.abs(game.closeup.drum)); + assert.ok(turned > 6, `il cilindro ha girato: ${turned.toFixed(1)} rad`); + await page.screenshot({ path: `${out}/${name}-gira.png` }); + // Il resto della cottura lo si fa fare al gioco a velocità giusta (le schermate con swiftshader sono lente). + await page.evaluate(() => { const c = game.closeup; c.speed = 4.5; for (const k of c.chestnuts) k.cook = 1.05; c.t = Math.max(c.t, 5); }); + await page.waitForFunction(() => !document.querySelector('#bocce-throw').disabled); + await page.screenshot({ path: `${out}/${name}-cotte.png` }); + if (mobile) await page.locator('#bocce-throw').tap(); else await page.locator('#bocce-throw').click(); + await page.waitForFunction(() => game.closeup?.step === 'result', null, { timeout: 10000 }); + assert.match(await page.locator('#bocce-status').textContent(), /bruciate buone su 18/); + await page.screenshot({ path: `${out}/${name}-sacco.png` }); + await page.locator('#closeup-close').click(); + await page.waitForFunction(() => game.state.flags.bruciate_fatte); + await page.close(); + } + assert.deepEqual(errors, []); + console.log(`ok: schermate in ${out}`); +} finally { await browser?.close(); server.kill(); } diff --git a/tests/bruciate.test.mjs b/tests/bruciate.test.mjs new file mode 100644 index 0000000..2a582df --- /dev/null +++ b/tests/bruciate.test.mjs @@ -0,0 +1,130 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as m from '../src/engine/bruciate.js'; +import { drawBruciate } from '../src/engine/bruciate-render.js'; +import { Game } from '../src/engine/game.js'; +import { story } from '../src/story/story.js'; +import { settle } from './helpers.mjs'; + +// Gira la manovella tenendola, a `w` radianti al secondo, finché le castagne non paiono cotte (o per `secs` secondi). +function crank(c, w, { secs = 60, until = 1.05 } = {}) { + c.grab ??= { pointerId: 1, heading: null, last: null }; + for (let i = 0; i < secs * 60 && c.step === 'turn' && m.doneness(c) < until; i++) { c.pending = w / 60; m.update(c, 1 / 60); } +} +function pourAndWait(c) { + assert.ok(m.shoot(c), 'si rovescia nel sacco'); + for (let i = 0; i < 200 && c.step === 'pour'; i++) m.update(c, 1 / 60); + assert.equal(c.step, 'result'); + return c.result; +} + +test('girando a modo le bruciate vengono tutte buone, nel tempo del livello', () => { + for (const level of ['bona', 'seria', 'male']) { + const c = m.newBruciate(42, { level }); + crank(c, 4.5); + assert.ok(c.t > 6 && c.t < m.LEVELS[level].time, `${level}: cotte in ${c.t.toFixed(1)} s`); + const r = pourAndWait(c); + assert.equal(r.winner, 'player', `${level}: ${JSON.stringify(r)}`); + assert.equal(r.spilled, 0); + } +}); + +test('ferme sul fuoco: quelle di sotto carbone, quelle di sopra crude', () => { + const c = m.newBruciate(42, { level: 'seria' }); + crank(c, 0); + const r = pourAndWait(c); + assert.equal(r.winner, 'cpu'); + assert.ok(r.burnt > 0 && r.raw > 0, JSON.stringify(r)); + assert.match(m.status(c), /Ne servivano 15/); +}); + +test('troppo forte è una centrifuga: le castagne volano nel fuoco', () => { + const c = m.newBruciate(42, { level: 'seria' }); + crank(c, 12, { secs: 4, until: 9 }); + assert.ok(c.chestnuts.some(k => k.out), 'qualcuna è volata fuori'); + assert.ok(c.flying.length > 0); + assert.match(c.line, /centrifuga|fuoco/); + assert.ok(c.events.some(e => e.kind === 'bruciate-cade')); + assert.equal(m.speedWord(c), 'troppo forte'); +}); + +test('rovesciarle troppo presto: crude; e il pulsante aspetta i primi secondi', () => { + const c = m.newBruciate(42, { level: 'bona' }); + assert.ok(!m.canThrow(c) && !m.shoot(c), 'appena cominciato non si rovescia'); + crank(c, 4.5, { until: 0.4 }); + const r = pourAndWait(c); + assert.equal(r.winner, 'cpu'); assert.ok(r.raw > 10); +}); + +test('il gesto: i giri, grandi o piccoli e dovunque, fanno girare il cilindro; una riga dritta no', () => { + const circles = (c, cx, cy, radius, turns, secs) => { + assert.ok(m.press(c, 7)); + const steps = Math.round(secs * 60); + for (let i = 0; i <= steps; i++) { + const a = i / steps * turns * Math.PI * 2; + m.aimScreen(c, [cx + Math.cos(a) * radius, cy + Math.sin(a) * radius]); + m.update(c, 1 / 60); + } + }; + for (const [x, y, r] of [[506, 190, 30], [200, 300, 80], [320, 180, 15]]) { + const c = m.newBruciate(1); + m.aimScreen(c, [x + r, y]); + circles(c, x, y, r, 2, 2); + assert.ok(Math.abs(c.speed) > 4 && Math.abs(c.speed) < 9, `giri di raggio ${r}: ${c.speed.toFixed(2)} rad/s`); + assert.ok(Math.abs(c.drum) > 6); + // Lasciata la manovella, il cilindro rallenta da sé. + assert.ok(m.release(c, 7)); + const w = Math.abs(c.speed); + for (let i = 0; i < 60; i++) m.update(c, 1 / 60); + assert.ok(Math.abs(c.speed) < w * 0.5); + } + const c = m.newBruciate(1); + m.aimScreen(c, [100, 200]); m.press(c, 1); + for (let i = 0; i < 120; i++) { m.aimScreen(c, [100 + i * 3, 200]); m.update(c, 1 / 60); } + assert.ok(Math.abs(c.speed) < 0.2, 'trascinare dritto non gira'); + assert.ok(!m.release(c, 99), 'un altro dito non lascia la manovella'); +}); + +test('senza gesto: Spazio e le frecce danno una spinta, Invio rovescia', () => { + const c = m.newBruciate(3); + assert.ok(m.key(c, ' ', true)); + assert.ok(Math.abs(c.speed) >= m.PUSH); + assert.ok(m.key(c, 'ArrowRight', true)); assert.ok(Math.abs(c.speed) >= m.PUSH * 1.5); + assert.ok(m.key(c, ' ', false), 'anche lasciando il tasto, il tasto è suo'); + assert.ok(!m.key(c, 'x', true)); + for (let i = 0; i < 60 * m.MIN_POUR + 2; i++) m.update(c, 1 / 60); + m.key(c, 'Enter', true); + assert.equal(c.step, 'pour'); +}); + +test('il disegno delle bruciate non cambia lo stato', () => { + const calls = [], ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => calls.push([k, ...args]) }); + const c = m.newBruciate(7, { level: 'bona' }), bg = {}; + const draw = () => { const before = structuredClone(c); drawBruciate(ctx, c, story.closeups.bruciate, bg); assert.deepEqual(c, before); }; + draw(); + assert.ok(calls.some(([op, img]) => op === 'drawImage' && img === bg)); + assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'GIRA!'), 'da fermo, l\'invito a girare'); + assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'COTTURA')); + crank(c, 12, { secs: 2, until: 9 }); draw(); + crank(c, 4.5); pourAndWait(c); calls.length = 0; draw(); + assert.ok(calls.some(([op, t]) => op === 'fillText' && /BRUCIATE/.test(t))); +}); + +test('alla Smarronata: dai volontari, «Vi do una mano alla manovella?», il livello e le bruciate', () => { + const g = new Game(story); g.go('smarronata_1993'); settle(g); + g.act('talk', 'volontari'); settle(g); + assert.ok(g.options().some(o => o.text.startsWith('Che Iddio ve ne renda merito')), 'il ringraziamento resta'); + g.choose(g.options().findIndex(o => o.text === 'Vi do una mano alla manovella?')); settle(g); + g.choose(g.options().findIndex(o => o.text === 'Seria.')); settle(g); + assert.equal(g.closeup?.id, 'bruciate'); assert.equal(g.closeup.level, 'seria'); + crank(g.closeup, 4.5); pourAndWait(g.closeup); + g.closeupClose(); settle(g); + assert.ok(g.state.flags.bruciate_fatte); + // Anche usando il cilindro si chiede la manovella; uscendo prima, la battuta di chi lascia. + g.act('use', 'bruciatore'); settle(g); + g.choose(g.options().findIndex(o => o.text === 'Vi do una mano alla manovella?')); settle(g); + g.choose(g.options().findIndex(o => o.text === 'Alla bona.')); settle(g); + assert.equal(g.closeup?.id, 'bruciate'); + g.closeupClose(); + assert.equal(g.queue[0]?.say, story.closeups.bruciate.quit[0].say); +});