diff --git a/assets/scenes/circolo-4-maggio.png b/assets/scenes/circolo-4-maggio.png new file mode 100644 index 0000000..e731b2a Binary files /dev/null and b/assets/scenes/circolo-4-maggio.png differ diff --git a/assets/scenes/mini/circolo-4-maggio.png b/assets/scenes/mini/circolo-4-maggio.png new file mode 100644 index 0000000..28cea9c Binary files /dev/null and b/assets/scenes/mini/circolo-4-maggio.png differ diff --git a/assets/voices/andrea-015dc16c6d.mp3 b/assets/voices/andrea-015dc16c6d.mp3 new file mode 100644 index 0000000..419319b Binary files /dev/null and b/assets/voices/andrea-015dc16c6d.mp3 differ diff --git a/assets/voices/andrea-0268742faf.mp3 b/assets/voices/andrea-0268742faf.mp3 new file mode 100644 index 0000000..8a72c25 Binary files /dev/null and b/assets/voices/andrea-0268742faf.mp3 differ diff --git a/assets/voices/andrea-0e0e42a1cd.mp3 b/assets/voices/andrea-0e0e42a1cd.mp3 new file mode 100644 index 0000000..923ee46 Binary files /dev/null and b/assets/voices/andrea-0e0e42a1cd.mp3 differ diff --git a/assets/voices/andrea-1eae1a8577.mp3 b/assets/voices/andrea-1eae1a8577.mp3 new file mode 100644 index 0000000..9c4a9ce Binary files /dev/null and b/assets/voices/andrea-1eae1a8577.mp3 differ diff --git a/assets/voices/andrea-2d04be65fb.mp3 b/assets/voices/andrea-2d04be65fb.mp3 new file mode 100644 index 0000000..cedc5ed Binary files /dev/null and b/assets/voices/andrea-2d04be65fb.mp3 differ diff --git a/assets/voices/andrea-4a03e51d2c.mp3 b/assets/voices/andrea-4a03e51d2c.mp3 new file mode 100644 index 0000000..e1c1541 Binary files /dev/null and b/assets/voices/andrea-4a03e51d2c.mp3 differ diff --git a/assets/voices/andrea-98b3e4a969.mp3 b/assets/voices/andrea-98b3e4a969.mp3 new file mode 100644 index 0000000..a83a5ca Binary files /dev/null and b/assets/voices/andrea-98b3e4a969.mp3 differ diff --git a/assets/voices/andrea-a496b0aca9.mp3 b/assets/voices/andrea-a496b0aca9.mp3 new file mode 100644 index 0000000..360fcf6 Binary files /dev/null and b/assets/voices/andrea-a496b0aca9.mp3 differ diff --git a/assets/voices/andrea-b1ba7d3072.mp3 b/assets/voices/andrea-b1ba7d3072.mp3 new file mode 100644 index 0000000..21ef8a3 Binary files /dev/null and b/assets/voices/andrea-b1ba7d3072.mp3 differ diff --git a/assets/voices/andrea-bd95e65b0a.mp3 b/assets/voices/andrea-bd95e65b0a.mp3 new file mode 100644 index 0000000..77ae2b5 Binary files /dev/null and b/assets/voices/andrea-bd95e65b0a.mp3 differ diff --git a/assets/voices/andrea-e393f0bc2a.mp3 b/assets/voices/andrea-e393f0bc2a.mp3 new file mode 100644 index 0000000..0f465cd Binary files /dev/null and b/assets/voices/andrea-e393f0bc2a.mp3 differ diff --git a/assets/voices/andrea-f0aef83c80.mp3 b/assets/voices/andrea-f0aef83c80.mp3 new file mode 100644 index 0000000..014f6c3 Binary files /dev/null and b/assets/voices/andrea-f0aef83c80.mp3 differ diff --git a/assets/voices/index.json b/assets/voices/index.json index 1b3ab37..8d75b08 100644 --- a/assets/voices/index.json +++ b/assets/voices/index.json @@ -75,6 +75,14 @@ "file": "andrea-a9df2e6bbb.mp3", "sec": 4.98 }, + "andrea|Il Circolo 4 Maggio. Dentro, dietro il bar, la sala dei biliardi: tre tavoli e il silenzio delle grandi occasioni.": { + "file": "andrea-f0aef83c80.mp3", + "sec": 9.43 + }, + "andrea|Da fuori non si parla con nessuno: al circolo si entra.": { + "file": "andrea-0e0e42a1cd.mp3", + "sec": 3.69 + }, "andrea|Il programma del Palio della Fortezza, con la foto del palio: la strada bianca che sale alla Fortezza, i ragazzi in fila, e uno con la maglia rossa che alza le braccia.": { "file": "andrea-6cc844498a.mp3", "sec": 10.59 @@ -635,6 +643,54 @@ "file": "andrea-d193b44288.mp3", "sec": 3.65 }, + "andrea|La sala dei biliardi. Qui dentro si parla piano, e si gioca forte.": { + "file": "andrea-0268742faf.mp3", + "sec": 5.45 + }, + "andrea|Il primo tavolo: il panno verde, il castello di birilli in mezzo, le biglie già pronte.": { + "file": "andrea-015dc16c6d.mp3", + "sec": 5.51 + }, + "andrea|L'Ing. Tozzi gli gira intorno con la stecca in mano, come se aspettasse qualcuno.": { + "file": "andrea-2d04be65fb.mp3", + "sec": 5.97 + }, + "ing_tozzi|Una partita ai birilli? Si va a venti.": { + "file": "ing_tozzi-7aef4a5420.mp3", + "sec": 3.3 + }, + "andrea|Gnamo.": { + "file": "andrea-a496b0aca9.mp3", + "sec": 0.58 + }, + "ing_tozzi|Si parla col panno, qui. Prendi la stecca.": { + "file": "ing_tozzi-4814e97759.mp3", + "sec": 3.36 + }, + "andrea|Una biglia in tasca? Mi sbattono fuori dal circolo.": { + "file": "andrea-98b3e4a969.mp3", + "sec": 3.49 + }, + "andrea|Le stecche del circolo, in fila. Ognuna ha il suo proprietario, e guai a sbagliare.": { + "file": "andrea-1eae1a8577.mp3", + "sec": 6.1 + }, + "andrea|La stecca me la dà l'Ingegnere, quando si gioca.": { + "file": "andrea-e393f0bc2a.mp3", + "sec": 2.83 + }, + "andrea|Un campione di biliardo in posa, con la stecca. Avrà il suo nome, ma da qui non si legge.": { + "file": "andrea-4a03e51d2c.mp3", + "sec": 7.65 + }, + "andrea|L'armadietto a vetri, con le stecche buone. Chiuso a chiave, naturalmente.": { + "file": "andrea-b1ba7d3072.mp3", + "sec": 5.06 + }, + "andrea|Chiuso. Le stecche buone sono per chi le sa usare.": { + "file": "andrea-bd95e65b0a.mp3", + "sec": 4.77 + }, "andrea|Il tabernacolo del ponte, con l'Annunciazione di Antonio Berti, scultore sanpierino.": { "file": "andrea-9c0935dc77.mp3", "sec": 5.48 @@ -1899,6 +1955,22 @@ "file": "mariano-39e27158b7.mp3", "sec": 2.83 }, + "ing_tozzi|Venti. Hai vinto te, stasera il caffè lo pago io.": { + "file": "ing_tozzi-51f855478a.mp3", + "sec": 4.42 + }, + "ing_tozzi|Venti! Al biliardo ci vuole la mano ferma, mica la corsa.": { + "file": "ing_tozzi-d557d58aec.mp3", + "sec": 4.66 + }, + "ing_tozzi|Quando vuoi, il tavolo è qui.": { + "file": "ing_tozzi-3d686c8cc5.mp3", + "sec": 2.05 + }, + "ing_tozzi|Lasci a metà? Ti segno la sconfitta a tavolino, eh.": { + "file": "ing_tozzi-282062c119.mp3", + "sec": 3.64 + }, "andrea|Laggiù, appoggiato al muro, c'è un angolo di tela dipinta: una strada bianca, e un omino con la maglia rossa e le braccia alzate.": { "file": "andrea-6484641c58.mp3", "sec": 8.67 diff --git a/assets/voices/ing_tozzi-282062c119.mp3 b/assets/voices/ing_tozzi-282062c119.mp3 new file mode 100644 index 0000000..aca6ae4 Binary files /dev/null and b/assets/voices/ing_tozzi-282062c119.mp3 differ diff --git a/assets/voices/ing_tozzi-3d686c8cc5.mp3 b/assets/voices/ing_tozzi-3d686c8cc5.mp3 new file mode 100644 index 0000000..050b72e Binary files /dev/null and b/assets/voices/ing_tozzi-3d686c8cc5.mp3 differ diff --git a/assets/voices/ing_tozzi-4814e97759.mp3 b/assets/voices/ing_tozzi-4814e97759.mp3 new file mode 100644 index 0000000..f9741dd Binary files /dev/null and b/assets/voices/ing_tozzi-4814e97759.mp3 differ diff --git a/assets/voices/ing_tozzi-51f855478a.mp3 b/assets/voices/ing_tozzi-51f855478a.mp3 new file mode 100644 index 0000000..73987bb Binary files /dev/null and b/assets/voices/ing_tozzi-51f855478a.mp3 differ diff --git a/assets/voices/ing_tozzi-7aef4a5420.mp3 b/assets/voices/ing_tozzi-7aef4a5420.mp3 new file mode 100644 index 0000000..43f123c Binary files /dev/null and b/assets/voices/ing_tozzi-7aef4a5420.mp3 differ diff --git a/assets/voices/ing_tozzi-d557d58aec.mp3 b/assets/voices/ing_tozzi-d557d58aec.mp3 new file mode 100644 index 0000000..c521cc5 Binary files /dev/null and b/assets/voices/ing_tozzi-d557d58aec.mp3 differ diff --git a/docs/IDEE.md b/docs/IDEE.md index 5bfcd53..26cb9b7 100644 --- a/docs/IDEE.md +++ b/docs/IDEE.md @@ -117,4 +117,6 @@ si segna qui con la data e il punto di `src/story/story.js` dove sta; le persone - *Caccia al tesoro*: un giro di indizi fra le scene che ci sono già, senza un minigioco nuovo. Fuori dal Palio: - *Briscola al circolino* con l'Arcu e il Barone (la gag del «Male male si divide»): una mano corta. - - *Rigori all'A. Bettini*: mira e forza come alle bocce, contro un portiere. + - *Rigori all'A. Bettini*: mira e forza come alle bocce, contro un portiere. Andrea ha ritrovato la foto di squadra + sul campo in terra (porta, rete, la torre della Pieve dietro, anni '90): lo sfondo del minigioco lo fa Codex da quella, + senza le persone (per i volti serve il consenso). Da salvare in `assets/raw/foto/campo-bettini-squadra.jpg`. diff --git a/index.html b/index.html index fd2252e..b8e5b1b 100644 --- a/index.html +++ b/index.html @@ -121,6 +121,14 @@
Tre bocce a testa. Tira chi non ha il punto; segna ogni boccia più vicina della migliore avversaria. Vince chi fa più punti nella mano.
+Tocca il tavolo per mirare: la linea mostra dove va la tua biglia e la freccia dove parte quella colpita. + Regola la forza, poi premi Tira. Oppure tieni premuto sul tavolo o Spazio e rilascia per tirare. + Frecce ← →: mira fine; ↑ ↓: forza. Esc esce dalla partita.
+Cinque birilli: devi colpire la biglia avversaria e mandarla sui birilli o sul pallino. Birillo bianco 2 punti, + rosso 4, rosso da solo 10; pallino toccato dalla biglia avversaria 3. Se manchi la biglia avversaria, tocchi prima + il pallino o abbatti birilli con la tua, i punti (almeno 2) vanno all'avversario. Un colpo a testa; vince chi arriva prima a 20.
+Gira il telefono: in orizzontale si gioca meglio.
diff --git a/src/engine/biliardo-render.js b/src/engine/biliardo-render.js new file mode 100644 index 0000000..a5180cb --- /dev/null +++ b/src/engine/biliardo-render.js @@ -0,0 +1,115 @@ +import { LEN, WID, R, PIN, VIEW, canThrow, predict, lastLine, status } from './biliardo.js'; +import { text, panel } from './bocce-render.js'; +import { ambientHash as hash } from './ambient.js'; + +const BALL = { player: ['#8f8160', '#f1e6c8', '#fffdf4'], cpu: ['#8a7220', '#ead77a', '#fff9d2'], pallino: ['#5e1612', '#c9372b', '#ffb3a4'] }; +const sx = x => VIEW.x + x * VIEW.k, sy = y => VIEW.y + y * VIEW.k; +// Le biglie si disegnano un po' più grandi del vero (la fisica resta quella), sennò sul telefono spariscono. +const rr = R * VIEW.k * 1.3; + +function sphere(ctx, role, x, y, r) { + const [dark, mid, light] = BALL[role]; + ctx.fillStyle = 'rgba(0,20,8,0.45)'; ctx.beginPath(); ctx.ellipse(x + r * 0.35, y + r * 0.45, r * 1.05, r * 0.8, 0, 0, Math.PI * 2); ctx.fill(); + const g = ctx.createRadialGradient(x - r * 0.35, y - r * 0.4, 0, x, y, r * 1.05); + g.addColorStop(0, light); g.addColorStop(0.35, mid); g.addColorStop(1, dark); + ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#ffffff'; ctx.fillRect(Math.round(x - r * 0.4), Math.round(y - r * 0.45), 1, 1); +} +function pin(ctx, p) { + const x = sx(p.x), y = sy(p.y), h = 7, w = 3; + const [base, top] = p.red ? ['#8d1f1a', '#e24a3c'] : ['#cfc3a3', '#fff8e4']; + if (p.down) { + // Birillo a terra, dalla parte in cui è caduto. + ctx.strokeStyle = top; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + Math.cos(p.dir) * h, y + Math.sin(p.dir) * h); ctx.stroke(); + ctx.fillStyle = base; ctx.fillRect(Math.round(x) - 1, Math.round(y) - 1, 2, 2); + return; + } + ctx.fillStyle = 'rgba(0,20,8,0.4)'; ctx.fillRect(Math.round(x) - 1, Math.round(y) + 1, w + 1, 2); + ctx.fillStyle = base; ctx.fillRect(Math.round(x - w / 2), Math.round(y - h + 2), w, h - 1); + ctx.fillStyle = top; ctx.fillRect(Math.round(x - w / 2), Math.round(y - h), w, 3); +} + +function table(ctx) { + const x0 = sx(0), y0 = sy(0), w = LEN * VIEW.k, h = WID * VIEW.k; + // Cornice di legno con i diamanti, sponda, panno. + ctx.fillStyle = '#3a2416'; ctx.fillRect(x0 - 16, y0 - 16, w + 32, h + 32); + ctx.fillStyle = '#5a3a22'; ctx.fillRect(x0 - 13, y0 - 13, w + 26, h + 26); + ctx.fillStyle = '#6e4a2c'; ctx.fillRect(x0 - 13, y0 - 13, w + 26, 3); + ctx.fillStyle = '#e7d7a8'; + for (let i = 1; i < 8; i++) { const dx = x0 + w * i / 8; ctx.fillRect(Math.round(dx) - 1, y0 - 9, 2, 2); ctx.fillRect(Math.round(dx) - 1, y0 + h + 7, 2, 2); } + for (let i = 1; i < 4; i++) { const dy = y0 + h * i / 4; ctx.fillRect(x0 - 9, Math.round(dy) - 1, 2, 2); ctx.fillRect(x0 + w + 7, Math.round(dy) - 1, 2, 2); } + ctx.fillStyle = '#1d5a33'; ctx.fillRect(x0 - 5, y0 - 5, w + 10, h + 10); + ctx.fillStyle = '#2a7a44'; ctx.fillRect(x0, y0, w, h); + for (let i = 0; i < 420; i++) { + ctx.fillStyle = i % 2 ? '#2f8349' : '#26713f'; + ctx.fillRect(Math.round(x0 + hash(i + 11) * w), Math.round(y0 + hash(i + 400) * h), 1, 1); + } + // La linea di metà tavolo e il punto del castello. + ctx.fillStyle = 'rgba(255,255,230,0.25)'; ctx.fillRect(Math.round(sx(LEN / 2)), y0, 1, h); +} + +function aimGuide(ctx, c) { + const p = predict(c), me = c.balls[c.current]; + ctx.strokeStyle = 'rgba(255,248,220,0.75)'; ctx.lineWidth = 1; ctx.setLineDash([3, 4]); + ctx.beginPath(); ctx.moveTo(sx(p.points[0][0]), sy(p.points[0][1])); + for (const [x, y] of p.points.slice(1)) ctx.lineTo(sx(x), sy(y)); + ctx.stroke(); ctx.setLineDash([]); + if (p.ghost) { + ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.beginPath(); ctx.arc(sx(p.ghost.x), sy(p.ghost.y), rr, 0, Math.PI * 2); ctx.stroke(); + if (p.dir) { + const o = c.balls[p.hit], L = 22; + ctx.strokeStyle = p.hit === 'pallino' ? 'rgba(255,120,100,0.9)' : 'rgba(150,210,255,0.9)'; + ctx.beginPath(); ctx.moveTo(sx(o.x), sy(o.y)); ctx.lineTo(sx(o.x) + p.dir[0] * L, sy(o.y) + p.dir[1] * L); ctx.stroke(); + } + } + // La stecca dietro la biglia, tirata indietro quanto la forza. + const a = c.angle, back = rr + 6 + c.power * 26, len = 150; + const bx = sx(me.x) - Math.cos(a) * back, by = sy(me.y) - Math.sin(a) * back; + ctx.lineCap = 'round'; + ctx.strokeStyle = '#2b1a10'; ctx.lineWidth = 5; + ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx - Math.cos(a) * len, by - Math.sin(a) * len); ctx.stroke(); + ctx.strokeStyle = '#c99a5b'; ctx.lineWidth = 3; + ctx.beginPath(); ctx.moveTo(bx - Math.cos(a) * 6, by - Math.sin(a) * 6); ctx.lineTo(bx - Math.cos(a) * len, by - Math.sin(a) * len); ctx.stroke(); + ctx.strokeStyle = '#4fb0ff'; ctx.lineWidth = 3; + ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx - Math.cos(a) * 5, by - Math.sin(a) * 5); ctx.stroke(); + ctx.lineCap = 'butt'; +} + +export function drawBiliardo(ctx, c, def) { + ctx.save(); + // La sala, in penombra, e il lume sopra il tavolo. + ctx.fillStyle = '#17110c'; ctx.fillRect(0, 0, 640, 360); + const lamp = ctx.createRadialGradient(320, 200, 60, 320, 200, 420); + lamp.addColorStop(0, 'rgba(255,220,150,0.16)'); lamp.addColorStop(1, 'rgba(255,220,150,0)'); + ctx.fillStyle = lamp; ctx.fillRect(0, 0, 640, 360); + table(ctx); + for (const p of c.pins) pin(ctx, p); + if (c.step === 'aim' && (c.current === 'player' || c.plan)) aimGuide(ctx, c); + for (const role of ['pallino', 'cpu', 'player']) { const b = c.balls[role]; sphere(ctx, role, sx(b.x), sy(b.y), rr); } + // Punteggio: i due nomi, i punti, chi deve tirare. + panel(ctx, 18, 8, 604, 62); + for (const [i, who] of ['player', 'cpu'].entries()) { + const x = 30 + i * 300, y = 24; + sphere(ctx, who, x + 4, y, 4); + text(ctx, def.names[who], x + 14, y, 7, BALL[who][2]); + text(ctx, String(c.points[who]), x + 14, y + 16, 10, c.step === 'aim' && c.current === who ? '#fff2a3' : '#f4e3c2'); + if (c.step === 'aim' && c.current === who) text(ctx, '< al tiro', x + 44, y + 16, 6, '#fff2a3'); + } + const last = c.last && c.last.age < 6 ? lastLine(c, def.names) : ''; + if (last) text(ctx, last, 610, 42, 6, c.last.foul ? '#f3b0a0' : '#c9eab3', 'right'); + text(ctx, c.step === 'aim' ? c.current === 'player' ? 'Tocca il tavolo per mirare; tieni premuto e lascia per tirare.' : `${def.names.cpu} studia il colpo…` : status(c, def.names), 320, 60, 6, '#d7ccb4', 'center'); + if (canThrow(c)) { + panel(ctx, 18, 318, 176, 34); + text(ctx, `FORZA ${Math.round(c.power * 100)}%`, 30, 329, 7); + ctx.fillStyle = '#3e3e36'; ctx.fillRect(30, 341, 150, 5); + ctx.fillStyle = '#b9d392'; ctx.fillRect(30, 341, Math.round(c.power * 150), 5); + } + if (c.step === 'result') { + panel(ctx, 170, 140, 300, 80); + text(ctx, 'PARTITA FINITA', 320, 160, 9, '#efd49e', 'center'); + text(ctx, status(c, def.names), 320, 184, 8, c.result.winner === 'player' ? '#b9e5b3' : '#f1d9be', 'center'); + text(ctx, 'Il tavolo resta così finché esci.', 320, 204, 6, '#d7ccb4', 'center'); + } + ctx.restore(); +} diff --git a/src/engine/biliardo.js b/src/engine/biliardo.js new file mode 100644 index 0000000..1d91030 --- /dev/null +++ b/src/engine/biliardo.js @@ -0,0 +1,259 @@ +// Biliardo all'italiana, cinque birilli, visto dall'alto. Regole da circolo, semplificate: +// si colpisce la propria biglia e la si manda sulla biglia avversaria; i birilli abbattuti dalla biglia avversaria +// o dal pallino fanno punti a chi tira (bianco 2, rosso 4, il rosso da solo 10), il pallino toccato dalla biglia +// avversaria ne vale 3. Fallo, con i punti all'avversario (almeno 2): mancare la biglia avversaria, toccare prima +// il pallino, abbattere birilli con la propria biglia. Un colpo a testa; vince chi arriva prima al traguardo. +// Unità: centimetri. x lungo il tavolo (0..LEN), y di traverso (0..WID). +export const LEN = 284, WID = 142, R = 3.05, PIN = 0.9, GAP = 6.3, TARGET = 20; +export const MIN_SPEED = 50, MAX_SPEED = 520, FRICTION = 70, DRAG = 0.35, CUSHION = 0.78; +export const SWING = 1.4, FIXED_STEP = 1 / 240; +const EPS = 0.5, REST = 0.6, CPU_THINK = 0.2, CPU_SHOT = 1.5; +export const OTHER = { player: 'cpu', cpu: 'player' }; +// Dove sta il tavolo nel primo piano 640×360: angolo in alto a sinistra del panno e pixel per centimetro. +export const VIEW = { x: 57, y: 78, k: 1.85 }; + +const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); +function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; } +const deviation = c => random(c) + random(c) + random(c) - 1.5; +const ball = (role, x, y) => ({ role, x, y, vx: 0, vy: 0 }); +const list = c => [c.balls.player, c.balls.cpu, c.balls.pallino]; +const angleTo = (a, b) => Math.atan2(b.y - a.y, b.x - a.x); + +// Il castello: il rosso in mezzo al tavolo, i quattro bianchi a croce intorno. +export function castle() { + const cx = LEN / 2, cy = WID / 2; + return [[0, 0, true], [-GAP, 0], [GAP, 0], [0, -GAP], [0, GAP]].map(([dx, dy, red]) => ({ x: cx + dx, y: cy + dy, red: !!red, down: false })); +} + +export const powerAt = time => clamp(time / SWING, 0, 1); +export const speedFor = power => MIN_SPEED + (MAX_SPEED - MIN_SPEED) * power; +// Quanta strada fa una biglia lanciata a velocità v, senza urti (attrito costante più un po' di resistenza). +export function reach(v) { + const k = FRICTION / DRAG, t = Math.log((v + k) / k) / DRAG; + return (v + k) / DRAG * (1 - Math.exp(-DRAG * t)) - k * t; +} +export const canThrow = c => c.step === 'aim' && c.current === 'player'; +export const moving = c => list(c).some(b => b.vx || b.vy); + +export function newBiliardo(seed = Date.now() >>> 0, def = {}) { + const c = { step: 'aim', t: 0, seed, current: 'player', target: def.target ?? TARGET, power: 0.45, angle: 0, + holding: false, holdTime: 0, accumulator: 0, events: [], points: { player: 0, cpu: 0 }, last: null, shot: null, plan: null }; + c.balls = { player: ball('player', LEN * 0.18, WID * 0.5), cpu: ball('cpu', LEN * 0.81, WID * 0.25), pallino: ball('pallino', LEN * 0.88, WID * 0.7) }; + c.pins = castle(); + c.angle = angleTo(c.balls.player, c.balls.cpu); + return c; +} + +// Valore dei birilli abbattuti in un colpo. +export function value(pins) { + if (pins.length === 1 && pins[0].red) return 10; + return pins.reduce((s, p) => s + (p.red ? 4 : 2), 0); +} +// Chi prende i punti di un colpo, e quanti. +export function judge(shot) { + const shooter = shot.shooter, other = OTHER[shooter]; + const own = shot.pins.filter(p => p.by === shooter); + const foul = shot.first === null ? 'miss' : shot.first === 'pallino' ? 'pallino' : own.length ? 'own' : null; + if (foul) return { shooter, to: other, points: Math.max(2, value(shot.pins)), foul }; + return { shooter, to: shooter, points: value(shot.pins) + (shot.pallino ? 3 : 0), foul: null }; +} + +function cushions(b) { + let impact = 0; + for (const [p, v, hi] of [['x', 'vx', LEN - R], ['y', 'vy', WID - R]]) { + if (b[p] < R || b[p] > hi) { + const out = b[p] < R ? b[v] < 0 : b[v] > 0; + b[p] = clamp(b[p], R, hi); + if (out) { impact = Math.max(impact, Math.abs(b[v])); b[v] *= -CUSHION; } + } + } + return impact; +} +// Urto fra due biglie uguali, quasi elastico; separa anche quelle ferme e sovrapposte. +export function collide(a, b) { + const dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy); + if (d >= 2 * R) return 0; + const nx = d > 1e-9 ? dx / d : 1, ny = d > 1e-9 ? dy / d : 0, overlap = (2 * R - d) / 2; + a.x -= nx * overlap; a.y -= ny * overlap; b.x += nx * overlap; b.y += ny * overlap; + const closing = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny; + if (closing <= 0) return 0; + const j = closing * 0.975; + a.vx -= j * nx; a.vy -= j * ny; b.vx += j * nx; b.vy += j * ny; + return closing; +} +function touch(shot, a, b) { + if (!shot) return; + const roles = [a.role, b.role], other = OTHER[shot.shooter]; + if (shot.first === null && roles.includes(shot.shooter)) shot.first = roles.find(r => r !== shot.shooter); + if (roles.includes(other) && roles.includes('pallino')) shot.pallino = true; +} +function step(w, dt) { + let hit = 0, rail = 0; + for (const b of w.balls) { + const v = Math.hypot(b.vx, b.vy); + if (v < EPS) { b.vx = b.vy = 0; continue; } + const nv = Math.max(0, v - (FRICTION + DRAG * v) * dt), travel = (v + nv) / 2 * dt; + b.x += b.vx / v * travel; b.y += b.vy / v * travel; + b.vx *= nv / v; b.vy *= nv / v; + rail = Math.max(rail, cushions(b)); + for (const p of w.pins) { + if (p.down || Math.hypot(b.x - p.x, b.y - p.y) >= R + PIN) continue; + // Il birillo cade dalla parte opposta a chi lo colpisce; la biglia perde un po' di slancio. + Object.assign(p, { down: true, by: b.role, dir: Math.atan2(p.y - b.y, p.x - b.x) }); + b.vx *= 0.88; b.vy *= 0.88; + w.shot?.pins.push(p); + if (w.events && w.events.length < 16) w.events.push({ kind: 'biliardo-pin', power: Math.min(1, v / 300) }); + } + } + for (let i = 0; i < w.balls.length; i++) for (let j = i + 1; j < w.balls.length; j++) { + const s = collide(w.balls[i], w.balls[j]); + if (s > 0) { touch(w.shot, w.balls[i], w.balls[j]); hit = Math.max(hit, s); } + } + if (w.events && w.events.length < 16 && (hit > 12 || rail > 12)) + w.events.push({ kind: hit >= rail ? 'biliardo-hit' : 'biliardo-rail', power: Math.min(1, Math.max(hit, rail) / 400) }); +} + +function strike(c, who, angle, power) { + const b = c.balls[who], v = speedFor(power); + b.vx = Math.cos(angle) * v; b.vy = Math.sin(angle) * v; + c.shot = { shooter: who, first: null, pallino: false, pins: [] }; + c.step = 'rolling'; c.t = c.accumulator = 0; c.angle = angle; c.power = power; c.plan = null; + c.events.push({ kind: 'biliardo-cue', power }); + return true; +} +// Il colpo provato a vuoto, su una copia del tavolo: serve all'avversario per scegliere. +export function simulate(c, who, angle, power) { + const balls = list(c).map(b => ({ ...b })), pins = c.pins.map(p => ({ ...p })); + const w = { balls, pins, shot: { shooter: who, first: null, pallino: false, pins: [] } }; + const b = balls.find(x => x.role === who), v = speedFor(power); + b.vx = Math.cos(angle) * v; b.vy = Math.sin(angle) * v; + for (let i = 0; i < 15 / FIXED_STEP && balls.some(x => x.vx || x.vy); i++) step(w, FIXED_STEP); + return judge(w.shot); +} + +// I birilli si rialzano; una biglia ferma sopra il posto di un birillo si sposta appena quanto basta. +function reset(c) { + for (const p of c.pins) { p.down = false; delete p.by; delete p.dir; } + for (let pass = 0; pass < 4; pass++) { + for (const b of list(c)) for (const p of c.pins) { + const d = Math.hypot(b.x - p.x, b.y - p.y), need = R + PIN + 0.3; + if (d >= need) continue; + const a = d > 1e-6 ? Math.atan2(b.y - p.y, b.x - p.x) : -Math.PI / 2; + b.x = p.x + Math.cos(a) * need; b.y = p.y + Math.sin(a) * need; + } + const all = list(c); + for (let i = 0; i < 3; i++) for (let j = i + 1; j < 3; j++) collide(all[i], all[j]); + all.forEach(cushions); + } +} +function finish(c) { + const j = judge(c.shot); + c.points[j.to] += j.points; + c.last = { ...j, age: 0 }; + c.shot = null; + reset(c); + c.t = 0; + if (c.points[j.to] >= c.target) { c.step = 'result'; c.result = { winner: j.to, points: { ...c.points } }; return; } + c.current = OTHER[j.shooter]; + c.step = 'aim'; + c.angle = angleTo(c.balls[c.current], c.balls[OTHER[c.current]]); +} + +// L'avversario prova tanti colpi a vuoto e sceglie quello che rende di più; poi la mano gli trema un poco. +// Le prove si fanno a pezzi, un po' per fotogramma: tutte insieme fermerebbero il gioco per un decimo di secondo. +function cpuCandidates(c) { + const base = angleTo(c.balls.cpu, c.balls.player), angles = []; + for (let i = 0; i < 64; i++) angles.push(i / 64 * Math.PI * 2); + for (let d = -0.3; d <= 0.3; d += 0.025) angles.push(base + d); + return angles.flatMap(angle => [0.3, 0.5, 0.7, 0.9].map(power => ({ angle, power }))); +} +function cpuThink(c, budget = 12) { + const t = c.thinking ??= { todo: cpuCandidates(c), best: null }; + for (let i = 0; i < budget && t.todo.length; i++) { + const { angle, power } = t.todo.pop(), j = simulate(c, 'cpu', angle, power), gain = j.to === 'cpu' ? j.points : -j.points; + if (!t.best || gain > t.best.gain) t.best = { angle, power, gain }; + } + if (t.todo.length) return; + c.plan = { angle: t.best.angle + deviation(c) * (c.wobble ?? 0.03), power: clamp(t.best.power + deviation(c) * 0.05, 0.05, 1) }; + c.angle = c.plan.angle; c.power = c.plan.power; c.thinking = null; +} + +export function update(c, dt) { + if (!Number.isFinite(dt) || dt <= 0) return; + dt = Math.min(dt, 0.25); c.t += dt; + if (c.last) c.last.age += dt; + if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); } + if (c.step === 'rolling') { + c.accumulator += dt; + const w = { balls: list(c), pins: c.pins, shot: c.shot, events: c.events }; + while (c.accumulator >= FIXED_STEP) { step(w, FIXED_STEP); c.accumulator -= FIXED_STEP; } + if (!moving(c)) { c.step = 'settle'; c.t = 0; } + } else if (c.step === 'settle' && c.t >= REST) finish(c); + else if (c.step === 'aim' && c.current === 'cpu') { + if (!c.plan && c.t >= CPU_THINK) cpuThink(c); + if (c.plan && c.t >= CPU_SHOT) strike(c, 'cpu', c.plan.angle, c.plan.power); + } +} + +// --- Comandi del giocatore: gli stessi delle bocce. +export function aimAt(c, x, y) { + if (!canThrow(c) || !Number.isFinite(x) || !Number.isFinite(y)) return false; + const b = c.balls.player; + if (Math.hypot(x - b.x, y - b.y) < R) return false; + c.angle = Math.atan2(y - b.y, x - b.x); return true; +} +export function aimScreen(c, [sx, sy]) { return aimAt(c, (sx - VIEW.x) / VIEW.k, (sy - VIEW.y) / VIEW.k); } +export function nudge(c, dir) { if (!canThrow(c) || c.holding) return false; c.angle += dir * 0.01; return true; } +export function setPower(c, value) { + if (!canThrow(c) || c.holding || !Number.isFinite(value)) return false; + c.power = clamp(value, 0, 1); return true; +} +export function shoot(c) { return canThrow(c) && !c.holding && strike(c, 'player', c.angle, c.power); } +export function press(c) { + if (!canThrow(c) || c.holding) return false; + c.previousPower = c.power; c.holding = true; c.holdTime = c.power = 0; return true; +} +export function cancel(c) { + if (!c.holding) return false; + c.holding = false; c.power = c.previousPower; c.holdTime = 0; return true; +} +export function release(c) { + if (!c.holding) return false; + if (c.holdTime < 0.14) return cancel(c); // un tocco breve mira soltanto + c.holding = false; return strike(c, 'player', c.angle, c.power); +} + +// Dove andrebbe la biglia: la linea fino al primo urto (con le sponde, fino a dove arriva con quella forza), +// la biglia fantasma dove tocca, e verso dove partirebbe quella colpita. +export function predict(c) { + const me = c.balls[c.current], others = list(c).filter(b => b !== me); + let x = me.x, y = me.y, dx = Math.cos(c.angle), dy = Math.sin(c.angle), left = reach(speedFor(c.power)); + const points = [[x, y]], STEP = 0.5; + for (let n = 0; left > 0 && n < 6000; n++) { + x += dx * STEP; y += dy * STEP; left -= STEP; + if (x < R || x > LEN - R) { x = clamp(x, R, LEN - R); dx = -dx; left *= 0.6; points.push([x, y]); } + if (y < R || y > WID - R) { y = clamp(y, R, WID - R); dy = -dy; left *= 0.6; points.push([x, y]); } + for (const o of others) { + const d = Math.hypot(o.x - x, o.y - y); + if (d < 2 * R) { points.push([x, y]); return { points, ghost: { x, y }, hit: o.role, dir: [(o.x - x) / d, (o.y - y) / d] }; } + } + for (const p of c.pins) if (!p.down && Math.hypot(p.x - x, p.y - y) < R + PIN) { points.push([x, y]); return { points, ghost: { x, y }, hit: 'pin', dir: null }; } + } + points.push([x, y]); + return { points, ghost: null, hit: null, dir: null }; +} + +const FOULS = { miss: 'biglia avversaria mancata', pallino: 'prima il pallino', own: 'birilli con la propria biglia' }; +export function lastLine(c, names) { + const l = c.last; + if (!l) return ''; + if (l.foul) return `Fallo di ${names[l.shooter]} (${FOULS[l.foul]}): ${l.points} a ${names[l.to]}.`; + return l.points ? `${names[l.to]} fa ${l.points} punti!` : `${names[l.shooter]}: nessun punto.`; +} +export function status(c, names) { + if (c.step === 'result') return `Partita a ${names[c.result.winner]}: ${c.points.player} a ${c.points.cpu}.`; + if (c.step === 'rolling') return 'Biglie in movimento…'; + if (c.step === 'settle') return 'Si contano i birilli…'; + const last = lastLine(c, names); + return `${last ? last + ' ' : ''}${names[c.current]} al tiro. (${c.points.player} a ${c.points.cpu}, si va a ${c.target})`; +} diff --git a/src/engine/bocce-render.js b/src/engine/bocce-render.js index bd2d4f9..1301e1c 100644 --- a/src/engine/bocce-render.js +++ b/src/engine/bocce-render.js @@ -3,10 +3,10 @@ import { ambientHash as hash } from './ambient.js'; const FONT = '"Press Start 2P", monospace'; const COLORS = { player: ['#172e58', '#3975c4', '#d9ecff'], cpu: ['#531d1b', '#c24e3b', '#ffe0bd'], jack: ['#70531b', '#e2b743', '#fff8cd'] }; -function text(ctx, line, x, y, size = 7, color = '#f4e3c2', align = 'left') { +export function text(ctx, line, x, y, size = 7, color = '#f4e3c2', align = 'left') { ctx.font = `${size}px ${FONT}`; ctx.textAlign = align; ctx.textBaseline = 'middle'; ctx.fillStyle = color; ctx.fillText(line, x, y); } -function panel(ctx, x, y, w, h) { +export function panel(ctx, x, y, w, h) { ctx.fillStyle = 'rgba(22,25,23,0.92)'; ctx.fillRect(x, y, w, h); ctx.strokeStyle = '#897b57'; ctx.lineWidth = 1; ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1); } diff --git a/src/engine/game.js b/src/engine/game.js index 7fd0ea6..0a91a28 100644 --- a/src/engine/game.js +++ b/src/engine/game.js @@ -1,7 +1,14 @@ // Il cuore dell'avventura, senza DOM: stato, comandi, coda delle azioni, dialoghi. // La storia (scene, oggetti, dialoghi) arriva tutta dai dati: qui non c'è nessun enigma scritto a mano. import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js'; -import { newBocce, update as bocceUpdate, press as boccePress, release as bocceRelease, cancel as bocceCancel, aimScreen } from './bocce.js'; +import * as bocce from './bocce.js'; +import * as biliardo from './biliardo.js'; + +// I minigiochi in primo piano: stessi comandi (mira, pressione, rilascio), motori diversi. +export const MINIGAMES = { + bocce: { create: seed => bocce.newBocce(seed), update: bocce.update, press: bocce.press, release: bocce.release, cancel: bocce.cancel, aim: bocce.aimScreen }, + biliardo: { create: seed => biliardo.newBiliardo(seed), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.aimScreen }, +}; export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give', 'drop']; export const VERB_LABELS = { walk: 'Vai a', look: 'Guarda', take: 'Prendi', use: 'Usa', talk: 'Parla con', open: 'Apri', give: 'Dai', drop: 'Butta' }; @@ -230,7 +237,7 @@ export class Game { closeupTap() { const c = this.closeup; if (!c || this.speech) return false; - if (this.story.closeups[c.id].type) return false; // torcia e bocce non si sfogliano + if (this.story.closeups[c.id].type) return false; // torcia e minigiochi non si sfogliano if (c.step === 'cover') Object.assign(c, { step: 'title', t: 0 }); else if (c.step === 'title') Object.assign(c, { step: 'turn', t: 0 }); else return false; @@ -248,8 +255,8 @@ export class Game { if (c.step === 'done') this.queue.unshift(...def.complete); return true; } - // Le bocce: a mano finita, la battuta di chi ha vinto; uscendo prima, quella di chi lascia. - if (def?.type === 'bocce') { + // Bocce e biliardo: a partita finita, la battuta di chi ha vinto; uscendo prima, quella di chi lascia. + if (MINIGAMES[def?.type]) { this.queue.unshift(...(c.step === 'result' ? def[c.result?.winner === 'player' ? 'win' : c.result?.winner === 'cpu' ? 'lose' : 'draw'] ?? [] : def.quit ?? [])); return true; } @@ -258,16 +265,18 @@ export class Game { return true; } - // Le bocce: si tiene premuto per caricare la forza e si lascia per tirare (mouse, dito o spazio). - closeupPress() { const c = this.closeup; return !!c && this.story.closeups[c.id].type === 'bocce' && !this.speech && boccePress(c); } - closeupRelease() { const c = this.closeup; return !!c && this.story.closeups[c.id].type === 'bocce' && bocceRelease(c); } - closeupCancel() { const c = this.closeup; return !!c && this.story.closeups[c.id].type === 'bocce' && bocceCancel(c); } + // Bocce e biliardo: si tiene premuto per caricare la forza e si lascia per tirare (mouse, dito o spazio). + minigame() { const c = this.closeup; return c ? MINIGAMES[this.story.closeups[c.id].type] ?? null : null; } + closeupPress() { const m = this.minigame(); return !!m && !this.speech && m.press(this.closeup); } + closeupRelease() { const m = this.minigame(); return !!m && m.release(this.closeup); } + closeupCancel() { const m = this.minigame(); return !!m && m.cancel(this.closeup); } // Solo luce, nessun clic sugli oggetti dietro al primo piano. Il progresso è transitorio: // uscire o ricaricare durante la ricerca permette di ripeterla, senza anticipare il finale. closeupAim([x, y]) { const c = this.closeup; - if (c && this.story.closeups[c.id].type === 'bocce') return aimScreen(c, [x, y]); + const m = this.minigame(); + if (m) return m.aim(c, [x, y]); if (!c || this.story.closeups[c.id].type !== 'torch' || !Number.isFinite(x) || !Number.isFinite(y)) return false; c.beam = [Math.max(70, Math.min(570, x)), Math.max(65, Math.min(295, y))]; return true; @@ -390,9 +399,10 @@ export class Game { const c = this.closeup; if (c) { if (this.story.closeups[c.id].type === 'torch') { this.updateTorch(dt); return; } - if (this.story.closeups[c.id].type === 'bocce') { - bocceUpdate(c, dt); - // Il risultato resta leggibile finché il giocatore torna al pallaio. + const m = this.minigame(); + if (m) { + m.update(c, dt); + // Il risultato resta leggibile finché il giocatore non esce. return; } c.t += dt; @@ -429,7 +439,7 @@ export class Game { else if (e.closeup) { const type = this.story.closeups[e.closeup].type; this.closeup = type === 'torch' ? { id: e.closeup, step: 'search', t: 0, age: 0, found: 0, dwell: 0, beam: [320, 180] } - : type === 'bocce' ? { id: e.closeup, ...newBocce(Math.floor(Math.random() * 4294967296)) } + : MINIGAMES[type] ? { id: e.closeup, ...MINIGAMES[type].create(Math.floor(Math.random() * 4294967296)) } : { id: e.closeup, step: 'cover', t: 0, age: 0 }; } else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e)); diff --git a/src/engine/render.js b/src/engine/render.js index 6a6179a..b72be1e 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -4,6 +4,7 @@ import { check, PAGE_TURN } from './game.js'; import { ambientEffects, ambientHash } from './ambient.js'; import { drawTorch } from './torch.js'; import { drawBocce } from './bocce-render.js'; +import { drawBiliardo } from './biliardo-render.js'; import { versioned } from '../version.js'; export const WIDTH = 640, HEIGHT = 360; @@ -675,6 +676,7 @@ function drawCloseup(ctx, game, time) { drawBocce(ctx, c, def, backgrounds.get(def.picture), time); return; } + if (def.type === 'biliardo') { drawBiliardo(ctx, c, def); return; } ctx.save(); ctx.fillStyle = `rgba(8, 5, 12, ${Math.min(0.78, c.age / 0.35 * 0.78)})`; ctx.fillRect(0, 0, WIDTH, HEIGHT); diff --git a/src/engine/soundscape.js b/src/engine/soundscape.js index f2a429b..b63a84d 100644 --- a/src/engine/soundscape.js +++ b/src/engine/soundscape.js @@ -104,6 +104,10 @@ export class Soundscape { else if (kind === 'bocce-roll') this.noiseSound(0.7, 650, 0.06 + strength * 0.08, { pan }); else if (kind === 'bocce-hit') { this.tone(760, 320, 0.075, 0.04 + strength * 0.07, 0, pan); this.noiseSound(0.09, 1600, 0.04, { pan }); } else if (kind === 'bocce-rail') this.noiseSound(0.16, 380, 0.04 + strength * 0.08, { pan }); + else if (kind === 'biliardo-cue') { this.tone(1900, 900, 0.05, 0.03 + strength * 0.05, 0, pan); this.noiseSound(0.05, 2400, 0.03, { pan }); } + else if (kind === 'biliardo-hit') { this.tone(2600, 1500, 0.045, 0.03 + strength * 0.08, 0, pan); this.noiseSound(0.04, 3200, 0.03, { pan }); } + else if (kind === 'biliardo-rail') this.noiseSound(0.12, 520, 0.03 + strength * 0.06, { pan }); + else if (kind === 'biliardo-pin') { this.tone(3200, 2200, 0.03, 0.04 + strength * 0.05, 0, pan); this.noiseSound(0.08, 1800, 0.035, { pan }); } else if (kind === 'wood') this.noiseSound(0.3, 750, 0.13, { pan }); else if (kind === 'drip') this.tone(1150, 540, 0.18, 0.055, 0, pan); else if (kind === 'bird') { diff --git a/src/main.js b/src/main.js index 44b4202..8992253 100644 --- a/src/main.js +++ b/src/main.js @@ -8,7 +8,8 @@ import { Music, trackFor } from './engine/music.js'; import { Voices } from './engine/voices.js'; import { Soundscape } from './engine/soundscape.js'; import { soundscapeFor } from './story/soundscapes.js'; -import { aim as bocceAim, shoot as bocceShoot, setPower as boccePower, canThrow as bocceReady, status as bocceStatus } from './engine/bocce.js'; +import * as bocce from './engine/bocce.js'; +import * as biliardo from './engine/biliardo.js'; import { VERSION, versioned } from './version.js'; const $ = id => document.getElementById(id); @@ -434,13 +435,18 @@ canvas.addEventListener('pointermove', e => { }); canvas.addEventListener('pointerleave', () => { mouse = null; }); -// Comandi delle bocce separati dai verbi dell'avventura: forza regolabile e lancio -// con un solo clic, anche per chi non vuole usare la pressione temporizzata. -const bocceState = () => running && game?.closeup?.id === 'bocce' ? game.closeup : null; -$('bocce-power').addEventListener('input', e => { const c = bocceState(); if (c) boccePower(c, Number(e.target.value) / 100); }); -$('bocce-throw').addEventListener('click', () => { const c = bocceState(); if (c && !game.speech) bocceShoot(c); }); -for (const [id, move] of [['bocce-left', -0.3], ['bocce-right', 0.3]]) - $(id).addEventListener('click', () => { const c = bocceState(); if (c) bocceAim(c, c.aim.u, c.aim.w + move); }); +// Comandi dei minigiochi (bocce e biliardo) separati dai verbi dell'avventura: mira a passi, forza regolabile e +// lancio con un solo clic, anche per chi non vuole usare la pressione temporizzata. +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: tocca il tavolo per mirare. Regola la forza e premi Tira.' }, +}; +const miniState = () => running && game?.closeup && MINI[story.closeups[game.closeup.id].type] ? game.closeup : null; +const mini = () => MINI[story.closeups[game.closeup.id].type]; +$('bocce-power').addEventListener('input', e => { const c = miniState(); if (c) mini().setPower(c, Number(e.target.value) / 100); }); +$('bocce-throw').addEventListener('click', () => { const c = miniState(); if (c && !game.speech) mini().shoot(c); }); +for (const [id, dir] of [['bocce-left', -1], ['bocce-right', 1]]) + $(id).addEventListener('click', () => { const c = miniState(); if (c) mini().nudge(c, dir); }); addEventListener('blur', () => game?.closeupCancel()); // --- Verbi. @@ -528,13 +534,13 @@ window.addEventListener('keydown', e => { if (!$('map').hidden) { if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') $('map').hidden = true; return; } // Nel primo piano: Invio, spazio o freccia sfogliano (o saltano la battuta), Esc chiude il libro. if (running && game.closeup) { - if (game.closeup.id === 'bocce') { + if (miniState()) { if (e.target.closest?.('#bocce-controls') && ['BUTTON', 'SUMMARY'].includes(e.target.tagName)) return; - const c = game.closeup; + const c = game.closeup, m = mini(); if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) { e.preventDefault(); - if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') bocceAim(c, c.aim.u, c.aim.w + (e.key === 'ArrowLeft' ? -0.3 : 0.3)); - else boccePower(c, c.power + (e.key === 'ArrowUp' ? 0.01 : -0.01)); + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') m.nudge(c, e.key === 'ArrowLeft' ? -1 : 1); + else m.setPower(c, c.power + (e.key === 'ArrowUp' ? 0.01 : -0.01)); return; } if (c.step === 'result' && [' ', 'Enter'].includes(e.key)) { e.preventDefault(); game.closeupClose(); return; } @@ -554,7 +560,7 @@ window.addEventListener('keydown', e => { return; } if (e.key === 'Escape') game.closeupClose(); - else if ([' ', 'Enter'].includes(e.key) && story.closeups[game.closeup.id].type === 'bocce') { e.preventDefault(); if (!e.repeat && !game.skip()) game.closeupPress(); } + else if ([' ', 'Enter'].includes(e.key) && miniState()) { e.preventDefault(); if (!e.repeat && !game.skip()) game.closeupPress(); } else if ([' ', 'Enter', 'ArrowRight'].includes(e.key)) { e.preventDefault(); if (!game.skip()) game.closeupTap(); } else if (e.key === 'm' || e.key === 'M') toggleMute(); return; @@ -572,7 +578,7 @@ window.addEventListener('keydown', e => { if (game.dialogue && n >= 1 && n <= 9) game.choose(n - 1); }); -// Alle bocce, lasciando spazio o Invio parte il tiro. +// Alle bocce e al biliardo, lasciando spazio o Invio parte il tiro. window.addEventListener('keyup', e => { if (running && [' ', 'Enter'].includes(e.key)) game?.closeupRelease(); }); // --- Il primo piano: si chiude col pulsante; la pagina che gira fruscia (rumore filtrato, niente file audio). @@ -613,17 +619,19 @@ function updateUi() { updateInventory(); updateChoices(); updateSpeech(); - const bocce = c?.id === 'bocce'; - $('bocce-controls').hidden = !bocce; + const mg = miniState() && mini(), def = mg && story.closeups[c.id]; + $('bocce-controls').hidden = !mg; $('bocce-controls').inert = !running; - if (bocce) { + if (mg) { $('panel').hidden = $('choices').hidden = true; - const line = bocceStatus(c, story.closeups.bocce.names); + 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 = !bocceReady(c) || c.holding || !!game.speech; - $('bocce-throw').textContent = c.step === 'result' ? 'Mano conclusa' : c.step === 'rolling' ? 'In movimento…' : c.current === 'cpu' ? 'Tira Mariano…' : 'Lancia'; + 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'; + $('bocce-help').hidden = def.type !== 'bocce'; + $('biliardo-help').hidden = def.type !== 'biliardo'; 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. @@ -632,11 +640,11 @@ function updateUi() { $('spots-button').hidden = !running || !!game.closeup; $('notebook-button').hidden = !running || !story.notebook || !!game.closeup; $('closeup-close').hidden = !running || !game.closeup; - $('closeup-close').textContent = torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : bocce ? c.step === 'result' ? 'Torna al pallaio' : 'Lascia stare' : 'Chiudi'; + $('closeup-close').textContent = torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : mg ? c.step === 'result' ? mg.back : 'Lascia stare' : 'Chiudi'; $('panel').inert = !!c; canvas.classList.toggle('torch', !!torch); - canvas.classList.toggle('bocce', bocce); - canvas.setAttribute('aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : bocce ? 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' : "La scena dell'avventura"); + canvas.classList.toggle('bocce', !!mg); + canvas.setAttribute('aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : mg ? mg.label : "La scena dell'avventura"); // La pagina che comincia a girare fa il suo fruscio. const step = game.closeup?.step ?? null; if (step === 'turn' && turning !== 'turn') ambience.play('page'); diff --git a/src/story/story.js b/src/story/story.js index 1f32464..9ac58e7 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -124,6 +124,21 @@ export const story = { quit: [{ say: 'Già stanco? Il pallaio ti aspetta.', who: 'mariano', pose: 'laugh' }], draw: [{ say: 'Quando vuoi la rivincita, sono qui.', who: 'mariano' }], }, + // 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', + names: { player: 'Andrea', cpu: 'L\'Ing. Tozzi' }, + win: [ + { say: 'Venti. Hai vinto te, stasera il caffè lo pago io.', who: 'ing_tozzi' }, + { set: 'biliardo_vinto' }, + ], + lose: [ + { say: 'Venti! Al biliardo ci vuole la mano ferma, mica la corsa.', who: 'ing_tozzi' }, + { say: 'Quando vuoi, il tavolo è qui.', who: 'ing_tozzi' }, + ], + quit: [{ say: 'Lasci a metà? Ti segno la sconfitta a tavolino, eh.', who: 'ing_tozzi' }], + draw: [], + }, fessura: { type: 'torch', picture: 'assets/closeups/palio-2007.jpg', @@ -170,6 +185,8 @@ export const story = { san_pietro: { name: 'San Pietro', color: '#e8e0c8' }, tosca: { name: 'La Tosca', color: '#ffb0d0' }, arcu: { name: 'L\'Arcu', color: '#7fb2ff', sprite: 'assets/characters/arcu.json' }, + // L'Ing. Tozzi, il campione del biliardo al Circolo 4 Maggio (sprite da fare con Codex: prompts/personaggi/ing-tozzi-sheet.txt). + ing_tozzi: { name: 'L\'Ing. Tozzi', color: '#d9c48a' }, tozzino: { name: 'Il Tozzino', color: '#c8f5b0', sprite: 'assets/characters/tozzino.json' }, barondeduke: { name: 'Il BaronDeDuke', color: '#e0c090', sprite: 'assets/characters/barondeduke.json' }, mariano: { name: 'Mariano', color: '#ffb0a3', sprite: 'assets/characters/mariano.json' }, @@ -345,6 +362,15 @@ export const story = { look: [{ say: 'La strada che porta al Fiocco di Neve. Già sento il profumo di cialda.' }], }, }, + { + // Il Circolo 4 Maggio: il portone ad arco del palazzo arancione porta nella sala dei biliardi. + id: 'circolo', name: 'Circolo 4 Maggio', box: [188, 190, 28, 58], at: [202, 252], face: 'up', default: 'open', + actions: { + look: [{ say: 'Il Circolo 4 Maggio. Dentro, dietro il bar, la sala dei biliardi: tre tavoli e il silenzio delle grandi occasioni.' }], + talk: [{ say: 'Da fuori non si parla con nessuno: al circolo si entra.' }], + }, + exit: { scene: 'circolo_4_maggio', at: [590, 202], face: 'left' }, + }, { id: 'manifesto', name: 'manifesto del Palio', box: [256, 196, 18, 24], at: [265, 250], face: 'up', art: [{ rect: [256, 196, 18, 24], fill: '#f4efe4' }, { rect: [258, 199, 14, 5], fill: '#b7342e' }, { rect: [258, 207, 12, 2], fill: '#3b3b44' }, { rect: [258, 211, 10, 2], fill: '#3b3b44' }, { rect: [258, 215, 12, 2], fill: '#2f5fb3' }], @@ -1022,6 +1048,57 @@ export const story = { }, ], }, + // La sala dei biliardi del Circolo 4 Maggio (dalla foto di Andrea, 25 ottobre): tre tavoli, il primo è quello + // dove si gioca ai birilli con l'Ing. Tozzi. [DA ANDREA: cosa c'è da ricordare della sala] + circolo_4_maggio: { + name: 'Circolo 4 Maggio, sala dei biliardi', + map: [29, 48], + background: 'assets/scenes/circolo-4-maggio.png', + indoor: true, + walk: [[0, 360], [0, 356], [90, 350], [175, 330], [236, 316], [318, 300], [334, 240], [402, 236], [480, 232], [520, 214], [600, 190], [640, 190], [640, 360]], + depth: [[190, 0.5], [360, 1.1]], + entry: [590, 202], + speakers: { ing_tozzi: [420, 150] }, + enter: [{ if: '!circolo_visto', then: [{ set: 'circolo_visto' }, { say: 'La sala dei biliardi. Qui dentro si parla piano, e si gioca forte.' }] }], + hotspots: [ + { + id: 'biliardo', name: 'biliardo', box: [0, 120, 358, 230], at: [380, 300], face: 'left', default: 'use', + actions: { + look: [{ say: 'Il primo tavolo: il panno verde, il castello di birilli in mezzo, le biglie già pronte.' }, { say: 'L\'Ing. Tozzi gli gira intorno con la stecca in mano, come se aspettasse qualcuno.' }], + use: [ + { say: 'Una partita ai birilli? Si va a venti.', who: 'ing_tozzi' }, + { say: 'Gnamo.' }, + { closeup: 'biliardo' }, + ], + talk: [{ say: 'Si parla col panno, qui. Prendi la stecca.', who: 'ing_tozzi' }], + take: [{ say: 'Una biglia in tasca? Mi sbattono fuori dal circolo.' }], + }, + }, + { + id: 'stecche', name: 'rastrelliera delle stecche', box: [140, 0, 60, 110], at: [150, 352], face: 'up', + actions: { + look: [{ say: 'Le stecche del circolo, in fila. Ognuna ha il suo proprietario, e guai a sbagliare.' }], + take: [{ say: 'La stecca me la dà l\'Ingegnere, quando si gioca.' }], + }, + }, + { + id: 'quadro', name: 'quadro alla parete', box: [300, 30, 36, 55], at: [352, 320], face: 'up', + actions: { + look: [{ say: 'Un campione di biliardo in posa, con la stecca. Avrà il suo nome, ma da qui non si legge.' }], + }, + }, + { + id: 'armadietto', name: 'armadietto delle stecche', box: [480, 58, 34, 48], at: [496, 242], face: 'up', + actions: { + look: [{ say: 'L\'armadietto a vetri, con le stecche buone. Chiuso a chiave, naturalmente.' }], + open: [{ say: 'Chiuso. Le stecche buone sono per chi le sa usare.' }], + }, + }, + { + id: 'alla_piazza', name: 'la porta, verso la piazza', box: [550, 30, 80, 160], at: [590, 202], exit: { scene: 'piazza', at: [202, 252], face: 'down' }, + }, + ], + }, // Il ponte sulla Sieve, proseguendo la Provinciale dalla farmacia; di là dal ponte, la polisportiva. ponte_sieve: { name: 'Ponte sulla Sieve', diff --git a/tests/biliardo.test.mjs b/tests/biliardo.test.mjs new file mode 100644 index 0000000..043f5c4 --- /dev/null +++ b/tests/biliardo.test.mjs @@ -0,0 +1,146 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { newBiliardo, update, press, release, setPower, shoot, judge, value, reach, speedFor, predict, castle, aimScreen, canThrow, LEN, WID, R, VIEW, SWING, powerAt } from '../src/engine/biliardo.js'; +import { story } from '../src/story/story.js'; +import { Game } from '../src/engine/game.js'; +import { settle } from './helpers.mjs'; + +const run = (c, seconds, dt = 1 / 60) => { for (let t = 0; t < seconds; t += dt) update(c, dt); }; +const still = c => { for (let i = 0; i < 3000 && !['aim', 'result'].includes(c.step); i++) update(c, 1 / 60); }; +const pins = (...which) => castle().filter((_, i) => which.includes(i)); + +test('il castello: il rosso in mezzo al tavolo, quattro bianchi a croce', () => { + const p = castle(); + assert.equal(p.length, 5); + assert.deepEqual(p.filter(x => x.red).map(x => [x.x, x.y]), [[LEN / 2, WID / 2]]); + assert.equal(p.filter(x => !x.red).length, 4); +}); + +test('il valore dei birilli: bianco 2, rosso 4, rosso da solo 10; e il fallo porta i punti all\'avversario', () => { + assert.equal(value(pins(1)), 2); + assert.equal(value(pins(0, 1)), 6); + assert.equal(value(pins(0)), 10); + assert.equal(value(pins(0, 1, 2, 3, 4)), 12); + const by = (list, who) => list.map(p => ({ ...p, by: who })); + assert.deepEqual(judge({ shooter: 'player', first: 'cpu', pallino: true, pins: by(pins(1, 2), 'cpu') }), { shooter: 'player', to: 'player', points: 7, foul: null }); + assert.deepEqual(judge({ shooter: 'player', first: null, pallino: false, pins: [] }), { shooter: 'player', to: 'cpu', points: 2, foul: 'miss' }); + assert.deepEqual(judge({ shooter: 'cpu', first: 'pallino', pallino: false, pins: [] }), { shooter: 'cpu', to: 'player', points: 2, foul: 'pallino' }); + assert.deepEqual(judge({ shooter: 'player', first: 'cpu', pallino: false, pins: by(pins(0), 'player') }), { shooter: 'player', to: 'cpu', points: 10, foul: 'own' }); +}); + +test('la forza: sale finché si tiene premuto; a metà la biglia fa circa un tavolo, a piena ne fa più di due', () => { + assert.equal(powerAt(0), 0); + assert.equal(powerAt(SWING * 2), 1); + assert.ok(reach(speedFor(0.5)) > LEN * 0.9 && reach(speedFor(0.5)) < LEN * 1.3, `a metà forza: ${reach(speedFor(0.5)).toFixed(0)} cm`); + assert.ok(reach(speedFor(1)) > LEN * 2); + assert.ok(reach(speedFor(0)) < 30, 'piano piano si sposta appena'); +}); + +test('un colpo dritto sulla biglia avversaria la manda avanti, poi tocca all\'altro', () => { + const c = newBiliardo(3); + const me = c.balls.player, other = c.balls.cpu, before = { ...other }; + c.angle = Math.atan2(other.y - me.y, other.x - me.x); + assert.ok(setPower(c, 0.5) && shoot(c)); + assert.equal(c.step, 'rolling'); + still(c); + assert.ok(other.x !== before.x || other.y !== before.y, 'la biglia avversaria si è mossa'); + assert.equal(c.current, 'cpu'); + assert.equal(c.last.foul, null); + assert.ok(c.last.points >= 0); +}); + +test('mancare la biglia avversaria è fallo: due punti all\'altro', () => { + const c = newBiliardo(5); + c.balls.player = { role: 'player', x: 30, y: 20, vx: 0, vy: 0 }; + c.balls.cpu = { role: 'cpu', x: 250, y: 120, vx: 0, vy: 0 }; + c.balls.pallino = { role: 'pallino', x: 250, y: 30, vx: 0, vy: 0 }; + c.angle = 0; // dritto lungo la sponda, lontano da tutto + assert.ok(setPower(c, 0.1) && shoot(c)); + still(c); + assert.deepEqual(c.points, { player: 0, cpu: 2 }); + assert.equal(c.last.foul, 'miss'); +}); + +test('un birillo abbattuto dalla biglia avversaria vale punti; poi i birilli si rialzano', () => { + const c = newBiliardo(9); + // La mia biglia piazzata dietro a quella avversaria, in linea col castello. + c.balls.cpu = { role: 'cpu', x: LEN / 2 - 30, y: WID / 2, vx: 0, vy: 0 }; + c.balls.player = { role: 'player', x: LEN / 2 - 60, y: WID / 2, vx: 0, vy: 0 }; + c.balls.pallino = { role: 'pallino', x: 20, y: 20, vx: 0, vy: 0 }; + c.angle = 0; + assert.ok(setPower(c, 0.5) && shoot(c)); + for (let i = 0; i < 3000 && c.step === 'rolling'; i++) update(c, 1 / 60); + assert.ok(c.pins.some(p => p.down), 'qualche birillo è caduto'); + assert.ok(c.pins.filter(p => p.down).every(p => p.by === 'cpu')); + still(c); + assert.equal(c.last.to, 'player'); + assert.ok(c.points.player >= 2, `punti: ${c.points.player}`); + assert.ok(c.pins.every(p => !p.down), 'il castello è di nuovo in piedi'); +}); + +test('l\'avversario pensa un po\' per fotogramma, tira da solo, e la partita finisce a venti', () => { + const c = newBiliardo(42, { target: 6 }); + c.current = 'cpu'; + let frames = 0; + while (c.step === 'aim' && frames++ < 600) update(c, 1 / 60); + assert.equal(c.step, 'rolling', 'l\'avversario ha tirato'); + assert.ok(frames > 20, 'non tira subito: ci pensa'); + for (let turn = 0; turn < 60 && c.step !== 'result'; turn++) { + still(c); + if (c.step === 'aim' && c.current === 'player') { + const me = c.balls.player, other = c.balls.cpu; + c.angle = Math.atan2(other.y - me.y, other.x - me.x); + press(c); run(c, 0.6); release(c); + } + } + assert.equal(c.step, 'result'); + assert.ok(['player', 'cpu'].includes(c.result.winner)); + assert.ok(c.points[c.result.winner] >= 6); + assert.equal(press(c), false, 'a partita finita non si tira più'); +}); + +test('mirare: un tocco sullo schermo gira la stecca verso quel punto; la previsione trova la biglia davanti', () => { + const c = newBiliardo(1); + const me = c.balls.player; + assert.ok(aimScreen(c, [VIEW.x + me.x * VIEW.k, VIEW.y + (me.y + 40) * VIEW.k])); + assert.ok(Math.abs(c.angle - Math.PI / 2) < 1e-9, 'verso il basso'); + assert.equal(aimScreen(c, [VIEW.x + me.x * VIEW.k, VIEW.y + me.y * VIEW.k]), false, 'sulla biglia stessa non si mira'); + c.balls.cpu = { role: 'cpu', x: me.x + 50, y: me.y, vx: 0, vy: 0 }; + c.angle = 0; + const p = predict(c); + assert.equal(p.hit, 'cpu'); + assert.ok(Math.abs(p.ghost.x - (me.x + 50 - 2 * R)) < 1, 'la biglia fantasma tocca quella avversaria'); + assert.ok(canThrow(c)); +}); + +test('al Circolo 4 Maggio: dal portone in piazza si entra nella sala, e al tavolo si gioca; vincere segna la bandierina', () => { + const g = new Game(story); + settle(g); + g.go('piazza'); settle(g); + assert.ok(g.act('walk', 'circolo')); + settle(g); + assert.equal(g.state.scene, 'circolo_4_maggio'); + assert.ok(g.act('use', 'biliardo')); + const before = settle(g).map(s => s.text); + assert.ok(before.some(t => t.includes('Si va a venti'))); + assert.equal(g.closeup?.id, 'biliardo'); + assert.equal(g.act('look', 'stecche'), false, 'durante la partita la scena non risponde'); + const c = g.closeup; + assert.ok(g.closeupAim([VIEW.x + c.balls.cpu.x * VIEW.k, VIEW.y + c.balls.cpu.y * VIEW.k]), 'si mira sulla biglia avversaria'); + assert.ok(g.closeupPress()); + for (let i = 0; i < 30; i++) g.update(1 / 60); + assert.ok(g.closeupRelease()); + assert.equal(c.step, 'rolling'); + c.step = 'result'; c.result = { winner: 'player', points: { player: 20, cpu: 4 } }; + for (let i = 0; i < 10; i++) g.update(0.25); + assert.equal(g.closeup, c, 'il tavolo resta a schermo finché non si esce'); + assert.ok(g.closeupClose()); + const after = settle(g).map(s => `${s.who}: ${s.text}`); + assert.ok(after.some(t => t.startsWith('ing_tozzi: Venti. Hai vinto te'))); + assert.ok(g.state.flags.biliardo_vinto); + // Lasciare a metà non segna niente. + g.act('use', 'biliardo'); settle(g); + assert.equal(g.closeup?.id, 'biliardo'); + assert.ok(g.closeupClose()); + assert.ok(settle(g).some(s => s.text.startsWith('Lasci a metà?'))); +}); diff --git a/tests/browser.mjs b/tests/browser.mjs index 6b76461..13b4455 100644 --- a/tests/browser.mjs +++ b/tests/browser.mjs @@ -390,6 +390,36 @@ try { await bocce.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 }); + await b.click('#splash'); + await b.click('#new-game'); + await b.click('#rione [data-rione="campomigliaio"]'); + await b.click('#skip-intro'); + await settle(b); + await b.evaluate(() => game.go('circolo_4_maggio')); + await settle(b); + await b.waitForTimeout(1500); // la dissolvenza del cambio scena + await b.screenshot({ path: `${OUT}/03d-circolo.png` }); + await clickScene(b, await hotspotCenter(b, 'biliardo')); + await b.waitForFunction(() => game.closeup?.id === 'biliardo' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 }); + const r = await b.locator('#scene').boundingBox(); + const [tx, ty] = await b.evaluate(() => { const c = game.closeup, V = c.balls.cpu; return [57 + V.x * 1.85, 78 + V.y * 1.85]; }); + await b.mouse.move(r.x + tx / 640 * r.width, r.y + ty / 360 * r.height); + await b.mouse.down(); + await b.waitForTimeout(500); + assert.ok(await b.evaluate(() => game.closeup.power > 0.2), 'tenendo premuto la forza sale'); + await b.screenshot({ path: `${OUT}/03e-biliardo-forza.png` }); + await b.mouse.up(); + assert.equal(await b.evaluate(() => game.closeup.step), 'rolling', 'lasciando, la biglia parte'); + await b.waitForFunction(() => game.closeup.step === 'aim' && game.closeup.current === 'cpu' || game.closeup.step === 'rolling' && game.closeup.shot?.shooter === 'cpu', null, { timeout: 20000 }); + await b.screenshot({ path: `${OUT}/03f-biliardo-socio.png` }); + await b.keyboard.press('Escape'); + await b.waitForFunction(() => game.speech?.text.startsWith('Lasci a metà?'), null, { timeout: 5000 }); + await b.close(); + } + const phone = await open({ width: 390, height: 844 }); await phone.click('#splash'); // un clic salta il cartello assert.ok(await phone.isVisible('#title'), 'il clic sul cartello porta al titolo'); diff --git a/tests/story.test.mjs b/tests/story.test.mjs index f6356e9..aaa7674 100644 --- a/tests/story.test.mjs +++ b/tests/story.test.mjs @@ -70,7 +70,7 @@ test('la storia è coerente: scene, oggetti, dialoghi e punti calpestabili esist if (e.closeup) { const c = story.closeups?.[e.closeup]; assert.ok(c, `${where}: primo piano ${e.closeup}`); - for (const src of c.picture ? [c.picture] : [c.closed, c.open]) assert.ok(existsSync(new URL('../' + src, import.meta.url)), `${where}: manca ${src}`); + for (const src of c.picture ? [c.picture] : c.closed ? [c.closed, c.open] : []) assert.ok(existsSync(new URL('../' + src, import.meta.url)), `${where}: manca ${src}`); } if (e.who) assert.ok(story.characters[e.who], `${where}: personaggio ${e.who}`); } diff --git a/tools/voci.json b/tools/voci.json index b97699d..d497fc3 100644 --- a/tools/voci.json +++ b/tools/voci.json @@ -1,16 +1,49 @@ { "_nota": "Una voce XTTS-v2 per personaggio (make voci). speaker: uno dei 58 di XTTS; filtro: catena ffmpeg -af applicata dopo (altezza, velocità, eco). Cambiare una voce rigenera solo le sue battute.", - "andrea": { "speaker": "Andrew Chipper" }, - "vecchietta": { "speaker": "Tammy Grit" }, - "vecchietto": { "speaker": "Viktor Eka" }, - "castello": { "speaker": "Damien Black", "filtro": "asetrate=24000*0.9,aresample=24000,atempo=0.95,aecho=0.8:0.7:90|180:0.25|0.15" }, - "leone": { "speaker": "Baldur Sanjin", "filtro": "asetrate=24000*0.84,aresample=24000,atempo=1.08" }, - "san_pietro": { "speaker": "Wulf Carlevaro", "filtro": "asetrate=24000*0.92,aresample=24000,atempo=0.95,aecho=0.8:0.6:60:0.2" }, - "tosca": { "speaker": "Gitta Nikolina" }, - "arcu": { "speaker": "Luis Moray" }, - "tozzino": { "speaker": "Xavier Hayasaka", "filtro": "asetrate=24000*1.12,aresample=24000,atempo=0.92" }, - "barondeduke": { "speaker": "Craig Gutsy" }, - "mariano": { "speaker": "Gilberto Mathias" }, - "fra_peppino": { "speaker": "Ludvig Milivoj" }, - "macchina": { "speaker": "Andrew Chipper" } + "andrea": { + "speaker": "Andrew Chipper" + }, + "vecchietta": { + "speaker": "Tammy Grit" + }, + "vecchietto": { + "speaker": "Viktor Eka" + }, + "castello": { + "speaker": "Damien Black", + "filtro": "asetrate=24000*0.9,aresample=24000,atempo=0.95,aecho=0.8:0.7:90|180:0.25|0.15" + }, + "leone": { + "speaker": "Baldur Sanjin", + "filtro": "asetrate=24000*0.84,aresample=24000,atempo=1.08" + }, + "san_pietro": { + "speaker": "Wulf Carlevaro", + "filtro": "asetrate=24000*0.92,aresample=24000,atempo=0.95,aecho=0.8:0.6:60:0.2" + }, + "tosca": { + "speaker": "Gitta Nikolina" + }, + "arcu": { + "speaker": "Luis Moray" + }, + "tozzino": { + "speaker": "Xavier Hayasaka", + "filtro": "asetrate=24000*1.12,aresample=24000,atempo=0.92" + }, + "barondeduke": { + "speaker": "Craig Gutsy" + }, + "mariano": { + "speaker": "Gilberto Mathias" + }, + "fra_peppino": { + "speaker": "Ludvig Milivoj" + }, + "macchina": { + "speaker": "Andrew Chipper" + }, + "ing_tozzi": { + "speaker": "Badr Odhiambo" + } }