La pesca al lago dell'Anatre: «S'ha andà a fallo?!?» coi ragazzi delle canne

Minigioco nuovo (idea di Andrea, 28 ottobre): parlando coi ragazzi in Fortezza si va a pescare. Primo piano del lago
(sfondo di Codex) con Andrea sulla riva con la canna di bambù (foglio di Codex: aspetta, lancia, recupera, mostra il
pesce) e i ragazzi accanto. Si tiene premuto per lanciare (più lontano, pesci più grossi: lasca, persico, cavedano,
tinca, carpa; sotto riva anche lo stivale, che non conta), il galleggiante ha tocchi finti e un'abboccata vera, si
ferra premendo in tempo e, senza lasciare, si recupera; il pesce strattona, la lenza si tende e bisogna mollare un
attimo o si spezza. Sei lanci, tre livelli (2/3/4 pesci per la gloria, finestra di ferrata e forza dei pesci).
Suoni di lancio, tuffo, tocco, abboccata, lenza, rottura e pesce a riva; stessi comandi delle bocce; test del
motore e prova nel browser. Revisione critica di Codex 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; il resto in docs/STATO.md. Nelle idee, le lavandaie del Palio come le racconta Andrea.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECdBYE8csKd4tcFvwntT
This commit is contained in:
2026-10-28 06:31:23 +01:00
co-authored by Claude Fable 5.1
parent f2a0f22f4a
commit 208e1d9535
36 changed files with 751 additions and 19 deletions
+2
View File
@@ -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'];
+125
View File
@@ -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();
}
+199
View File
@@ -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' : '…';
+6
View File
@@ -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'));
+10
View File
@@ -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.
+10 -6
View File
@@ -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.
+45 -3
View File
@@ -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: {