diff --git a/assets/closeups/muro-cerbottana.png b/assets/closeups/muro-cerbottana.png new file mode 100644 index 0000000..083695f Binary files /dev/null and b/assets/closeups/muro-cerbottana.png differ diff --git a/assets/voices/andrea-12cbe6f9a8.mp3 b/assets/voices/andrea-12cbe6f9a8.mp3 new file mode 100644 index 0000000..af2da1f Binary files /dev/null and b/assets/voices/andrea-12cbe6f9a8.mp3 differ diff --git a/assets/voices/andrea-2ede7e7324.mp3 b/assets/voices/andrea-2ede7e7324.mp3 new file mode 100644 index 0000000..82f8b2f Binary files /dev/null and b/assets/voices/andrea-2ede7e7324.mp3 differ diff --git a/assets/voices/andrea-585cd65f93.mp3 b/assets/voices/andrea-585cd65f93.mp3 new file mode 100644 index 0000000..3e642b5 Binary files /dev/null and b/assets/voices/andrea-585cd65f93.mp3 differ diff --git a/assets/voices/andrea-62c5d74b82.mp3 b/assets/voices/andrea-62c5d74b82.mp3 new file mode 100644 index 0000000..2b9ef01 Binary files /dev/null and b/assets/voices/andrea-62c5d74b82.mp3 differ diff --git a/assets/voices/andrea-78ad61a67a.mp3 b/assets/voices/andrea-78ad61a67a.mp3 new file mode 100644 index 0000000..84c2433 Binary files /dev/null and b/assets/voices/andrea-78ad61a67a.mp3 differ diff --git a/assets/voices/andrea-9cc0991d39.mp3 b/assets/voices/andrea-9cc0991d39.mp3 new file mode 100644 index 0000000..b450221 Binary files /dev/null and b/assets/voices/andrea-9cc0991d39.mp3 differ diff --git a/assets/voices/andrea-c7dcb11e66.mp3 b/assets/voices/andrea-c7dcb11e66.mp3 new file mode 100644 index 0000000..be2766f Binary files /dev/null and b/assets/voices/andrea-c7dcb11e66.mp3 differ diff --git a/assets/voices/andrea-cb15f8f919.mp3 b/assets/voices/andrea-cb15f8f919.mp3 new file mode 100644 index 0000000..596e1c6 Binary files /dev/null and b/assets/voices/andrea-cb15f8f919.mp3 differ diff --git a/assets/voices/andrea-eaaf19eef0.mp3 b/assets/voices/andrea-eaaf19eef0.mp3 new file mode 100644 index 0000000..1ad0de0 Binary files /dev/null and b/assets/voices/andrea-eaaf19eef0.mp3 differ diff --git a/assets/voices/index.json b/assets/voices/index.json index f0442b5..c02cc46 100644 --- a/assets/voices/index.json +++ b/assets/voices/index.json @@ -95,6 +95,10 @@ "file": "andrea-a9df2e6bbb.mp3", "sec": 4.98 }, + "andrea|La bottega di Pierin di Frilli, sotto la tenda rossa. Da ragazzi ci si comprava lo stucco per la cerbottana.": { + "file": "andrea-cb15f8f919.mp3", + "sec": 7.29 + }, "andrea|Il Circolo 4 Maggio. Dentro, dietro il bar, la sala dei biliardi: tre tavoli e il silenzio delle grandi occasioni.": { "file": "andrea-f0aef83c80.mp3", "sec": 9.43 @@ -2487,6 +2491,26 @@ "file": "mariano-d65fb6e871.mp3", "sec": 3.42 }, + "andrea|Una cerbottana e un po' di stucco, Pierino!": { + "file": "andrea-eaaf19eef0.mp3", + "sec": 2.77 + }, + "andrea|Niente, grazie: guardavo.": { + "file": "andrea-12cbe6f9a8.mp3", + "sec": 2.26 + }, + "andrea|Lo stucco verde di Pierino: quell'odore non te lo levi più dalle dita.": { + "file": "andrea-2ede7e7324.mp3", + "sec": 3.82 + }, + "andrea|Ora un muro. Quello della piazza va benissimo.": { + "file": "andrea-9cc0991d39.mp3", + "sec": 3.32 + }, + "andrea|No, dopo.": { + "file": "andrea-78ad61a67a.mp3", + "sec": 1.22 + }, "andrea|Si fa una gara alle macchinine?!?!": { "file": "andrea-17216997e2.mp3", "sec": 2.74 @@ -2903,6 +2927,18 @@ "file": "bertovo-ca941bee91.mp3", "sec": 5.41 }, + "andrea|Tutti sul muro, verdi in fila. Lo stucco di Pierino un tradisce mai.": { + "file": "andrea-585cd65f93.mp3", + "sec": 5.2 + }, + "andrea|Finito lo stucco. Domani si ripassa da Pierino.": { + "file": "andrea-62c5d74b82.mp3", + "sec": 3.29 + }, + "andrea|Il muro resta lì. E l'odore dello stucco, sulle dita, pure.": { + "file": "andrea-c7dcb11e66.mp3", + "sec": 5.52 + }, "chiaru|Hai vinto te. Ma la mia aveva le ruote piene di gesso, sia chiaro.": { "file": "chiaru-91a4b23b22.mp3", "sec": 4.44 diff --git a/docs/IMMAGINI.md b/docs/IMMAGINI.md index d484526..160fa2c 100644 --- a/docs/IMMAGINI.md +++ b/docs/IMMAGINI.md @@ -34,6 +34,7 @@ ne è un'opera derivata. Le foto scattate da Andrea non hanno problemi; quelle p | `via-liguri-magelli-muro.png` | Codex, ritocco di `via-liguri-magelli.png` con la Ghiacciomobile nel muro | come `via-liguri-magelli.png` | Street View | **no**: come lo sfondo da cui viene | | `characters/zio_mario.png` | Codex | `foto/zio-mario.png` | Andrea (29 ottobre) | sì (famiglia di Andrea) | | `terrazza-cardetole.png` | Codex (dalla foto, poi il terrazzo davanti) | `cardetole-finestra.jpg` | Andrea (29 ottobre) | **da decidere**: come `cardetole.png`, dice dove abita Andrea | +| `closeups/muro-cerbottana.png` | Codex, solo testo (stile dalla piazza) | nessuna | — | sì | | `cardetole.png` | Codex | `cardetole.jpg` | Andrea (EXIF) | **da decidere**: è la casa vera di Andrea, pubblicarla dice dove abita | | `pieve-interno.png` | Codex | `pieve-interno.png` | Mariano Mirannalti (mandata ad Andrea su WhatsApp) | sì, se Mariano è d'accordo; citarlo nei crediti | | `panorama-intro.png` | Codex | `altre/panorama-intro.png` | da internet (screenshot con l'icona di Google Lens) | **no**, finché non si sostituisce | diff --git a/docs/STATO.md b/docs/STATO.md index 6877585..6f1037f 100644 --- a/docs/STATO.md +++ b/docs/STATO.md @@ -18,6 +18,13 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto, ## Dove siamo arrivati +- **La cerbottana (29 ottobre)**: in piazza, la bottega di Pierin di Frilli sotto la tenda rossa; si compra lo stucco + verde e si tira al muro della piazza (`muro-cerbottana.png`, Codex). Motore `cerbottana.js`, disegno + `cerbottana-render.js`: si mira, si tiene premuto per il fiato (giusto al 70%: poco cade sotto, troppo va sopra), il + mirino ondeggia col respiro, troppo a fiato pieno si tossisce. Bersaglio di gesso (10/5/2), lattine sul muretto + (3), barattolo appeso (8, seria e a fassi male); 12 pallini, servono 30/40/50 punti; i pallini restano sul muro. + Test in `cerbottana.test.mjs` e `npm run browser:cerbottana`. [DA ANDREA] le battute, com'era la bottega, se far + comparire Pierino (è scomparso da anni: decide Andrea). - **Il terrazzo di casa a Cardetole (29 ottobre)**: dalla foto di Andrea dalla finestra (il paese che sale e lassù la Fortezza), Codex l'ha dipinta e ci ha messo davanti il terrazzo (`terrazza-cardetole.png`). Ci si sale da «casa di Andrea» a Cardetole; la battuta dell'apparecchiare ora è sulla porta-finestra. Battute provvisorie diff --git a/index.html b/index.html index 8b1ad4e..dce91d2 100644 --- a/index.html +++ b/index.html @@ -255,6 +255,15 @@ il pulsante alterna Recupera e Molla. Sei lanci. La gloria: 2 pesci alla bona, 3 seria, 4 a fassi male (lo stivale non conta, e non pesa). Frecce ← →: distanza del lancio; Esc esce.
+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 + e A fassi male) 8. Servono 30 punti alla bona, 40 Seria, 50 A fassi male. I pallini restano attaccati al muro.
+Muovi il mouse o tocca il muro per mirare: il mirino ondeggia col respiro (di più salendo di livello). Tieni + premuto per prendere fiato e lascia per sparare: col fiato giusto il pallino va dove punti, con poco cade sotto, + con troppo va sopra. Alla bona la barra del fiato segna il punto giusto. Se resti troppo a fiato pieno tossisci, e + il pallino è perso. Si può anche regolare il fiato col cursore e premere Spara. Frecce per spostare il mirino.
+La pista col gesso sul parcheggio di Cardetole: un giro contro il Chiaru, una spinta a testa (prima tu, poi lui). La tua macchinina è la rossa. Vince chi taglia per primo il traguardo a scacchi; se ci arrivate tutti e due diff --git a/package.json b/package.json index a3c788c..644ac4d 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "browser:biliardo": "node tests/biliardo.browser.mjs", "browser:micro": "node tests/micro.browser.mjs", "browser:gara": "node tests/gara.browser.mjs", + "browser:cerbottana": "node tests/cerbottana.browser.mjs", "browser:tressette": "node tests/tressette.browser.mjs", "browser:scopa": "node tests/scopa.browser.mjs", "browser:briscola": "node tests/briscola.browser.mjs", diff --git a/src/engine/cerbottana-render.js b/src/engine/cerbottana-render.js new file mode 100644 index 0000000..e9d91bd --- /dev/null +++ b/src/engine/cerbottana-render.js @@ -0,0 +1,102 @@ +import { TARGET, CAN, SWINGER, LEDGE, PELLETS, FLIGHT, BEST, rings, reticle, swinger, canThrow, overHeld } from './cerbottana.js'; +import { text, panel } from './bocce-render.js'; + +// Il gesso sul muro: tre cerchi un po' storti (sempre gli stessi) e i numeri dei punti, in alto, ognuno nel suo anello. +function chalkTarget(ctx, c) { + let inner = 0; + for (const [r, p] of rings(c)) { + ctx.strokeStyle = 'rgba(250,248,236,0.85)'; ctx.lineWidth = 1.5; ctx.beginPath(); + for (let k = 0; k <= 48; k++) { + const a = k / 48 * Math.PI * 2, j = 0.6 * Math.sin(k * 2.3 + r); + const x = TARGET.x + Math.cos(a) * (r + j), y = TARGET.y + Math.sin(a) * (r + j); + if (k) ctx.lineTo(x, y); else ctx.moveTo(x, y); + } + ctx.stroke(); + if (inner) text(ctx, String(p), TARGET.x, TARGET.y - (inner + r) / 2, 6, 'rgba(250,248,236,0.8)', 'center'); + inner = r; + } + ctx.fillStyle = 'rgba(250,248,236,0.9)'; ctx.fillRect(TARGET.x - 1, TARGET.y - 1, 2, 2); +} +// Una lattina: rossa con la fascia chiara, i bordi d'alluminio. +function can(ctx, x, y, angle = 0, color = '#c8321e') { + ctx.save(); ctx.translate(Math.round(x), Math.round(y)); ctx.rotate(angle); + ctx.fillStyle = 'rgba(20,16,10,0.35)'; ctx.fillRect(-CAN.w / 2 + 2, -1, CAN.w, 2); + ctx.fillStyle = color; ctx.fillRect(-CAN.w / 2, -CAN.h, CAN.w, CAN.h); + ctx.fillStyle = '#f2e6c8'; ctx.fillRect(-CAN.w / 2, -CAN.h + 6, CAN.w, 4); + ctx.fillStyle = '#c9cdd2'; ctx.fillRect(-CAN.w / 2, -CAN.h, CAN.w, 2); ctx.fillRect(-CAN.w / 2, -2, CAN.w, 2); + ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.fillRect(-CAN.w / 2 + 2, -CAN.h + 2, 1, CAN.h - 4); + ctx.restore(); +} +// Un pallino di stucco: verdastro, un po' schiacciato sul muro. +function pellet(ctx, x, y, r = 2.5) { + ctx.fillStyle = '#2b3a1c'; ctx.beginPath(); ctx.arc(x, y, r + 0.8, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#8fa05a'; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#c3cf8e'; ctx.fillRect(Math.round(x - r / 2), Math.round(y - r / 2), 1, 1); +} +// La cerbottana in basso, in prospettiva: grossa vicino alla bocca, più fine in punta, girata verso il mirino, col +// pallino di stucco in punta. +function blowpipe(ctx, c, to) { + const from = { x: 320, y: 376 }, a = Math.atan2(to.y - from.y, to.x - from.x), len = 64; + const tip = { x: from.x + Math.cos(a) * len, y: from.y + Math.sin(a) * len }, nx = -Math.sin(a), ny = Math.cos(a); + const tube = (w0, w1, color) => { + ctx.fillStyle = color; ctx.beginPath(); + ctx.moveTo(from.x + nx * w0, from.y + ny * w0); ctx.lineTo(tip.x + nx * w1, tip.y + ny * w1); + ctx.lineTo(tip.x - nx * w1, tip.y - ny * w1); ctx.lineTo(from.x - nx * w0, from.y - ny * w0); ctx.fill(); + }; + tube(9, 4, '#2a2018'); tube(7, 3, '#6b5236'); + ctx.fillStyle = '#8a6a44'; ctx.fillRect(Math.round(tip.x - 1), Math.round(tip.y - 1), 2, 2); + if (canThrow(c)) pellet(ctx, tip.x, tip.y - 1, 3.5); +} + +export function drawCerbottana(ctx, c, def, bg) { + ctx.save(); ctx.imageSmoothingEnabled = false; + if (bg) ctx.drawImage(bg, 0, 0, 640, 360); else { ctx.fillStyle = '#d9a640'; ctx.fillRect(0, 0, 640, 360); } + chalkTarget(ctx, c); + for (const p of c.stuck) pellet(ctx, p.x, p.y); + // Le lattine sul muretto; colpite, cadono all'indietro. + for (const k of c.cans) { if (k.fall < 1) can(ctx, k.x, LEDGE + k.fall * 6, -k.fall * 1.4); } + if (c.skill.swinger) { + const s = swinger(c); + ctx.strokeStyle = '#3a3024'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(SWINGER.x, SWINGER.y); ctx.lineTo(s.x, s.y); ctx.stroke(); + can(ctx, s.x, s.y + SWINGER.h, s.a + c.swing.spin * Math.sin(c.visual.time * 30) * 0.6, '#2f6fb0'); + } + // Il pallino in volo: parte grande dalla cerbottana e rimpicciolisce andando verso il muro. + if (c.shot) { + const k = Math.min(1, c.shot.t / FLIGHT), { from, to } = c.shot; + pellet(ctx, from.x + (to.x - from.x) * k, from.y + (to.y - from.y) * k - Math.sin(k * Math.PI) * 18, 6 - 3.5 * k); + } + const r = reticle(c); + if (canThrow(c)) { + const over = overHeld(c); + ctx.strokeStyle = over ? '#ff6a4a' : '#fff8dc'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.arc(r.x, r.y, 7, 0, Math.PI * 2); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(r.x - 11, r.y); ctx.lineTo(r.x - 3, r.y); ctx.moveTo(r.x + 3, r.y); ctx.lineTo(r.x + 11, r.y); + ctx.moveTo(r.x, r.y - 11); ctx.lineTo(r.x, r.y - 3); ctx.moveTo(r.x, r.y + 3); ctx.lineTo(r.x, r.y + 11); ctx.stroke(); + } + blowpipe(ctx, c, r); + // Il fiato: una barra in basso a sinistra (alla bona col punto giusto segnato). + if (canThrow(c)) { + const x = 16, y = 300, h = 50; + ctx.fillStyle = '#17160fd0'; ctx.fillRect(x - 3, y - 3, 18, h + 6); + ctx.fillStyle = overHeld(c) ? '#ff6a4a' : c.power > BEST + 0.08 ? '#ffd23c' : '#9be07c'; + const f = Math.round(h * c.power); ctx.fillRect(x, y + h - f, 12, f); + if (c.skill.sweet) { ctx.fillStyle = '#fff8dc'; ctx.fillRect(x - 3, Math.round(y + h - h * BEST) - 1, 18, 2); } + text(ctx, 'FIATO', x + 6, y - 9, 6, '#efd49e', 'center'); + } + // In alto: punti, pallini rimasti e cosa è successo. + ctx.fillStyle = '#17160fe8'; ctx.fillRect(0, 0, 640, 22); + text(ctx, 'LA CERBOTTANA', 8, 7, 8, '#efd49e'); + text(ctx, { bona: 'alla bona', seria: 'seria', male: 'a fassi male' }[c.level], 8, 16, 7, '#d9ccba'); + text(ctx, `Punti ${c.points} / ${c.skill.goal}`, 260, 11, 9, c.points >= c.skill.goal ? '#b9e5b3' : '#fff0c8', 'center'); + for (let i = 0; i < PELLETS; i++) pellet(ctx, 430 + i * 15, 11, i < c.left ? 3 : 1.2); + const line = overHeld(c) ? 'Lascia, sennò tossisci!' : c.line; + if (line) { ctx.fillStyle = '#17160fd0'; ctx.fillRect(170, 24, 300, 14); text(ctx, line, 320, 31, 8, overHeld(c) || /corto|alto|Coff/.test(line) ? '#ffbf9f' : '#9fd0ff', 'center'); } + if (c.step === 'result') { + const won = c.result.winner === 'player'; + panel(ctx, 170, 140, 300, 76); + text(ctx, won ? 'CENTRATO!' : 'LO STUCCO È FINITO', 320, 160, 12, won ? '#b9e5b3' : '#efd49e', 'center'); + text(ctx, `${c.result.points} punti, ne servivano ${c.result.goal}`, 320, 182, 8, '#efd49e', 'center'); + text(ctx, 'Torna in piazza per uscire.', 320, 200, 8, '#d9ccba', 'center'); + } + ctx.restore(); +} diff --git a/src/engine/cerbottana.js b/src/engine/cerbottana.js new file mode 100644 index 0000000..584ea81 --- /dev/null +++ b/src/engine/cerbottana.js @@ -0,0 +1,146 @@ +// La cerbottana (idea di Andrea, 29 ottobre): da ragazzi in piazza si comprava da Pierin di Frilli lo stucco verde, +// dall'odore inconfondibile, lo si metteva intorno alla cerbottana, si facevano i pallini e si «sparava» al muro. +// Qui: un muro della piazza visto di fronte, un bersaglio fatto col gesso, le lattine sul muretto e (a seria e a fassi +// male) un barattolo appeso che dondola. Si mira, si tiene premuto per prendere fiato e si lascia: con poco fiato il +// pallino cade sotto, con troppo va sopra; il mirino ondeggia col respiro, e chi resta troppo a fiato pieno tossisce. +// I pallini di stucco restano attaccati al muro. Unità: i pixel del primo piano 640×360. + +export const WALL_BOTTOM = 244; // sotto comincia il muretto: lì il pallino non attacca +export const LEDGE = 244; // dove poggiano le lattine +export const PELLETS = 12, SWING = 1, TAP = 0.14, COUGH = 0.6, FLIGHT = 0.3, REST = 0.5, BEST = 0.7, DROP = 300; +export const TARGET = { x: 320, y: 118, rings: [[8, 10], [18, 5], [30, 2]] }; +export const CAN = { w: 10, h: 16, points: 3 }, SWINGER = { x: 470, y: 8, length: 92, w: 12, h: 16, points: 8 }; +// I livelli: quanto ondeggia il mirino (px), quanti punti servono, se c'è il barattolo appeso, se la barra del fiato +// mostra il punto giusto, e quanto sono grandi i cerchi del bersaglio. +export const LEVELS = { + bona: { sway: 3, goal: 30, swinger: false, sweet: true, rings: 1.3 }, + seria: { sway: 6, goal: 40, swinger: true, sweet: false, rings: 1 }, + male: { sway: 10, goal: 50, swinger: true, sweet: false, rings: 0.85 }, +}; +const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); +function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; } + +export function newCerbottana(seed = Date.now() >>> 0, def = {}) { + const level = LEVELS[def.level] ? def.level : 'seria'; + return { step: 'aim', t: 0, seed, level, skill: LEVELS[level], aim: { x: TARGET.x, y: TARGET.y }, power: BEST, + holding: false, holdTime: 0, charging: false, left: PELLETS, points: 0, events: [], shot: null, stuck: [], line: null, + cans: [200, 250, 390, 440].map(x => ({ x, down: false, fall: 0 })), swing: { hits: 0, spin: 0 }, visual: { time: 0 } }; +} +export const canThrow = c => c.step === 'aim' && c.left > 0; +export const canAdjust = canThrow; +export const powerAt = time => clamp(time / SWING, 0, 1); +export const rings = c => TARGET.rings.map(([r, p]) => [r * c.skill.rings, p]); + +// Il respiro: il mirino gira piano intorno al punto voluto (due onde, non si ripete subito); trattenendo il fiato pieno +// trema di più. +export function sway(c) { + const t = c.visual.time, a = c.skill.sway * (1 + (c.overHold ?? 0) * 2); + return { x: a * (Math.sin(t * 1.7) + 0.5 * Math.sin(t * 3.1 + 1)), y: a * 0.8 * (Math.sin(t * 1.3 + 2) + 0.5 * Math.sin(t * 2.9)) }; +} +export function reticle(c) { const s = sway(c); return { x: c.aim.x + s.x, y: c.aim.y + s.y }; } +// Dove arriva il pallino: dove punta il mirino, più su o più giù secondo il fiato (BEST è il fiato giusto). +export const landing = (c, power = c.power) => { const r = reticle(c); return { x: r.x, y: r.y + (BEST - power) * DROP }; }; +// Il barattolo appeso: un pendolo che dondola sotto la gronda (e gira su sé stesso se colpito). +export function swinger(c) { + const a = 0.5 * Math.sin(c.visual.time * 1.6); + return { x: SWINGER.x + Math.sin(a) * SWINGER.length, y: SWINGER.y + Math.cos(a) * SWINGER.length, a }; +} + +export function shoot(c) { + if (!canThrow(c) || c.holding) return false; + return fire(c, c.power); +} +function fire(c, power, cough = false) { + const from = { x: 320, y: 380 }, to = cough ? { x: 300 + random(c) * 40, y: 330 } : landing(c, power); + c.left--; c.power = power; + c.shot = { from, to, t: 0, cough }; + c.step = 'flying'; c.line = null; + c.events.push({ kind: cough ? 'cerbottana-tosse' : 'cerbottana-pff', power }); + return true; +} +// Arrivato: prima il barattolo appeso, poi le lattine, poi il bersaglio; sul muro il pallino resta attaccato. +function land(c) { + const { x, y } = c.shot.to; + let got = 0, what = null; + if (c.shot.cough) what = 'Coff! Il pallino è finito per terra.'; + if (!what && c.skill.swinger) { + const s = swinger(c); + if (Math.abs(x - s.x) <= SWINGER.w / 2 + 1 && Math.abs(y - s.y - SWINGER.h / 2) <= SWINGER.h / 2 + 1) { + got = SWINGER.points; c.swing.hits++; c.swing.spin = 1; what = `Il barattolo appeso! ${got} punti.`; + c.events.push({ kind: 'cerbottana-latta', power: 1 }); + } + } + if (!what) { + const can = c.cans.find(k => !k.down && Math.abs(x - k.x) <= CAN.w / 2 + 1 && y >= LEDGE - CAN.h - 1 && y <= LEDGE); + if (can) { can.down = true; got = CAN.points; what = `Lattina giù! ${got} punti.`; c.events.push({ kind: 'cerbottana-latta', power: 0.8 }); } + } + if (!what) { + if (y < WALL_BOTTOM && y > 4 && x > 4 && x < 636) { + c.stuck.push({ x, y }); + const d = Math.hypot(x - TARGET.x, y - TARGET.y), ring = rings(c).find(([r]) => d <= r); + got = ring ? ring[1] : 0; + what = got ? `Nel bersaglio: ${got} ${got === 1 ? 'punto' : 'punti'}.` : y < TARGET.y - 40 ? 'Troppo fiato: alto.' : 'Sul muro, fuori dal bersaglio.'; + c.events.push({ kind: 'cerbottana-muro', power: 0.6 }); + } else what = 'Poco fiato: il pallino è caduto corto.'; + } + c.points += got; c.line = what; + c.shot = null; c.step = 'rest'; c.t = 0; +} + +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; + for (const k of c.cans) if (k.down) k.fall = Math.min(1, k.fall + dt * 3); + if (c.swing.spin) c.swing.spin = Math.max(0, c.swing.spin - dt); + if (c.holding) { + c.holdTime += dt; c.charging = c.holdTime >= TAP; + c.power = powerAt(c.holdTime); + c.overHold = c.power >= 1 ? (c.overHold ?? 0) + dt : 0; + // Troppo a fiato pieno: tosse, e il pallino è perso. + if (c.overHold > COUGH) { c.holding = c.charging = false; c.overHold = 0; fire(c, 1, true); } + } + if (c.step === 'flying') { c.shot.t += dt; if (c.shot.t >= FLIGHT) land(c); } + else if (c.step === 'rest' && c.t >= REST) { + if (c.left > 0) c.step = 'aim'; + else { c.step = 'result'; c.result = { winner: c.points >= c.skill.goal ? 'player' : 'cpu', points: c.points, goal: c.skill.goal }; } + } +} + +// --- Comandi: il mirino segue il mouse o il dito; tenere premuto prende fiato, lasciare spara. +export function aimScreen(c, [x, y]) { + if (!canThrow(c) || c.holding || !Number.isFinite(x) || !Number.isFinite(y)) return false; + c.aim = { x: clamp(x, 10, 630), y: clamp(y, 24, 300) }; return true; +} +export function nudge(c, dir) { if (!canThrow(c) || c.holding) return false; c.aim.x = clamp(c.aim.x + dir * 4, 10, 630); return true; } +export function setPower(c, value) { if (!canThrow(c) || c.holding || !Number.isFinite(value)) return false; c.power = clamp(value, 0, 1); return true; } +export function key(c, key, down) { + const move = { ArrowLeft: [-4, 0], ArrowRight: [4, 0], ArrowUp: [0, -4], ArrowDown: [0, 4] }[key]; + if (!move) return false; + if (down && canThrow(c) && !c.holding) c.aim = { x: clamp(c.aim.x + move[0], 10, 630), y: clamp(c.aim.y + move[1], 24, 300) }; + return true; +} +export function press(c, pointerId) { + if (!canThrow(c) || c.holding) return false; + c.pointerId = pointerId; c.holding = true; c.charging = false; c.holdTime = 0; c.previousPower = c.power; c.power = 0; c.overHold = 0; + return true; +} +export function cancel(c, pointerId) { + if (pointerId !== undefined && c.pointerId !== pointerId) return false; + if (!c.holding) return false; + c.holding = c.charging = false; c.power = c.previousPower; c.holdTime = 0; c.overHold = 0; return true; +} +export function release(c, pointerId) { + if (c.pointerId !== pointerId || !c.holding) return false; + if (c.holdTime < TAP) return cancel(c, pointerId); // il tocco secco mira soltanto + c.holding = c.charging = false; c.overHold = 0; + return fire(c, c.power); +} +// Trattenendo il fiato pieno: il disegno avvisa prima della tosse. +export const overHeld = c => c.holding && (c.overHold ?? 0) > 0; + +export function button(c) { return c.step === 'result' ? 'Finita' : c.step === 'aim' ? 'Spara' : 'In volo…'; } +export function status(c) { + const score = `Punti ${c.points} su ${c.skill.goal}, pallini ${c.left} di ${PELLETS}.`; + if (c.step === 'result') return `${c.result.winner === 'player' ? 'Fatto! ' : 'Lo stucco è finito. '}${score}`; + return `${score} ${c.line ?? 'Mira, tieni premuto per prendere fiato e lascia.'}`; +} diff --git a/src/engine/game.js b/src/engine/game.js index 271b72f..f1466b5 100644 --- a/src/engine/game.js +++ b/src/engine/game.js @@ -11,6 +11,7 @@ import * as tressette from './tressette.js'; import * as scopa from './scopa.js'; import * as gara from './gara.js'; import * as micro from './micro.js'; +import * as cerbottana from './cerbottana.js'; // I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta. export const MINIGAMES = { @@ -22,6 +23,7 @@ export const MINIGAMES = { rinquarto: { create: (seed, level) => rinquarto.newRinquarto(seed, { level }), update: rinquarto.update, press: rinquarto.press, release: rinquarto.release, cancel: rinquarto.cancel, aim: rinquarto.aimScreen }, basket: { create: (seed, level) => basket.newBasket(seed, { level }), update: basket.update, press: basket.press, release: basket.release, cancel: basket.cancel, aim: basket.aimScreen }, pesca: { create: (seed, level) => pesca.newPesca(seed, { level }), update: pesca.update, press: pesca.press, release: pesca.release, cancel: pesca.cancel, aim: pesca.aimScreen }, + cerbottana: { create: (seed, level) => cerbottana.newCerbottana(seed, { level }), update: cerbottana.update, press: cerbottana.press, release: cerbottana.release, cancel: cerbottana.cancel, aim: cerbottana.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 6ab6e9c..d355aaa 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -10,6 +10,7 @@ import { drawBasket } from './basket-render.js'; import { drawPesca } from './pesca-render.js'; import { drawGara } from './gara-render.js'; import { drawMicro } from './micro-render.js'; +import { drawCerbottana } from './cerbottana-render.js'; import { drawBriscola } from './briscola-render.js'; import { drawTressette } from './tressette-render.js'; import { drawScopa } from './scopa-render.js'; @@ -687,6 +688,11 @@ function drawCloseup(ctx, game, time) { drawBocce(ctx, c, def, backgrounds.get(def.picture), time); return; } + if (def.type === 'cerbottana') { + if (!backgrounds.has(def.picture)) loadBackground(def.picture); + drawCerbottana(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 10d9f83..62f0983 100644 --- a/src/engine/soundscape.js +++ b/src/engine/soundscape.js @@ -136,6 +136,11 @@ export class Soundscape { else if (kind === 'gara-sobbalzo') this.tone(160, 90, 0.1, 0.05, 0, pan); else if (kind === 'gara-giro') this.tone(660, 990, 0.15, 0.05, 0, pan); else if (kind === 'gara-arrivo') { this.tone(660, 660, 0.12, 0.06 * strength, 0, pan); this.tone(990, 990, 0.3, 0.06 * strength, 0.14, pan); } + // La cerbottana: il soffio, il pallino che si schiaccia sul muro, la lattina, la tosse. + else if (kind === 'cerbottana-pff') this.noiseSound(0.12, 2200, 0.05 + strength * 0.05, { pan }); + else if (kind === 'cerbottana-muro') { this.tone(180, 90, 0.06, 0.05, 0, pan); this.noiseSound(0.05, 600, 0.04, { pan }); } + else if (kind === 'cerbottana-latta') { this.tone(2400, 1600, 0.08, 0.05 + strength * 0.04, 0, pan); this.tone(3400, 2600, 0.05, 0.03, 0.03, 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 }); } else if (kind === 'micro-strisciata') this.noiseSound(0.3 + strength * 0.7, 900, 0.03 + strength * 0.04, { pan }); diff --git a/src/main.js b/src/main.js index 14a3798..6ccbad4 100644 --- a/src/main.js +++ b/src/main.js @@ -12,6 +12,7 @@ import * as bocce from './engine/bocce.js'; import * as biliardo from './engine/biliardo.js'; import * as rinquarto from './engine/rinquarto.js'; import * as micro from './engine/micro.js'; +import * as cerbottana from './engine/cerbottana.js'; import * as basket from './engine/basket.js'; import * as pesca from './engine/pesca.js'; import * as gara from './engine/gara.js'; @@ -510,7 +511,7 @@ canvas.addEventListener('pointerdown', e => { } if (press?.closeup) return; const aimed = game.closeupAim(scenePoint(e)); - if (['biliardo', 'rinquarto', 'basket', 'micro'].includes(story.closeups[game.closeup.id].type)) { + if (['biliardo', 'rinquarto', 'basket', 'micro', 'cerbottana'].includes(story.closeups[game.closeup.id].type)) { if (!aimed && !game.speech) return; // dove non si può mirare (dietro Andrea) non si carica nemmeno if (aimed) game.closeup.aimLocked = true; // il clic fissa la mira } @@ -582,7 +583,7 @@ canvas.addEventListener('pointermove', e => { if (press?.gara === e.pointerId) gara.aimScreen(game.closeup, scenePoint(e), e.pointerId); return; } - const aiming = game.closeup && ['biliardo', 'rinquarto', 'bocce', 'basket', 'pesca', 'micro'].includes(story.closeups[game.closeup.id].type); + const aiming = game.closeup && ['biliardo', 'rinquarto', 'bocce', 'basket', 'pesca', 'micro', 'cerbottana'].includes(story.closeups[game.closeup.id].type); if (aiming) { if (e.pointerType === 'mouse' && !press && !game.closeup.aimLocked) game.closeupAim(scenePoint(e)); } else if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e)); }); @@ -603,6 +604,7 @@ const MINI = { bocce: { ...bocce, nudge: (c, dir) => bocce.aim(c, c.aim.u, c.aim.w + dir * 0.3), back: 'Torna al pallaio', label: 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' }, biliardo: { ...biliardo, back: 'Torna al bar', label: 'Biliardo: clic o tocco fissa la mira. Frecce per affinarla, Maiusc per passi più piccoli. Regola la forza e premi Tira.' }, rinquarto: { ...rinquarto, back: 'Torna da Trappizzio Berti', label: 'I rinquarti di Trappizzio Berti: clic o tocco fissa la mira, frecce o pulsanti Mira per girare la stecca (Maiusc per i passi fini), Tira per tirare. Dopo l’esito, Prossima.' }, + 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.' }, 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). @@ -1013,7 +1015,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' }[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' }[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; @@ -1029,7 +1031,7 @@ function updateUi() { const easy = def.type === 'gara' && gara.simple(c); setProp(powerLabel, 'hidden', def.type === 'rinquarto' || easy); for (const id of ['bocce-left', 'bocce-right']) setProp($(id), 'hidden', easy); - const powerName = def.type === 'gara' ? 'Sensibilità ' : 'Forza '; + 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'); setProp($('gara-pad'), 'hidden', def.type !== 'gara' || easy); @@ -1047,6 +1049,7 @@ function updateUi() { setProp($('biliardo-help'), 'hidden', def.type !== 'biliardo'); setProp($('rinquarto-help'), 'hidden', def.type !== 'rinquarto'); setProp($('micro-help'), 'hidden', def.type !== 'micro'); + setProp($('cerbottana-help'), 'hidden', def.type !== 'cerbottana'); setProp($('basket-help'), 'hidden', def.type !== 'basket'); setProp($('pesca-help'), 'hidden', def.type !== 'pesca'); setProp($('gara-help'), 'hidden', def.type !== 'gara'); @@ -1064,7 +1067,7 @@ function updateUi() { canvas.classList.toggle('torch', !!torch); canvas.classList.toggle('bocce', !!mg); // Biliardo e GP di Fortezza: audio, pausa e uscita fuori dal quadro (al GP la pista passa negli angoli). - $('stage').classList.toggle('biliardo', !!c && ['biliardo', 'rinquarto', 'gara', 'micro'].includes(story.closeups[c.id].type)); + $('stage').classList.toggle('biliardo', !!c && ['biliardo', 'rinquarto', 'gara', 'micro', 'cerbottana'].includes(story.closeups[c.id].type)); $('stage').classList.toggle('briscola', !!cards); setAttr(canvas, 'aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : mg ? mg.label : "La scena dell'avventura"); // La pagina che comincia a girare fa il suo fruscio. diff --git a/src/story/story.js b/src/story/story.js index 6673b61..fbc910d 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -233,6 +233,17 @@ export const story = { quit: [{ say: 'Questo me lo tengo per la prossima.' }], draw: [{ say: 'Visto? Geometrie celestiali. Si riprova quando vuoi.', who: 'bertovo', pose: 'explain' }], }, + // La cerbottana con lo stucco verde di Pierin di Frilli, contro il muro della piazza (Andrea, 29 ottobre). + // [DA ANDREA: le battute sono provvisorie] + cerbottana: { + type: 'cerbottana', + picture: 'assets/closeups/muro-cerbottana.png', + names: { player: 'Andrea' }, + win: [{ say: 'Tutti sul muro, verdi in fila. Lo stucco di Pierino un tradisce mai.' }, { set: 'cerbottana_vinta' }], + lose: [{ say: 'Finito lo stucco. Domani si ripassa da Pierino.' }], + quit: [{ say: 'Il muro resta lì. E l\'odore dello stucco, sulle dita, pure.' }], + draw: [], + }, // Le macchinine sulla pista col gesso, sul parcheggio di Cardetole, contro il Chiaru (Andrea, 28 e 29 ottobre). // [DA ANDREA: le battute sono provvisorie] micro: { @@ -505,6 +516,17 @@ export const story = { look: [{ say: 'La strada che porta al Fiocco di Neve. Già sento il profumo di cialda.' }], }, }, + { + // La bottega di Pierin di Frilli sotto la tenda rossa: da ragazzi ci si comprava lo stucco verde per la + // cerbottana (Andrea, 29 ottobre). [DA ANDREA: com'era dentro, e cosa diceva Pierino] + id: 'pierin', name: 'la bottega di Pierin di Frilli', box: [598, 176, 42, 92], at: [612, 306], face: 'right', default: 'talk', + actions: { + look: [{ say: 'La bottega di Pierin di Frilli, sotto la tenda rossa. Da ragazzi ci si comprava lo stucco per la cerbottana.' }], + talk: [{ dialogue: 'pierin' }], + open: [{ dialogue: 'pierin' }], + use: [{ dialogue: 'pierin' }], + }, + }, { // Il Circolo 4 Maggio: il portone ad arco del palazzo arancione porta nella sala dei biliardi. id: 'circolo', name: 'Circolo 4 Maggio', box: [188, 190, 28, 58], at: [202, 252], face: 'up', @@ -3311,6 +3333,28 @@ export const story = { }, }, }, + // Da Pierin di Frilli: la cerbottana e lo stucco verde, poi il muro della piazza (Andrea, 29 ottobre). + pierin: { + nodes: { + start: { + options: [ + { text: 'Una cerbottana e un po\' di stucco, Pierino!', next: 'livello', then: [ + { say: 'Lo stucco verde di Pierino: quell\'odore non te lo levi più dalle dita.' }, + { say: 'Ora un muro. Quello della piazza va benissimo.' }, + ] }, + { text: 'Niente, grazie: guardavo.', next: 'end' }, + ], + }, + livello: { + options: [ + { text: 'Alla bona.', next: 'end', then: [{ closeup: 'cerbottana', level: 'bona' }] }, + { text: 'Seria.', next: 'end', then: [{ closeup: 'cerbottana', level: 'seria' }] }, + { text: 'A fassi male.', next: 'end', then: [{ closeup: 'cerbottana', level: 'male' }] }, + { text: 'No, dopo.', next: 'end' }, + ], + }, + }, + }, // Il Chiaru al parcheggio di Cardetole, accanto alla pista col gesso (Andrea, 29 ottobre). [DA ANDREA: battute provvisorie] chiaru: { nodes: { diff --git a/style.css b/style.css index 8a66ceb..94ef500 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 { 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 { cursor: pointer; padding: 6px 0; } -#bocce-help p, #biliardo-help p, #basket-help p, #pesca-help p, #gara-help p, #rinquarto-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 { 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 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 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 { font-size: 9px; } + #bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-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/cerbottana.browser.mjs b/tests/cerbottana.browser.mjs new file mode 100644 index 0000000..367dd9f --- /dev/null +++ b/tests/cerbottana.browser.mjs @@ -0,0 +1,44 @@ +// La cerbottana nel browser vero: dalla bottega di Pierin di Frilli al muro della piazza; mira, fiato e tiro col mouse +// e col dito. npm run browser:cerbottana — schermate in /tmp/regolo-cerbottana. +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-cerbottana'; +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('piazza'); settle(game); + game.act('talk', 'pierin'); settle(game); + game.choose(game.options().findIndex(o => o.text.startsWith('Una cerbottana'))); settle(game); + game.choose(game.options().findIndex(o => o.text === 'Seria.')); settle(game); + }); + await page.waitForSelector('#bocce-controls:not([hidden])'); + await page.waitForFunction(async () => (await import('/src/engine/render.js')).hasBackground('assets/closeups/muro-cerbottana.png')); + assert.equal(await page.locator('#bocce-throw').textContent(), 'Spara'); + assert.ok(await page.locator('#cerbottana-help').isVisible()); + await page.waitForTimeout(1600); + const box = await page.locator('#scene').boundingBox(), P = (x, y) => [box.x + x / 640 * box.width, box.y + y / 360 * box.height]; + if (mobile) await page.touchscreen.tap(...P(320, 118)); else { await page.mouse.move(...P(320, 118)); await page.mouse.click(...P(320, 118)); } + assert.equal(await page.evaluate(() => game.closeup.step), 'aim', 'un tocco mira soltanto'); + await page.screenshot({ path: `${out}/${name}-mira.png` }); + if (mobile) { await page.locator('#bocce-power').fill('70'); await page.locator('#bocce-throw').tap(); } + else { await page.mouse.down(); await page.waitForFunction(() => game.closeup.power >= 0.6); await page.mouse.up(); } + await page.waitForFunction(() => game.closeup.left === 11 && game.closeup.step === 'aim', null, { timeout: 10000 }); + await page.screenshot({ path: `${out}/${name}-dopo.png` }); + await page.close(); + } + assert.deepEqual(errors, []); + console.log(`ok: schermate in ${out}`); +} finally { await browser?.close(); server.kill(); } diff --git a/tests/cerbottana.test.mjs b/tests/cerbottana.test.mjs new file mode 100644 index 0000000..2738baa --- /dev/null +++ b/tests/cerbottana.test.mjs @@ -0,0 +1,82 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as m from '../src/engine/cerbottana.js'; +import { drawCerbottana } from '../src/engine/cerbottana-render.js'; +import { Game, MINIGAMES } from '../src/engine/game.js'; +import { story } from '../src/story/story.js'; +import { settle } from './helpers.mjs'; + +// Ferma il respiro (il mirino va dove si punta), spara col fiato dato e aspetta che il pallino arrivi. +function shot(c, x, y, power = m.BEST) { + c.skill = { ...c.skill, sway: 0 }; c.aim = { x, y }; + assert.ok(m.setPower(c, power)); assert.ok(m.shoot(c)); + for (let i = 0; i < 200 && c.step === 'flying'; i++) m.update(c, 1 / 60); + return c.line; +} + +test('col fiato giusto il pallino va dove si punta; poco fiato cade sotto, troppo va sopra; resta attaccato al muro', () => { + const c = m.newCerbottana(1, { level: 'seria' }); + assert.match(shot(c, m.TARGET.x, m.TARGET.y), /10 punti/); + assert.equal(c.points, 10); assert.deepEqual(c.stuck.at(-1), { x: m.TARGET.x, y: m.TARGET.y }); + for (let i = 0; i < 60; i++) m.update(c, 1 / 60); + assert.ok(m.landing(c, 0.5).y > m.TARGET.y + 40, 'poco fiato: sotto'); + assert.ok(m.landing(c, 0.9).y < m.TARGET.y - 40, 'troppo fiato: sopra'); + shot(c, 320, 200, 0.2); + assert.match(c.line, /corto/); assert.equal(c.stuck.length, 1, 'il pallino corto non resta sul muro'); +}); + +test('le lattine cadono, il barattolo appeso c\'è solo a seria e a fassi male; finito lo stucco si guarda il punteggio', () => { + const c = m.newCerbottana(2, { level: 'bona' }); + const k = c.cans[0]; + assert.match(shot(c, k.x, m.LEDGE - 8), /Lattina giù/); + assert.ok(k.down); assert.equal(c.points, m.CAN.points); + assert.equal(m.LEVELS.bona.swinger, false); assert.equal(m.LEVELS.seria.swinger, true); + // Il barattolo dondola mentre il pallino vola: si mira dove sarà all'arrivo. + const s = m.newCerbottana(3, { level: 'seria' }), p = m.swinger({ visual: { time: s.visual.time + m.FLIGHT + 1 / 120 } }); + assert.match(shot(s, p.x, p.y + m.SWINGER.h / 2), /barattolo appeso/); + for (let i = 0; i < 60; i++) m.update(c, 1 / 60); + while (c.left > 0) { for (let i = 0; i < 60 && !m.canThrow(c); i++) m.update(c, 1 / 60); shot(c, m.TARGET.x, m.TARGET.y); } + for (let i = 0; i < 60; i++) m.update(c, 1 / 60); + assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'player'); + assert.equal(c.stuck.length, m.PELLETS - 1); +}); + +test('i comandi: un tocco mira, tenere premuto prende fiato da zero, lasciare spara; troppo a fiato pieno si tossisce', () => { + const c = m.newCerbottana(4); + assert.ok(m.aimScreen(c, [200, 100])); assert.deepEqual(c.aim, { x: 200, y: 100 }); + assert.ok(m.press(c, 1)); m.update(c, 0.05); assert.equal(m.release(c, 1), true); assert.equal(c.step, 'aim', 'il tocco breve non spara'); + assert.ok(m.press(c, 1)); assert.equal(c.power, 0); + for (let i = 0; i < 40; i++) m.update(c, 1 / 60); + assert.ok(c.charging && c.power > 0.5 && c.power < 0.8); + assert.ok(m.release(c, 1)); assert.equal(c.step, 'flying'); assert.equal(c.left, m.PELLETS - 1); + const d = m.newCerbottana(5); m.press(d, 1); + for (let i = 0; i < 60 * (m.SWING + m.COUGH + 0.2); i++) m.update(d, 1 / 60); + assert.equal(d.holding, false); assert.ok(d.events.some(e => e.kind === 'cerbottana-tosse')); + for (let i = 0; i < 60; i++) m.update(d, 1 / 60); + assert.match(d.line, /Coff/); assert.equal(d.left, m.PELLETS - 1); + assert.ok(m.key(d, 'ArrowUp', true)); assert.equal(d.aim.y, m.TARGET.y - 4); +}); + +test('il disegno: muro, gesso, lattine, pallini; lo stato non cambia', () => { + const calls = [], ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => calls.push([k, ...args]) }); + const c = m.newCerbottana(6, { level: 'seria' }), bg = {}; + const draw = () => { const before = structuredClone(c); drawCerbottana(ctx, c, story.closeups.cerbottana, 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 === 'LA CERBOTTANA')); + c.step = 'result'; c.result = { winner: 'player', points: 44, goal: 40 }; calls.length = 0; draw(); + assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'CENTRATO!')); +}); + +test('da Pierin di Frilli, in piazza: lo stucco, il livello e il muro', () => { + assert.ok(MINIGAMES.cerbottana); + const g = new Game(story); g.go('piazza'); settle(g); + const h = g.hotspot('pierin'); + assert.equal(g.hotspotAt([h.box[0] + h.box[2] / 2, h.box[1] + h.box[3] / 2]).id, 'pierin'); + g.act('talk', 'pierin'); settle(g); + g.choose(g.options().findIndex(o => o.text.startsWith('Una cerbottana'))); settle(g); + g.choose(g.options().findIndex(o => o.text === 'A fassi male.')); settle(g); + assert.equal(g.closeup?.id, 'cerbottana'); assert.equal(g.closeup.level, 'male'); + g.closeup.step = 'result'; g.closeup.result = { winner: 'player', points: 52, goal: 50 }; + g.closeupClose(); settle(g); + assert.ok(g.state.flags.cerbottana_vinta); +});