diff --git a/assets/characters/andrea-pesca.json b/assets/characters/andrea-pesca.json new file mode 100644 index 0000000..7ef4ba6 --- /dev/null +++ b/assets/characters/andrea-pesca.json @@ -0,0 +1,38 @@ +{ + "image": "andrea-pesca.png", + "height": 120, + "frames": { + "hold": { + "x": 0, + "y": 0, + "w": 101, + "h": 120, + "ax": 31.2, + "ay": 120 + }, + "cast": { + "x": 103, + "y": 0, + "w": 145, + "h": 89, + "ax": 56.1, + "ay": 89 + }, + "reel": { + "x": 250, + "y": 0, + "w": 102, + "h": 107, + "ax": 32.5, + "ay": 107 + }, + "show": { + "x": 354, + "y": 0, + "w": 129, + "h": 113, + "ax": 87.9, + "ay": 113 + } + } +} diff --git a/assets/characters/andrea-pesca.png b/assets/characters/andrea-pesca.png new file mode 100644 index 0000000..17c07a3 Binary files /dev/null and b/assets/characters/andrea-pesca.png differ diff --git a/assets/closeups/lago-anatre.png b/assets/closeups/lago-anatre.png new file mode 100644 index 0000000..c39c20e Binary files /dev/null and b/assets/closeups/lago-anatre.png differ diff --git a/assets/voices/andrea-1c668beaef.mp3 b/assets/voices/andrea-1c668beaef.mp3 new file mode 100644 index 0000000..6640135 Binary files /dev/null and b/assets/voices/andrea-1c668beaef.mp3 differ diff --git a/assets/voices/andrea-1d758ed60c.mp3 b/assets/voices/andrea-1d758ed60c.mp3 new file mode 100644 index 0000000..dc747c0 Binary files /dev/null and b/assets/voices/andrea-1d758ed60c.mp3 differ diff --git a/assets/voices/andrea-5ebf35bc74.mp3 b/assets/voices/andrea-5ebf35bc74.mp3 new file mode 100644 index 0000000..30576ab Binary files /dev/null and b/assets/voices/andrea-5ebf35bc74.mp3 differ diff --git a/assets/voices/andrea-72bf22a3f9.mp3 b/assets/voices/andrea-72bf22a3f9.mp3 new file mode 100644 index 0000000..64909f1 Binary files /dev/null and b/assets/voices/andrea-72bf22a3f9.mp3 differ diff --git a/assets/voices/andrea-b00b469ef8.mp3 b/assets/voices/andrea-b00b469ef8.mp3 new file mode 100644 index 0000000..eb63ed0 Binary files /dev/null and b/assets/voices/andrea-b00b469ef8.mp3 differ diff --git a/assets/voices/andrea-b74e348e25.mp3 b/assets/voices/andrea-b74e348e25.mp3 new file mode 100644 index 0000000..52f403b Binary files /dev/null and b/assets/voices/andrea-b74e348e25.mp3 differ diff --git a/assets/voices/andrea-cb3510ac67.mp3 b/assets/voices/andrea-cb3510ac67.mp3 new file mode 100644 index 0000000..4b6fdca Binary files /dev/null and b/assets/voices/andrea-cb3510ac67.mp3 differ diff --git a/assets/voices/index.json b/assets/voices/index.json index d5442d6..005d838 100644 --- a/assets/voices/index.json +++ b/assets/voices/index.json @@ -211,9 +211,9 @@ "file": "andrea-22b8efe94b.mp3", "sec": 1.75 }, - "pescatori|Si preparano le canne: domattina si va a pescare al lago dell'Anatre, dietro Cardetole!": { - "file": "pescatori-486103d189.mp3", - "sec": 3.74 + "pescatori|Si preparano le canne: si va a pescare al lago dell'Anatre, dietro Cardetole!": { + "file": "pescatori-e7edfae41f.mp3", + "sec": 3.51 }, "pescatori|Guarda questa: la lenza la regge anche una carpa di dieci chili.": { "file": "pescatori-afda722e0c.mp3", @@ -2035,6 +2035,50 @@ "file": "arcu-406cd02197.mp3", "sec": 4.46 }, + "andrea|S'ha andà a fallo?!?": { + "file": "andrea-1c668beaef.mp3", + "sec": 1.08 + }, + "andrea|Quanti ne prendete, di solito?": { + "file": "andrea-1d758ed60c.mp3", + "sec": 1.84 + }, + "andrea|Buona pesca, ragazzi.": { + "file": "andrea-cb3510ac67.mp3", + "sec": 1.85 + }, + "pescatori|S'ha andà! La canna te la diamo noi.": { + "file": "pescatori-519ac990d8.mp3", + "sec": 3.16 + }, + "pescatori|Come peschi?": { + "file": "pescatori-520fe85721.mp3", + "sec": 0.56 + }, + "pescatori|Dipende da chi racconta. Il Nanni dice dieci, il cestino dice due.": { + "file": "pescatori-901d65f3bc.mp3", + "sec": 4.09 + }, + "pescatori|Se vedi il palio, salutacelo!": { + "file": "pescatori-52125dc78f.mp3", + "sec": 1.74 + }, + "andrea|Alla bona, tanto per bagnare la lenza.": { + "file": "andrea-b74e348e25.mp3", + "sec": 2.55 + }, + "pescatori|Alla bona. Due pesci e si va a casa contenti.": { + "file": "pescatori-8af3768e38.mp3", + "sec": 3.26 + }, + "pescatori|Seria: tre pesci, e lo stivale non conta.": { + "file": "pescatori-e417a89a3a.mp3", + "sec": 2.82 + }, + "pescatori|A fassi male? Quattro pesci, e le carpe tirano come trattori.": { + "file": "pescatori-befa87eee8.mp3", + "sec": 4.17 + }, "andrea|Due tiri alla bona, tanto per scaldarmi.": { "file": "andrea-d564365fc2.mp3", "sec": 2.53 @@ -2315,6 +2359,26 @@ "file": "andrea-ee42022092.mp3", "sec": 3.98 }, + "pescatori|Oh, hai visto? Mica male, per una canna di bambù.": { + "file": "pescatori-2583bf891f.mp3", + "sec": 3.79 + }, + "andrea|Stasera si frigge. Ma prima il palio.": { + "file": "andrea-b00b469ef8.mp3", + "sec": 3.48 + }, + "pescatori|I pesci c'erano. Eri te che non c'eri.": { + "file": "pescatori-53d5588588.mp3", + "sec": 2.41 + }, + "andrea|Si rifà. Dopo il palio.": { + "file": "andrea-5ebf35bc74.mp3", + "sec": 2.37 + }, + "andrea|Lascio la canna ai ragazzi. Il lago non scappa, il palio sì.": { + "file": "andrea-72bf22a3f9.mp3", + "sec": 4.49 + }, "ing_tozzi|Venti. Hai vinto te, stasera il caffè lo pago io.": { "file": "ing_tozzi-51f855478a.mp3", "sec": 4.42 diff --git a/assets/voices/pescatori-2583bf891f.mp3 b/assets/voices/pescatori-2583bf891f.mp3 new file mode 100644 index 0000000..f6a0d29 Binary files /dev/null and b/assets/voices/pescatori-2583bf891f.mp3 differ diff --git a/assets/voices/pescatori-486103d189.mp3 b/assets/voices/pescatori-486103d189.mp3 deleted file mode 100644 index 4bc11fd..0000000 Binary files a/assets/voices/pescatori-486103d189.mp3 and /dev/null differ diff --git a/assets/voices/pescatori-519ac990d8.mp3 b/assets/voices/pescatori-519ac990d8.mp3 new file mode 100644 index 0000000..2ca2c41 Binary files /dev/null and b/assets/voices/pescatori-519ac990d8.mp3 differ diff --git a/assets/voices/pescatori-520fe85721.mp3 b/assets/voices/pescatori-520fe85721.mp3 new file mode 100644 index 0000000..6aae8b6 Binary files /dev/null and b/assets/voices/pescatori-520fe85721.mp3 differ diff --git a/assets/voices/pescatori-52125dc78f.mp3 b/assets/voices/pescatori-52125dc78f.mp3 new file mode 100644 index 0000000..3cff158 Binary files /dev/null and b/assets/voices/pescatori-52125dc78f.mp3 differ diff --git a/assets/voices/pescatori-53d5588588.mp3 b/assets/voices/pescatori-53d5588588.mp3 new file mode 100644 index 0000000..81b4b3f Binary files /dev/null and b/assets/voices/pescatori-53d5588588.mp3 differ diff --git a/assets/voices/pescatori-8af3768e38.mp3 b/assets/voices/pescatori-8af3768e38.mp3 new file mode 100644 index 0000000..43fac0d Binary files /dev/null and b/assets/voices/pescatori-8af3768e38.mp3 differ diff --git a/assets/voices/pescatori-901d65f3bc.mp3 b/assets/voices/pescatori-901d65f3bc.mp3 new file mode 100644 index 0000000..bb38ac4 Binary files /dev/null and b/assets/voices/pescatori-901d65f3bc.mp3 differ diff --git a/assets/voices/pescatori-befa87eee8.mp3 b/assets/voices/pescatori-befa87eee8.mp3 new file mode 100644 index 0000000..70ef132 Binary files /dev/null and b/assets/voices/pescatori-befa87eee8.mp3 differ diff --git a/assets/voices/pescatori-e417a89a3a.mp3 b/assets/voices/pescatori-e417a89a3a.mp3 new file mode 100644 index 0000000..f2737e3 Binary files /dev/null and b/assets/voices/pescatori-e417a89a3a.mp3 differ diff --git a/assets/voices/pescatori-e7edfae41f.mp3 b/assets/voices/pescatori-e7edfae41f.mp3 new file mode 100644 index 0000000..08f3d0c Binary files /dev/null and b/assets/voices/pescatori-e7edfae41f.mp3 differ diff --git a/docs/IDEE.md b/docs/IDEE.md index 70dfe30..6763d03 100644 --- a/docs/IDEE.md +++ b/docs/IDEE.md @@ -112,7 +112,7 @@ si segna qui con la data e il punto di `src/story/story.js` dove sta; le persone 4 Maggio; «Fai due tiri» al campo da basket del Circolo (27 ottobre). I prossimi due, se Andrea è d'accordo: il calcio balilla (riusa la fisica del biliardo, fa subito bar) e il tiro alla fune (è il Palio, e vale come enigma del rione). - Le gare del Palio (il secondo capitolo): + Le gare del Palio (il secondo capitolo: passamano, tiro alla fune, carrette, lavandaie, caccia al tesoro): - *Passamano dei mattoni*: un gioco di ritmo, i mattoni arrivano lungo la fila e si passano al momento giusto, un tasto per ogni passaggio; sbagliando il tempo ne cade uno. Facile col dito, e chiude il cerchio col finale (i mattoni portati in Fortezza). @@ -121,7 +121,10 @@ si segna qui con la data e il punto di `src/story/story.js` dove sta; le persone `docs/PROGETTO.md`. - *Le carrette*: corsa in discesa vista di lato, a destra e sinistra fra buche e balle di paglia. La più spettacolare, la più lunga da disegnare: il «gioco grosso», con Mitrugno (consenso). - - *Bandierine (lavanderine)*: correre a prendere la bandiera prima dell'avversario, di riflessi. + - *Le lavandaie* (Andrea, 28 ottobre; prima era segnato male come «bandierine»): un vascone pieno d'acqua con + dentro gli stracci, pezzi di stoffa dei quattro colori dei rioni mescolati; ogni squadra deve ripescare i suoi. + Come minigioco: vista dall'alto sul vascone, gli stracci galleggiano e girano, si tocca il proprio colore per + tirarlo fuori, un errore (il colore di un altro) fa perdere tempo; contro il tempo o contro le altre squadre. - *Caccia al tesoro*: un giro di indizi fra le scene che ci sono già, senza un minigioco nuovo. Al bar e al circolo: - *Calcio balilla al River*: due stecche sole (difesa e attacco), vista dall'alto come il biliardo, che ha già diff --git a/docs/PROGETTO.md b/docs/PROGETTO.md index f46abbf..68ce6bd 100644 --- a/docs/PROGETTO.md +++ b/docs/PROGETTO.md @@ -165,7 +165,7 @@ tipi riconoscibili che persone identificabili, salvo accordi. Obiettivo: entrare nella Fortezza e trovare il palio. Serve l'aiuto dei tre rioni oltre a quello di Andrea; ogni rione vuole una sfida del **Palietto** (i giochi dei bambini del Palio: passaggio dei mattoni, tiro alla fune, corsa -coi carretti, gioco dell'elastico), qui trasformate in enigmi comici. +coi carretti, gioco dell'elastico, le lavandaie: gli stracci dei quattro colori nel vascone, ognuno ripesca i suoi), qui trasformate in enigmi comici. Le gare che Andrea ricorda davvero: diff --git a/docs/STATO.md b/docs/STATO.md index b8c6a52..3061ce6 100644 --- a/docs/STATO.md +++ b/docs/STATO.md @@ -218,6 +218,17 @@ I prompt fissi (stile, scene, foglio sprite) sono in `prompts/STILE.md`, escluso da Via Cipriani, indietro, Via Massorondinaio → campeggio. Sfondi da Street View, battute provvisorie. - In Fortezza quattro ragazzi inventati si fanno le canne da pesca col bambù per il lago dell'Anatre (sprite di Codex, `assets/raw/pescatori-sheet.png`, ritagliato 2x2 a mano: work, talk, laugh, proud); battuta finale di Andrea. + Parlandoci: «S'ha andà a fallo?!?» (Andrea, 28 ottobre) e si va a pescare: minigioco `src/engine/pesca.js` + (primo piano del lago, sfondo e Andrea con la canna di Codex; lancio tenendo premuto, tocchi finti e abboccata, + ferrata a tempo, recupero con la lenza che si può spezzare; sei lanci; pesci più grossi al largo, lo stivale sotto + riva; tre livelli). Battute provvisorie. Il lago come scena da visitare non c'è: serve una foto di Andrea. + Revisione di Codex (28 ottobre) applicata nei punti gravi (galleggiante che saltava, sesto lancio bloccato dal + pannello, invito a ferrare fuori tempo, lenza che non si spezzava mai, mira che si sbloccava) e in parecchi utili + (un gesto solo per ferrare e recuperare, pulsante Recupera/Molla, recupero breve per i pesci piccoli, attese ripetute + dimezzate, tocchi finti variabili, lenza dalla punta vera della canna, stivale con la sua battuta e senza peso, + risultato deciso all'ultimo pesce). Restano le sue idee: «Porta a casa la pescata» a gloria raggiunta; premio per + peso o varietà; i ragazzi che reagiscono (stivale, carpa, lenza rotta); paesaggio sonoro del lago (ora quello della + Fortezza); abboccata e tensione anche nei comandi HTML e un richiamo grande sul telefono; Esc col fuoco sui pulsanti. - Andrea rifatto dalla sua foto del 2007 (`assets/raw/andrea-sheet-prova-2.png`; il vecchio foglio resta in `andrea-sheet.png`). - Strada del campeggio (poi Bosco ai Frati) e strada della scuola: per ora battute, scene da fare con le foto di Andrea. diff --git a/index.html b/index.html index 207536c..1993fca 100644 --- a/index.html +++ b/index.html @@ -219,6 +219,17 @@ palla: entra anche sporca, ma non da sotto. La gloria: 3 canestri alla bona, 5 seria, 7 a fassi male. Alla bona la guida mostra la parabola fino al primo urto, Seria solo l’inizio, A fassi male niente.
+Un tocco sull’acqua sceglie dove lanciare (o regola la forza: più lontano, pesci più grossi, ma attese più + lunghe). Tieni premuto sulla scena (o Spazio): la forza riparte da zero e sale, lascia per lanciare; oppure premi + Lancia. Poi si aspetta: il galleggiante ha dei tocchi finti (si abbassa appena) e un’abboccata vera (va sotto del + tutto): premi in quel momento per ferrare, e resta premuto: stai già recuperando. Premendo a vuoto si + ricupera la lenza vuota e il lancio è perso; se non ferri in tempo il pesce lo sputa, e riprova fino a tre volte.
+Nel recupero guarda la lenza e la barra in basso: quando il pesce strattona (la lenza trema) la barra sale verso + il rosso, lascia andare un attimo, poi riprendi. I pesci piccoli vengono a riva in fretta, le carpe no. Dal pannello + 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.
+Clic o tocco sul tavolo fissa la mira: puoi spostarti sui comandi senza cambiarla. diff --git a/src/engine/game.js b/src/engine/game.js index dd90bb4..2922ee0 100644 --- a/src/engine/game.js +++ b/src/engine/game.js @@ -4,6 +4,7 @@ import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js import * as bocce from './bocce.js'; import * as biliardo from './biliardo.js'; import * as basket from './basket.js'; +import * as pesca from './pesca.js'; import * as briscola from './briscola.js'; import * as tressette from './tressette.js'; import * as scopa from './scopa.js'; @@ -16,6 +17,7 @@ export const MINIGAMES = { bocce: { create: (seed, level) => bocce.newBocce(seed, { level }), update: bocce.update, press: bocce.press, release: bocce.release, cancel: bocce.cancel, aim: bocce.aimScreen }, biliardo: { create: (seed, level) => biliardo.newBiliardo(seed, { level }), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.aimScreen }, basket: { create: (seed, level) => basket.newBasket(seed, { level }), update: basket.update, press: basket.press, release: basket.release, cancel: basket.cancel, aim: basket.aimScreen }, + pesca: { create: (seed, level) => pesca.newPesca(seed, { level }), update: pesca.update, press: pesca.press, release: pesca.release, cancel: pesca.cancel, aim: pesca.aimScreen }, }; export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give', 'drop']; diff --git a/src/engine/pesca-render.js b/src/engine/pesca-render.js new file mode 100644 index 0000000..8219c08 --- /dev/null +++ b/src/engine/pesca-render.js @@ -0,0 +1,125 @@ +import { BANK, WATER, CASTS, left, fishCount, distanceFor, status } from './pesca.js'; +import { text, panel } from './bocce-render.js'; +import { ambientHash as hash } from './ambient.js'; + +const ANDREA = { x: 92, y: 258, scale: 0.68 }, BOYS = { x: 44, y: 262, scale: 0.5 }; +// La punta della canna in ogni posa, rispetto ai piedi di Andrea (misurata sul foglio di Codex): la lenza parte da lì. +const TIPS = { hold: [60, -81], cast: [80, -57], reel: [43, -66], show: [60, -81] }; +const ROD_TIP = { x: ANDREA.x + TIPS.hold[0], y: ANDREA.y + TIPS.hold[1] }; +export function andreaPose(c) { + if (c.step === 'cast') return 'cast'; + if (c.step === 'fight') return 'reel'; + if (c.step === 'settle' && c.how === 'landed') return c.fish?.gag ? 'hold' : 'show'; + return 'hold'; +} +function sprite(ctx, sheet, pose, { x, y, scale }) { + if (!sheet) return; + const frame = sheet.atlas.frames[pose] ?? Object.values(sheet.atlas.frames)[0]; + ctx.drawImage(sheet.img, frame.x, frame.y, frame.w, frame.h, + Math.round(x - frame.ax * scale), Math.round(y - frame.ay * scale), Math.round(frame.w * scale), Math.round(frame.h * scale)); +} +// Dove sta la punta della canna: segue la posa, così la lenza parte sempre dalla cima. +function rodTip(c) { + const [dx, dy] = TIPS[andreaPose(c)] ?? TIPS.hold; + return { x: ANDREA.x + dx, y: ANDREA.y + dy }; +} +function floatAt(c) { + const f = c.float; + if (!f) return null; + if (c.step === 'cast') { + // La parabola del lancio dalla punta della canna all'acqua. + const tip = rodTip(c), k = f.fly, x = tip.x + (f.x - tip.x) * k, y = tip.y + (WATER - tip.y) * k - Math.sin(k * Math.PI) * 60; + return { x, y }; + } + return { x: f.x, y: WATER + f.dip + Math.sin(c.t * 2.2 + f.x) * 0.8 }; +} +function fishShadow(ctx, c, p) { + const fish = c.fish; if (!fish || c.step !== 'fight') return; + const w = 10 + fish.weight * 9, h = 4 + fish.weight * 2.5, x = p.x + 8 + Math.sin(c.t * 3) * 6, y = WATER + 22 + Math.sin(c.t * 1.7) * 5; + ctx.fillStyle = 'rgba(20,40,38,0.55)'; + ctx.beginPath(); ctx.ellipse(x, y, w / 2, h / 2, 0, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.moveTo(x + w / 2, y); ctx.lineTo(x + w / 2 + 5, y - 4); ctx.lineTo(x + w / 2 + 5, y + 4); ctx.fill(); +} +function fishIcon(ctx, f, x, y) { + if (f.gag) { ctx.fillStyle = '#4a3a2a'; ctx.fillRect(x - 3, y - 5, 4, 8); ctx.fillRect(x - 3, y + 1, 8, 3); return; } + const w = 6 + f.weight * 3; + ctx.fillStyle = f.kind === 'carpa' ? '#c9a24a' : f.kind === 'tinca' ? '#5a7a3a' : f.kind === 'persico' ? '#d9b14a' : '#9fb3b8'; + ctx.beginPath(); ctx.ellipse(x, y, w / 2, 2.5 + f.weight, 0, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.moveTo(x - w / 2, y); ctx.lineTo(x - w / 2 - 3, y - 3); ctx.lineTo(x - w / 2 - 3, y + 3); ctx.fill(); + ctx.fillStyle = '#1a1a1a'; ctx.fillRect(Math.round(x + w / 2 - 3), Math.round(y - 1), 1, 1); +} + +export function drawPesca(ctx, c, def, bg, sheet, boys) { + ctx.save(); + if (bg) ctx.drawImage(bg, 0, 0, 640, 360); + else { ctx.fillStyle = '#5c7f7a'; ctx.fillRect(0, 0, 640, 360); ctx.fillStyle = '#7a9a4a'; ctx.fillRect(0, 240, BANK, 120); } + // I ragazzi sulla riva, dietro Andrea, intenti alle loro canne. + sprite(ctx, boys, 'work', BOYS); + sprite(ctx, sheet, andreaPose(c), ANDREA); + // I cerchi nell'acqua: al tuffo del galleggiante e quando il pesce abbocca. + for (const r of c.ripples) { + ctx.strokeStyle = `rgba(220,240,235,${0.6 * (1 - r.age / 1.6)})`; ctx.lineWidth = 1; + for (const k of [0, 0.4]) { const a = r.age - k; if (a < 0) continue; ctx.beginPath(); ctx.ellipse(r.x, WATER, 4 + a * 18, 1.5 + a * 5, 0, 0, Math.PI * 2); ctx.stroke(); } + } + // A lancio finito (pesce in mano, lenza rotta o vuota) niente galleggiante: la lenza è fuori dall'acqua. + const p = ['cast', 'wait', 'fight'].includes(c.step) ? floatAt(c) : null; + if (p) { + // La lenza, con un po' di pancia; tesa e tremante quando il pesce tira. + const tip = rodTip(c), sag = c.step === 'fight' ? 2 - c.tension * 2 : c.step === 'wait' ? 10 : 4; + // Trema già allo strattone, prima che la lenza si tenda: il tempo di mollare. + const shake = c.step === 'fight' && (c.tension > 0.75 || c.danger > 0.55) ? Math.sin(c.t * 60) * 1.2 : 0; + ctx.strokeStyle = c.step === 'fight' && c.tension > 0.85 ? '#ffb0a0' : 'rgba(240,240,230,0.85)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(tip.x, tip.y); ctx.quadraticCurveTo((tip.x + p.x) / 2, Math.max(tip.y, p.y) + sag + shake, p.x, p.y); ctx.stroke(); + fishShadow(ctx, c, p); + // Il galleggiante: rosso sopra, bianco sotto; sott'acqua si vede appena. + const under = Math.max(0, p.y - WATER); + ctx.fillStyle = '#e2e2d8'; ctx.fillRect(Math.round(p.x) - 1, Math.round(p.y) - 1, 3, 4); + ctx.fillStyle = '#d8402a'; ctx.fillRect(Math.round(p.x) - 1, Math.round(p.y) - 6, 3, 5); + if (under > 0) { ctx.fillStyle = 'rgba(40,70,80,0.55)'; ctx.fillRect(Math.round(p.x) - 2, WATER, 5, Math.round(under) + 1); } + } + // La mira: dove cadrà il galleggiante, e il filo tratteggiato della parabola. + if (c.step === 'aim' && c.casts < CASTS) { + const x = distanceFor(c.power); + ctx.strokeStyle = 'rgba(255,246,210,0.9)'; ctx.setLineDash([2, 4]); + ctx.beginPath(); ctx.moveTo(ROD_TIP.x, ROD_TIP.y); ctx.quadraticCurveTo((ROD_TIP.x + x) / 2, ROD_TIP.y - 70, x, WATER); ctx.stroke(); ctx.setLineDash([]); + ctx.beginPath(); ctx.ellipse(x, WATER, 7, 2.5, 0, 0, Math.PI * 2); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(x, WATER - 8); ctx.lineTo(x, WATER + 8); ctx.stroke(); + } + panel(ctx, 182, 50, 440, 24); + text(ctx, status(c, def.names), 402, 63, 6, '#fff0cb', 'center'); + // I lanci rimasti, il cestino dei pesci. + panel(ctx, 18, 86, 196, 58); + text(ctx, 'LANCI', 26, 98, 6, '#eed5a0'); + for (let n = 0; n < CASTS; n++) { + const x = 70 + n * 12, y = 98; + if (n < left(c)) { ctx.fillStyle = '#d8402a'; ctx.fillRect(x - 1, y - 4, 3, 4); ctx.fillStyle = '#e2e2d8'; ctx.fillRect(x - 1, y, 3, 3); } + else { ctx.strokeStyle = '#766e5c'; ctx.strokeRect(x - 2, y - 3, 5, 6); } + } + text(ctx, 'CESTINO', 26, 116, 6, '#eed5a0'); + c.caught.forEach((f, i) => fishIcon(ctx, f, 80 + i * 22, 116)); + const fish = fishCount(c); + text(ctx, `${fish} su ${c.skill.goal} per la gloria · ${c.weight} kg`, 26, 133, 6, fish >= c.skill.goal ? '#b9e5b3' : '#d7ccb4'); + // In basso a sinistra: la forza del lancio, o la tensione della lenza. + if (c.step === 'aim' || c.holding) { + panel(ctx, 18, 306, 176, 37); + text(ctx, `FORZA ${Math.round(c.power * 100)}%`, 30, 318, 7); + ctx.fillStyle = '#3e3e36'; ctx.fillRect(30, 331, 150, 5); + ctx.fillStyle = '#b9d392'; ctx.fillRect(30, 331, Math.round(c.power * 150), 5); + } else if (c.step === 'fight') { + panel(ctx, 18, 306, 176, 37); + text(ctx, `LENZA ${Math.round(c.tension * 100)}%`, 30, 318, 7, c.tension > 0.85 ? '#ffb0a0' : '#f4e3c2'); + text(ctx, `riva ${Math.round((1 - Math.min(1, c.fish.dist)) * 100)}%`, 182, 318, 6, '#d7ccb4', 'right'); + ctx.fillStyle = '#3e3e36'; ctx.fillRect(30, 331, 150, 5); + ctx.fillStyle = 'rgba(224,87,74,0.35)'; ctx.fillRect(30 + 127, 331, 23, 5); // la zona rossa: qui si molla + ctx.fillStyle = c.tension > 0.85 ? '#e0574a' : c.tension > 0.65 ? '#e0b24a' : '#b9d392'; ctx.fillRect(30, 331, Math.round(c.tension * 150), 5); + ctx.fillStyle = '#fff'; ctx.fillRect(30 + 127, 329, 1, 9); + } + if (c.step === 'result') { + panel(ctx, 206, 91, 397, 78); + text(ctx, c.result.winner === 'player' ? 'BELLA PESCATA!' : 'ACQUA CHETA', 404, 108, 9, '#efd49e', 'center'); + text(ctx, `${c.result.fish} pesci su ${CASTS} lanci, ${c.result.weight} kg`, 404, 130, 9, c.result.winner === 'player' ? '#b9e5b3' : '#f1d9be', 'center'); + text(ctx, c.result.winner === 'player' ? `Obiettivo ${c.result.goal}: fatto.` : `Per la gloria ne servivano ${c.result.goal}.`, 404, 148, 6, '#d6c7af', 'center'); + c.result.caught.forEach((f, i) => fishIcon(ctx, f, 404 - (c.result.caught.length - 1) * 11 + i * 22, 160)); + } + ctx.restore(); +} diff --git a/src/engine/pesca.js b/src/engine/pesca.js new file mode 100644 index 0000000..588ac06 --- /dev/null +++ b/src/engine/pesca.js @@ -0,0 +1,199 @@ +// La pesca al lago dell'Anatre coi ragazzi delle canne (idea di Andrea, 28 ottobre). Vista di lato, coordinate dello +// schermo (640x360): Andrea sulla riva a sinistra, l'acqua a destra. Si tiene premuto per lanciare (più forza, più +// lontano: e al largo i pesci sono più grossi); il galleggiante balla, ai tocchi finti non si abbocca, quando va sotto +// davvero si ferra premendo in tempo e, senza lasciare, si recupera: quando il pesce strattona la lenza si tende, e +// bisogna mollare un attimo. Sei lanci; la gloria dipende da quanti pesci si tirano a riva (LEVELS). +// Rivista con la revisione di Codex del 28 ottobre (galleggiante che saltava, sesto lancio bloccato dal pannello, +// invito a ferrare fuori tempo, lenza che non si spezzava mai, mira che si sbloccava da sola). +export const BANK = 150, WATER = 250, NEAR = 190, FAR = 600, SHORE = NEAR - 20; +export const CASTS = 6, SWING = 1.4, FLY = 0.8, REST = 1.4, WAIT_MAX = 45; +export const LEVELS = { + bona: { goal: 2, window: 1.0, pull: 0.5 }, + seria: { goal: 3, window: 0.62, pull: 0.78 }, + male: { goal: 4, window: 0.38, pull: 1.0 }, +}; +// I pesci del lago, dal più piccolo al più grosso: al largo si pescano i grossi. Lo stivale è la beffa della riva. +export const FISHES = [ + { kind: 'stivale', name: 'uno stivale', weight: 0.9, strength: 0.25, stamina: 0.3, far: [0, 0.25], gag: true }, + { kind: 'lasca', name: 'una lasca', weight: 0.15, strength: 0.3, stamina: 0.5, far: [0, 0.55] }, + { kind: 'persico', name: 'un persico sole', weight: 0.3, strength: 0.45, stamina: 0.7, far: [0.1, 0.7] }, + { kind: 'cavedano', name: 'un cavedano', weight: 0.8, strength: 0.6, stamina: 0.9, far: [0.3, 0.9] }, + { kind: 'tinca', name: 'una tinca', weight: 1.4, strength: 0.75, stamina: 1.1, far: [0.45, 1] }, + { kind: 'carpa', name: 'una carpa', weight: 3.2, strength: 1, stamina: 1.4, far: [0.7, 1] }, +]; +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 newPesca(seed = Date.now() >>> 0, def = {}) { + const level = LEVELS[def.level] ? def.level : 'seria'; + return { step: 'aim', t: 0, age: 0, seed, level, skill: LEVELS[level], current: 'player', casts: 0, caught: [], weight: 0, + power: 0.5, holding: false, holdTime: 0, reeling: false, float: null, fish: null, tension: 0, pull: 0, events: [], ripples: [], last: null }; +} +export const left = c => Math.max(0, CASTS - c.casts); +export const fishCount = c => c.caught.filter(f => !f.gag).length; +// Tre domande diverse: si può lanciare, si può agire (ferrare, recuperare), si può regolare la distanza. +export const canCast = c => c.step === 'aim' && !c.holding && c.casts < CASTS; +export const canThrow = c => canCast(c) || ((c.step === 'wait' || c.step === 'fight') && !c.holding); +export const canAdjust = c => c.step === 'aim' && !c.holding && c.casts < CASTS; +export const aimFree = c => c.step !== 'aim'; +export const powerAt = time => clamp(time / SWING, 0, 1); +export const distanceFor = power => NEAR + (FAR - NEAR) * clamp(power, 0, 1); +export function setPower(c, value) { + if (!canAdjust(c) || !Number.isFinite(value)) return false; + c.power = clamp(value, 0, 1); return true; +} +// Un tocco sull'acqua (non sul cielo, non sulla riva) sceglie dove lanciare: la forza che ci vuole per arrivarci. +export function aimScreen(c, [x, y]) { + if (!canAdjust(c) || !Number.isFinite(x) || !Number.isFinite(y) || x < BANK + 10 || y < WATER - 40) return false; + return setPower(c, (x - NEAR) / (FAR - NEAR)); +} +export const nudge = (c, dir, fine = false) => setPower(c, c.power + dir * (fine ? 0.01 : 0.05)); + +function event(c, kind, strength = 0.5) { if (c.events.length < 12) c.events.push({ kind, power: clamp(strength, 0, 1) }); } +// Quando arriva il pesce: da uno a tre tocchi finti, a ritmo diverso, poi quello vero. Al largo si aspetta un po' di +// più; dopo un'abboccata mancata l'attesa è la metà. +function schedule(c) { + const f = c.float, far = (f.x - NEAR) / (FAR - NEAR); + f.biteAt = f.t + (2.5 + random(c) * 4 + far * 2) * (f.missed ? 0.5 : 1); + const n = 1 + Math.floor(random(c) * 3); + f.nibbles = Array.from({ length: n }, (_, i) => ({ at: f.biteAt - (n - i) * (0.6 + random(c) * 0.9), for: 0.18 + random(c) * 0.2 })); + f.bitten = false; +} +function pickFish(c, far) { + const pool = FISHES.filter(f => far >= f.far[0] && far <= f.far[1] && (!f.gag || random(c) < 0.35)); + const f = pool[Math.floor(random(c) * pool.length)] ?? FISHES[1]; + const size = 0.8 + random(c) * 0.5; // non tutti i cavedani sono uguali + return { ...f, weight: Math.round(f.weight * size * 100) / 100, stamina: f.stamina * size, dist: 1, energy: 1, burst: random(c) * 6, + reel: 0.22 / (0.7 + f.stamina * size) }; // i piccoli vengono a riva in fretta, le carpe no +} +export function cast(c) { + if (!canCast(c)) return false; + c.float = { x: distanceFor(c.power), y: WATER, t: 0, total: 0, fly: 0, dip: 0, missed: 0, from: null }; + c.step = 'cast'; c.t = 0; c.casts++; c.fish = null; c.tension = 0; c.pull = 0; c.reeling = false; c.last = null; + event(c, 'pesca-lancio', c.power); + return true; +} +// Il tasto dei comandi: in attesa ferra; nel recupero alterna recupera e molla (col dito si tiene premuto da sé). +export function shoot(c) { + if (c.step === 'aim') return cast(c); + if (c.step === 'wait') return strike(c); + if (c.step === 'fight') { c.reeling = !c.reeling; return true; } + return false; +} +// Un gesto solo: premendo si ferra e, se il pesce c'è, si comincia subito a recuperare; lasciando si molla. +export function press(c) { + if (c.step === 'aim') { if (!canCast(c)) return false; c.previousPower = c.power; c.holding = true; c.holdTime = c.power = 0; return true; } + if (c.step === 'wait') { strike(c); if (c.step === 'fight') c.reeling = true; return true; } + if (c.step === 'fight') { c.reeling = true; return true; } + return false; +} +export function cancel(c) { + if (c.holding) { c.holding = false; c.power = c.previousPower; c.holdTime = 0; return true; } + if (c.reeling) { c.reeling = false; return true; } + return false; +} +export function release(c) { + if (c.holding) { + if (c.holdTime < 0.14) return cancel(c); // un tocco breve non spreca il lancio + c.holding = false; return cast(c); + } + if (c.reeling) { c.reeling = false; return true; } + return false; +} +export const biting = c => c.step === 'wait' && c.float?.bitten && c.float.t - c.float.biteAt <= c.skill.window; +// Ferrare: dentro la finestra il pesce è preso; prima, o dopo, si recupera la lenza vuota (lancio sprecato). +export function strike(c) { + const f = c.float; + if (c.step !== 'wait' || !f) return false; + if (biting(c)) { + c.fish = pickFish(c, (f.x - NEAR) / (FAR - NEAR)); + f.from = f.x; + c.step = 'fight'; c.t = 0; c.tension = 0.3; c.reeling = false; c.danger = 0; + event(c, 'pesca-ferrata', 0.8); + } else { + const late = f.bitten && f.t > f.biteAt; + const touched = f.nibbles.some(n => f.t >= n.at); + c.last = { kind: late ? 'late' : touched ? 'nibble' : 'early', + text: late ? 'Troppo tardi: l\'ha già sputato.' : touched ? 'Era solo un tocco: a tirare così l\'ho spaventato.' : 'Ho tirato a vuoto: non aveva ancora abboccato.' }; + event(c, 'pesca-vuoto', 0.4); + end(c, 'empty'); + } + return true; +} +function end(c, how) { + if (how === 'landed') { + c.caught.push(c.fish); + if (!c.fish.gag) c.weight = Math.round((c.weight + c.fish.weight) * 100) / 100; + event(c, 'pesca-preso', 0.9); + } + if (how === 'broken') event(c, 'pesca-rottura', 1); + if (how === 'free') event(c, 'pesca-vuoto', 0.6); + c.step = 'settle'; c.t = 0; c.how = how; c.reeling = false; c.pull = 0; + // L'ultimo lancio decide subito il risultato: uscire durante la posa non è abbandonare. + if (c.casts >= CASTS) finish(c); +} +function finish(c) { + c.result = { winner: fishCount(c) >= c.skill.goal ? 'player' : 'cpu', fish: fishCount(c), weight: c.weight, goal: c.skill.goal, caught: c.caught }; +} +export function update(c, dt) { + if (!Number.isFinite(dt) || dt <= 0) return; + dt = Math.min(dt, 0.25); c.t += dt; + if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); } + const f = c.float; + if (c.step === 'cast') { + f.fly = Math.min(1, c.t / FLY); + if (c.t >= FLY) { c.step = 'wait'; f.t = 0; schedule(c); c.ripples.push({ x: f.x, age: 0 }); event(c, 'pesca-tuffo', 0.4); } + } else if (c.step === 'wait') { + f.t += dt; f.total += dt; + const nibbling = f.nibbles.some(n => f.t >= n.at && f.t < n.at + n.for); + if (!f.bitten && f.t >= f.biteAt) { f.bitten = true; event(c, 'pesca-abbocca', 0.6); c.ripples.push({ x: f.x, age: 0 }); } + const since = f.bitten ? f.t - f.biteAt : -1; + if (f.bitten && since > c.skill.window + 0.4) { + // Non ferrato in tempo: il pesce se ne va, si riprova, ma non all'infinito. + f.missed++; f.t = 0; schedule(c); + if (f.missed >= 3) { c.last = { kind: 'gone', text: 'Tre abboccate e niente: qui non abbocca più nessuno.' }; end(c, 'empty'); } + } else if (nibbling) { if (!f.nibbleSound) { event(c, 'pesca-tocco', 0.3); f.nibbleSound = true; } } + else f.nibbleSound = false; + // Sotto finché si può ferrare; poi riaffiora mentre il pesce lo sputa; i tocchi lo abbassano appena. + f.dip = biting(c) ? 7 : f.bitten && since <= c.skill.window + 0.4 ? 3 : nibbling ? 2.5 : 0; + if (f.total > WAIT_MAX) { c.last = { kind: 'gone', text: 'Niente. Qui i pesci dormono.' }; end(c, 'empty'); } + } else if (c.step === 'fight') { + const fish = c.fish, k = c.skill.pull; + fish.burst += dt; + // Gli strattoni: brevi, a picco; la lenza tesa li sente poco dopo, e c'è il tempo di mollare. + const burst = 0.35 + 0.95 * Math.max(0, Math.sin(fish.burst * 2.1)) ** 3, peak = fish.strength * k * (0.3 + 0.7 * fish.energy); + c.pull = peak * burst; + c.danger = burst / 1.3; // quanto è forte lo strattone rispetto al massimo di questo pesce: la lenza trema prima + const target = c.reeling ? 0.42 + c.pull * 1.1 : c.pull * 0.3; + c.tension += (target - c.tension) * Math.min(1, dt * 6.5); + if (c.reeling) { + fish.dist -= dt * fish.reel * (1 - c.pull * 0.4); fish.energy = Math.max(0, fish.energy - dt * 0.09 / fish.stamina); + // Il cigolio della lenza tesa, non a ogni fotogramma. + c.reelSound = (c.reelSound ?? 0) - dt; + if (c.tension > 0.6 && c.reelSound <= 0) { event(c, 'pesca-mulinello', c.tension); c.reelSound = 0.14; } + } else { fish.dist += dt * (0.02 + 0.05 * c.pull); fish.energy = Math.max(0, fish.energy - dt * 0.03 / fish.stamina); } + f.x = SHORE + fish.dist * (f.from - SHORE); + f.dip = 5 + Math.sin(c.t * 7) * 2; + if (c.tension >= 1) { c.last = { kind: 'broken', text: 'Crac! La lenza non ha retto.' }; end(c, 'broken'); } + else if (fish.dist >= 1.35) { c.last = { kind: 'free', text: 'Ha preso troppo filo: si è slamato.' }; end(c, 'free'); } + else if (fish.dist <= 0) { + c.last = { kind: 'landed', text: fish.gag ? 'Uno stivale. Numero quarantatré. Di chi sarà?' : `${fish.name[0].toUpperCase()}${fish.name.slice(1)}, ${fish.weight} kg!` }; + end(c, 'landed'); + } + } else if (c.step === 'settle' && c.t >= REST) { + c.float = null; c.fish = null; c.tension = 0; + if (c.casts >= CASTS) c.step = 'result'; else { c.step = 'aim'; c.t = 0; } + } + for (const r of c.ripples) r.age += dt; + c.ripples = c.ripples.filter(r => r.age < 1.6); +} +export function status(c, names = { player: 'Andrea' }) { + const tally = `Pesci ${fishCount(c)} (ne servono ${c.skill.goal}), ${c.weight} kg.`; + if (c.step === 'result') return `${c.result.fish} pesci su ${CASTS} lanci, ${c.result.weight} kg. ${c.result.winner === 'player' ? `Obiettivo ${c.result.goal}: fatto!` : `Ne servivano ${c.result.goal}.`}`; + if (c.step === 'cast') return 'Lancio…'; + if (c.step === 'wait') return biting(c) ? 'ABBOCCA! Ferra!' : c.float.bitten ? 'Lo sta sputando…' : c.float.dip > 0 ? 'Un tocco… aspetta.' : c.float.missed ? `Riprova: ${3 - c.float.missed} abboccate ancora.` : 'Il galleggiante è fermo. Si aspetta.'; + if (c.step === 'fight') return c.tension > 0.85 ? 'La lenza tira troppo: MOLLA!' : c.danger > 0.55 ? 'Strattona…' : c.reeling ? 'Recupera…' : 'Tieni premuto per recuperare.'; + if (c.step === 'settle') return c.last?.text ?? ''; + return `${names.player}, lancio ${c.casts + 1} di ${CASTS}. ${tally}`; +} +export const button = c => c.step === 'result' ? 'Finita' : c.step === 'wait' ? 'Ferra' : c.step === 'fight' ? (c.reeling ? 'Molla' : 'Recupera') : c.step === 'aim' ? 'Lancia' : '…'; diff --git a/src/engine/render.js b/src/engine/render.js index f727eeb..fbae840 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -6,6 +6,7 @@ import { drawTorch } from './torch.js'; import { drawBocce } from './bocce-render.js'; import { drawBiliardo } from './biliardo-render.js'; import { drawBasket } from './basket-render.js'; +import { drawPesca } from './pesca-render.js'; import { drawBriscola } from './briscola-render.js'; import { drawTressette } from './tressette-render.js'; import { drawScopa } from './scopa-render.js'; @@ -683,6 +684,11 @@ function drawCloseup(ctx, game, time) { drawBocce(ctx, c, def, backgrounds.get(def.picture), time); return; } + if (def.type === 'pesca') { + if (!backgrounds.has(def.picture)) loadBackground(def.picture); + drawPesca(ctx, c, def, backgrounds.get(def.picture), sprites.get('andrea_pesca'), sprites.get('pescatori')); + return; + } if (def.type === 'basket') { if (!backgrounds.has(def.picture)) loadBackground(def.picture); drawBasket(ctx, c, def, backgrounds.get(def.picture), sprites.get('andrea_basket')); diff --git a/src/engine/soundscape.js b/src/engine/soundscape.js index 45e79f6..39765c9 100644 --- a/src/engine/soundscape.js +++ b/src/engine/soundscape.js @@ -125,6 +125,16 @@ export class Soundscape { else if (kind === 'basket-board') { this.tone(280, 170, 0.09, 0.05 + strength * 0.08, 0, pan); this.noiseSound(0.07, 900, 0.07, { pan }); } else if (kind === 'basket-swish') this.noiseSound(0.28, 3200, 0.1, { paper: true, pan }); else if (kind === 'basket-in') { this.tone(1300, 1000, 0.08, 0.04, 0, pan); this.noiseSound(0.22, 2800, 0.08, { paper: true, pan }); } + // La pesca: il lancio, il tuffo del galleggiante, il tocco, l'abboccata, la ferrata, la lenza che tira, lo strappo, il pesce a riva. + else if (kind === 'pesca-lancio') this.noiseSound(0.22, 1800, 0.05 + strength * 0.06, { pan }); + else if (kind === 'pesca-tuffo') { this.tone(420, 180, 0.07, 0.07, 0, pan); this.noiseSound(0.12, 900, 0.06, { pan }); } + else if (kind === 'pesca-tocco') this.tone(900, 700, 0.04, 0.03, 0, pan); + else if (kind === 'pesca-abbocca') { this.tone(520, 260, 0.1, 0.08, 0, pan); this.noiseSound(0.1, 1100, 0.06, { pan }); } + else if (kind === 'pesca-ferrata') this.noiseSound(0.1, 2600, 0.08, { pan }); + else if (kind === 'pesca-mulinello') this.tone(1800 + strength * 900, 1700 + strength * 900, 0.05, 0.015 + strength * 0.02, 0, pan); + else if (kind === 'pesca-rottura') { this.tone(2400, 300, 0.12, 0.1, 0, pan); this.noiseSound(0.08, 3000, 0.06, { pan }); } + else if (kind === 'pesca-vuoto') this.noiseSound(0.14, 1400, 0.05, { pan }); + else if (kind === 'pesca-preso') { this.noiseSound(0.25, 1000, 0.1, { pan }); this.tone(660, 990, 0.18, 0.05, 0.1, pan); } else if (kind === 'wood') this.noiseSound(0.3, 750, 0.13, { pan }); else if (kind === 'tavolo-lontano') { // Su un altro tavolo: il colpo di stecca, poi la biglia che ne prende un'altra, piano, da lontano. diff --git a/src/main.js b/src/main.js index ba4cc1b..284b7c0 100644 --- a/src/main.js +++ b/src/main.js @@ -11,6 +11,7 @@ import { soundscapeFor } from './story/soundscapes.js'; import * as bocce from './engine/bocce.js'; import * as biliardo from './engine/biliardo.js'; import * as basket from './engine/basket.js'; +import * as pesca from './engine/pesca.js'; import * as briscola from './engine/briscola.js'; import { cardAt } from './engine/briscola-render.js'; import * as tressette from './engine/tressette.js'; @@ -501,7 +502,7 @@ canvas.addEventListener('pointerdown', e => { 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 } - if (['bocce', 'basket'].includes(story.closeups[game.closeup.id].type) && aimed) game.closeup.aimLocked = true; + if (['bocce', 'basket', 'pesca'].includes(story.closeups[game.closeup.id].type) && aimed) game.closeup.aimLocked = true; press = { closeup: true }; // Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta). if (!game.skip()) game.closeupPress(); @@ -551,7 +552,7 @@ canvas.addEventListener('pointermove', e => { // Al biliardo e alle bocce la mira segue il mouse finché un clic (o una regolazione fine) non la fissa: dopo, // attraversare il campo per raggiungere la forza, «Tira» o «Lancia» non cambia il colpo. Al tiro dopo torna libera. // (Bocce: scoperto girando il video «Come si gioca», la mira finiva a due passi andando verso il cursore.) - const aiming = game.closeup && ['biliardo', 'bocce', 'basket'].includes(story.closeups[game.closeup.id].type); + const aiming = game.closeup && ['biliardo', 'bocce', 'basket', 'pesca'].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)); }); @@ -565,6 +566,7 @@ const MINI = { bocce: { ...bocce, nudge: (c, dir) => bocce.aim(c, c.aim.u, c.aim.w + dir * 0.3), back: 'Torna al pallaio', label: 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' }, biliardo: { ...biliardo, back: 'Torna al bar', label: 'Biliardo: clic o tocco fissa la mira. Frecce per affinarla, Maiusc per passi più piccoli. Regola la forza e premi Tira.' }, 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.' }, + pesca: { ...pesca, back: 'Torna in Fortezza', label: 'La pesca: tieni premuto per lanciare, premi quando il galleggiante va sotto, tieni premuto per recuperare.' }, }; const miniState = () => running && game?.closeup && MINI[story.closeups[game.closeup.id].type] ? game.closeup : null; const mini = () => MINI[story.closeups[game.closeup.id].type]; @@ -906,18 +908,20 @@ function updateUi() { } } if (mg && !cards) { - $('bocce-controls').setAttribute('aria-label', { biliardo: 'Comandi del biliardo', basket: 'Comandi del basket' }[def.type] ?? 'Comandi delle bocce'); + $('bocce-controls').setAttribute('aria-label', { biliardo: 'Comandi del biliardo', basket: 'Comandi del basket', pesca: 'Comandi della pesca' }[def.type] ?? 'Comandi delle bocce'); $('panel').hidden = $('choices').hidden = true; const line = mg.status(c, def.names); if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line; $('bocce-power').value = Math.round(c.power * 100); $('bocce-power-value').value = `${Math.round(c.power * 100)}%`; - for (const id of ['bocce-power', 'bocce-left', 'bocce-right', 'bocce-throw']) $(id).disabled = !mg.canThrow(c) || c.holding || !!game.speech; - $('bocce-throw').textContent = c.step === 'result' ? 'Finita' : c.step === 'rolling' ? 'In movimento…' : c.current === 'cpu' ? `Tira ${def.names.cpu}…` : def.type === 'bocce' ? 'Lancia' : 'Tira'; + for (const id of ['bocce-power', 'bocce-left', 'bocce-right']) $(id).disabled = !(mg.canAdjust?.(c) ?? mg.canThrow(c)) || c.holding || !!game.speech; + $('bocce-throw').disabled = !mg.canThrow(c) || c.holding || !!game.speech; + $('bocce-throw').textContent = mg.button?.(c) ?? (c.step === 'result' ? 'Finita' : c.step === 'rolling' ? 'In movimento…' : c.current === 'cpu' ? `Tira ${def.names.cpu}…` : def.type === 'bocce' ? 'Lancia' : 'Tira'); $('bocce-help').hidden = def.type !== 'bocce'; $('biliardo-help').hidden = def.type !== 'biliardo'; $('basket-help').hidden = def.type !== 'basket'; - if (!mg.canThrow(c)) c.aimLocked = false; // a ogni nuovo tiro la mira torna libera + $('pesca-help').hidden = def.type !== 'pesca'; + if (mg.aimFree?.(c) ?? !mg.canThrow(c)) c.aimLocked = false; // a ogni nuovo tiro la mira torna libera } if (mg) for (const event of c.events.splice(0)) ambience.play(event.kind, { pan: 0, strength: event.power }); // Il pulsante della mappa c'è solo in partita, fuori dai dialoghi, e quando c'è almeno un altro posto dove andare. diff --git a/src/story/story.js b/src/story/story.js index dd8b079..06f0a5c 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -166,6 +166,24 @@ export const story = { quit: [{ say: 'Lascio il pallone lì. Tanto lo sanno tutti che è mio.' }], draw: [], }, + // La pesca al lago dell'Anatre coi ragazzi delle canne (idea di Andrea, 28 ottobre): sei lanci, da solo con la + // canna di bambù; la gloria dipende dal livello (pesca.LEVELS). [DA ANDREA: le sue battute] + pesca: { + type: 'pesca', + picture: 'assets/closeups/lago-anatre.png', + names: { player: 'Andrea', cpu: 'il lago' }, + win: [ + { say: 'Oh, hai visto? Mica male, per una canna di bambù.', who: 'pescatori', pose: 'laugh' }, + { say: 'Stasera si frigge. Ma prima il palio.' }, + { set: 'pesca_vinta' }, + ], + lose: [ + { say: 'I pesci c\'erano. Eri te che non c\'eri.', who: 'pescatori', pose: 'talk' }, + { say: 'Si rifà. Dopo il palio.' }, + ], + quit: [{ say: 'Lascio la canna ai ragazzi. Il lago non scappa, il palio sì.' }], + draw: [], + }, // Il biliardo del Circolo 4 Maggio contro l'Ing. Tozzi: cinque birilli, un colpo a testa, si va a 20. [DA ANDREA: le sue battute] biliardo: { type: 'biliardo', @@ -233,6 +251,7 @@ export const story = { andrea: { name: 'Andrea', color: '#ffffff', sprite: 'assets/characters/andrea.json' }, // Andrea col pallone, per «Fai due tiri» (foglio di Codex dal suo sprite): non parla, lo disegna basket-render. andrea_basket: { name: 'Andrea', sprite: 'assets/characters/andrea-basket.json' }, + andrea_pesca: { name: 'Andrea', sprite: 'assets/characters/andrea-pesca.json' }, // Quattro ragazzi inventati (sprite di Codex, idea di Andrea del 27 ottobre): nessuna persona reale. pescatori: { name: 'I ragazzi delle canne', color: '#b8f08a', sprite: 'assets/characters/pescatori.json' }, vecchietta: { name: 'Vecchietta', color: '#ffd27a', sprite: 'assets/characters/vecchietta.json' }, @@ -529,12 +548,13 @@ export const story = { id: 'pescatori', name: 'ragazzi con le canne', box: [92, 236, 120, 86], at: [240, 334], face: 'left', default: 'talk', actions: { look: [{ say: 'Quattro ragazzi che affilano canne di bambù col coltellino e ci legano le lenze.' }], - talk: [ + talk: [{ if: '!pescatori_parlato', then: [ + { set: 'pescatori_parlato' }, { say: 'Oh, che fate?' }, - { say: 'Si preparano le canne: domattina si va a pescare al lago dell\'Anatre, dietro Cardetole!', who: 'pescatori', pose: 'talk' }, + { say: 'Si preparano le canne: si va a pescare al lago dell\'Anatre, dietro Cardetole!', who: 'pescatori', pose: 'talk' }, { say: 'Guarda questa: la lenza la regge anche una carpa di dieci chili.', who: 'pescatori', pose: 'proud' }, { say: '(Vedi, mia mamma aveva ragione: in Fortezza si viene a farsi le canne.)' }, - ], + ] }, { dialogue: 'pescatori' }], }, }, { @@ -2695,6 +2715,28 @@ export const story = { }, }, // Mariano al pallaio: una mano a bocce, solo per gloria; se Andrea vince, la promessa di Brento Sanico. + // I ragazzi delle canne: «S'ha andà a fallo?!?» (Andrea, 28 ottobre) e si va a pescare. + pescatori: { + nodes: { + start: { + options: [ + { text: 'S\'ha andà a fallo?!?', next: 'livello', then: [ + { say: 'S\'ha andà! La canna te la diamo noi.', who: 'pescatori', pose: 'laugh' }, + { say: 'Come peschi?', who: 'pescatori', pose: 'talk' }, + ] }, + { text: 'Quanti ne prendete, di solito?', then: [{ say: 'Dipende da chi racconta. Il Nanni dice dieci, il cestino dice due.', who: 'pescatori', pose: 'laugh' }] }, + { text: 'Buona pesca, ragazzi.', next: 'end', then: [{ say: 'Se vedi il palio, salutacelo!', who: 'pescatori', pose: 'talk' }] }, + ], + }, + livello: { + options: [ + { text: 'Alla bona, tanto per bagnare la lenza.', next: 'end', then: [{ say: 'Alla bona. Due pesci e si va a casa contenti.', who: 'pescatori' }, { closeup: 'pesca', level: 'bona' }] }, + { text: 'Seria.', next: 'end', then: [{ say: 'Seria: tre pesci, e lo stivale non conta.', who: 'pescatori', pose: 'proud' }, { closeup: 'pesca', level: 'seria' }] }, + { text: 'A fassi male.', next: 'end', then: [{ say: 'A fassi male? Quattro pesci, e le carpe tirano come trattori.', who: 'pescatori', pose: 'laugh' }, { closeup: 'pesca', level: 'male' }] }, + ], + }, + }, + }, // Da solo al campo del Circolo: Andrea decide quanto farsi male. basket: { nodes: { diff --git a/style.css b/style.css index 7d90d49..c7357dc 100644 --- a/style.css +++ b/style.css @@ -53,16 +53,16 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h .bocce-adjust button { min-height: 44px; padding: 8px 12px; border: 1px solid #897b57; border-radius: 4px; background: #38453c; color: #f2e1bf; cursor: pointer; font-size: 11px; } #bocce-throw { background: #ead09a; color: #202b25; min-width: 100px; } .bocce-adjust button:disabled { opacity: .4; cursor: default; } -#bocce-help, #biliardo-help, #basket-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; } -#bocce-help summary, #biliardo-help summary, #basket-help summary { cursor: pointer; padding: 6px 0; } -#bocce-help p, #biliardo-help p, #basket-help p { max-width: 76em; line-height: 1.7; } +#bocce-help, #biliardo-help, #basket-help, #pesca-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; } +#bocce-help summary, #biliardo-help summary, #basket-help summary, #pesca-help summary { cursor: pointer; padding: 6px 0; } +#bocce-help p, #biliardo-help p, #basket-help p, #pesca-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 { font-size: 9px; } + #bocce-help, #biliardo-help, #basket-help, #pesca-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/browser.mjs b/tests/browser.mjs index f11c1d8..01594e4 100644 --- a/tests/browser.mjs +++ b/tests/browser.mjs @@ -483,6 +483,38 @@ try { await k.close(); } + // La pesca al lago dell'Anatre: coi ragazzi in Fortezza «S'ha andà a fallo?!?», poi il lancio tenendo premuto e l'attesa. + { + const k = await open({ width: 1280, height: 860 }); + await k.click('#splash'); + await k.click('#new-game'); + await k.click('#rione [data-rione="campomigliaio"]'); + await k.click('#skip-intro'); + await settle(k); + await k.evaluate(() => game.go('fortezza', [240, 334])); + await settle(k); + await clickScene(k, await hotspotCenter(k, 'pescatori')); + await k.waitForFunction(() => game.dialogue && !game.busy, null, { timeout: 40000 }); + await k.locator('#choices li', { hasText: 'S’ha andà a fallo?!?' }).or(k.locator('#choices li', { hasText: "S'ha andà a fallo?!?" })).first().click(); + await k.waitForFunction(() => game.options().some(o => o.text === 'Seria.'), null, { timeout: 30000 }); + await k.locator('#choices li', { hasText: 'Seria.' }).click(); + await k.waitForFunction(() => game.closeup?.id === 'pesca' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 }); + const r = await k.locator('#scene').boundingBox(); + await k.mouse.move(r.x + r.width * 0.7, r.y + r.height * 0.7); + assert.ok(await k.evaluate(() => game.closeup.power > 0.5), 'toccando l\'acqua al largo la forza sale'); + await k.mouse.down(); + await k.waitForTimeout(500); + assert.ok(await k.evaluate(() => game.closeup.holding && game.closeup.power > 0.2), 'tenendo premuto si carica il lancio'); + await k.mouse.up(); + await k.waitForFunction(() => game.closeup.step === 'wait', null, { timeout: 5000 }); + await k.waitForTimeout(600); + await k.screenshot({ path: `${OUT}/03g-pesca-attesa.png` }); + assert.equal(await k.textContent('#bocce-throw'), 'Ferra'); + await k.keyboard.press('Escape'); + await k.waitForFunction(() => game.speech?.text.startsWith('Lascio la canna'), null, { timeout: 5000 }); + await k.close(); + } + // Il biliardo del Circolo 4 Maggio: la sala, poi la partita; un tocco sul tavolo mira, tenendo premuto la forza sale. { const b = await open({ width: 1280, height: 860 }); diff --git a/tests/pesca.test.mjs b/tests/pesca.test.mjs new file mode 100644 index 0000000..3042052 --- /dev/null +++ b/tests/pesca.test.mjs @@ -0,0 +1,185 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { newPesca, press, release, cancel, update, cast, strike, shoot, setPower, aimScreen, nudge, status, canThrow, canCast, canAdjust, + distanceFor, biting, CASTS, LEVELS, FISHES, NEAR, FAR, BANK, SHORE, WATER, FLY, REST } from '../src/engine/pesca.js'; +import { story } from '../src/story/story.js'; +import { Game } from '../src/engine/game.js'; +import { settle } from './helpers.mjs'; + +// Come il gioco: a ogni fotogramma gli eventi sonori si consumano (e qui si ricordano). +const heard = new Set(); +const run = (c, seconds, dt = 1 / 60) => { for (let t = 0; t < seconds; t += dt) { update(c, dt); for (const e of c.events.splice(0)) heard.add(e.kind); } }; +// Lancia e aspetta l'abboccata vera (non i tocchi), poi ferra: la mano ferma dei test. +function hook(c) { + assert.ok(cast(c)); run(c, FLY + 0.05); + assert.equal(c.step, 'wait'); + for (let i = 0; i < 60 * 15 && !c.float.bitten; i++) update(c, 1 / 60); + assert.ok(c.float.bitten, 'prima o poi abbocca'); + assert.ok(strike(c)); assert.equal(c.step, 'fight'); +} +// Recupera con giudizio: tiene premuto finché la lenza non è quasi al limite, poi molla un attimo. +function land(c) { + for (let i = 0; i < 60 * 90 && c.step === 'fight'; i++) { + if (c.tension > 0.8 && c.reeling) release(c); + else if (c.tension < 0.55 && !c.reeling) press(c); + update(c, 1 / 60); for (const e of c.events.splice(0)) heard.add(e.kind); + } + assert.notEqual(c.step, 'fight', 'il recupero finisce'); +} + +test('la forza sale finché si tiene premuto; un tocco breve non spreca il lancio; dove si tocca l\'acqua si lancia', () => { + const c = newPesca(1); + assert.ok(press(c)); update(c, 0.05); assert.ok(release(c)); + assert.equal(c.casts, 0); assert.equal(c.power, 0.5); + assert.ok(aimScreen(c, [FAR, 250])); assert.equal(c.power, 1); + assert.ok(aimScreen(c, [NEAR, 250])); assert.equal(c.power, 0); + assert.ok(!aimScreen(c, [BANK - 20, 250]), 'sulla riva non si lancia'); + assert.ok(!aimScreen(c, [400, 60]), 'il cielo non è acqua'); + assert.ok(nudge(c, 1)); assert.ok(Math.abs(c.power - 0.05) < 1e-9); + setPower(c, 0.5); + assert.ok(press(c)); update(c, 0.7); assert.ok(release(c)); + assert.equal(c.casts, 1); assert.equal(c.step, 'cast'); + assert.ok(Math.abs(c.float.x - distanceFor(c.power)) < 1e-9); + assert.ok(c.events.some(e => e.kind === 'pesca-lancio')); + assert.ok(!setPower(c, 0.2), 'a lenza in acqua la forza non si tocca'); +}); + +test('il galleggiante: i tocchi finti abbassano appena, l\'abboccata lo porta sotto; ferrare a vuoto perde il lancio', () => { + const c = newPesca(2); cast(c); run(c, FLY + 0.05); + const f = c.float; + assert.ok(f.nibbles.length >= 1 && f.nibbles.every(n => n.at < f.biteAt), 'i tocchi vengono prima'); + assert.ok(strike(c)); assert.equal(c.step, 'settle'); assert.equal(c.how, 'empty'); + assert.match(c.last.text, /a vuoto|spaventato/); + run(c, REST + 0.1); assert.equal(c.step, 'aim'); assert.equal(c.casts, 1); + const d = newPesca(3); cast(d); run(d, FLY + 0.05); + const g = d.float; + for (let i = 0; i < 60 * 15 && !(g.t >= g.nibbles[0].at && g.t < g.nibbles[0].at + g.nibbles[0].for); i++) update(d, 1 / 60); + assert.ok(g.dip > 0 && g.dip < 5, `un tocco finto: ${g.dip}`); + for (let i = 0; i < 60 * 15 && !g.bitten; i++) update(d, 1 / 60); + assert.equal(g.dip, 7, 'abboccata: sotto'); + assert.ok(d.events.some(e => e.kind === 'pesca-abbocca')); +}); + +test('non ferrata in tempo, l\'abboccata passa; alla terza il pesce se ne va', () => { + const c = newPesca(4, { level: 'male' }); cast(c); run(c, FLY + 0.05); + for (let i = 0; i < 60 * 15 && !c.float.bitten; i++) update(c, 1 / 60); + // Il segnale e la finestra coincidono: finché dice «Ferra!» si ferra; dopo, lo sta sputando. + assert.ok(biting(c) && c.float.dip === 7 && /Ferra!/.test(status(c))); + run(c, c.skill.window + 0.05); + assert.ok(!biting(c) && c.float.bitten && c.float.dip === 3 && /sputando/.test(status(c))); + const late = structuredClone(c); strike(late); + assert.equal(late.last.kind, 'late'); assert.match(late.last.text, /tardi/); + run(c, 0.5); + assert.ok(!c.float.bitten && c.float.missed === 1, 'persa la prima'); + assert.equal(c.step, 'wait'); + assert.match(status(c), /2 abboccate ancora/); + run(c, 60); + assert.equal(c.how, 'empty', 'dopo tre, o dopo quaranta secondi, si ricupera a vuoto'); + assert.equal(c.last.kind, 'gone'); assert.equal(c.step, 'aim'); +}); + +test('ferrato in tempo si combatte: tenendo premuto la lenza tira e il pesce si avvicina, mollando si allontana; troppo tesa si spezza', () => { + const c = newPesca(5, { level: 'bona' }); setPower(c, 0.5); hook(c); + assert.ok(c.fish && c.fish.dist === 1); + const x0 = c.float.x; + press(c); run(c, 1.5); + assert.ok(c.float.x < x0 && c.float.x > SHORE, `il galleggiante viene verso riva senza salti: ${x0} → ${c.float.x}`); + assert.ok(c.fish.dist < 1 && c.tension > 0.3, 'recuperando si avvicina'); + const d0 = c.fish.dist; + release(c); run(c, 1.5); + assert.ok(c.fish.dist > d0 - 0.001, 'mollando non si avvicina'); + land(c); + assert.equal(c.how, 'landed', 'con giudizio si tira a riva'); + assert.equal(c.caught.length, 1); assert.ok(c.weight > 0); + assert.ok(heard.has('pesca-preso')); + // A fassi male, una carpa al largo tenuta sempre tesa spezza la lenza. + const d = newPesca(6, { level: 'male' }); setPower(d, 1); hook(d); + d.fish = { ...FISHES.at(-1), dist: 1, energy: 1, burst: 0, reel: 0.11 }; + press(d); run(d, 8); + assert.equal(d.how, 'broken', `invece: ${d.step} ${d.how}`); + assert.ok(heard.has('pesca-rottura')); +}); + +test('un gesto solo: premendo all\'abboccata si ferra e si recupera; il pannello alterna recupera e molla; il sesto lancio si può finire', () => { + const c = newPesca(21, { level: 'bona' }); setPower(c, 0.3); cast(c); run(c, FLY + 0.05); + for (let i = 0; i < 60 * 15 && !c.float.bitten; i++) update(c, 1 / 60); + assert.ok(press(c)); assert.equal(c.step, 'fight'); assert.ok(c.reeling, 'ferrato e già in recupero'); + assert.ok(release(c)); assert.ok(!c.reeling); + assert.ok(shoot(c)); assert.ok(c.reeling, 'dal pannello: recupera'); assert.ok(shoot(c)); assert.ok(!c.reeling, 'dal pannello: molla'); + const d = newPesca(22); d.casts = CASTS - 1; + assert.ok(canCast(d)); cast(d); run(d, FLY + 0.05); + assert.ok(!canCast(d) && canThrow(d), 'al sesto lancio si può ancora ferrare'); + assert.ok(!canAdjust(d), 'ma non regolare la distanza'); + for (let i = 0; i < 60 * 15 && !d.float.bitten; i++) update(d, 1 / 60); + assert.ok(press(d)); assert.equal(d.step, 'fight'); assert.ok(canThrow(d), 'e recuperare'); + d.fish = { ...FISHES[1], dist: 1, energy: 1, burst: 0, reel: 0.2 }; land(d); + assert.equal(d.how, 'landed'); + assert.ok(d.result, 'il risultato è deciso appena il pesce è a riva, prima della posa'); + run(d, REST + 0.1); assert.equal(d.step, 'result'); +}); + +test('la lenza si spezza davvero: tenendo sempre premuto, a seria un cavedano o più grosso la rompe, una lasca no', () => { + const fight = (kind, level) => { const c = newPesca(23, { level }); setPower(c, 0.5); hook(c); c.fish = { ...FISHES.find(f => f.kind === kind), dist: 1, energy: 1, burst: 0, reel: 0.15 }; press(c); run(c, 20); return c.how; }; + assert.equal(fight('lasca', 'seria'), 'landed'); + assert.equal(fight('cavedano', 'seria'), 'broken'); + assert.equal(fight('carpa', 'bona'), 'broken'); + assert.equal(fight('cavedano', 'bona'), 'landed'); + // Mollata e ripresa, la stessa carpa viene a riva. + const c = newPesca(24, { level: 'male' }); setPower(c, 1); hook(c); c.fish = { ...FISHES.at(-1), dist: 1, energy: 1, burst: 0, reel: 0.11 }; land(c); + assert.equal(c.how, 'landed'); +}); + +test('al largo i pesci sono più grossi; lo stivale solo sotto riva e non conta per la gloria', () => { + const near = new Set(), far = new Set(); + for (let seed = 1; seed < 40; seed++) { + for (const [power, set] of [[0, near], [1, far]]) { + const c = newPesca(seed); setPower(c, power); hook(c); set.add(c.fish.kind); + } + } + assert.ok(far.has('carpa') || far.has('tinca'), `al largo: ${[...far]}`); + assert.ok(!near.has('carpa'), 'sotto riva niente carpe'); + assert.ok(!far.has('stivale'), 'lo stivale sta sotto riva'); + const c = newPesca(7, { level: 'bona' }); c.caught = [{ ...FISHES[0] }, { ...FISHES[1] }]; c.weight = 1; + assert.match(status(c), /Pesci 1 /, 'lo stivale non è un pesce'); + const d = newPesca(25); setPower(d, 0); hook(d); d.fish = { ...FISHES[0], dist: 1, energy: 1, burst: 0, reel: 0.3 }; land(d); + assert.equal(d.weight, 0, 'e non pesa'); assert.match(d.last.text, /stivale/); +}); + +test('sei lanci, poi il risultato: la gloria dipende dal livello', () => { + for (const [level, { goal }] of Object.entries(LEVELS)) { + const c = newPesca(8, { level }), fishes = []; + for (let i = 0; i < CASTS; i++) { + assert.ok(canThrow(c)); + // Finché serve si pesca per davvero (con una lasca docile sotto riva), poi si ricupera a vuoto. + if (c.caught.length < goal) { setPower(c, 0); hook(c); c.fish = { ...FISHES[1], dist: 1, energy: 1, burst: 0, reel: 0.2 }; land(c); assert.equal(c.how, 'landed'); } + else { cast(c); run(c, FLY + 0.05); strike(c); } + run(c, REST + 0.1); + } + assert.equal(c.step, 'result'); assert.ok(!canThrow(c)); assert.ok(!cast(c)); + assert.equal(c.result.fish, goal); assert.equal(c.result.winner, 'player', level); + assert.match(status(c), /pesci su 6 lanci/); + } + const miss = newPesca(9, { level: 'bona' }); + for (let i = 0; i < CASTS; i++) { cast(miss); run(miss, FLY + 0.05); strike(miss); run(miss, REST + 0.1); } + assert.equal(miss.result.winner, 'cpu'); +}); + +test('nella storia: coi ragazzi in Fortezza «S\'ha andà a fallo?!?», loro accettano, si sceglie e si pesca; la gloria lascia il flag', () => { + const g = new Game(story); + g.go('fortezza', [240, 334]); settle(g); + g.act('talk', 'pescatori'); const said = settle(g).map(s => s.text); + assert.ok(said.some(t => t.includes('mia mamma aveva ragione')), 'la prima volta le canne'); + assert.ok(g.dialogue, 'poi si parla'); + g.choose(g.options().findIndex(o => o.text === 'S\'ha andà a fallo?!?')); + const yes = settle(g).map(s => `${s.who ?? 'andrea'}: ${s.text}`); + assert.ok(yes.includes('pescatori: S\'ha andà! La canna te la diamo noi.')); + g.choose(g.options().findIndex(o => o.text === 'Seria.')); settle(g); + assert.equal(g.closeup?.id, 'pesca'); assert.equal(g.closeup.level, 'seria'); + g.closeup.result = { winner: 'player', fish: 3, weight: 2.1, goal: 3, caught: [] }; g.closeup.step = 'result'; + g.closeupClose(); const after = settle(g).map(s => s.text); + assert.ok(g.state.flags.pesca_vinta); + assert.ok(after.some(t => t.includes('Stasera si frigge'))); + // La seconda volta si va dritti al dialogo. + g.act('talk', 'pescatori'); settle(g); + assert.ok(g.dialogue); +});