diff --git a/assets/closeups/campone-campana.png b/assets/closeups/campone-campana.png new file mode 100644 index 0000000..c5a1073 Binary files /dev/null and b/assets/closeups/campone-campana.png differ diff --git a/assets/voices/andrea-356a1afe8d.mp3 b/assets/voices/andrea-356a1afe8d.mp3 new file mode 100644 index 0000000..7a4f803 Binary files /dev/null and b/assets/voices/andrea-356a1afe8d.mp3 differ diff --git a/assets/voices/andrea-3ed6165062.mp3 b/assets/voices/andrea-3ed6165062.mp3 new file mode 100644 index 0000000..35acddc Binary files /dev/null and b/assets/voices/andrea-3ed6165062.mp3 differ diff --git a/assets/voices/andrea-595df0a426.mp3 b/assets/voices/andrea-595df0a426.mp3 new file mode 100644 index 0000000..1928e61 Binary files /dev/null and b/assets/voices/andrea-595df0a426.mp3 differ diff --git a/assets/voices/andrea-68378e4c21.mp3 b/assets/voices/andrea-68378e4c21.mp3 new file mode 100644 index 0000000..4358865 Binary files /dev/null and b/assets/voices/andrea-68378e4c21.mp3 differ diff --git a/assets/voices/index.json b/assets/voices/index.json index 283c944..970d45e 100644 --- a/assets/voices/index.json +++ b/assets/voices/index.json @@ -2835,6 +2835,10 @@ "file": "andrea-3f77b83f35.mp3", "sec": 0.87 }, + "andrea|E una partita a campana, Taglia?": { + "file": "andrea-595df0a426.mp3", + "sec": 2.23 + }, "andrea|Ma quanto sei alto, Taglia?": { "file": "andrea-01ecdf3ed8.mp3", "sec": 1.96 @@ -2859,6 +2863,14 @@ "file": "taglia-ac012a1066.mp3", "sec": 4.21 }, + "taglia|A campana? Alla nostra età?": { + "file": "taglia-0a7f69770d.mp3", + "sec": 2.77 + }, + "taglia|Va bene: il gesso ce l'ho in tasca, il sassolino lo scelgo io. Sul vialetto, dalla 1 alla 8, e chi pesta la riga passa la mano.": { + "file": "taglia-c00be84faf.mp3", + "sec": 11.54 + }, "taglia|Abbastanza da vedere i pallini arrivare prima di te.": { "file": "taglia-d99c875252.mp3", "sec": 2.57 @@ -2875,6 +2887,18 @@ "file": "taglia-4117f6613d.mp3", "sec": 4.84 }, + "taglia|Alla bona: le righe un contano. Comincia te.": { + "file": "taglia-328d930124.mp3", + "sec": 4.12 + }, + "taglia|Seria: chi tocca la riga è fuori. Comincia te.": { + "file": "taglia-1266463538.mp3", + "sec": 4.23 + }, + "taglia|A fassi male? Io la campana la facevo a occhi chiusi.": { + "file": "taglia-7938d8d5e4.mp3", + "sec": 5.05 + }, "andrea|Si fa una gara alle macchinine?!?!": { "file": "andrea-17216997e2.mp3", "sec": 2.74 @@ -3343,6 +3367,26 @@ "file": "andrea-aba12c5241.mp3", "sec": 3.1 }, + "andrea|Dalla 1 fino in cima, e ritorno. A ventisett'anni, davanti alle poste.": { + "file": "andrea-68378e4c21.mp3", + "sec": 6.15 + }, + "taglia|Te lo dico io: se ti vede qualcuno, domani lo sa tutto il paese.": { + "file": "taglia-1e6660932d.mp3", + "sec": 3.34 + }, + "andrea|Il Taglia, su un piede solo, è più alto di me su due.": { + "file": "andrea-3ed6165062.mp3", + "sec": 3.31 + }, + "taglia|È questione di gambe lunghe.": { + "file": "taglia-9782816264.mp3", + "sec": 1.71 + }, + "andrea|Il gesso lo lascio lì. Ci giocherà qualche bambino, che è più in allenamento di me.": { + "file": "andrea-356a1afe8d.mp3", + "sec": 5.91 + }, "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/assets/voices/taglia-0a7f69770d.mp3 b/assets/voices/taglia-0a7f69770d.mp3 new file mode 100644 index 0000000..ea1cd08 Binary files /dev/null and b/assets/voices/taglia-0a7f69770d.mp3 differ diff --git a/assets/voices/taglia-1266463538.mp3 b/assets/voices/taglia-1266463538.mp3 new file mode 100644 index 0000000..306f985 Binary files /dev/null and b/assets/voices/taglia-1266463538.mp3 differ diff --git a/assets/voices/taglia-1e6660932d.mp3 b/assets/voices/taglia-1e6660932d.mp3 new file mode 100644 index 0000000..abc861b Binary files /dev/null and b/assets/voices/taglia-1e6660932d.mp3 differ diff --git a/assets/voices/taglia-328d930124.mp3 b/assets/voices/taglia-328d930124.mp3 new file mode 100644 index 0000000..32fd0d7 Binary files /dev/null and b/assets/voices/taglia-328d930124.mp3 differ diff --git a/assets/voices/taglia-7938d8d5e4.mp3 b/assets/voices/taglia-7938d8d5e4.mp3 new file mode 100644 index 0000000..f35c5ea Binary files /dev/null and b/assets/voices/taglia-7938d8d5e4.mp3 differ diff --git a/assets/voices/taglia-9782816264.mp3 b/assets/voices/taglia-9782816264.mp3 new file mode 100644 index 0000000..fd59358 Binary files /dev/null and b/assets/voices/taglia-9782816264.mp3 differ diff --git a/assets/voices/taglia-c00be84faf.mp3 b/assets/voices/taglia-c00be84faf.mp3 new file mode 100644 index 0000000..8ef701b Binary files /dev/null and b/assets/voices/taglia-c00be84faf.mp3 differ diff --git a/docs/IMMAGINI.md b/docs/IMMAGINI.md index ba455b6..535fbdd 100644 --- a/docs/IMMAGINI.md +++ b/docs/IMMAGINI.md @@ -38,6 +38,7 @@ ne è un'opera derivata. Le foto scattate da Andrea non hanno problemi; quelle p | `closeups/muro-cerbottana.png` | Codex, solo testo (stile dalla piazza) | nessuna | — | sì | | `closeups/macchina-dietro.png`, `macchina-lampi.png`, `macchina-davanti.png` | Codex, dallo sprite della macchina del tempo | nessuna | — | sì | | `closeups/campone-condor.png` | Codex, dallo sfondo del campone delle poste | nessuna | — | sì | +| `closeups/campone-campana.png` | Codex, solo testo (stile dal campone delle poste) | nessuna | — | sì | | `barrettino-1997.png`, `closeups/cruscotto-tempo.png` | Codex, solo testo | nessuna | — | sì | | `characters/vannino.png`, `characters/pierino_parigi.png` | Codex, dalla descrizione di Andrea (niente foto) | nessuna | — | sì (Pierino: consenso del figlio Marco) | | `cardetole.png` | Codex | `cardetole.jpg` | Andrea (EXIF) | **da decidere**: è la casa vera di Andrea, pubblicarla dice dove abita | diff --git a/docs/STATO.md b/docs/STATO.md index 1f035c0..79e9e69 100644 --- a/docs/STATO.md +++ b/docs/STATO.md @@ -18,6 +18,16 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto, ## Dove siamo arrivati +- **La campana al campone delle poste (30 ottobre)**: dal Taglia, «E una partita a campana, Taglia?», poi il livello. + Primo piano `campana` visto dall'alto sul vialetto (`campone-campana.png`, Codex); la campana col gesso la disegna il + codice (`campana.js`, `campana-render.js`): 1-2-3, coppia 4-5, 6, coppia 7-8 e in cima il mezzo cerchio con scritto + CAMPANA (Andrea: non «cielo»), ogni casella tinta tenue col suo gesso. Il sassolino: la mira dà la direzione, tenendo + premuto la forza va e torna, si lascia; deve cadere nella casella che tocca senza toccare le righe (alla bona le + righe non contano e il puntino giallo mostra dove cade). I salti: la lancetta dell'equilibrio attraversa la barra e + si preme nel verde (presto: giù l'altro piede; tardi: pestata la riga); si scavalca la casella col sassolino e + tornando lo si raccoglie. Chi ce la fa passa alla casella dopo e ritira, chi sbaglia passa la mano; il Taglia gioca + da solo (in simulazione gli servono circa 22, 10 e 6 turni secondo il livello). Vince chi finisce la 8 (flag + `campana_vinta`). Test `campana.test.mjs`, prova `npm run browser:campana`. [DA ANDREA] le battute. - **La macchina del tempo da fuori (30 ottobre)**: dopo il cruscotto, la partenza giù per Via Liguri Magelli (primo piano `corsa`, `src/engine/corsa.js`): accelera, il fulmine la prende, il bianco, e restano due sgommate di fuoco che bruciano e si spengono; al ritorno dal 1997 l'arrivo: il fulmine in fondo alla strada, lei viene avanti e diff --git a/index.html b/index.html index 72facdb..fd58a96 100644 --- a/index.html +++ b/index.html @@ -283,6 +283,17 @@ mira un po' più sopra; alla bona il puntino giallo segna dove arriva. Il palloncino e la lattina in aria si muovono: si mira dove saranno. Si può anche premere Spara, senza trattenere il fiato. Frecce per spostare il mirino.
+La campana col gesso sul vialetto del campone, a turni col Taglia. Si tira il sassolino nella casella che tocca, + dalla 1 alla 8: deve cadere dentro, senza toccare le righe (alla bona le righe non contano). Poi si salta tutta la + campana fino in cima (il mezzo cerchio) e indietro, su un piede nelle caselle sole e a gambe larghe nella 4-5 e nella 7-8, senza mai + mettere il piede dove sta il sassolino; tornando lo si raccoglie dalla casella prima. Chi ce la fa passa alla casella + dopo e ritira; chi sbaglia passa la mano. Vince chi finisce la 8.
+Il tiro: muovi il mouse o tocca la campana per mirare, tieni premuto per la forza (sale e scende) e lascia; oppure + regola la forza e premi Tira. Alla bona il puntino giallo segna dove cade. Frecce su e giù per la mira, destra e + 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.
+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 3abd46c..f30075f 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "browser:gara": "node tests/gara.browser.mjs", "browser:cerbottana": "node tests/cerbottana.browser.mjs", "browser:condor": "node tests/condor.browser.mjs", + "browser:campana": "node tests/campana.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/campana-render.js b/src/engine/campana-render.js new file mode 100644 index 0000000..45193ad --- /dev/null +++ b/src/engine/campana-render.js @@ -0,0 +1,141 @@ +import { W, CY, START, CIELO, FROM, BOXES, LAST, FLIGHT, HOP, center, landing, needle, canAdjust } from './campana.js'; +import { text, panel } from './bocce-render.js'; + +function rect(ctx, x, y, w, h, color) { ctx.fillStyle = color; ctx.fillRect(Math.round(x), Math.round(y), Math.round(w), Math.round(h)); } +// Il gesso: tratti a puntini un po' storti, con dei buchi (sempre gli stessi, da un piccolo hash: non tremano). +const hash = n => { const s = Math.sin(n * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); }; +function chalkLine(ctx, x1, y1, x2, y2, seed) { + const n = Math.max(2, Math.round(Math.hypot(x2 - x1, y2 - y1))); + for (let i = 0; i <= n; i++) { + const k = i / n, h = hash(seed * 1000 + i); + if (h < 0.07) continue; + ctx.fillStyle = h > 0.8 ? 'rgba(255,255,250,0.95)' : 'rgba(240,238,228,0.78)'; + ctx.fillRect(Math.round(x1 + (x2 - x1) * k + (hash(seed + i) - 0.5)), Math.round(y1 + (y2 - y1) * k + (hash(seed * 7 + i) - 0.5)), 2, 2); + } +} +// I gessi colorati: ogni casella ripassata tenue col suo colore, a puntini come il gesso sull'asfalto (idea di Andrea). +const TINTS = { 1: [244, 160, 180], 2: [250, 222, 120], 3: [150, 200, 240], 4: [160, 220, 150], 5: [200, 170, 230], 6: [250, 180, 120], 7: [140, 220, 210], 8: [240, 150, 150], top: [255, 240, 200] }; +function tint(ctx, [r, g, b], x, y, w, h, seed, inside = () => true) { + ctx.fillStyle = `rgba(${r},${g},${b},0.16)`; + for (let yy = y; yy < y + h; yy += 2) for (let xx = x; xx < x + w; xx += 2) { + if (!inside(xx, yy)) continue; + const k = hash(seed * 7777 + xx * 13 + yy * 7); + if (k < 0.18) continue; // l'asfalto che spunta fra il gesso + ctx.fillStyle = `rgba(${r},${g},${b},${k > 0.85 ? 0.42 : 0.24})`; ctx.fillRect(xx, yy, 2, 2); + } +} +// Il gesso non cambia mai: tinte e righe si disegnano una volta su una tela a parte (a puntini, a ogni fotogramma, +// rallentavano la pagina). Fuori dal browser (nei test) si disegnano direttamente. +let layer = null; +function chalkwork(ctx) { + let s = 1; + for (const [id, b] of Object.entries(BOXES)) tint(ctx, TINTS[id], b.x + 1, b.y + 1, W - 2, W - 2, Number(id)); + tint(ctx, TINTS.top, CIELO.x + 1, CY - CIELO.r, CIELO.r, CIELO.r * 2, 9, (x, y) => Math.hypot(x + 1 - CIELO.x, y + 1 - CY) < CIELO.r - 2); + for (const b of Object.values(BOXES)) { + chalkLine(ctx, b.x, b.y, b.x + W, b.y, s++); chalkLine(ctx, b.x, b.y + W, b.x + W, b.y + W, s++); + chalkLine(ctx, b.x, b.y, b.x, b.y + W, s++); chalkLine(ctx, b.x + W, b.y, b.x + W, b.y + W, s++); + } + // In cima la campana: il mezzo cerchio in fondo, dove ci si gira. + for (let i = 0; i < 40; i++) { + const a1 = -Math.PI / 2 + i / 40 * Math.PI, a2 = -Math.PI / 2 + (i + 1) / 40 * Math.PI; + chalkLine(ctx, CIELO.x + Math.cos(a1) * CIELO.r, CY + Math.sin(a1) * CIELO.r, CIELO.x + Math.cos(a2) * CIELO.r, CY + Math.sin(a2) * CIELO.r, 200 + i); + } + // La riga di partenza. + chalkLine(ctx, START - 14, CY - W, START - 14, CY + W, 300); +} +function grid(ctx, c) { + if (typeof document === 'undefined') chalkwork(ctx); + else { + if (!layer) { layer = document.createElement('canvas'); layer.width = 640; layer.height = 360; chalkwork(layer.getContext('2d')); } + ctx.drawImage(layer, 0, 0); + } + // Il numero in alto nella casella: il mezzo resta libero per il sassolino e i piedi. Quella che tocca ha il bordo giallo. + for (const [id, b] of Object.entries(BOXES)) { + const p = center(id), next = Number(id) === c.target[c.current] && c.step !== 'result'; + if (next) { ctx.strokeStyle = 'rgba(255,236,140,0.55)'; ctx.lineWidth = 2; ctx.strokeRect(b.x + 4, b.y + 4, W - 8, W - 8); } + text(ctx, id, p.x, p.y - 17, 14, next ? '#fff3a8' : 'rgba(245,243,232,0.88)', 'center'); + } + text(ctx, 'CAMPANA', CIELO.x + W * 0.53, CY - 26, 8, 'rgba(245,243,232,0.88)', 'center'); +} +// Il sassolino: grigio, col lume in alto; in volo, più su e con l'ombra sotto. +function stone(ctx, x, y, lift = 0) { + ctx.fillStyle = 'rgba(30,25,20,0.35)'; ctx.beginPath(); ctx.ellipse(x + 1, y + 2, 5, 3, 0, 0, Math.PI * 2); ctx.fill(); + const s = 1 + lift / 60, yy = y - lift; + ctx.fillStyle = '#4a4744'; ctx.beginPath(); ctx.ellipse(x, yy, 5 * s, 4 * s, 0.3, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#8d8984'; ctx.beginPath(); ctx.ellipse(x - 0.5, yy - 0.5, 4 * s, 3 * s, 0.3, 0, Math.PI * 2); ctx.fill(); + rect(ctx, x - 2 * s, yy - 2 * s, 2, 1, '#cfcac2'); +} +// Dove stanno i piedi: a gambe larghe nelle coppie e nel cielo, su un piede nelle caselle sole, alla partenza tutti e due. +function feet(beat) { + if (!beat || beat.out) return [{ x: FROM.x + 2, y: CY - 9 }, { x: FROM.x + 2, y: CY + 9 }]; + if (beat.boxes[0] === 'cielo') { const p = center('cielo'); return [{ x: p.x, y: p.y - 12 }, { x: p.x, y: p.y + 12 }]; } + return beat.boxes.map(id => center(id)); +} +// Una scarpa vista dall'alto: la punta verso dove si va. +function shoe(ctx, x, y, dir, scale, [sole, upper, lace]) { + ctx.save(); ctx.translate(Math.round(x), Math.round(y)); ctx.scale(dir * scale, scale); + ctx.fillStyle = 'rgba(30,25,20,0.3)'; ctx.fillRect(-9, -3, 21, 9); + ctx.fillStyle = sole; ctx.fillRect(-10, -5, 20, 10); ctx.fillRect(10, -4, 2, 8); + ctx.fillStyle = upper; ctx.fillRect(-9, -4, 18, 8); + ctx.fillStyle = lace; ctx.fillRect(-1, -2, 7, 1); ctx.fillRect(-1, 1, 7, 1); + ctx.fillStyle = '#2a2420'; ctx.fillRect(-8, -2, 4, 4); // la caviglia, il buco della scarpa + ctx.restore(); +} +const SHOES = { player: ['#e8e6e0', '#3d6fb8', '#ffffff'], cpu: ['#2b2b2b', '#b8322a', '#f0d8a8'] }; +function walker(ctx, c) { + const beat = c.beats?.[c.beat], prev = c.beat > 0 ? c.beats[c.beat - 1] : null, now = feet(c.beat >= 0 ? beat : null); + const k = c.hop ? Math.min(1, c.hop.t / HOP) : 1, from = feet(c.beat > 0 ? prev : null); + const mid = list => list.reduce((a, p) => ({ x: a.x + p.x / list.length, y: a.y + p.y / list.length }), { x: 0, y: 0 }); + const a = mid(from), b = mid(now), off = { x: (a.x - b.x) * (1 - k), y: (a.y - b.y) * (1 - k) }; + const scale = (c.current === 'cpu' ? 1.45 : 1.3) * (1 + 0.35 * Math.sin(Math.PI * k)); + const dir = beat?.back ? -1 : 1; + for (const p of now) shoe(ctx, p.x + off.x, p.y + off.y, dir, scale, SHOES[c.current]); +} + +export function drawCampana(ctx, c, def, bg) { + ctx.save(); ctx.imageSmoothingEnabled = false; + if (bg) ctx.drawImage(bg, 0, 0, 640, 360); else { ctx.fillStyle = '#9a948a'; ctx.fillRect(0, 0, 640, 360); } + grid(ctx, c); + if (c.stone) stone(ctx, c.stone.x, c.stone.y); + walker(ctx, c); + if (c.shot) { + const k = Math.min(1, c.shot.t / FLIGHT), { from, to } = c.shot; + stone(ctx, from.x + (to.x - from.x) * k, from.y + (to.y - from.y) * k, 40 * Math.sin(Math.PI * k)); + } + // La mira: un cerchietto di gesso dove si punta; alla bona, il puntino dove cade il sassolino con questa forza. + if (canAdjust(c)) { + ctx.strokeStyle = '#fff8dc'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.arc(c.aim.x, c.aim.y, 6, 0, Math.PI * 2); ctx.stroke(); + if (c.skill.guide) { const l = landing(c); rect(ctx, l.x - 2, l.y - 2, 4, 4, '#f5d02a'); rect(ctx, l.x - 1, l.y - 1, 2, 2, '#7a5a10'); } + // La forza: la barra in basso a sinistra. + const x = 16, y = 296, h = 50, f = Math.round(h * c.power); + rect(ctx, x - 3, y - 3, 18, h + 6, '#17160fd0'); + rect(ctx, x, y + h - f, 12, f, c.holding ? '#ffd23c' : '#d9ccba'); + text(ctx, 'FORZA', x + 6, y - 9, 6, '#efd49e', 'center'); + } + // L'equilibrio: la lancetta attraversa la barra; si salta quando è nel verde. + if (c.step === 'hop' && c.meter) { + const x = 220, y = 318, w = 200, zone = c.skill.zone * w / 2, u = needle(c); + rect(ctx, x - 4, y - 14, w + 8, 30, '#17160fd8'); + rect(ctx, x, y, w, 8, '#7a3a2a'); rect(ctx, x + w / 2 - zone, y, zone * 2, 8, '#5fae4a'); + rect(ctx, x + w / 2 + u * w / 2 - 1, y - 3, 3, 14, '#fff3a8'); + text(ctx, 'EQUILIBRIO', x + w / 2, y - 7, 6, '#efd49e', 'center'); + } + // In alto: a che casella è ciascuno, di chi è il turno, cosa è successo. + rect(ctx, 0, 0, 640, 22, '#17160fe8'); + text(ctx, 'LA CAMPANA', 8, 7, 8, '#efd49e'); + text(ctx, { bona: 'alla bona', seria: 'seria', male: 'a fassi male' }[c.level], 8, 16, 7, '#d9ccba'); + const at = n => n > LAST ? 'finita' : `alla ${n}`; + text(ctx, `Andrea ${at(c.target.player)}`, 220, 11, 9, c.current === 'player' ? '#fff0c8' : '#a8a08c', 'center'); + text(ctx, `Il Taglia ${at(c.target.cpu)}`, 360, 11, 9, c.current === 'cpu' ? '#9fd0ff' : '#7f95a8', 'center'); + text(ctx, c.current === 'player' ? 'tocca a te' : 'tocca al Taglia', 560, 11, 8, '#d9ccba', 'center'); + if (c.line) { rect(ctx, 150, 24, 340, 14, '#17160fd0'); text(ctx, c.line, 320, 31, 8, /riga|fuori|Troppo|equilibrio|piede|non nella/i.test(c.line) ? '#ffbf9f' : '#9fd0ff', 'center'); } + if (c.step === 'result') { + const won = c.result.winner === 'player'; + panel(ctx, 170, 140, 300, 76); + text(ctx, won ? 'BATTUTO IL TAGLIA!' : 'HA VINTO IL TAGLIA', 320, 160, 12, won ? '#b9e5b3' : '#efd49e', 'center'); + text(ctx, won ? 'Tutta la campana, dalla 1 alla 8.' : `Tu eri arrivato alla ${c.target.player}.`, 320, 182, 8, '#efd49e', 'center'); + text(ctx, 'Torna al campone per uscire.', 320, 200, 8, '#d9ccba', 'center'); + } + ctx.restore(); +} diff --git a/src/engine/campana.js b/src/engine/campana.js new file mode 100644 index 0000000..b3e5318 --- /dev/null +++ b/src/engine/campana.js @@ -0,0 +1,220 @@ +// La campana al campone delle poste (idea di Andrea, 30 ottobre): disegnata col gesso sul vialetto, a turni col Taglia. +// Si tira il sassolino nella casella che tocca (dalla 1 alla 8): se cade dentro, senza toccare le righe, si salta tutta +// la campana fino in cima e indietro, su un piede nelle caselle sole e a gambe larghe nelle coppie, senza mai mettere +// il piede dove sta il sassolino; tornando, lo si raccoglie dalla casella prima. Chi sbaglia (sassolino fuori o sulla +// riga, salto fuori tempo) passa la mano; chi ce la fa va avanti alla casella dopo e ritira. Vince chi finisce la 8. +// Il tiro: la mira dà la direzione, tenere premuto carica la forza (va e torna), lasciare tira. I salti: a ogni salto +// la lancetta dell'equilibrio attraversa la barra; si preme quando passa nel verde. Unità: i pixel del primo piano +// 640×360, visto dall'alto (sfondo di Codex: assets/closeups/campone-campana.png). + +export const W = 66, CY = 181, START = 80, CIELO = { x: START + 6 * W, r: W }, FROM = { x: 40, y: CY }, REACH = 520; +export const TAP = 0.14, CHARGE = 1.3, FLIGHT = 0.55, HOP = 0.22, REST = 1.6, DONE = 1.1, CPU_WAIT = 0.9, CPU_BEAT = 0.42; +// Le caselle: 1, 2, 3 da sole; 4 e 5 in coppia (la 4 a sinistra di chi salta, cioè in alto); la 6; 7 e 8 in coppia; +// in fondo la campana (il mezzo cerchio dove ci si gira; nel codice «cielo», come la chiamano in altri posti). +export const BOXES = { + 1: { x: START, y: CY - W / 2 }, 2: { x: START + W, y: CY - W / 2 }, 3: { x: START + 2 * W, y: CY - W / 2 }, + 4: { x: START + 3 * W, y: CY - W }, 5: { x: START + 3 * W, y: CY }, 6: { x: START + 4 * W, y: CY - W / 2 }, + 7: { x: START + 5 * W, y: CY - W }, 8: { x: START + 5 * W, y: CY }, +}; +export const GROUPS = [[1], [2], [3], [4, 5], [6], [7, 8], ['cielo']]; +export const LAST = 8; +// I livelli: quanto si sparpaglia il sassolino (px), se si vede dove cade, quanto è larga la riga che non si può +// toccare, quanto ci mette la lancetta dell'equilibrio e quanto è largo il verde; il Taglia: quante volte il sassolino +// va dove vuole e quanto spesso, a ogni salto, mette giù il piede. +export const LEVELS = { + bona: { scatter: 4, guide: true, line: 0, sweep: 1.25, zone: 0.32, cpu: { throw: 0.5, slip: 0.045 } }, + seria: { scatter: 7, guide: false, line: 3, sweep: 1, zone: 0.2, cpu: { throw: 0.7, slip: 0.025 } }, + male: { scatter: 9, guide: false, line: 4, sweep: 0.8, zone: 0.15, cpu: { throw: 0.8, slip: 0.018 } }, +}; +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 center(id) { + if (id === 'cielo') return { x: CIELO.x + W * 0.42, y: CY }; + const b = BOXES[id]; return { x: b.x + W / 2, y: b.y + W / 2 }; +} +// In che casella cade un punto, e se tocca la riga (entro `tol` pixel da un bordo). +export function boxAt([x, y], tol = 0) { + for (const [id, b] of Object.entries(BOXES)) { + if (x < b.x || x > b.x + W || y < b.y || y > b.y + W) continue; + return { id: Number(id), line: Math.min(x - b.x, b.x + W - x, y - b.y, b.y + W - y) < tol }; + } + const d = Math.hypot(x - CIELO.x, y - CY); + if (x >= CIELO.x && d <= CIELO.r) return { id: 'cielo', line: Math.min(x - CIELO.x, CIELO.r - d) < tol }; + return null; +} + +// I salti per la casella `n`: all'andata tutte le caselle, saltando quella col sassolino (nella coppia si mette un +// piede solo nell'altra); il cielo; al ritorno, dalla casella prima si raccoglie il sassolino; poi fuori. +export function beats(n) { + const step = (g, back) => g.includes(n) ? (g.length > 1 ? [{ boxes: g.filter(k => k !== n), back }] : []) : [{ boxes: g, back }]; + const out = GROUPS.flatMap(g => step(g, false)); + for (let i = GROUPS.length - 2; i >= 0; i--) { + if (GROUPS[i].includes(n)) out.push({ ...out[out.length - 1], back: true, pick: true }); + out.push(...step(GROUPS[i], true)); + } + out.push({ boxes: [], back: true, out: true }); + return out; +} + +export function newCampana(seed = Date.now() >>> 0, def = {}) { + const level = LEVELS[def.level] ? def.level : 'seria'; + return { step: 'aim', t: 0, seed, level, skill: LEVELS[level], current: 'player', target: { player: 1, cpu: 1 }, + aim: center(1), power: 0.2, holding: false, holdTime: 0, charging: false, stone: null, shot: null, + beats: null, beat: -1, meter: null, hop: null, line: null, next: null, events: [], visual: { time: 0 } }; +} +export const canThrow = c => c.current === 'player' && (c.step === 'aim' || c.step === 'hop'); +export const canAdjust = c => c.current === 'player' && c.step === 'aim'; +export const aimFree = c => !canAdjust(c); + +// Dove cade il sassolino tirato con questa forza, senza lo sparpaglio. +export function landing(c, power = c.power) { + const dx = c.aim.x - FROM.x, dy = c.aim.y - FROM.y, d = Math.hypot(dx, dy) || 1; + return { x: FROM.x + dx / d * power * REACH, y: FROM.y + dy / d * power * REACH }; +} +function toss(c, to) { + c.shot = { from: { ...FROM }, to, t: 0 }; c.stone = null; + c.step = 'flying'; c.line = null; + c.events.push({ kind: 'campana-lancio', power: 0.6 }); +} +function throwStone(c) { + const p = landing(c), s = c.skill.scatter, spread = () => (random(c) + random(c) + random(c) - 1.5) * s * 1.4; + toss(c, { x: p.x + spread(), y: p.y + spread() }); + return true; +} +const who = c => c.current === 'cpu' ? 'Il Taglia: ' : ''; +const cap = line => line[0].toUpperCase() + line.slice(1); +// Il sassolino è arrivato: nella casella giusta si salta, se no passa la mano. +function land(c) { + const to = c.shot.to, n = c.target[c.current], hit = boxAt([to.x, to.y], c.skill.line); + c.shot = null; c.stone = to; + c.events.push({ kind: 'campana-sasso', power: 0.8 }); + if (hit && hit.id === n && !hit.line) { + c.line = cap(`${who(c)}il sassolino è nella ${n}. Salta!`); + c.beats = beats(n); c.beat = -1; c.hop = null; c.t = 0; + c.step = c.current === 'cpu' ? 'cpuHop' : 'hop'; + if (c.step === 'hop') c.meter = { t: -0.5, dir: 1 }; + return; + } + fail(c, `${who(c)}${hit?.line ? 'sulla riga!' : hit ? `nella ${hit.id === 'cielo' ? 'campana in cima' : hit.id}, non nella ${n}.` : 'fuori dalla campana.'}`); +} +function fail(c, line) { + c.line = cap(line); c.step = 'rest'; c.t = 0; c.meter = null; + c.next = c.current === 'player' ? 'cpu' : 'player'; + c.events.push({ kind: 'campana-riga', power: 0.7 }); +} +// Un salto riuscito: la casella dopo, o il sassolino raccolto, o fuori dalla campana (e la casella è fatta). +function advance(c) { + c.beat++; + const b = c.beats[c.beat]; + c.hop = { t: 0 }; + if (b.pick) { c.stone = null; c.line = cap(`${who(c)}preso il sassolino!`); c.events.push({ kind: 'campana-sasso', power: 0.5 }); } + else c.events.push({ kind: 'campana-salto', power: 0.6 }); + if (b.out) { + const n = c.target[c.current]++; + if (n >= LAST) { + c.step = 'result'; c.meter = null; + c.result = { winner: c.current, player: c.target.player - 1, cpu: c.target.cpu - 1 }; + return; + } + c.line = cap(`${who(c)}fatta la ${n}! Ora la ${n + 1}.`); c.step = 'done'; c.t = 0; c.meter = null; + return; + } + if (c.meter) c.meter = { t: 0, dir: -c.meter.dir }; +} +// La lancetta dell'equilibrio, da -1 a 1 (o al contrario, un salto sì e uno no). +export function needle(c) { + if (!c.meter) return 0; + return c.meter.dir * (-1 + 2 * clamp(c.meter.t, 0, c.skill.sweep) / c.skill.sweep); +} +function jump(c) { + if (c.hop && c.hop.t < HOP) return true; // ancora in aria: il tocco non conta + const u = needle(c); + if (Math.abs(u) <= c.skill.zone) { advance(c); return true; } + const early = u * c.meter.dir < 0; + fail(c, early ? 'Troppo presto: giù l\'altro piede!' : 'Troppo tardi: pestata la riga!'); + return true; +} +// Tocca all'altro, o allo stesso per la casella dopo: dalla partenza, senza sassolino. +function turn(c, current) { + Object.assign(c, { current, stone: null, beats: null, beat: -1, hop: null, meter: null, t: 0, next: null, + step: current === 'player' ? 'aim' : 'cpuAim' }); + if (current === 'player') c.aim = center(c.target.player); +} + +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.hop) c.hop.t += dt; + if (c.holding) { + c.holdTime += dt; c.charging = c.holdTime >= TAP; + const k = (c.holdTime / CHARGE) % 2; c.power = k <= 1 ? k : 2 - k; + } + if (c.step === 'flying') { c.shot.t += dt; if (c.shot.t >= FLIGHT) land(c); } + else if (c.step === 'hop') { + if (!c.hop || c.hop.t >= HOP) c.meter.t += dt; + if (c.meter.t > c.skill.sweep) fail(c, 'Hai perso l\'equilibrio!'); + } else if (c.step === 'cpuAim' && c.t >= CPU_WAIT) { + // Il Taglia: o nella casella, ben dentro, o mezza casella più in là (sulla riga o nell'altra). + const n = c.target.cpu, p = center(n), ok = random(c) < c.skill.cpu.throw, j = () => (random(c) - 0.5) * 20; + toss(c, ok ? { x: p.x + j(), y: p.y + j() } : { x: p.x + (random(c) < 0.5 ? -1 : 1) * W * (0.5 + random(c) * 0.3), y: p.y + j() }); + } else if (c.step === 'cpuHop' && c.t >= CPU_BEAT) { + c.t = 0; + if (random(c) < c.skill.cpu.slip) fail(c, random(c) < 0.5 ? 'Il Taglia ha pestato la riga!' : 'Il Taglia ha messo giù l\'altro piede!'); + else advance(c); + } else if (c.step === 'rest' && c.t >= REST) turn(c, c.next); + else if (c.step === 'done' && c.t >= DONE) turn(c, c.current); +} + +// --- Comandi: la mira segue il mouse o il dito; tenere premuto carica la forza, lasciare tira; ai salti si preme. +export function aimScreen(c, [x, y]) { + if (!canAdjust(c) || c.holding || !Number.isFinite(x) || !Number.isFinite(y)) return false; + c.aim = { x: clamp(x, FROM.x + 30, 630), y: clamp(y, 80, 285) }; return true; +} +export function setPower(c, v) { if (!canAdjust(c) || c.holding) return false; c.power = clamp(v, 0, 1); return true; } +export function nudge(c, dir) { if (!canAdjust(c) || c.holding) return false; c.aim.y = clamp(c.aim.y + dir * 3, 80, 285); return true; } +// Frecce: su e giù la mira, destra e sinistra la forza (più in là, più in qua). +export function key(c, key, down) { + const move = { ArrowUp: [0, -3], ArrowDown: [0, 3], ArrowLeft: [-0.01, 0], ArrowRight: [0.01, 0] }[key]; + if (!move) return false; + if (down && canAdjust(c) && !c.holding) { c.aim.y = clamp(c.aim.y + move[1], 80, 285); c.power = clamp(c.power + move[0], 0, 1); } + return true; +} +export function press(c, pointerId) { + if (c.current !== 'player') return false; + if (c.step === 'hop') return jump(c); + if (!canAdjust(c) || c.holding) return false; + c.pointerId = pointerId; c.holding = true; c.charging = false; c.holdTime = 0; c.power = 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.holdTime = 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.holdTime = 0; + return throwStone(c); +} +// Il pulsante: tira con la forza del cursore, o salta. +export function shoot(c) { + if (c.current !== 'player' || c.holding) return false; + if (c.step === 'hop') return jump(c); + return canAdjust(c) && throwStone(c); +} + +const box = n => n > LAST ? 'finito' : `alla ${n}`; +export function button(c) { + if (c.step === 'result') return 'Finita'; + if (c.current === 'cpu') return 'Tocca al Taglia…'; + return { aim: 'Tira', flying: 'In volo…', hop: 'Salta' }[c.step] ?? 'Aspetta…'; +} +export function status(c) { + const score = `Andrea ${box(c.target.player)}, il Taglia ${box(c.target.cpu)}.`; + if (c.step === 'result') return `${c.result.winner === 'player' ? 'Campana finita: battuto il Taglia! ' : 'Il Taglia ha finito la campana. '}${score}`; + const hint = c.current === 'cpu' ? 'Tocca al Taglia.' : c.step === 'hop' ? 'Salta quando la lancetta è nel verde.' + : `Tira il sassolino nella ${c.target.player}: mira, tieni premuto per la forza e lascia.`; + return `${score} ${c.line ?? hint}`; +} diff --git a/src/engine/game.js b/src/engine/game.js index 8ae93cf..e5d7d0a 100644 --- a/src/engine/game.js +++ b/src/engine/game.js @@ -14,6 +14,7 @@ import * as micro from './micro.js'; import * as cerbottana from './cerbottana.js'; import { newCorsa, updateCorsa } from './corsa.js'; import * as condor from './condor.js'; +import * as campana from './campana.js'; import * as poker from './poker.js'; // I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta. @@ -29,6 +30,7 @@ export const MINIGAMES = { poker: { create: (seed, level) => poker.newPoker(seed, { level }), update: poker.update, play: poker.play }, 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 }, 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 70e0687..164566c 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -13,6 +13,7 @@ import { drawMicro } from './micro-render.js'; import { drawCerbottana } from './cerbottana-render.js'; import { drawCorsa } from './corsa.js'; import { drawCondor } from './condor-render.js'; +import { drawCampana } from './campana-render.js'; import { drawPoker } from './poker-render.js'; import { drawViaggio } from './viaggio.js'; import { drawBriscola } from './briscola-render.js'; @@ -718,6 +719,11 @@ function drawCloseup(ctx, game, time) { drawCondor(ctx, c, def, backgrounds.get(def.picture)); return; } + if (def.type === 'campana') { + if (!backgrounds.has(def.picture)) loadBackground(def.picture); + drawCampana(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 4fc7aae..6621add 100644 --- a/src/engine/soundscape.js +++ b/src/engine/soundscape.js @@ -148,6 +148,11 @@ export class Soundscape { // La macchina del tempo: il tuono del fulmine e lo sfrigolio delle sgommate di fuoco. else if (kind === 'corsa-tuono') { this.noiseSound(0.9, 400, 0.16, { pan }); this.tone(90, 40, 0.8, 0.1, 0, pan); } else if (kind === 'corsa-fuoco') this.noiseSound(1.6, 2400, 0.05, { pan }); + // La campana: il sassolino che vola e batte sull'asfalto, il salto, il piede giù. + else if (kind === 'campana-lancio') this.noiseSound(0.1, 1500, 0.025, { pan }); + 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); } 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 0d81e88..f692513 100644 --- a/src/main.js +++ b/src/main.js @@ -14,6 +14,7 @@ import * as rinquarto from './engine/rinquarto.js'; 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 poker from './engine/poker.js'; import { cardAt as pokerAt } from './engine/poker-render.js'; import * as basket from './engine/basket.js'; @@ -587,7 +588,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', 'cerbottana', 'condor'].includes(story.closeups[game.closeup.id].type); + const aiming = game.closeup && ['biliardo', 'rinquarto', 'bocce', 'basket', 'pesca', 'micro', 'cerbottana', 'condor', 'campana'].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)); }); @@ -611,6 +612,7 @@ const MINI = { poker: { ...poker, back: 'Torna al Barrettino', label: 'Il pokerino: tocca le carte da cambiare, poi Cambia; nei giri di puntate Lascio, Vedo o Parola, Apro o Rilancio. Dopo la mostra un tocco va avanti.' }, 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.' }, 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). @@ -1040,7 +1042,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' }[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' }[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; @@ -1055,7 +1057,7 @@ function updateUi() { // 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); + for (const id of ['bocce-left', 'bocce-right']) setProp($(id), 'hidden', easy || def.type === 'campana'); 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'); @@ -1076,6 +1078,7 @@ function updateUi() { setProp($('micro-help'), 'hidden', def.type !== 'micro'); setProp($('cerbottana-help'), 'hidden', def.type !== 'cerbottana'); setProp($('condor-help'), 'hidden', def.type !== 'condor'); + setProp($('campana-help'), 'hidden', def.type !== 'campana'); setProp($('basket-help'), 'hidden', def.type !== 'basket'); setProp($('pesca-help'), 'hidden', def.type !== 'pesca'); setProp($('gara-help'), 'hidden', def.type !== 'gara'); @@ -1096,7 +1099,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', 'cerbottana', 'condor'].includes(story.closeups[c.id].type)); + $('stage').classList.toggle('biliardo', !!c && ['biliardo', 'rinquarto', 'gara', 'micro', 'cerbottana', 'condor', 'campana'].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 233b607..ba1a853 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -301,6 +301,17 @@ export const story = { draw: [{ say: 'Pari. Si rifà?' }, { say: 'Si rifà.', who: 'taglia', pose: 'explain' }], quit: [{ say: 'La Condor la rendo al Taglia. Per ora.' }], }, + // La campana col gesso sul vialetto del campone delle poste, a turni col Taglia (Andrea, 30 ottobre). + // [DA ANDREA: le battute sono provvisorie] + campana: { + type: 'campana', + picture: 'assets/closeups/campone-campana.png', + names: { player: 'Andrea', cpu: 'il Taglia' }, + win: [{ say: 'Dalla 1 fino in cima, e ritorno. A ventisett\'anni, davanti alle poste.' }, { say: 'Te lo dico io: se ti vede qualcuno, domani lo sa tutto il paese.', who: 'taglia', pose: 'laugh' }, { set: 'campana_vinta' }], + lose: [{ say: 'Il Taglia, su un piede solo, è più alto di me su due.' }, { say: 'È questione di gambe lunghe.', who: 'taglia', pose: 'explain' }], + draw: [], + quit: [{ say: 'Il gesso lo lascio lì. Ci giocherà qualche bambino, che è più in allenamento di me.' }], + }, // 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: { @@ -3975,6 +3986,10 @@ export const story = { { say: 'Il cartone sull\'albero, le lattine sulla panchina, le bottigliette sulla porta e il palloncino all\'altalena.', who: 'taglia', pose: 'explain' }, { say: 'E se la vuoi seria, ogni tanto ti tiro una lattina in aria.', who: 'taglia', pose: 'laugh' }, ] }, + { text: 'E una partita a campana, Taglia?', next: 'campana', then: [ + { say: 'A campana? Alla nostra età?', who: 'taglia', pose: 'laugh' }, + { say: 'Va bene: il gesso ce l\'ho in tasca, il sassolino lo scelgo io. Sul vialetto, dalla 1 alla 8, e chi pesta la riga passa la mano.', who: 'taglia', pose: 'explain' }, + ] }, { text: 'Ma quanto sei alto, Taglia?', then: [{ say: 'Abbastanza da vedere i pallini arrivare prima di te.', who: 'taglia', pose: 'laugh' }] }, { text: 'Ci si vede, Taglia.', next: 'end' }, ], @@ -3987,6 +4002,14 @@ export const story = { { text: 'No, dopo.', next: 'start' }, ], }, + campana: { + options: [ + { text: 'Alla bona.', next: 'end', then: [{ say: 'Alla bona: le righe un contano. Comincia te.', who: 'taglia', pose: 'talk' }, { closeup: 'campana', level: 'bona' }] }, + { text: 'Seria.', next: 'end', then: [{ say: 'Seria: chi tocca la riga è fuori. Comincia te.', who: 'taglia', pose: 'explain' }, { closeup: 'campana', level: 'seria' }] }, + { text: 'A fassi male.', next: 'end', then: [{ say: 'A fassi male? Io la campana la facevo a occhi chiusi.', who: 'taglia', pose: 'laugh' }, { closeup: 'campana', level: 'male' }] }, + { text: 'No, dopo.', next: 'start' }, + ], + }, }, }, // Il Chiaru al parcheggio di Cardetole, accanto alla pista col gesso (Andrea, 29 ottobre). [DA ANDREA: battute provvisorie] diff --git a/style.css b/style.css index 16375ae..38e5d0c 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 { 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 { 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 { 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 { 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; } @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 { font-size: 9px; } + #bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-help, #condor-help, #campana-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/campana.browser.mjs b/tests/campana.browser.mjs new file mode 100644 index 0000000..b9a9e1a --- /dev/null +++ b/tests/campana.browser.mjs @@ -0,0 +1,75 @@ +// La campana nel browser vero: dal Taglia al campone delle poste, il tiro col mouse e col dito, i salti. +// npm run browser:campana — schermate in /tmp/regolo-campana. +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-campana'; +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('giardini_poste'); settle(game); + game.act('talk', 'taglia'); settle(game); + game.choose(game.options().findIndex(o => o.text.startsWith('E una partita a campana'))); 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/campone-campana.png')); + assert.equal(await page.locator('#bocce-throw').textContent(), 'Tira'); + assert.ok(await page.locator('#campana-help').isVisible()); + assert.ok(!(await page.locator('#bocce-left').isVisible()), 'niente pulsanti Mira'); + const box = await page.locator('#scene').boundingBox(), P = (x, y) => [box.x + x / 640 * box.width, box.y + y / 360 * box.height]; + // Mira alla 1, poi la forza giusta: col mouse tenendo premuto, col dito il cursore e Tira. + const one = await page.evaluate(async () => { const m = window.campanaModule = await import('/src/engine/campana.js'); const p = m.center(1); return [p.x, p.y, Math.hypot(p.x - m.FROM.x, p.y - m.FROM.y) / m.REACH]; }); + if (mobile) { + await page.touchscreen.tap(...P(one[0], one[1])); + assert.equal(await page.evaluate(() => game.closeup.step), 'aim', 'un tocco mira soltanto'); + await page.evaluate(p => { game.closeup.power = p; game.closeup.skill.scatter = 0; }, one[2]); + await page.locator('#bocce-throw').tap(); + } else { + await page.mouse.move(...P(one[0], one[1])); + await page.evaluate(() => { game.closeup.skill.scatter = 0; }); + await page.mouse.down(); + await page.waitForFunction(p => Math.abs(game.closeup.power - p) < 0.02, one[2], { polling: 'raf' }); + await page.screenshot({ path: `${out}/${name}-forza.png` }); + await page.mouse.up(); + // Col mouse il momento giusto dipende da quanto è lesta la pagina: se è andato fuori, si ritira col pulsante. + await page.waitForFunction(() => !['aim', 'flying'].includes(game.closeup.step)); + if (await page.evaluate(() => game.closeup.step) !== 'hop') { + await page.evaluate(p => { Object.assign(game.closeup, { step: 'aim', current: 'player', next: null, line: null, power: p }); }, one[2]); + await page.locator('#bocce-throw').click(); + } + } + await page.waitForFunction(() => game.closeup.step === 'hop', null, { timeout: 5000 }).catch(async e => { console.log(await page.evaluate(() => JSON.stringify({ ...game.closeup, events: 0 }))); await page.screenshot({ path: `${out}/${name}-errore.png` }); throw e; }); + assert.equal(await page.locator('#bocce-throw').textContent(), 'Salta'); + // Quanti salti riescono: la lancetta dopo una schermata (lenta, con swiftshader) può già essere scappata. + let hops = 0; + // I salti: si preme quando la lancetta passa nel verde (col dito sul pulsante Salta, col mouse sulla scena). + for (let i = 0; i < 4; i++) { + await page.waitForFunction(() => { const m = window.campanaModule, c = game.closeup; return c.step !== 'hop' || ((!c.hop || c.hop.t >= m.HOP) && c.meter.t > 0 && Math.abs(m.needle(c)) < 0.15); }, null, { polling: 'raf', timeout: 5000 }); + if (await page.evaluate(() => game.closeup.step) !== 'hop') break; + if (mobile) await page.locator('#bocce-throw').tap(); else await page.mouse.click(...P(320, 200)); + hops = Math.max(hops, await page.evaluate(() => game.closeup.beat + 1)); + if (i === 1) await page.screenshot({ path: `${out}/${name}-salti.png` }); + } + assert.ok(hops >= 2, `salti riusciti: ${hops}`); + // Poi tocca al Taglia: lo si fa sbagliare per vederlo saltare. + await page.evaluate(() => { const c = game.closeup; c.step = 'rest'; c.t = 0; c.next = 'cpu'; c.line = 'Prova: tocca al Taglia.'; }); + await page.waitForFunction(() => game.closeup.step === 'cpuHop' && game.closeup.beat >= 3 || game.closeup.current === 'player' && game.closeup.step === 'aim', null, { timeout: 20000 }); + await page.screenshot({ path: `${out}/${name}-taglia.png` }); + await page.close(); + } + assert.deepEqual(errors, []); + console.log(`ok: schermate in ${out}`); +} finally { await browser?.close(); server.kill(); } diff --git a/tests/campana.test.mjs b/tests/campana.test.mjs new file mode 100644 index 0000000..1fdc875 --- /dev/null +++ b/tests/campana.test.mjs @@ -0,0 +1,125 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as m from '../src/engine/campana.js'; +import { drawCampana } from '../src/engine/campana-render.js'; +import { Game, MINIGAMES } from '../src/engine/game.js'; +import { story } from '../src/story/story.js'; +import { settle } from './helpers.mjs'; + +// Tira senza sparpaglio, con la forza giusta per arrivare al punto, e aspetta che il sassolino cada. +function toss(c, [x, y]) { + c.skill = { ...c.skill, scatter: 0 }; c.aim = { x, y }; + c.power = Math.hypot(x - m.FROM.x, y - m.FROM.y) / m.REACH; + assert.ok(m.shoot(c)); + for (let i = 0; i < 200 && c.step === 'flying'; i++) m.update(c, 1 / 60); +} +// Salta ogni volta che la lancetta passa nel mezzo. +function hopAll(c) { + for (let i = 0; i < 20000 && c.step === 'hop'; i++) { + m.update(c, 1 / 120); + if (c.step === 'hop' && (!c.hop || c.hop.t >= m.HOP) && Math.abs(m.needle(c)) < 0.03 && c.meter.t > 0) m.press(c, 1); + } +} +const at = id => { const p = m.center(id); return [p.x, p.y]; }; + +test('le caselle: 1-2-3, la coppia 4-5, la 6, la coppia 7-8 e il cielo; le righe', () => { + for (let n = 1; n <= 8; n++) assert.equal(m.boxAt(at(n), 3).id, n); + assert.equal(m.boxAt(at('cielo')).id, 'cielo'); + assert.ok(m.center(4).y < m.center(5).y, 'la 4 a sinistra di chi salta'); + const b = m.BOXES[2]; + assert.ok(m.boxAt([b.x + 1, m.CY], 3).line, 'sul bordo si tocca la riga'); + assert.ok(!m.boxAt([b.x + 1, m.CY], 0).line, 'alla bona le righe non contano'); + assert.equal(m.boxAt([20, 20]), null); +}); + +test('i salti: si scavalca la casella col sassolino, nella coppia un piede solo; tornando lo si raccoglie', () => { + const ids = list => list.map(b => b.pick ? 'prendi' : b.out ? 'fuori' : b.boxes.join('+')); + assert.deepEqual(ids(m.beats(1)), ['2', '3', '4+5', '6', '7+8', 'cielo', '7+8', '6', '4+5', '3', '2', 'prendi', 'fuori']); + assert.deepEqual(ids(m.beats(5)), ['1', '2', '3', '4', '6', '7+8', 'cielo', '7+8', '6', 'prendi', '4', '3', '2', '1', 'fuori']); + assert.deepEqual(ids(m.beats(8)), ['1', '2', '3', '4+5', '6', '7', 'cielo', 'prendi', '7', '6', '4+5', '3', '2', '1', 'fuori']); + assert.ok(m.beats(3).every(b => !b.boxes.includes(3)), 'mai il piede dove sta il sassolino'); +}); + +test('il sassolino nella casella giusta fa saltare; tutta la campana fatta, si passa alla dopo', () => { + const c = m.newCampana(1, { level: 'seria' }); + toss(c, at(1)); + assert.equal(c.step, 'hop'); assert.match(c.line, /nella 1/); + hopAll(c); + assert.equal(c.step, 'done'); assert.equal(c.target.player, 2); assert.equal(c.stone, null, 'raccolto'); + for (let i = 0; i < 120 && c.step !== 'aim'; i++) m.update(c, 1 / 60); + assert.equal(c.current, 'player', 'chi ce la fa ritira'); +}); + +test('fuori, sulla riga o nella casella sbagliata passa la mano al Taglia', () => { + for (const [point, why] of [[at(2), /Nella 2, non nella 1/], [[m.START + 1, m.CY], /riga/], [[60, 40], /fuori/i]]) { + const c = m.newCampana(2, { level: 'seria' }); + toss(c, point); + assert.equal(c.step, 'rest'); assert.match(c.line, why); + for (let i = 0; i < 200 && c.step === 'rest'; i++) m.update(c, 1 / 60); + assert.equal(c.current, 'cpu'); assert.equal(c.target.player, 1); + } +}); + +test('l\'equilibrio: troppo presto o troppo tardi si sbaglia', () => { + const early = m.newCampana(3, { level: 'male' }); + toss(early, at(1)); + while (early.meter.t < 0.05) m.update(early, 1 / 120); + m.press(early, 1); assert.equal(early.step, 'rest'); assert.match(early.line, /presto/); + const late = m.newCampana(3, { level: 'male' }); + toss(late, at(1)); + for (let i = 0; i < 1000 && late.step === 'hop'; i++) m.update(late, 1 / 60); + assert.match(late.line, /equilibrio/); +}); + +test('il tiro col dito: un tocco secco mira soltanto; tenendo premuto la forza va e torna', () => { + const c = m.newCampana(4, { level: 'bona' }); + assert.ok(m.aimScreen(c, [300, 150])); + m.press(c, 1); m.update(c, 0.05); m.release(c, 1); assert.equal(c.step, 'aim'); + m.press(c, 1); + for (let i = 0; i < 60 * m.CHARGE; i++) m.update(c, 1 / 60); + assert.ok(c.power > 0.95); + for (let i = 0; i < 30 * m.CHARGE; i++) m.update(c, 1 / 60); + assert.ok(c.power < 0.6, 'e poi torna giù'); + assert.ok(m.release(c, 1)); assert.equal(c.step, 'flying'); + assert.ok(m.key(c, 'ArrowUp', true)); +}); + +test('il Taglia gioca da solo; chi finisce la 8 vince', () => { + const c = m.newCampana(5, { level: 'seria' }); + toss(c, [60, 40]); + let turns = 0; + for (let i = 0; i < 60 * 600 && c.step !== 'result'; i++) { + m.update(c, 1 / 60); + if (c.current === 'player' && c.step === 'aim') { turns++; toss(c, [60, 40]); } + } + assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'cpu'); assert.equal(c.target.cpu, 9); + assert.ok(turns > 0, 'il Taglia ogni tanto sbaglia e ripassa la mano'); + const w = m.newCampana(6, { level: 'bona' }); + w.target.player = 8; + toss(w, at(8)); hopAll(w); + assert.equal(w.step, 'result'); assert.equal(w.result.winner, 'player'); +}); + +test('il disegno della campana 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.newCampana(7, { level: 'bona' }), bg = {}; + const draw = () => { const before = structuredClone(c); drawCampana(ctx, c, story.closeups.campana, 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 CAMPANA')); + toss(c, at(1)); for (let i = 0; i < 40; i++) m.update(c, 1 / 60); draw(); + assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'EQUILIBRIO')); + c.step = 'result'; c.result = { winner: 'player' }; calls.length = 0; draw(); + assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'BATTUTO IL TAGLIA!')); + assert.ok(MINIGAMES.campana); +}); + +test('dal Taglia, al campone delle poste: la campana, il livello e la vittoria', () => { + const g = new Game(story); g.go('giardini_poste'); settle(g); + g.act('talk', 'taglia'); settle(g); + g.choose(g.options().findIndex(o => o.text.startsWith('E una partita a campana'))); settle(g); + g.choose(g.options().findIndex(o => o.text === 'Seria.')); settle(g); + assert.equal(g.closeup?.id, 'campana'); assert.equal(g.closeup.level, 'seria'); + g.closeup.step = 'result'; g.closeup.result = { winner: 'player' }; + g.closeupClose(); settle(g); + assert.ok(g.state.flags.campana_vinta); +});