Circolo 4 Maggio: dal portone in piazza si entra nella sala dei biliardi (sfondo in stile Lucas fatto da Andrea con Codex) e al primo tavolo si gioca a biliardo coi cinque birilli contro l'Ing. Tozzi (battute provvisorie, sprite da fare). Motore nuovo: fisica delle biglie e delle sponde, birilli che cadono, regole e falli del gioco italiano, avversario che prova i colpi a vuoto un po' per fotogramma; disegno dall'alto con stecca, linea di mira e biglia fantasma; stessi comandi delle bocce (pannello, tastiera, pressione); suoni; test; nelle idee la foto per i rigori

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
This commit is contained in:
2026-10-25 22:34:19 +01:00
co-authored by Claude Fable 5.1
parent 394e8bdde3
commit 053728a086
35 changed files with 819 additions and 53 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+72
View File
@@ -75,6 +75,14 @@
"file": "andrea-a9df2e6bbb.mp3", "file": "andrea-a9df2e6bbb.mp3",
"sec": 4.98 "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.": { "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", "file": "andrea-6cc844498a.mp3",
"sec": 10.59 "sec": 10.59
@@ -635,6 +643,54 @@
"file": "andrea-d193b44288.mp3", "file": "andrea-d193b44288.mp3",
"sec": 3.65 "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.": { "andrea|Il tabernacolo del ponte, con l'Annunciazione di Antonio Berti, scultore sanpierino.": {
"file": "andrea-9c0935dc77.mp3", "file": "andrea-9c0935dc77.mp3",
"sec": 5.48 "sec": 5.48
@@ -1899,6 +1955,22 @@
"file": "mariano-39e27158b7.mp3", "file": "mariano-39e27158b7.mp3",
"sec": 2.83 "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.": { "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", "file": "andrea-6484641c58.mp3",
"sec": 8.67 "sec": 8.67
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -1
View File
@@ -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. - *Caccia al tesoro*: un giro di indizi fra le scene che ci sono già, senza un minigioco nuovo.
Fuori dal Palio: Fuori dal Palio:
- *Briscola al circolino* con l'Arcu e il Barone (la gag del «Male male si divide»): una mano corta. - *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`.
+8
View File
@@ -121,6 +121,14 @@
<p>Tre bocce a testa. Tira chi non ha il punto; segna ogni boccia più vicina della migliore avversaria. <p>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.</p> Vince chi fa più punti nella mano.</p>
</details> </details>
<details id="biliardo-help" hidden><summary>Come si gioca</summary>
<p>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.</p>
<p>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.</p>
</details>
</section> </section>
</div> </div>
<p id="rotate">Gira il telefono: in orizzontale si gioca meglio.</p> <p id="rotate">Gira il telefono: in orizzontale si gioca meglio.</p>
+115
View File
@@ -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();
}
+259
View File
@@ -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})`;
}
+2 -2
View File
@@ -3,10 +3,10 @@ import { ambientHash as hash } from './ambient.js';
const FONT = '"Press Start 2P", monospace'; const FONT = '"Press Start 2P", monospace';
const COLORS = { player: ['#172e58', '#3975c4', '#d9ecff'], cpu: ['#531d1b', '#c24e3b', '#ffe0bd'], jack: ['#70531b', '#e2b743', '#fff8cd'] }; 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); 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.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); ctx.strokeStyle = '#897b57'; ctx.lineWidth = 1; ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
} }
+23 -13
View File
@@ -1,7 +1,14 @@
// Il cuore dell'avventura, senza DOM: stato, comandi, coda delle azioni, dialoghi. // 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. // 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 { 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 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' }; 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() { closeupTap() {
const c = this.closeup; const c = this.closeup;
if (!c || this.speech) return false; 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 }); if (c.step === 'cover') Object.assign(c, { step: 'title', t: 0 });
else if (c.step === 'title') Object.assign(c, { step: 'turn', t: 0 }); else if (c.step === 'title') Object.assign(c, { step: 'turn', t: 0 });
else return false; else return false;
@@ -248,8 +255,8 @@ export class Game {
if (c.step === 'done') this.queue.unshift(...def.complete); if (c.step === 'done') this.queue.unshift(...def.complete);
return true; return true;
} }
// Le bocce: a mano finita, la battuta di chi ha vinto; uscendo prima, quella di chi lascia. // Bocce e biliardo: a partita finita, la battuta di chi ha vinto; uscendo prima, quella di chi lascia.
if (def?.type === 'bocce') { if (MINIGAMES[def?.type]) {
this.queue.unshift(...(c.step === 'result' ? def[c.result?.winner === 'player' ? 'win' : c.result?.winner === 'cpu' ? 'lose' : 'draw'] ?? [] : def.quit ?? [])); this.queue.unshift(...(c.step === 'result' ? def[c.result?.winner === 'player' ? 'win' : c.result?.winner === 'cpu' ? 'lose' : 'draw'] ?? [] : def.quit ?? []));
return true; return true;
} }
@@ -258,16 +265,18 @@ export class Game {
return true; return true;
} }
// Le bocce: si tiene premuto per caricare la forza e si lascia per tirare (mouse, dito o spazio). // Bocce e biliardo: 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); } minigame() { const c = this.closeup; return c ? MINIGAMES[this.story.closeups[c.id].type] ?? null : null; }
closeupRelease() { const c = this.closeup; return !!c && this.story.closeups[c.id].type === 'bocce' && bocceRelease(c); } closeupPress() { const m = this.minigame(); return !!m && !this.speech && m.press(this.closeup); }
closeupCancel() { const c = this.closeup; return !!c && this.story.closeups[c.id].type === 'bocce' && bocceCancel(c); } 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: // 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. // uscire o ricaricare durante la ricerca permette di ripeterla, senza anticipare il finale.
closeupAim([x, y]) { closeupAim([x, y]) {
const c = this.closeup; 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; 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))]; c.beam = [Math.max(70, Math.min(570, x)), Math.max(65, Math.min(295, y))];
return true; return true;
@@ -390,9 +399,10 @@ export class Game {
const c = this.closeup; const c = this.closeup;
if (c) { if (c) {
if (this.story.closeups[c.id].type === 'torch') { this.updateTorch(dt); return; } if (this.story.closeups[c.id].type === 'torch') { this.updateTorch(dt); return; }
if (this.story.closeups[c.id].type === 'bocce') { const m = this.minigame();
bocceUpdate(c, dt); if (m) {
// Il risultato resta leggibile finché il giocatore torna al pallaio. m.update(c, dt);
// Il risultato resta leggibile finché il giocatore non esce.
return; return;
} }
c.t += dt; c.t += dt;
@@ -429,7 +439,7 @@ export class Game {
else if (e.closeup) { else if (e.closeup) {
const type = this.story.closeups[e.closeup].type; 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] } 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 }; : { id: e.closeup, step: 'cover', t: 0, age: 0 };
} }
else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e)); else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e));
+2
View File
@@ -4,6 +4,7 @@ import { check, PAGE_TURN } from './game.js';
import { ambientEffects, ambientHash } from './ambient.js'; import { ambientEffects, ambientHash } from './ambient.js';
import { drawTorch } from './torch.js'; import { drawTorch } from './torch.js';
import { drawBocce } from './bocce-render.js'; import { drawBocce } from './bocce-render.js';
import { drawBiliardo } from './biliardo-render.js';
import { versioned } from '../version.js'; import { versioned } from '../version.js';
export const WIDTH = 640, HEIGHT = 360; export const WIDTH = 640, HEIGHT = 360;
@@ -675,6 +676,7 @@ function drawCloseup(ctx, game, time) {
drawBocce(ctx, c, def, backgrounds.get(def.picture), time); drawBocce(ctx, c, def, backgrounds.get(def.picture), time);
return; return;
} }
if (def.type === 'biliardo') { drawBiliardo(ctx, c, def); return; }
ctx.save(); ctx.save();
ctx.fillStyle = `rgba(8, 5, 12, ${Math.min(0.78, c.age / 0.35 * 0.78)})`; ctx.fillStyle = `rgba(8, 5, 12, ${Math.min(0.78, c.age / 0.35 * 0.78)})`;
ctx.fillRect(0, 0, WIDTH, HEIGHT); ctx.fillRect(0, 0, WIDTH, HEIGHT);
+4
View File
@@ -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-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-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 === '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 === '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 === 'drip') this.tone(1150, 540, 0.18, 0.055, 0, pan);
else if (kind === 'bird') { else if (kind === 'bird') {
+31 -23
View File
@@ -8,7 +8,8 @@ import { Music, trackFor } from './engine/music.js';
import { Voices } from './engine/voices.js'; import { Voices } from './engine/voices.js';
import { Soundscape } from './engine/soundscape.js'; import { Soundscape } from './engine/soundscape.js';
import { soundscapeFor } from './story/soundscapes.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'; import { VERSION, versioned } from './version.js';
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
@@ -434,13 +435,18 @@ canvas.addEventListener('pointermove', e => {
}); });
canvas.addEventListener('pointerleave', () => { mouse = null; }); canvas.addEventListener('pointerleave', () => { mouse = null; });
// Comandi delle bocce separati dai verbi dell'avventura: forza regolabile e lancio // Comandi dei minigiochi (bocce e biliardo) separati dai verbi dell'avventura: mira a passi, forza regolabile e
// con un solo clic, anche per chi non vuole usare la pressione temporizzata. // lancio con un solo clic, anche per chi non vuole usare la pressione temporizzata.
const bocceState = () => running && game?.closeup?.id === 'bocce' ? game.closeup : null; const MINI = {
$('bocce-power').addEventListener('input', e => { const c = bocceState(); if (c) boccePower(c, Number(e.target.value) / 100); }); 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.' },
$('bocce-throw').addEventListener('click', () => { const c = bocceState(); if (c && !game.speech) bocceShoot(c); }); biliardo: { ...biliardo, back: 'Torna al bar', label: 'Biliardo: tocca il tavolo per mirare. Regola la forza e premi Tira.' },
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); }); 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()); addEventListener('blur', () => game?.closeupCancel());
// --- Verbi. // --- 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; } 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. // Nel primo piano: Invio, spazio o freccia sfogliano (o saltano la battuta), Esc chiude il libro.
if (running && game.closeup) { if (running && game.closeup) {
if (game.closeup.id === 'bocce') { if (miniState()) {
if (e.target.closest?.('#bocce-controls') && ['BUTTON', 'SUMMARY'].includes(e.target.tagName)) return; 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)) { if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
e.preventDefault(); e.preventDefault();
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') bocceAim(c, c.aim.u, c.aim.w + (e.key === 'ArrowLeft' ? -0.3 : 0.3)); if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') m.nudge(c, e.key === 'ArrowLeft' ? -1 : 1);
else boccePower(c, c.power + (e.key === 'ArrowUp' ? 0.01 : -0.01)); else m.setPower(c, c.power + (e.key === 'ArrowUp' ? 0.01 : -0.01));
return; return;
} }
if (c.step === 'result' && [' ', 'Enter'].includes(e.key)) { e.preventDefault(); game.closeupClose(); return; } if (c.step === 'result' && [' ', 'Enter'].includes(e.key)) { e.preventDefault(); game.closeupClose(); return; }
@@ -554,7 +560,7 @@ window.addEventListener('keydown', e => {
return; return;
} }
if (e.key === 'Escape') game.closeupClose(); 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 ([' ', 'Enter', 'ArrowRight'].includes(e.key)) { e.preventDefault(); if (!game.skip()) game.closeupTap(); }
else if (e.key === 'm' || e.key === 'M') toggleMute(); else if (e.key === 'm' || e.key === 'M') toggleMute();
return; return;
@@ -572,7 +578,7 @@ window.addEventListener('keydown', e => {
if (game.dialogue && n >= 1 && n <= 9) game.choose(n - 1); 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(); }); 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). // --- Il primo piano: si chiude col pulsante; la pagina che gira fruscia (rumore filtrato, niente file audio).
@@ -613,17 +619,19 @@ function updateUi() {
updateInventory(); updateInventory();
updateChoices(); updateChoices();
updateSpeech(); updateSpeech();
const bocce = c?.id === 'bocce'; const mg = miniState() && mini(), def = mg && story.closeups[c.id];
$('bocce-controls').hidden = !bocce; $('bocce-controls').hidden = !mg;
$('bocce-controls').inert = !running; $('bocce-controls').inert = !running;
if (bocce) { if (mg) {
$('panel').hidden = $('choices').hidden = true; $('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; if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line;
$('bocce-power').value = Math.round(c.power * 100); $('bocce-power').value = Math.round(c.power * 100);
$('bocce-power-value').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; 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' ? 'Mano conclusa' : c.step === 'rolling' ? 'In movimento…' : c.current === 'cpu' ? 'Tira Mariano…' : 'Lancia'; $('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 }); 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. // 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; $('spots-button').hidden = !running || !!game.closeup;
$('notebook-button').hidden = !running || !story.notebook || !!game.closeup; $('notebook-button').hidden = !running || !story.notebook || !!game.closeup;
$('closeup-close').hidden = !running || !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; $('panel').inert = !!c;
canvas.classList.toggle('torch', !!torch); canvas.classList.toggle('torch', !!torch);
canvas.classList.toggle('bocce', bocce); 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.' : bocce ? 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' : "La scena dell'avventura"); 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. // La pagina che comincia a girare fa il suo fruscio.
const step = game.closeup?.step ?? null; const step = game.closeup?.step ?? null;
if (step === 'turn' && turning !== 'turn') ambience.play('page'); if (step === 'turn' && turning !== 'turn') ambience.play('page');
+77
View File
@@ -124,6 +124,21 @@ export const story = {
quit: [{ say: 'Già stanco? Il pallaio ti aspetta.', who: 'mariano', pose: 'laugh' }], quit: [{ say: 'Già stanco? Il pallaio ti aspetta.', who: 'mariano', pose: 'laugh' }],
draw: [{ say: 'Quando vuoi la rivincita, sono qui.', who: 'mariano' }], 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: { fessura: {
type: 'torch', type: 'torch',
picture: 'assets/closeups/palio-2007.jpg', picture: 'assets/closeups/palio-2007.jpg',
@@ -170,6 +185,8 @@ export const story = {
san_pietro: { name: 'San Pietro', color: '#e8e0c8' }, san_pietro: { name: 'San Pietro', color: '#e8e0c8' },
tosca: { name: 'La Tosca', color: '#ffb0d0' }, tosca: { name: 'La Tosca', color: '#ffb0d0' },
arcu: { name: 'L\'Arcu', color: '#7fb2ff', sprite: 'assets/characters/arcu.json' }, 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' }, tozzino: { name: 'Il Tozzino', color: '#c8f5b0', sprite: 'assets/characters/tozzino.json' },
barondeduke: { name: 'Il BaronDeDuke', color: '#e0c090', sprite: 'assets/characters/barondeduke.json' }, barondeduke: { name: 'Il BaronDeDuke', color: '#e0c090', sprite: 'assets/characters/barondeduke.json' },
mariano: { name: 'Mariano', color: '#ffb0a3', sprite: 'assets/characters/mariano.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.' }], 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', 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' }], 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. // Il ponte sulla Sieve, proseguendo la Provinciale dalla farmacia; di là dal ponte, la polisportiva.
ponte_sieve: { ponte_sieve: {
name: 'Ponte sulla Sieve', name: 'Ponte sulla Sieve',
+146
View File
@@ -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à?')));
});
+30
View File
@@ -390,6 +390,36 @@ try {
await bocce.close(); 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 }); const phone = await open({ width: 390, height: 844 });
await phone.click('#splash'); // un clic salta il cartello await phone.click('#splash'); // un clic salta il cartello
assert.ok(await phone.isVisible('#title'), 'il clic sul cartello porta al titolo'); assert.ok(await phone.isVisible('#title'), 'il clic sul cartello porta al titolo');
+1 -1
View File
@@ -70,7 +70,7 @@ test('la storia è coerente: scene, oggetti, dialoghi e punti calpestabili esist
if (e.closeup) { if (e.closeup) {
const c = story.closeups?.[e.closeup]; const c = story.closeups?.[e.closeup];
assert.ok(c, `${where}: primo piano ${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}`); if (e.who) assert.ok(story.characters[e.who], `${where}: personaggio ${e.who}`);
} }
+46 -13
View File
@@ -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.", "_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" }, "andrea": {
"vecchietta": { "speaker": "Tammy Grit" }, "speaker": "Andrew Chipper"
"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" }, "vecchietta": {
"leone": { "speaker": "Baldur Sanjin", "filtro": "asetrate=24000*0.84,aresample=24000,atempo=1.08" }, "speaker": "Tammy Grit"
"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" }, "vecchietto": {
"arcu": { "speaker": "Luis Moray" }, "speaker": "Viktor Eka"
"tozzino": { "speaker": "Xavier Hayasaka", "filtro": "asetrate=24000*1.12,aresample=24000,atempo=0.92" }, },
"barondeduke": { "speaker": "Craig Gutsy" }, "castello": {
"mariano": { "speaker": "Gilberto Mathias" }, "speaker": "Damien Black",
"fra_peppino": { "speaker": "Ludvig Milivoj" }, "filtro": "asetrate=24000*0.9,aresample=24000,atempo=0.95,aecho=0.8:0.7:90|180:0.25|0.15"
"macchina": { "speaker": "Andrew Chipper" } },
"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"
}
} }