diff --git a/assets/closeups/campone-condor.png b/assets/closeups/campone-condor.png new file mode 100644 index 0000000..fa1984e Binary files /dev/null and b/assets/closeups/campone-condor.png differ diff --git a/assets/voices/andrea-3ff6547461.mp3 b/assets/voices/andrea-3ff6547461.mp3 new file mode 100644 index 0000000..9d6a169 Binary files /dev/null and b/assets/voices/andrea-3ff6547461.mp3 differ diff --git a/assets/voices/andrea-6e4ba77b44.mp3 b/assets/voices/andrea-6e4ba77b44.mp3 new file mode 100644 index 0000000..bb7fb40 Binary files /dev/null and b/assets/voices/andrea-6e4ba77b44.mp3 differ diff --git a/assets/voices/andrea-6eabf3443c.mp3 b/assets/voices/andrea-6eabf3443c.mp3 new file mode 100644 index 0000000..7859bca Binary files /dev/null and b/assets/voices/andrea-6eabf3443c.mp3 differ diff --git a/assets/voices/andrea-aba12c5241.mp3 b/assets/voices/andrea-aba12c5241.mp3 new file mode 100644 index 0000000..d7aaa1c Binary files /dev/null and b/assets/voices/andrea-aba12c5241.mp3 differ diff --git a/assets/voices/index.json b/assets/voices/index.json index 908f17c..64df6d0 100644 --- a/assets/voices/index.json +++ b/assets/voices/index.json @@ -3231,6 +3231,22 @@ "file": "andrea-c7dcb11e66.mp3", "sec": 5.52 }, + "andrea|Battuto il Taglia! Con la sua Condor, per giunta.": { + "file": "andrea-3ff6547461.mp3", + "sec": 10.2 + }, + "andrea|Il Taglia ha la mano ferma. Io ho il fiatone.": { + "file": "andrea-6eabf3443c.mp3", + "sec": 3.83 + }, + "andrea|Pari. Si rifà, tanto i pallini gialli un mancano mai.": { + "file": "andrea-6e4ba77b44.mp3", + "sec": 5.67 + }, + "andrea|La Condor la rendo al Taglia. Per ora.": { + "file": "andrea-aba12c5241.mp3", + "sec": 3.1 + }, "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 d677927..5307d96 100644 --- a/docs/IMMAGINI.md +++ b/docs/IMMAGINI.md @@ -35,6 +35,7 @@ ne è un'opera derivata. Le foto scattate da Andrea non hanno problemi; quelle p | `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ì | +| `closeups/campone-condor.png` | Codex, dallo sfondo del 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 d712595..e7a5b96 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 Condor al campone delle poste (29 ottobre)**: la gara col Taglia (Andrea Tagliaferri, consenso dato) con la + pistola ad aria compressa coi pallini gialli (`condor.js`, `condor-render.js`, primo piano `condor`, sfondo di Codex). + Come la cerbottana: il mirino ondeggia col respiro; tenere premuto trattiene il fiato (il mirino si ferma), lasciare + spara, troppo e il colpo parte da solo. Il pallino cala con la distanza (alla porta in fondo si mira sopra). Il + cartone sull'albero (10/5/2), le lattine sulla panchina (3), le bottigliette sulla traversa (5), il palloncino + all'altalena (6), la lattina tirata in aria dal Taglia a seria e a fassi male (8). Si deve battere il punteggio del + Taglia (22, 34, 44 secondo il livello, ±3). [DA ANDREA] la foto del Taglia per lo sprite, e le battute. - **La macchina del tempo e il pokerino del 1997 (29 ottobre)**: dall'Arcu sotto la tenda del Trappola, «Bei tempi quando qui si faceva i pokerini…»: compare la macchina del tempo in Via Liguri Magelli. Dentro, il cruscotto (Codex) compone 10/07/1997 18:30, poi tremori, lampi e il bianco (primo piano `viaggio`, `src/engine/viaggio.js`, tempi in diff --git a/index.html b/index.html index cdc8a47..72facdb 100644 --- a/index.html +++ b/index.html @@ -272,6 +272,17 @@ 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 Condor, la pistola ad aria compressa coi pallini gialli, al campone delle poste: dodici pallini, e il Taglia ha + già tirato. Il suo punteggio è in alto: per vincere bisogna fare di più. Il cartone sull'albero vale 10, 5 o 2 punti + secondo il cerchio, ogni lattina sulla panchina 3, ogni bottiglietta sulla traversa della porta 5, il palloncino + legato all'altalena 6, la lattina che il Taglia tira in aria (Seria e A fassi male) 8.
+Muovi il mouse o tocca il campo per mirare: il mirino ondeggia col respiro (di più salendo di livello). Tieni + premuto per trattenere il fiato: il mirino si ferma (diventa verde); lascia per sparare. Se lo trattieni troppo il + fiato finisce, il mirino trema e il colpo parte da solo. Il pallino cala con la distanza: alla porta in fondo si + 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 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 561acfe..c57c1dd 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "browser:micro": "node tests/micro.browser.mjs", "browser:gara": "node tests/gara.browser.mjs", "browser:cerbottana": "node tests/cerbottana.browser.mjs", + "browser:condor": "node tests/condor.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/condor-render.js b/src/engine/condor-render.js new file mode 100644 index 0000000..fd93bc4 --- /dev/null +++ b/src/engine/condor-render.js @@ -0,0 +1,118 @@ +import { CARTONE, BENCH, CAN, BAR, BOTTLE, BALLOON, VOLO, PELLETS, STILL, BREATH, GASP, rings, reticle, landing, balloon, voloAt, canThrow, breathless } from './condor.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 cartone inchiodato al tronco: tre cerchi col pennarello, i chiodini in alto e i buchi dei pallini. +function cartone(ctx, c) { + const { x, y, w, h } = CARTONE; + rect(ctx, x - w / 2 + 2, y - h / 2 + 2, w, h, 'rgba(30,20,10,0.35)'); + rect(ctx, x - w / 2, y - h / 2, w, h, '#c9a46a'); + rect(ctx, x - w / 2, y - h / 2, w, 2, '#e0bf86'); rect(ctx, x - w / 2, y + h / 2 - 2, w, 2, '#a8844e'); + rect(ctx, x - w / 2 + 3, y - h / 2 + 2, 2, 2, '#5a5a5a'); rect(ctx, x + w / 2 - 5, y - h / 2 + 2, 2, 2, '#5a5a5a'); + ctx.strokeStyle = '#2a1f1a'; ctx.lineWidth = 1.2; + for (const [r] of rings(c)) { ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.stroke(); } + ctx.fillStyle = '#2a1f1a'; ctx.beginPath(); ctx.arc(x, y, 2, 0, Math.PI * 2); ctx.fill(); + for (const p of c.holes) { rect(ctx, p.x - 1, p.y - 1, 2, 2, '#1a120c'); rect(ctx, p.x, p.y - 1, 1, 1, '#f2d24a'); } +} +// Una lattina (sulla panchina o in aria), rossa con la fascia chiara. +function can(ctx, x, bottom, angle = 0, w = CAN.w, h = CAN.h, color = '#c8321e') { + ctx.save(); ctx.translate(Math.round(x), Math.round(bottom)); ctx.rotate(angle); + ctx.fillStyle = color; ctx.fillRect(-w / 2, -h, w, h); + ctx.fillStyle = '#f2e6c8'; ctx.fillRect(-w / 2, -h + Math.round(h * 0.35), w, 3); + ctx.fillStyle = '#c9cdd2'; ctx.fillRect(-w / 2, -h, w, 2); ctx.fillRect(-w / 2, -2, w, 2); + ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.fillRect(-w / 2 + 2, -h + 2, 1, h - 4); + ctx.restore(); +} +// Una bottiglietta d'acqua, vuota: plastica azzurrina e il tappo blu. +function bottle(ctx, x, bottom, angle = 0) { + const { w, h } = BOTTLE; + ctx.save(); ctx.translate(Math.round(x), Math.round(bottom)); ctx.rotate(angle); + ctx.fillStyle = 'rgba(190,228,240,0.85)'; ctx.fillRect(-w / 2, -h + 4, w, h - 4); ctx.fillRect(-w / 2 + 1, -h + 2, w - 2, 2); + ctx.fillStyle = '#2f6fb0'; ctx.fillRect(-1, -h, 3, 2); + ctx.fillStyle = '#ffffff'; ctx.fillRect(-w / 2 + 1, -h + 5, 1, h - 7); + ctx.restore(); +} +// Il pallino giallo. +function pellet(ctx, x, y, r) { + ctx.fillStyle = '#7a5a10'; ctx.beginPath(); ctx.arc(x, y, r + 0.7, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#f5d02a'; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); + if (r > 1.5) rect(ctx, x - r / 2, y - r / 2, 1, 1, '#fff6b0'); +} +// La Condor in basso, nera, puntata verso il mirino: il calcio di legno dritto in giù (la mano lo tiene così), la canna +// che gira verso il mirino, il mirino in punta. +function pistol(ctx, to) { + const from = { x: 430, y: 330 }, a = Math.atan2(to.y - from.y, to.x - from.x); + rect(ctx, from.x - 13, from.y + 2, 26, 34, '#1a120c'); rect(ctx, from.x - 12, from.y + 2, 24, 32, '#5a3a22'); + rect(ctx, from.x - 9, from.y + 5, 4, 26, '#7a5232'); rect(ctx, from.x - 2, from.y + 8, 2, 20, '#3e2816'); + ctx.save(); ctx.translate(from.x, from.y); ctx.rotate(a); + ctx.fillStyle = '#0b0b0e'; ctx.fillRect(-16, -11, 84, 20); + ctx.fillStyle = '#26252c'; ctx.fillRect(-14, -9, 80, 5); + ctx.fillStyle = '#4a4955'; ctx.fillRect(-14, -9, 80, 1); + ctx.fillStyle = '#0b0b0e'; ctx.fillRect(62, -14, 5, 4); ctx.fillRect(66, -5, 6, 10); + ctx.restore(); +} + +export function drawCondor(ctx, c, def, bg) { + ctx.save(); ctx.imageSmoothingEnabled = false; + if (bg) ctx.drawImage(bg, 0, 0, 640, 360); else { ctx.fillStyle = '#7fb24a'; ctx.fillRect(0, 0, 640, 360); } + cartone(ctx, c); + for (const k of c.bottles) if (k.fall < 1) bottle(ctx, k.x + k.fall * 6, BAR + k.fall * 10, k.fall * 1.6); + for (const k of c.cans) if (k.fall < 1) can(ctx, k.x, BENCH + k.fall * 8, -k.fall * 1.4); + // Il palloncino rosso legato alla sbarra dell'altalena; scoppiato, resta lo spago e qualche brandello. + const b = balloon(c); + ctx.strokeStyle = '#e8e2d0'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(BALLOON.x, BALLOON.knot); + ctx.quadraticCurveTo(BALLOON.x + (b.x - BALLOON.x) * 0.3 + 3, (BALLOON.knot + b.y) / 2, b.x, c.balloon.popped ? b.y + 14 : b.y + BALLOON.r); ctx.stroke(); + if (!c.balloon.popped) { + ctx.fillStyle = '#d8283a'; ctx.beginPath(); ctx.ellipse(b.x, b.y, BALLOON.r, BALLOON.r * 1.2, 0, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#ff8a8a'; ctx.fillRect(Math.round(b.x - 4), Math.round(b.y - 6), 2, 3); + rect(ctx, b.x - 1, b.y + BALLOON.r * 1.2 - 1, 3, 2, '#a01828'); + } else if (c.balloon.pop < 1) { + ctx.fillStyle = '#d8283a'; + for (let i = 0; i < 6; i++) { const a = i / 6 * Math.PI * 2, d = 4 + c.balloon.pop * 14; ctx.fillRect(Math.round(b.x + Math.cos(a) * d), Math.round(b.y + Math.sin(a) * d), 2, 2); } + } + // La lattina tirata in aria dal Taglia (o che ricade, colpita). + const v = voloAt(c); + if (c.volo?.fall) can(ctx, c.volo.fall.x, c.volo.fall.y + VOLO.h / 2, c.visual.time * 14, VOLO.w, VOLO.h, '#2f6fb0'); + else if (v) can(ctx, v.x, v.y + VOLO.h / 2, v.a, VOLO.w, VOLO.h, '#2f6fb0'); + // Il pallino in volo: grande vicino alla pistola, piccolo verso il bersaglio. + if (c.shot) { + const k = Math.min(1, c.shot.t / c.shot.time), { from, to } = c.shot; + pellet(ctx, from.x + (to.x - from.x) * k, from.y + (to.y - from.y) * k, 4 - 2.8 * k); + } + const r = reticle(c); + if (canThrow(c)) { + // Alla bona: dove arriva il pallino, un puntino giallo sotto il mirino. + if (c.skill.guide) { const l = landing(c); rect(ctx, l.x - 1, l.y - 1, 3, 3, '#f5d02a'); } + const out = breathless(c); + ctx.strokeStyle = out ? '#ff6a4a' : c.holding && c.holdTime >= STILL ? '#b9e5b3' : '#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(); + } + pistol(ctx, r); + // Il fiato trattenuto: la barra si riempie fino a «fermo» (verde), poi si svuota in rosso finché il colpo parte. + if (canThrow(c) && c.holding) { + const x = 16, y = 300, h = 50, still = Math.min(1, c.holdTime / STILL), left = c.holdTime > BREATH ? 1 - (c.holdTime - BREATH) / GASP : 1; + rect(ctx, x - 3, y - 3, 18, h + 6, '#17160fd0'); + const f = Math.round(h * (c.holdTime > BREATH ? left : still)); + rect(ctx, x, y + h - f, 12, f, c.holdTime > BREATH ? '#ff6a4a' : still >= 1 ? '#9be07c' : '#ffd23c'); + text(ctx, 'FIATO', x + 6, y - 9, 6, '#efd49e', 'center'); + } + // In alto: i punti, quelli del Taglia, i pallini rimasti e cosa è successo. + rect(ctx, 0, 0, 640, 22, '#17160fe8'); + text(ctx, 'LA CONDOR', 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}`, 200, 11, 9, c.points > c.taglia ? '#b9e5b3' : '#fff0c8', 'center'); + text(ctx, `Il Taglia ${c.taglia}`, 310, 11, 9, '#9fd0ff', 'center'); + for (let i = 0; i < PELLETS; i++) pellet(ctx, 430 + i * 15, 11, i < c.left ? 3 : 1.2); + const line = breathless(c) ? 'Il fiato finisce: spara!' : c.line; + if (line) { rect(ctx, 170, 24, 300, 14, '#17160fd0'); text(ctx, line, 320, 31, 8, breathless(c) || /Niente|fuori|Senza/.test(line) ? '#ffbf9f' : '#9fd0ff', 'center'); } + if (c.step === 'result') { + const w = c.result.winner; + panel(ctx, 170, 140, 300, 76); + text(ctx, { player: 'BATTUTO IL TAGLIA!', draw: 'PARI COL TAGLIA', cpu: 'HA VINTO IL TAGLIA' }[w], 320, 160, 12, w === 'player' ? '#b9e5b3' : '#efd49e', 'center'); + text(ctx, `Tu ${c.result.points}, il Taglia ${c.result.taglia}`, 320, 182, 8, '#efd49e', 'center'); + text(ctx, 'Torna al campone per uscire.', 320, 200, 8, '#d9ccba', 'center'); + } + ctx.restore(); +} diff --git a/src/engine/condor.js b/src/engine/condor.js new file mode 100644 index 0000000..ddb1f57 --- /dev/null +++ b/src/engine/condor.js @@ -0,0 +1,195 @@ +// La Condor al campone delle poste (idea di Andrea e del Taglia, 29 ottobre): la pistola ad aria compressa coi pallini +// gialli. Si gareggia col Taglia: lui ha già tirato, e il suo punteggio è quello da battere. Come la cerbottana: il +// mirino ondeggia col respiro; tenere premuto trattiene il fiato (il mirino si ferma), lasciare spara; chi tiene troppo +// resta senza fiato, il mirino trema e il colpo parte da solo. Il pallino cala con la distanza: l'albero è vicino, la +// panchina a metà, l'altalena e la porta in fondo (lì si mira più sopra). Bersagli: il cartone inchiodato all'albero, le lattine sulla panchina rossa, le +// bottigliette sulla traversa della porta, il palloncino legato all'altalena e (a seria e a fassi male) la lattina che +// il Taglia tira in aria. Unità: i pixel del primo piano 640×360. + +export const PELLETS = 12, TAP = 0.14, REST = 0.6, STILL = 0.8, BREATH = 2.6, GASP = 1, DROP = 26; +// Dove stanno le cose nello sfondo di Codex (assets/closeups/campone-condor.png): il cartone sul tronco, il sedile +// della panchina, la traversa della porta, la sbarra dell'altalena dove è legato il palloncino. +export const CARTONE = { x: 98, y: 140, w: 46, h: 50, rings: [[5, 10], [11, 5], [17, 2]] }; +export const BENCH = 210, CAN = { w: 10, h: 14, points: 3 }; +export const BAR = 177, BOTTLE = { w: 6, h: 12, points: 5 }; +export const BALLOON = { x: 428, y: 134, knot: 170, r: 8, points: 6 }; +export const VOLO = { w: 12, h: 16, points: 8 }; +// I livelli: quanto ondeggia il mirino (px), se si vede dove arriva il pallino, la lattina in aria (ogni quanti +// secondi, quanto ci mette ad attraversare), quanto sono grandi i cerchi del cartone e quanto ha fatto il Taglia. +export const LEVELS = { + bona: { sway: 3, guide: true, volo: null, rings: 1.25, taglia: 22 }, + seria: { sway: 6, guide: false, volo: { every: 6, cross: 2.2 }, rings: 1, taglia: 34 }, + male: { sway: 10, guide: false, volo: { every: 4.5, cross: 1.7 }, rings: 0.85, taglia: 44 }, +}; +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 newCondor(seed = Date.now() >>> 0, def = {}) { + const level = LEVELS[def.level] ? def.level : 'seria'; + const c = { step: 'aim', t: 0, seed, level, skill: LEVELS[level], aim: { x: CARTONE.x, y: CARTONE.y }, power: 0, + holding: false, holdTime: 0, charging: false, left: PELLETS, points: 0, events: [], shot: null, holes: [], line: null, + cans: [246, 276, 306, 336].map(x => ({ x, down: false, fall: 0 })), + bottles: [538, 560, 582].map(x => ({ x, down: false, fall: 0 })), + balloon: { popped: false, pop: 0 }, volo: null, nextVolo: 2.5, visual: { time: 0 } }; + // Il punteggio del Taglia: quello del livello, qualche punto in più o in meno. + c.taglia = c.skill.taglia + Math.floor(random(c) * 7) - 3; + return c; +} +export const canThrow = c => c.step === 'aim' && c.left > 0; +export const canAdjust = canThrow; +export const rings = c => CARTONE.rings.map(([r, p]) => [r * c.skill.rings, p]); + +// Il fiato: fermo dopo STILL secondi di respiro trattenuto (il mirino ondeggia un terzo), poi dopo BREATH comincia a +// mancare e il mirino trema sempre di più; dopo GASP ancora il colpo parte da solo. +export function steady(c) { + if (!c.holding) return 1; + const h = c.holdTime; + if (h < BREATH) return 1 - 0.7 * Math.min(1, h / STILL); + return 0.3 + (h - BREATH) * 2.5; +} +export function sway(c) { + const t = c.visual.time, a = c.skill.sway * steady(c), shake = c.holding && c.holdTime > BREATH ? Math.sin(t * 31) * 0.4 : 0; + return { x: a * (Math.sin(t * 1.7) + 0.5 * Math.sin(t * 3.1 + 1) + shake), y: a * 0.8 * (Math.sin(t * 1.3 + 2) + 0.5 * Math.sin(t * 2.9) + shake) }; +} +export function reticle(c) { const s = sway(c); return { x: c.aim.x + s.x, y: c.aim.y + s.y }; } +// Quanto è lontano un punto, a strisce: l'albero, la panchina, l'altalena, la porta in fondo. +export const far = x => x < 170 ? 0.45 : x < 380 ? 0.6 : x < 490 ? 0.85 : 1; +// Dove arriva il pallino: sotto il mirino, tanto più quanto il punto è lontano. +export function landing(c) { const r = reticle(c); return { x: r.x, y: r.y + DROP * far(r.x) ** 2 }; } +export const flight = x => 0.22 + 0.2 * far(x); +// Il palloncino legato all'altalena: dondola col vento. +export function balloon(c) { + const t = c.visual.time; + return { x: BALLOON.x + 7 * Math.sin(t * 1.1), y: BALLOON.y + 4 * Math.sin(t * 1.7 + 1) }; +} +// La lattina che tira il Taglia: da destra in basso, una parabola verso sinistra. +export function voloAt(c, t = c.volo?.t) { + if (!c.volo) return null; + const k = t / c.volo.cross; + if (k < 0 || k > 1) return null; + return { x: 610 - 560 * k, y: 300 - 4 * 230 * k * (1 - k), a: k * 9 }; +} + +export function shoot(c) { + if (!canThrow(c) || c.holding) return false; + return fire(c); +} +function fire(c, gasp = false) { + const to = landing(c); + c.left--; + // La lattina in aria è vicina (la tira il Taglia, accanto): lì il pallino non fa in tempo a calare. + c.shot = { from: { x: 352, y: 372 }, to, aim: reticle(c), t: 0, time: flight(to.x), gasp }; + c.step = 'flying'; c.line = null; + c.events.push({ kind: 'condor-pam', power: 1 }); + return true; +} +const inBox = (x, y, cx, bottom, w, h) => Math.abs(x - cx) <= w / 2 + 1 && y >= bottom - h - 1 && y <= bottom + 1; +// Arrivato: prima quello che si muove (la lattina in aria, il palloncino), poi bottiglie, lattine e il cartone. +function land(c) { + const { x, y } = c.shot.to; + let got = 0, what = null; + const v = voloAt(c), a = c.shot.aim; + if (v && !c.volo.hit && Math.hypot(a.x - v.x, a.y - v.y) <= VOLO.w / 2 + 2) { + got = VOLO.points; c.volo.hit = true; c.volo.fall = { x: v.x, y: v.y, vy: -60 }; what = `Presa al volo! ${got} punti.`; + c.events.push({ kind: 'cerbottana-latta', power: 1 }); + } + if (!what && !c.balloon.popped) { + const b = balloon(c); + if (Math.hypot(x - b.x, y - b.y) <= BALLOON.r + 1) { + c.balloon.popped = true; got = BALLOON.points; what = `Bum! Il palloncino. ${got} punti.`; + c.events.push({ kind: 'condor-palloncino', power: 1 }); + } + } + if (!what) { + const bottle = c.bottles.find(k => !k.down && inBox(x, y, k.x, BAR, BOTTLE.w, BOTTLE.h)); + if (bottle) { bottle.down = true; got = BOTTLE.points; what = `Bottiglietta giù! ${got} punti.`; c.events.push({ kind: 'condor-plastica', power: 0.8 }); } + } + if (!what) { + const can = c.cans.find(k => !k.down && inBox(x, y, k.x, BENCH, CAN.w, CAN.h)); + if (can) { can.down = true; got = CAN.points; what = `Lattina giù! ${got} punti.`; c.events.push({ kind: 'cerbottana-latta', power: 0.8 }); } + } + if (!what) { + const inCard = Math.abs(x - CARTONE.x) <= CARTONE.w / 2 && Math.abs(y - CARTONE.y) <= CARTONE.h / 2; + if (inCard) { + c.holes.push({ x, y }); + const d = Math.hypot(x - CARTONE.x, y - CARTONE.y), ring = rings(c).find(([r]) => d <= r); + got = ring ? ring[1] : 0; + what = got ? `Nel cartone: ${got} ${got === 1 ? 'punto' : 'punti'}.` : 'Nel cartone, fuori dai cerchi.'; + c.events.push({ kind: 'cerbottana-muro', power: 0.5 }); + } else what = 'Niente: il pallino giallo è finito nel prato.'; + } + if (c.shot.gasp && !got) what = 'Senza fiato! ' + what; + 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, ...c.bottles]) if (k.down) k.fall = Math.min(1, k.fall + dt * 3); + if (c.balloon.popped) c.balloon.pop = Math.min(1, c.balloon.pop + dt * 4); + // La lattina in aria: il Taglia la tira ogni tanto (non a fine partita). + const v = c.skill.volo; + if (v && c.step !== 'result') { + if (c.volo) { + c.volo.t += dt; + if (c.volo.fall) { c.volo.fall.vy += 600 * dt; c.volo.fall.y += c.volo.fall.vy * dt; c.volo.fall.x -= 40 * dt; } + if (c.volo.t > c.volo.cross + 0.6) { c.volo = null; c.nextVolo = v.every * (0.8 + random(c) * 0.4); } + } else if ((c.nextVolo -= dt) <= 0) { + c.volo = { t: 0, cross: v.cross, hit: false, fall: null }; + c.events.push({ kind: 'condor-lancio', power: 0.6 }); + } + } + if (c.holding) { + c.holdTime += dt; c.charging = c.holdTime >= TAP; + c.power = clamp(c.holdTime / STILL, 0, 1); + if (c.holdTime > BREATH + GASP) { c.holding = c.charging = false; c.holdTime = 0; c.power = 0; fire(c, true); } + } + if (c.step === 'flying') { + c.shot.t += dt; if (c.shot.t >= c.shot.time) land(c); + } else if (c.step === 'rest' && c.t >= REST) { + if (c.left > 0) c.step = 'aim'; + else { + c.step = 'result'; + const winner = c.points > c.taglia ? 'player' : c.points === c.taglia ? 'draw' : 'cpu'; + c.result = { winner, points: c.points, taglia: c.taglia }; + } + } +} + +// --- Comandi: il mirino segue il mouse o il dito; tenere premuto trattiene il 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, 30, 320) }; 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 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], 30, 320) }; + 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.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; c.power = 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; c.power = 0; + return fire(c); +} +export const breathless = c => c.holding && c.holdTime > BREATH; + +export function button(c) { return c.step === 'result' ? 'Finita' : c.step === 'aim' ? 'Spara' : c.step === 'flying' ? 'In volo…' : 'Ricarico…'; } +export function status(c) { + const score = `Punti ${c.points}, il Taglia ne ha fatti ${c.taglia}; pallini ${c.left} di ${PELLETS}.`; + if (c.step === 'result') return `${{ player: 'Battuto il Taglia! ', draw: 'Pari col Taglia. ', cpu: 'Ha vinto il Taglia. ' }[c.result.winner]}${score}`; + return `${score} ${c.line ?? 'Mira, tieni premuto per trattenere il fiato e lascia per sparare.'}`; +} diff --git a/src/engine/game.js b/src/engine/game.js index 7096b25..eda2b33 100644 --- a/src/engine/game.js +++ b/src/engine/game.js @@ -12,6 +12,7 @@ import * as scopa from './scopa.js'; import * as gara from './gara.js'; import * as micro from './micro.js'; import * as cerbottana from './cerbottana.js'; +import * as condor from './condor.js'; import * as poker from './poker.js'; // I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta. @@ -26,6 +27,7 @@ export const MINIGAMES = { pesca: { create: (seed, level) => pesca.newPesca(seed, { level }), update: pesca.update, press: pesca.press, release: pesca.release, cancel: pesca.cancel, aim: pesca.aimScreen }, 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 }, 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 2b248fa..5a211c1 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -11,6 +11,7 @@ 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 { drawCondor } from './condor-render.js'; import { drawPoker } from './poker-render.js'; import { drawViaggio } from './viaggio.js'; import { drawBriscola } from './briscola-render.js'; @@ -701,6 +702,11 @@ function drawCloseup(ctx, game, time) { drawCerbottana(ctx, c, def, backgrounds.get(def.picture)); return; } + if (def.type === 'condor') { + if (!backgrounds.has(def.picture)) loadBackground(def.picture); + drawCondor(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 62f0983..fc7e6b4 100644 --- a/src/engine/soundscape.js +++ b/src/engine/soundscape.js @@ -140,6 +140,11 @@ export class Soundscape { 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); } + // La Condor: lo scatto della molla, il palloncino che scoppia, la bottiglietta, il Taglia che tira la lattina. + else if (kind === 'condor-pam') { this.noiseSound(0.06, 3200, 0.07, { pan }); this.tone(520, 220, 0.05, 0.05, 0, pan); } + else if (kind === 'condor-palloncino') { this.noiseSound(0.12, 1800, 0.14, { pan }); this.tone(140, 60, 0.08, 0.08, 0, pan); } + else if (kind === 'condor-plastica') { this.tone(1300, 900, 0.06, 0.05, 0, pan); this.noiseSound(0.05, 2600, 0.03, { pan }); } + else if (kind === 'condor-lancio') this.noiseSound(0.2, 700, 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 }); } diff --git a/src/main.js b/src/main.js index 3614911..0d81e88 100644 --- a/src/main.js +++ b/src/main.js @@ -13,6 +13,7 @@ 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 condor from './engine/condor.js'; import * as poker from './engine/poker.js'; import { cardAt as pokerAt } from './engine/poker-render.js'; import * as basket from './engine/basket.js'; @@ -514,7 +515,7 @@ canvas.addEventListener('pointerdown', e => { } if (press?.closeup) return; const aimed = game.closeupAim(scenePoint(e)); - if (['biliardo', 'rinquarto', 'basket', 'micro', 'cerbottana'].includes(story.closeups[game.closeup.id].type)) { + if (['biliardo', 'rinquarto', 'basket', 'micro', 'cerbottana', 'condor'].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 } @@ -586,7 +587,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'].includes(story.closeups[game.closeup.id].type); + const aiming = game.closeup && ['biliardo', 'rinquarto', 'bocce', 'basket', 'pesca', 'micro', 'cerbottana', 'condor'].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)); }); @@ -609,6 +610,7 @@ const MINI = { 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.' }, 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.' }, 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). @@ -1038,7 +1040,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' }[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' }[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; @@ -1052,7 +1054,7 @@ function updateUi() { // Ai rinquarti la forza è sempre piena: il cursore non serve. Nella guida semplice del GP (alla bona e seria) si // usa solo il gas: niente sensibilità, sterzo, freno né riquadro dello swipe. const easy = def.type === 'gara' && gara.simple(c); - setProp(powerLabel, 'hidden', def.type === 'rinquarto' || easy); + setProp(powerLabel, 'hidden', def.type === 'rinquarto' || def.type === 'condor' || easy); for (const id of ['bocce-left', 'bocce-right']) setProp($(id), 'hidden', easy); const powerName = def.type === 'gara' ? 'Sensibilità ' : def.type === 'cerbottana' ? 'Fiato ' : 'Forza '; if (powerLabel.firstChild.nodeValue !== powerName) powerLabel.firstChild.nodeValue = powerName; @@ -1073,6 +1075,7 @@ function updateUi() { setProp($('rinquarto-help'), 'hidden', def.type !== 'rinquarto'); setProp($('micro-help'), 'hidden', def.type !== 'micro'); setProp($('cerbottana-help'), 'hidden', def.type !== 'cerbottana'); + setProp($('condor-help'), 'hidden', def.type !== 'condor'); setProp($('basket-help'), 'hidden', def.type !== 'basket'); setProp($('pesca-help'), 'hidden', def.type !== 'pesca'); setProp($('gara-help'), 'hidden', def.type !== 'gara'); @@ -1093,7 +1096,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'].includes(story.closeups[c.id].type)); + $('stage').classList.toggle('biliardo', !!c && ['biliardo', 'rinquarto', 'gara', 'micro', 'cerbottana', 'condor'].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 d2c45a8..fa9febf 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -271,6 +271,16 @@ export const story = { quit: [{ say: 'Il muro resta lì. E l\'odore dello stucco, sulle dita, pure.' }], draw: [], }, + // La Condor coi pallini gialli al campone delle poste, in gara col Taglia (Andrea, 29 ottobre). [DA ANDREA: le battute] + condor: { + type: 'condor', + picture: 'assets/closeups/campone-condor.png', + names: { player: 'Andrea', cpu: 'il Taglia' }, + win: [{ say: 'Battuto il Taglia! Con la sua Condor, per giunta.' }, { set: 'condor_vinta' }], + lose: [{ say: 'Il Taglia ha la mano ferma. Io ho il fiatone.' }], + draw: [{ say: 'Pari. Si rifà, tanto i pallini gialli un mancano mai.' }], + quit: [{ say: 'La Condor la rendo al Taglia. Per ora.' }], + }, // 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: { diff --git a/style.css b/style.css index b14f566..16375ae 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 { 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; } +#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; } @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 { font-size: 9px; } + #bocce-help, #biliardo-help, #basket-help, #pesca-help, #gara-help, #rinquarto-help, #micro-help, #cerbottana-help, #condor-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/condor.browser.mjs b/tests/condor.browser.mjs new file mode 100644 index 0000000..1cad311 --- /dev/null +++ b/tests/condor.browser.mjs @@ -0,0 +1,43 @@ +// La Condor nel browser vero: al campone delle poste, mira, fiato trattenuto e tiro col mouse e col dito. +// npm run browser:condor — schermate in /tmp/regolo-condor. +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-condor'; +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.queue.push({ closeup: 'condor', level: 'seria' }); settle(game); + }); + await page.waitForSelector('#bocce-controls:not([hidden])'); + await page.waitForFunction(async () => (await import('/src/engine/render.js')).hasBackground('assets/closeups/campone-condor.png')); + assert.equal(await page.locator('#bocce-throw').textContent(), 'Spara'); + assert.ok(await page.locator('#condor-help').isVisible()); + assert.ok(!(await page.locator('#bocce-power').isVisible()), 'niente cursore della forza'); + await page.waitForTimeout(1200); + 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(98, 138)); else { await page.mouse.move(...P(98, 138)); await page.mouse.click(...P(98, 138)); } + assert.equal(await page.evaluate(() => game.closeup.step), 'aim', 'un tocco mira soltanto'); + if (mobile) await page.locator('#bocce-throw').tap(); + else { await page.mouse.down(); await page.waitForFunction(() => game.closeup.holdTime >= 0.9); await page.screenshot({ path: `${out}/${name}-fiato.png` }); await page.mouse.up(); } + await page.waitForFunction(() => game.closeup.left === 11 && game.closeup.step === 'aim', null, { timeout: 10000 }); + await page.waitForFunction(() => game.closeup.volo && game.closeup.volo.t > 0.8, null, { timeout: 15000 }); + 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/condor.test.mjs b/tests/condor.test.mjs new file mode 100644 index 0000000..56cb702 --- /dev/null +++ b/tests/condor.test.mjs @@ -0,0 +1,90 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as m from '../src/engine/condor.js'; +import { drawCondor } from '../src/engine/condor-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 e aspetta che il pallino arrivi. +function shot(c, x, y) { + c.skill = { ...c.skill, sway: 0 }; c.aim = { x, y }; + assert.ok(m.shoot(c)); + for (let i = 0; i < 200 && c.step === 'flying'; i++) m.update(c, 1 / 60); + return c.line; +} +// Aspetta la ricarica. +const reload = c => { for (let i = 0; i < 120 && !m.canThrow(c) && c.step !== 'result'; i++) m.update(c, 1 / 60); }; +// Mira sopra quanto cala il pallino a quella distanza. +const over = (x, y) => [x, y - m.DROP * m.far(x) ** 2]; + +test('il pallino cala con la distanza: al cartone poco, alla porta di più; si mira sopra', () => { + const c = m.newCondor(1, { level: 'seria' }); + assert.ok(m.far(m.CARTONE.x) < m.far(560)); + assert.match(shot(c, ...over(m.CARTONE.x, m.CARTONE.y)), /10 punti/); + assert.equal(c.holes.length, 1); reload(c); + const b = c.bottles[1]; + assert.match(shot(c, b.x, m.BAR - 6), /prato/, 'mirando dritto alla bottiglietta in fondo si va sotto'); + reload(c); + assert.match(shot(c, ...over(b.x, m.BAR - 6)), /Bottiglietta giù/); + assert.ok(b.down); reload(c); + const k = c.cans[0]; + assert.match(shot(c, ...over(k.x, m.BENCH - 6)), /Lattina giù/); + assert.equal(c.points, 10 + m.BOTTLE.points + m.CAN.points); +}); + +test('il palloncino scoppia; la lattina in aria del Taglia c\'è solo a seria e a fassi male', () => { + const c = m.newCondor(2, { level: 'bona' }); + const at = m.balloon({ visual: { time: c.visual.time + m.flight(m.BALLOON.x) } }); + assert.match(shot(c, ...over(at.x, at.y)), /palloncino/); + assert.ok(c.balloon.popped); + for (let i = 0; i < 60 * 20; i++) m.update(c, 1 / 60); + assert.equal(c.volo, null, 'alla bona il Taglia non tira niente in aria'); + const s = m.newCondor(3, { level: 'seria' }); + while (!s.volo) m.update(s, 1 / 60); + // Si mira dove sarà la lattina quando arriva il pallino (lì, vicina, non cala). + while (s.volo.t < 0.6) m.update(s, 1 / 60); + s.skill = { ...s.skill, sway: 0 }; + let v = m.voloAt(s, s.volo.t + 0.3); + for (let i = 0; i < 3; i++) v = m.voloAt(s, s.volo.t + m.flight(v.x)); + s.aim = { x: v.x, y: v.y }; m.shoot(s); + for (let i = 0; i < 200 && s.step === 'flying'; i++) m.update(s, 1 / 60); + assert.match(s.line, /al volo/); assert.equal(s.points, m.VOLO.points); +}); + +test('trattenere il fiato ferma il mirino; troppo e il colpo parte da solo; il tocco secco mira soltanto', () => { + const c = m.newCondor(4, { level: 'male' }); + assert.ok(m.aimScreen(c, [300, 150])); assert.deepEqual(c.aim, { x: 300, y: 150 }); + assert.ok(m.press(c, 1)); m.update(c, 0.05); assert.equal(m.release(c, 1), true); assert.equal(c.step, 'aim'); + assert.equal(m.steady(c), 1); + m.press(c, 1); + for (let i = 0; i < 60 * m.STILL; i++) m.update(c, 1 / 60); + assert.ok(m.steady(c) < 0.35, 'fermo'); + assert.ok(m.release(c, 1)); assert.equal(c.step, 'flying'); assert.equal(c.left, m.PELLETS - 1); + reload(c); + m.press(c, 2); + for (let i = 0; i < 60 * (m.BREATH + m.GASP + 0.2); i++) m.update(c, 1 / 60); + assert.equal(c.holding, false); assert.equal(c.left, m.PELLETS - 2, 'senza fiato il colpo è partito'); + assert.ok(m.key(c, 'ArrowUp', true)); +}); + +test('finiti i pallini si confronta col Taglia: vince chi ha di più, pari se uguali', () => { + for (const [points, winner] of [[50, 'player'], [null, 'draw'], [0, 'cpu']]) { + const c = m.newCondor(5, { level: 'seria' }); + c.left = 1; c.points = points ?? c.taglia; + shot(c, 600, 340); reload(c); + assert.equal(c.step, 'result'); assert.equal(c.result.winner, winner); + } +}); + +test('il disegno della Condor 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.newCondor(6, { level: 'male' }), bg = {}; + while (!c.volo) m.update(c, 1 / 60); + const draw = () => { const before = structuredClone(c); drawCondor(ctx, c, story.closeups.condor, 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 CONDOR')); + c.step = 'result'; c.result = { winner: 'player', points: 50, taglia: 44 }; calls.length = 0; draw(); + assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'BATTUTO IL TAGLIA!')); + assert.ok(MINIGAMES.condor); +});