diff --git a/index.html b/index.html index 9dc39e5..fd2252e 100644 --- a/index.html +++ b/index.html @@ -103,6 +103,25 @@
+

Gira il telefono: in orizzontale si gioca meglio.

diff --git a/src/engine/bocce-render.js b/src/engine/bocce-render.js new file mode 100644 index 0000000..bd2d4f9 --- /dev/null +++ b/src/engine/bocce-render.js @@ -0,0 +1,130 @@ +import { project, detailPoint, DETAIL, HALF, canThrow, landing, left, closest, gap, score, status } from './bocce.js'; +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') { + 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) { + 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); +} +function sphere(ctx, b, x, y, r, top = false) { + const [dark, mid, light] = COLORS[b.jack ? 'jack' : b.who]; + ctx.fillStyle = 'rgba(29,22,13,0.4)'; ctx.beginPath(); + ctx.ellipse(x + r * 0.4, y + r * 0.12, r * 1.25, r * 0.45, 0.3, 0, Math.PI * 2); ctx.fill(); + const cy = top ? y : y - r; + const shade = ctx.createRadialGradient(x - r * 0.35, cy - r * 0.4, 0, x, cy, r * 1.1); + shade.addColorStop(0, light); shade.addColorStop(0.28, mid); shade.addColorStop(1, dark); + ctx.fillStyle = shade; ctx.beginPath(); ctx.arc(x, cy, r, 0, Math.PI * 2); ctx.fill(); + if (!b.jack) { + ctx.save(); ctx.beginPath(); ctx.arc(x, cy, r - 0.2, 0, Math.PI * 2); ctx.clip(); + ctx.strokeStyle = 'rgba(240,230,210,0.65)'; ctx.lineWidth = Math.max(0.6, r * 0.1); + // Le incisioni si spostano con la distanza percorsa: si vede il rotolamento. + const offset = Math.sin(b.spin ?? 0) * r * 0.8; + ctx.beginPath(); ctx.ellipse(x + offset, cy, Math.max(0.5, r * 0.35), r * 1.1, -0.3, 0, Math.PI * 2); ctx.stroke(); + ctx.restore(); + } + ctx.fillStyle = light; ctx.fillRect(Math.round(x - r * 0.35), Math.round(cy - r * 0.4), 1, 1); +} +function cross(ctx, p, radius, color) { + ctx.strokeStyle = color; ctx.lineWidth = 1; + ctx.beginPath(); ctx.ellipse(p.x, p.y, radius, radius * 0.45, 0, 0, Math.PI * 2); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(p.x - radius - 3, p.y); ctx.lineTo(p.x + radius + 3, p.y); + ctx.moveTo(p.x, p.y - radius * 0.45 - 3); ctx.lineTo(p.x, p.y + radius * 0.45 + 3); ctx.stroke(); +} +const cm = (c, b) => b ? `${(gap(b, c.jack) * 20).toFixed(1)} cm` : '—'; + +function detail(ctx, c, def) { + const d = DETAIL; + panel(ctx, d.x, d.y, d.w, d.h); + text(ctx, 'VICINO AL PALLINO', d.x + d.w / 2, d.y + 13, 6, '#eed5a0', 'center'); + ctx.save(); ctx.beginPath(); ctx.rect(d.x + 5, d.y + 25, d.w - 10, d.h - 49); ctx.clip(); + ctx.fillStyle = '#b79a6c'; ctx.fillRect(d.x, d.y + 25, d.w, d.h); + for (let i = 0; i < 160; i++) { + ctx.fillStyle = i % 2 ? '#c9b17d' : '#9c8059'; + ctx.fillRect(Math.round(d.x + hash(i + 5) * d.w), Math.round(d.y + 25 + hash(i + 190) * (d.h - 49)), 1, 1); + } + // Le sponde compaiono anche nel dettaglio quando il pallino si avvicina al bordo. + for (const w of [-HALF, HALF]) { + const p = detailPoint(c, { u: c.jack.u, w }); + ctx.fillStyle = '#73513b'; ctx.fillRect(p.x - 2, d.y + 25, 4, d.h); + } + const jack = detailPoint(c, c.jack), leader = score(c).winner; + for (const who of ['player', 'cpu']) { + const b = closest(c, who); + if (!b) continue; + const p = detailPoint(c, b); + ctx.strokeStyle = who === 'player' ? '#315d92' : '#923827'; ctx.setLineDash([2, 2]); + ctx.beginPath(); ctx.moveTo(jack.x, jack.y); ctx.lineTo(p.x, p.y); ctx.stroke(); ctx.setLineDash([]); + if (leader === who) { ctx.strokeStyle = '#fff2a3'; ctx.beginPath(); ctx.arc(p.x, p.y, b.r * d.scale + 3, 0, Math.PI * 2); ctx.stroke(); } + } + if (canThrow(c)) { + const target = detailPoint(c, c.aim), end = detailPoint(c, landing(c)); + cross(ctx, target, 6, '#fff4c9'); + ctx.setLineDash([2, 2]); cross(ctx, end, 5, '#315d92'); ctx.setLineDash([]); + } + for (const b of [...c.balls, c.jack]) { + const p = detailPoint(c, b); + sphere(ctx, b, p.x, p.y, Math.max(b.jack ? 2 : 3, b.r * d.scale), true); + } + ctx.restore(); + text(ctx, 'Tocca qui per mirare', d.x + d.w / 2, d.y + d.h - 12, 6, '#d7ccb4', 'center'); + for (const [i, who] of ['player', 'cpu'].entries()) { + text(ctx, `${def.names[who]}: ${cm(c, closest(c, who))}`, d.x + 2, d.y + d.h + 13 + i * 14, 6, COLORS[who][2]); + } +} + +export function drawBocce(ctx, c, def, bg) { + ctx.save(); + if (bg) ctx.drawImage(bg, 0, 0, 640, 360); + else { ctx.fillStyle = '#bca16d'; ctx.fillRect(0, 0, 640, 360); } + // Polvere breve dietro l'ultima boccia, agganciata al terreno e subito dissolta. + for (const [i, t] of c.trails.entries()) { + const p = project(t.u, t.w); + ctx.fillStyle = `rgba(218,195,145,${0.22 * (1 - t.age / 0.55)})`; + ctx.fillRect(Math.round(p.x + (hash(i) - 0.5) * 5), Math.round(p.y), Math.max(1, p.s * 8), 1); + } + if (canThrow(c)) { + const from = project(1, 0), target = project(c.aim.u, c.aim.w), end = landing(c); + const to = project(Math.min(99, end.u), Math.max(-HALF, Math.min(HALF, end.w))); + ctx.strokeStyle = 'rgba(255,246,210,0.8)'; ctx.lineWidth = 1; ctx.setLineDash([3, 5]); + ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); ctx.stroke(); ctx.setLineDash([]); + cross(ctx, target, 5, '#fff4c9'); cross(ctx, to, 7, '#8fc7ff'); + } + for (const b of [c.jack, ...c.balls].sort((a, b) => b.u - a.u)) { + const p = project(b.u, b.w); + const r = Math.max(b.jack ? 1.7 : 2.5, b.r * 33.9 * p.s * (1 + 0.65 * (1 - p.s))); + sphere(ctx, b, p.x, p.y, r); + } + // Pallino identificabile anche senza distinguere i colori delle squadre. + const j = project(c.jack.u, c.jack.w); + text(ctx, 'P', j.x, j.y - 13, 6, '#fff7c7', 'center'); + detail(ctx, c, def); + panel(ctx, 18, 306, 180, 43); + for (const [i, who] of ['player', 'cpu'].entries()) { + const y = 319 + i * 17; + text(ctx, def.names[who], 28, y, 7, COLORS[who][2]); + for (let n = 0; n < 3; n++) { + const x = 139 + n * 18; + if (n < left(c, who)) sphere(ctx, { who, spin: 0 }, x, y, 4, true); + else { ctx.strokeStyle = '#766e5c'; ctx.strokeRect(x - 3, y - 3, 6, 6); } + } + } + panel(ctx, 182, 50, 440, 24); + text(ctx, status(c, def.names), 402, 63, 6, '#fff0cb', 'center'); + if (canThrow(c)) { + panel(ctx, 446, 274, 176, 37); + text(ctx, `FORZA ${Math.round(c.power * 100)}%`, 458, 286, 7); + ctx.fillStyle = '#3e3e36'; ctx.fillRect(458, 299, 150, 5); + ctx.fillStyle = '#b9d392'; ctx.fillRect(458, 299, Math.round(c.power * 150), 5); + } + if (c.step === 'result') { + panel(ctx, 206, 91, 397, 70); + text(ctx, 'MANO CONCLUSA', 404, 108, 9, '#efd49e', 'center'); + text(ctx, status(c, def.names), 404, 132, 10, c.result.winner === 'player' ? '#b9e5b3' : '#f1d9be', 'center'); + text(ctx, 'Le distanze restano visibili fino a quando esci.', 404, 150, 6, '#d6c7af', 'center'); + } + ctx.restore(); +} diff --git a/src/engine/bocce.js b/src/engine/bocce.js index 45049f3..0d3c94b 100644 --- a/src/engine/bocce.js +++ b/src/engine/bocce.js @@ -1,172 +1,190 @@ -// Le bocce al pallaio (primo piano `type: 'bocce'`): una mano da tre bocce a testa contro il campione. -// Tieni premuto e la forza va su e giù; lasci e la boccia parte. Vince la mano chi ha la boccia più vicina al pallino. -// La pista è un piano: `u` lungo la pista (0 dove si tira, 100 la sponda di fondo), `w` di traverso (-10…10). -// Niente fisica vera: attrito costante, urti semplici (chi colpisce cede quasi tutta la spinta), sponde che respingono. +// Mano amichevole da tre bocce: mira libera, forza ripetibile, gioca chi non ha il punto. +// Il campo usa coordinate u (lunghezza) e w (larghezza). Un'unità rappresenta 20 cm. +// È una versione da circolo: sponde elastiche, senza falli o dichiarazioni arbitrali. +export const LANE = 100, HALF = 10, FRICTION = 8, BALL = 0.27, JACK = 0.1, BALLS = 3; +export const SWING = 1.6; +export const FIXED_STEP = 1 / 240; +const REST = 0.75, EPS = 0.005; +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 distance = (a, b) => Math.hypot(a.u - b.u, a.w - b.w); +export const gap = (a, b) => Math.max(0, distance(a, b) - a.r - b.r); +export const left = (c, who) => Math.max(0, BALLS - c.balls.filter(b => b.who === who).length); +export const thrower = c => c.current; +export const canThrow = c => c.step === 'aim' && c.current === 'player' && left(c, 'player') > 0; +export const powerAt = time => clamp(time / SWING, 0, 1); +export const rangeFor = power => 8 + 96 * power; +export const powerFor = distance => clamp((distance - 8) / 96, 0, 1); +export const moving = c => [c.jack, ...c.balls].some(b => Math.hypot(b.vu, b.vw) > EPS); +export const closest = (c, who) => c.balls.filter(b => b.who === who).sort((a, b) => gap(a, c.jack) - gap(b, c.jack))[0]; -export const LANE = 100, HALF = 10, FRICTION = 40, BALL = 1.25, JACK = 0.6, BALLS = 3; -export const SWING = 1.6; // secondi per un giro della forza (0 → 1 → 0) -const REST = 0.6; // pausa a bocce ferme prima del tiro seguente - -// Numeri «a caso» ripetibili: lo stato del generatore sta nel primo piano, così le prove sono sempre uguali. -function random(c) { - c.seed = (c.seed * 1664525 + 1013904223) % 4294967296; - return c.seed / 4294967296; -} -const gauss = c => (random(c) + random(c) + random(c) - 1.5) * 1.15; - -export function newBocce(seed = Date.now() % 4294967296) { - const c = { step: 'aim', t: 0, age: 0, seed, balls: [], turn: 0, power: 0, holding: false, holdTime: 0 }; - c.jack = { u: 38 + random(c) * 22, w: (random(c) - 0.5) * 5, vu: 0, vw: 0, r: JACK, jack: true }; +export function newBocce(seed = Date.now() >>> 0) { + const c = { step: 'aim', t: 0, age: 0, seed, balls: [], turn: 0, current: 'player', power: 0.5, + holding: false, holdTime: 0, accumulator: 0, events: [], trails: [], travel: 0, lastThrower: null }; + c.jack = { u: 45 + random(c) * 18, w: (random(c) - 0.5) * 5, vu: 0, vw: 0, r: JACK, mass: 0.1, jack: true, spin: 0 }; + c.aim = { u: c.jack.u, w: c.jack.w }; return c; } - -// Di chi è il tiro: Andrea ai tiri pari, il campione ai dispari. -export const thrower = c => (c.turn % 2 ? 'cpu' : 'player'); -export const left = (c, who) => BALLS - c.balls.filter(b => b.who === who).length; -const moving = c => [c.jack, ...c.balls].some(b => Math.hypot(b.vu, b.vw) > 0.01); -const dist = (a, b) => Math.hypot(a.u - b.u, a.w - b.w); - -// Una boccia lanciata verso un punto: parte da dove si tira con la velocità che basta ad arrivarci. -function launch(c, who, u, w) { - const d = Math.hypot(u, w), v = Math.sqrt(2 * FRICTION * d); - c.balls.push({ who, u: 0, w: 0, vu: v * u / d, vw: v * w / d, r: BALL }); - c.step = 'rolling'; - c.t = 0; +export function setPower(c, value) { + if (!canThrow(c) || c.holding || !Number.isFinite(value)) return false; + c.power = clamp(value, 0, 1); return true; } - -// Andrea: la forza decide quanto lontano; la direzione punta al pallino, con un po' di mano che trema. -export function press(c) { - if (c.step !== 'aim' || thrower(c) !== 'player' || c.holding) return false; - c.holding = true; - c.holdTime = 0; - c.power = 0; +export function aim(c, u, w) { + if (!canThrow(c) || c.holding || !Number.isFinite(u) || !Number.isFinite(w)) return false; + c.aim = { u: clamp(u, 10, LANE - 2), w: clamp(w, -HALF + BALL, HALF - BALL) }; return true; +} +export function landing(c) { + const d = Math.hypot(c.aim.u, c.aim.w), range = rangeFor(c.power); + return { u: BALL + 0.02 + range * c.aim.u / d, w: range * c.aim.w / d }; +} +function launch(c, who, u, w, range) { + if (left(c, who) <= 0) return false; + const d = Math.hypot(u, w), v = Math.sqrt(2 * FRICTION * range); + c.balls.push({ who, number: BALLS - left(c, who) + 1, u: BALL + 0.02, w: 0, + vu: v * u / d, vw: v * w / d, r: BALL, mass: 1, spin: 0 }); + c.step = 'rolling'; c.t = c.accumulator = c.travel = 0; c.lastThrower = who; + c.events.push({ kind: 'bocce-roll', power: c.power }); return true; } +export function shoot(c) { + if (!canThrow(c) || c.holding) return false; + return launch(c, 'player', c.aim.u, c.aim.w, rangeFor(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; - c.holding = false; - launch(c, 'player', 8 + c.power * (LANE - 4), c.jack.w + gauss(c) * 0.9); - return true; + // Un tocco breve serve a mirare, non spreca una boccia. + if (c.holdTime < 0.14) return cancel(c); + c.holding = false; return shoot(c); } -export const powerAt = holdTime => (1 - Math.cos((2 * Math.PI * holdTime) / SWING)) / 2; +// Impulso normale fra masse diverse: niente accelerazioni create dagli urti, e +// separazione geometrica anche quando due pezzi sono già fermi o sovrapposti. +export function collide(a, b) { + const du = b.u - a.u, dw = b.w - a.w, d = Math.hypot(du, dw), r = a.r + b.r; + if (d >= r) return 0; + const nu = d > 1e-9 ? du / d : 1, nw = d > 1e-9 ? dw / d : 0; + const ia = 1 / (a.mass ?? (a.jack ? 0.1 : 1)), ib = 1 / (b.mass ?? (b.jack ? 0.1 : 1)); + const overlap = r - d + 0.00001; + a.u -= nu * overlap * ia / (ia + ib); a.w -= nw * overlap * ia / (ia + ib); + b.u += nu * overlap * ib / (ia + ib); b.w += nw * overlap * ib / (ia + ib); + const closing = (a.vu - b.vu) * nu + (a.vw - b.vw) * nw; + if (closing <= 0) return 0; + const impulse = 1.68 * closing / (ia + ib); + a.vu -= impulse * ia * nu; a.vw -= impulse * ia * nw; + b.vu += impulse * ib * nu; b.vw += impulse * ib * nw; + return closing; +} +function bounds(b) { + let impact = 0; + for (const [pos, vel, low, high] of [['u', 'vu', b.r, LANE - b.r], ['w', 'vw', -HALF + b.r, HALF - b.r]]) { + if (b[pos] < low || b[pos] > high) { + const outward = b[pos] < low ? b[vel] < 0 : b[vel] > 0; + b[pos] = clamp(b[pos], low, high); + if (outward) { impact = Math.max(impact, Math.abs(b[vel])); b[vel] *= -0.32; } + } + } + return impact; +} function step(c, dt) { const all = [c.jack, ...c.balls]; + let hit = 0, rail = 0; for (const b of all) { const v = Math.hypot(b.vu, b.vw); - if (v <= 0.01) { b.vu = b.vw = 0; continue; } - const slow = Math.max(0, v - FRICTION * dt) / v; - b.vu *= slow; b.vw *= slow; - b.u += b.vu * dt; b.w += b.vw * dt; - // Le sponde: di lato e in fondo respingono, piano. - if (Math.abs(b.w) > HALF - b.r) { b.w = Math.sign(b.w) * (HALF - b.r); b.vw *= -0.4; } - if (b.u > LANE - b.r) { b.u = LANE - b.r; b.vu *= -0.3; } + if (v <= EPS) { b.vu = b.vw = 0; continue; } + const stop = Math.min(dt, v / FRICTION), travel = v * stop - 0.5 * FRICTION * stop * stop; + b.u += b.vu / v * travel; b.w += b.vw / v * travel; + b.spin = (b.spin ?? 0) + travel / b.r; + const next = Math.max(0, v - FRICTION * dt) / v; + b.vu *= next; b.vw *= next; + rail = Math.max(rail, bounds(b)); } - // Gli urti: chi va più forte spinge l'altro lungo la congiungente e si tiene poco. - for (let i = 0; i < all.length; i++) for (let j = i + 1; j < all.length; j++) { - const a = all[i], b = all[j], d = dist(a, b), min = a.r + b.r; - if (d >= min || d === 0) continue; - const [hit, other] = Math.hypot(a.vu, a.vw) >= Math.hypot(b.vu, b.vw) ? [a, b] : [b, a]; - const nu = (other.u - hit.u) / d, nw = (other.w - hit.w) / d, push = hit.vu * nu + hit.vw * nw; - if (push <= 0) continue; - const k = other.jack ? 1.1 : 0.85; - other.vu += nu * push * k; other.vw += nw * push * k; - hit.vu -= nu * push * 0.85; hit.vw -= nw * push * 0.85; - // Si staccano subito, così non restano incollate. - const back = (min - d) / 2; - hit.u -= nu * back; hit.w -= nw * back; other.u += nu * back; other.w += nw * back; + for (let pass = 0; pass < 3; pass++) { + for (let i = 0; i < all.length; i++) for (let j = i + 1; j < all.length; j++) hit = Math.max(hit, collide(all[i], all[j])); + all.forEach(bounds); + } + if (hit > 0.8 || rail > 1) { + // Un solo evento per urto, limitato anche se il chiamante non consuma l'audio. + if (c.events.length < 12) c.events.push({ kind: hit > rail ? 'bocce-hit' : 'bocce-rail', power: Math.min(1, Math.max(hit, rail) / 30) }); } } - -// Il punto: chi ha la boccia più vicina al pallino vince, e segna una boccia per ognuna più vicina della migliore -// dell'altro. export function score(c) { - const by = who => c.balls.filter(b => b.who === who).map(b => dist(b, c.jack)).sort((a, b) => a - b); - const p = by('player'), q = by('cpu'); - if (!p.length || !q.length) return { winner: null, points: 0 }; - const winner = p[0] <= q[0] ? 'player' : 'cpu', [mine, theirs] = winner === 'player' ? [p, q] : [q, p]; - return { winner, points: mine.filter(d => d < theirs[0]).length }; + const a = closest(c, 'player'), b = closest(c, 'cpu'); + if (!a && !b) return { winner: null, points: 0 }; + const p = a ? gap(a, c.jack) : Infinity, q = b ? gap(b, c.jack) : Infinity; + if (Math.abs(p - q) < EPS) return { winner: null, points: 0 }; + const winner = p < q ? 'player' : 'cpu', limit = Math.min(p, q) === p ? q : p; + return { winner, points: c.balls.filter(ball => ball.who === winner && gap(ball, c.jack) < limit - EPS).length }; +} +function nextTurn(c) { + if (!left(c, 'player') && !left(c, 'cpu')) { c.step = 'result'; c.result = score(c); return; } + const winner = score(c).winner; + const behind = winner ? (winner === 'player' ? 'cpu' : 'player') : c.lastThrower; + c.current = left(c, behind) ? behind : behind === 'player' ? 'cpu' : 'player'; + c.step = 'aim'; +} +function cpuThrow(c) { + // Mariano cerca un accosto libero; con un avversario attaccato al pallino può + // tentare di spostarlo. Usa gli stessi lanci e la stessa fisica del giocatore. + const other = closest(c, 'player'), attack = other && gap(other, c.jack) < 0.6 && random(c) < 0.45; + const target = attack ? other : c.jack; + const u = clamp(target.u + deviation(c) * (attack ? 0.5 : 2.2), 10, LANE - 2); + const w = clamp(target.w + deviation(c) * (attack ? 0.18 : 0.8), -HALF + BALL, HALF - BALL); + launch(c, 'cpu', u, w, Math.hypot(u, w) + (attack ? 14 : -0.35)); } - -// L'età del primo piano (`age`) la conta il motore; qui solo il tempo del passo in corso. export function update(c, dt) { - c.t += dt; + if (!Number.isFinite(dt) || dt <= 0) return; + dt = Math.min(dt, 0.25); c.t += dt; if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); } if (c.step === 'rolling') { - for (let s = 0; s < 4; s++) step(c, dt / 4); - if (!moving(c) && c.t > 0.3) { c.step = 'settle'; c.t = 0; } + c.accumulator += dt; + while (c.accumulator >= FIXED_STEP) { step(c, FIXED_STEP); c.accumulator -= FIXED_STEP; } + const b = c.balls.at(-1); + if (b && Math.hypot(b.vu, b.vw) > 1) { + c.trails.push({ u: b.u, w: b.w, age: 0 }); + if (c.trails.length > 18) c.trails.shift(); + } + if (!moving(c)) { c.step = 'settle'; c.t = 0; } } else if (c.step === 'settle' && c.t >= REST) { - c.turn++; - c.t = 0; - c.step = c.turn >= BALLS * 2 ? 'result' : 'aim'; - if (c.step === 'result') c.result = score(c); - } else if (c.step === 'aim' && thrower(c) === 'cpu' && c.t >= 1.1) { - // Il campione mira al pallino e sbaglia di poco, a volte di più. - launch(c, 'cpu', Math.min(LANE - 2, Math.max(10, c.jack.u + gauss(c) * 7)), c.jack.w + gauss(c) * 1.4); - } + c.turn++; c.t = 0; nextTurn(c); + } else if (c.step === 'aim' && c.current === 'cpu' && c.t >= 1.25) cpuThrow(c); + for (const t of c.trails) t.age += dt; + c.trails = c.trails.filter(t => t.age < 0.55); } -// --- Il disegno: lo sfondo della pista e, sopra, bocce e pallino in prospettiva (misurata sullo sfondo di Codex). +// Prospettiva calibrata sulle sponde dello sfondo esistente. const NEAR = { y: 360, x: 355, half: 339 }, FAR = { y: 187, x: 298, half: 37.5 }; const S_FAR = FAR.half / NEAR.half, Q = (1 / S_FAR - 1) / LANE, HORIZON = (FAR.y - NEAR.y * S_FAR) / (1 - S_FAR); export function project(u, w) { const s = 1 / (1 + Q * u), k = (s - S_FAR) / (1 - S_FAR); - const cx = FAR.x + (NEAR.x - FAR.x) * k; - return { x: cx + (w / HALF) * NEAR.half * s, y: HORIZON + (NEAR.y - HORIZON) * s, s }; + return { x: FAR.x + (NEAR.x - FAR.x) * k + w / HALF * NEAR.half * s, y: HORIZON + (NEAR.y - HORIZON) * s, s }; } - -const COLORS = { player: ['#1d3f8f', '#3f6fd1', '#a9c4ff'], cpu: ['#7a1f1a', '#c2392d', '#ffb0a3'], jack: ['#a8862a', '#f2d75c', '#fff6c4'] }; -// Il disegno ha la sua misura (le bocce vere su una pista di 4 metri), più piccola di quella degli urti; lontano si -// ingrandisce un po' (fino a 2,6 volte in fondo), sennò in prospettiva esatta resterebbero puntini. -const LOOK = { player: 0.42, cpu: 0.42, jack: 0.2 }; -function ball(ctx, b, kind) { - const { x, y, s } = project(b.u, b.w), r = Math.max(2, LOOK[kind] * (NEAR.half / HALF) * s * (1 + 1.6 * (1 - s))); - const [dark, mid, light] = COLORS[kind]; - ctx.fillStyle = 'rgba(0,0,0,0.28)'; - ctx.beginPath(); ctx.ellipse(x + r * 0.25, y + r * 0.15, r, r * 0.45, 0, 0, Math.PI * 2); ctx.fill(); - ctx.fillStyle = dark; ctx.beginPath(); ctx.arc(x, y - r, r, 0, Math.PI * 2); ctx.fill(); - ctx.fillStyle = mid; ctx.beginPath(); ctx.arc(x - r * 0.15, y - r * 1.12, r * 0.72, 0, Math.PI * 2); ctx.fill(); - ctx.fillStyle = light; ctx.fillRect(Math.round(x - r * 0.45), Math.round(y - r * 1.55), Math.max(1, Math.round(r * 0.35)), Math.max(1, Math.round(r * 0.3))); +export function unproject(x, y) { + const s = clamp((y - HORIZON) / (NEAR.y - HORIZON), S_FAR, 1); + const u = (1 / s - 1) / Q, k = (s - S_FAR) / (1 - S_FAR); + return { u, w: (x - FAR.x - (NEAR.x - FAR.x) * k) / (NEAR.half * s) * HALF }; } - -const FONT = '"Press Start 2P", monospace'; -function label(ctx, text, x, y, size = 7, color = '#ffd27a', align = 'center') { - ctx.font = `${size}px ${FONT}`; - ctx.textAlign = align; - ctx.textBaseline = 'middle'; - const w = ctx.measureText(text).width, x0 = align === 'center' ? x - w / 2 : align === 'right' ? x - w : x; - ctx.fillStyle = 'rgba(0,0,0,0.62)'; - ctx.fillRect(Math.round(x0 - 6), Math.round(y - size), Math.round(w + 12), size * 2); - ctx.fillStyle = color; - ctx.fillText(text, Math.round(x), Math.round(y)); +export const DETAIL = { x: 18, y: 86, w: 150, h: 170, scale: 11 }; +export function detailPoint(c, b) { return { x: DETAIL.x + DETAIL.w / 2 + (b.w - c.jack.w) * DETAIL.scale, y: DETAIL.y + 93 - (b.u - c.jack.u) * DETAIL.scale }; } +export function aimScreen(c, [x, y]) { + const d = DETAIL; + if (x >= d.x && x <= d.x + d.w && y >= d.y + 24 && y <= d.y + d.h - 18) + return aim(c, c.jack.u + (d.y + 93 - y) / d.scale, c.jack.w + (x - d.x - d.w / 2) / d.scale); + if (y < 185 || y > 360) return false; + const p = unproject(x, y); return aim(c, p.u, p.w); } - -export function drawBocce(ctx, c, def, bg, time) { - ctx.save(); - if (bg) ctx.drawImage(bg, 0, 0, 640, 360); else { ctx.fillStyle = '#c9b48a'; ctx.fillRect(0, 0, 640, 360); } - const pieces = [{ b: c.jack, kind: 'jack' }, ...c.balls.map(b => ({ b, kind: b.who }))].sort((a, b) => b.b.u - a.b.u); - for (const p of pieces) ball(ctx, p.b, p.kind); - // Il punteggio in basso a sinistra (in alto ci sono Pausa e il volume): le bocce che restano a ciascuno. - const dots = (who, y) => label(ctx, `${def.names[who]} ${'o'.repeat(left(c, who))}`, 16, y, 7, who === 'player' ? '#a9c4ff' : '#ffb0a3', 'left'); - dots('player', 326); - dots('cpu', 344); - if (c.step === 'aim' && thrower(c) === 'player') { - // La forza: una barra che sale e scende finché si tiene premuto. - const x = 586, y = 320, h = 120; - ctx.fillStyle = 'rgba(0,0,0,0.6)'; ctx.fillRect(x - 3, y - h - 3, 20, h + 6); - ctx.fillStyle = '#3a2d42'; ctx.fillRect(x, y - h, 14, h); - const p = c.holding ? c.power : 0, grad = ['#7fe08a', '#ffd27a', '#ff7a5a']; - ctx.fillStyle = grad[Math.min(2, Math.floor(p * 3))]; ctx.fillRect(x, Math.round(y - h * p), 14, Math.round(h * p)); - label(ctx, 'FORZA', x + 7, y + 14, 6); - if (!c.holding && c.age > 0.4) { - ctx.globalAlpha = 0.65 + 0.35 * Math.sin(time / 300); - label(ctx, 'Tieni premuto per caricare, lascia per tirare', 320, 62); - ctx.globalAlpha = 1; - } - } else if (c.step === 'aim') label(ctx, `Tira ${def.names.cpu}...`, 320, 62); - if (c.step === 'result' && c.result) { - const win = c.result.winner === 'player'; - label(ctx, win ? `Punto per ${def.names.player}!` : `Punto per ${def.names.cpu}!`, 320, 150, 12, win ? '#7fe08a' : '#ffb0a3'); - } - ctx.restore(); +export function status(c, names) { + if (c.step === 'result') return c.result.winner ? `${names[c.result.winner]}: ${c.result.points} ${c.result.points === 1 ? 'punto' : 'punti'}.` : 'Parità: nessun punto.'; + if (c.step === 'rolling') return 'Boccia in movimento…'; + if (c.step === 'settle') return 'Misuriamo il punto…'; + const leader = score(c).winner; + return `${names[c.current]} al tiro. ${leader ? `Il punto è di ${names[leader]}.` : 'Il punto è da prendere.'}`; } diff --git a/src/engine/game.js b/src/engine/game.js index f47f94e..7fd0ea6 100644 --- a/src/engine/game.js +++ b/src/engine/game.js @@ -1,7 +1,7 @@ // 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 } from './bocce.js'; +import { newBocce, update as bocceUpdate, press as boccePress, release as bocceRelease, cancel as bocceCancel, aimScreen } from './bocce.js'; 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' }; @@ -250,7 +250,7 @@ export class Game { } // Le bocce: a mano finita, la battuta di chi ha vinto; uscendo prima, quella di chi lascia. if (def?.type === 'bocce') { - this.queue.unshift(...(c.step === 'result' ? def[c.result?.winner === 'player' ? 'win' : 'lose'] : def.quit ?? [])); + this.queue.unshift(...(c.step === 'result' ? def[c.result?.winner === 'player' ? 'win' : c.result?.winner === 'cpu' ? 'lose' : 'draw'] ?? [] : def.quit ?? [])); return true; } // Chi è arrivato all'ultima pagina, chiudendo, la commenta (`closing`). @@ -261,11 +261,13 @@ export class Game { // 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); } // 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]); 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,8 +392,7 @@ export class Game { 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 a schermo un momento, poi si torna al pallaio per i commenti. - if (c.step === 'result' && c.t >= 2.4) this.closeupClose(); + // Il risultato resta leggibile finché il giocatore torna al pallaio. return; } c.t += dt; diff --git a/src/engine/render.js b/src/engine/render.js index ce662d9..6a6179a 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -3,7 +3,7 @@ import { check, PAGE_TURN } from './game.js'; import { ambientEffects, ambientHash } from './ambient.js'; import { drawTorch } from './torch.js'; -import { drawBocce } from './bocce.js'; +import { drawBocce } from './bocce-render.js'; import { versioned } from '../version.js'; export const WIDTH = 640, HEIGHT = 360; diff --git a/src/engine/soundscape.js b/src/engine/soundscape.js index 4cf021f..f2a429b 100644 --- a/src/engine/soundscape.js +++ b/src/engine/soundscape.js @@ -98,9 +98,12 @@ export class Soundscape { source.start(at); source.stop(at + duration + 0.03); } - play(kind, { pan = (this.random() - 0.5) * 1.3 } = {}) { + play(kind, { pan = (this.random() - 0.5) * 1.3, strength = 0.5 } = {}) { if (!this.audible) return; if (kind === 'page') this.noiseSound(0.42, 2800, 0.24, { paper: true }); + 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 === '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 3b13f58..44b4202 100644 --- a/src/main.js +++ b/src/main.js @@ -8,6 +8,7 @@ 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 { VERSION, versioned } from './version.js'; const $ = id => document.getElementById(id); @@ -265,6 +266,7 @@ $('spots-button').addEventListener('click', showSpots); function openPause() { if (!running) return; running = false; + game.closeupCancel(); music.hold = true; voices.pause(); ambience.configure({ active: false }); @@ -397,9 +399,9 @@ canvas.addEventListener('pointerdown', e => { // Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano. if (game.closeup) { press = { closeup: true }; + game.closeupAim(scenePoint(e)); // Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta). if (!game.skip()) game.closeupPress(); - game.closeupAim(scenePoint(e)); canvas.setPointerCapture(e.pointerId); return; } @@ -419,7 +421,8 @@ canvas.addEventListener('pointerup', e => { } press = null; }); -canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; }); +canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; game?.closeupCancel(); }); +canvas.addEventListener('lostpointercapture', () => { if (press?.closeup) { press = null; game?.closeupCancel(); } }); canvas.addEventListener('contextmenu', e => { e.preventDefault(); if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true }); @@ -431,6 +434,15 @@ 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); }); +addEventListener('blur', () => game?.closeupCancel()); + // --- Verbi. for (const verb of VERBS) { const b = document.createElement('button'); @@ -516,6 +528,17 @@ 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 (e.target.closest?.('#bocce-controls') && ['BUTTON', 'SUMMARY'].includes(e.target.tagName)) return; + const c = game.closeup; + 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)); + return; + } + if (c.step === 'result' && [' ', 'Enter'].includes(e.key)) { e.preventDefault(); game.closeupClose(); return; } + } if (story.closeups[game.closeup.id].type === 'torch' && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter'].includes(e.key)) { // Invio cerca il prossimo dettaglio anche per chi non può usare il puntatore. e.preventDefault(); @@ -590,16 +613,30 @@ function updateUi() { updateInventory(); updateChoices(); updateSpeech(); + const bocce = c?.id === 'bocce'; + $('bocce-controls').hidden = !bocce; + $('bocce-controls').inert = !running; + if (bocce) { + $('panel').hidden = $('choices').hidden = true; + const line = bocceStatus(c, story.closeups.bocce.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 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. $('map-button').hidden = !running || !!game.dialogue || !!game.closeup || game.mapPlaces().length < 2; $('pause-button').hidden = !running; $('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' : c && story.closeups[c.id].type === 'bocce' ? 'Lascia stare' : 'Chiudi'; + $('closeup-close').textContent = torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : bocce ? c.step === 'result' ? 'Torna al pallaio' : 'Lascia stare' : 'Chiudi'; $('panel').inert = !!c; canvas.classList.toggle('torch', !!torch); - canvas.setAttribute('aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : "La scena dell'avventura"); + 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"); // 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 45d9e29..b393895 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -122,6 +122,7 @@ export const story = { { say: 'Quando vuoi la rivincita, sono qui.', who: 'mariano' }, ], quit: [{ say: 'Già stanco? Il pallaio ti aspetta.', who: 'mariano', pose: 'laugh' }], + draw: [{ say: 'Quando vuoi la rivincita, sono qui.', who: 'mariano' }], }, fessura: { type: 'torch', diff --git a/style.css b/style.css index c591479..00a2019 100644 --- a/style.css +++ b/style.css @@ -25,6 +25,26 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h #stage { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000; user-select: none; touch-action: manipulation; } #scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; } #scene.torch { touch-action: none; } +#scene.bocce { touch-action: none; } +#bocce-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; } +#bocce-status { color: #f3dcac; margin: 0 0 10px; line-height: 1.5; min-height: 1.5em; } +.bocce-adjust { display: flex; align-items: center; justify-content: center; gap: 12px; } +.bocce-adjust label { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; min-width: 120px; max-width: 280px; flex: 1; } +#bocce-power { width: 100%; accent-color: #b9d392; min-height: 32px; margin: 0; } +.bocce-adjust button { min-height: 44px; padding: 8px 12px; border: 1px solid #897b57; border-radius: 4px; background: #38453c; color: #f2e1bf; cursor: pointer; font-size: 11px; } +#bocce-throw { background: #ead09a; color: #202b25; min-width: 100px; } +.bocce-adjust button:disabled { opacity: .4; cursor: default; } +#bocce-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; } +#bocce-help summary { cursor: pointer; padding: 6px 0; } +#bocce-help p { max-width: 76em; line-height: 1.7; } +@media (max-width: 600px), (orientation: landscape) and (max-height: 520px) { + #bocce-controls { padding: 8px; font-size: 10px; overflow-y: auto; min-height: 0; } + .bocce-adjust { flex-wrap: wrap; gap: 6px; } + .bocce-adjust label { order: -1; flex-basis: 100%; max-width: none; } + .bocce-adjust button { flex: 1; font-size: 9px; padding: 6px; } + #bocce-throw { flex-basis: 100%; } + #bocce-help { font-size: 9px; } +} .effects-control { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; font-size: clamp(9px, 1.2vw, 13px); } #effects-volume { width: 130px; accent-color: var(--accent); } diff --git a/tests/bocce.test.mjs b/tests/bocce.test.mjs index a449e18..f4ced25 100644 --- a/tests/bocce.test.mjs +++ b/tests/bocce.test.mjs @@ -14,10 +14,11 @@ function throwTo(c, u) { assert.ok(release(c)); } -test('la forza sale e scende: a metà giro è piena, a giro finito torna a zero', () => { +test('la forza sale finché si tiene premuto, fino al massimo: niente tempismo da azzeccare', () => { assert.equal(powerAt(0), 0); - assert.ok(Math.abs(powerAt(SWING / 2) - 1) < 1e-9); - assert.ok(powerAt(SWING) < 1e-9); + assert.ok(Math.abs(powerAt(SWING / 2) - 0.5) < 1e-9); + assert.equal(powerAt(SWING), 1); + assert.equal(powerAt(SWING * 3), 1, 'oltre il massimo resta piena, non ricomincia'); }); test('una boccia va lontano quanto dice la forza, verso il pallino', () => { @@ -64,6 +65,7 @@ test('una mano intera: tre bocce a testa, a turno, poi il risultato', () => { assert.equal(c.balls.length, BALLS * 2); assert.ok(['player', 'cpu'].includes(c.result.winner)); assert.equal(press(c), false, 'a mano finita non si tira più'); + assert.ok(['player', 'cpu'].includes(c.result.winner) || c.result.winner === null); }); test('la prospettiva: lontano è più in alto e più piccolo, al centro della pista', () => { @@ -95,8 +97,9 @@ test('al pallaio: Mariano sfida, la mano si gioca nel primo piano; vincere vale } assert.equal(c.step, 'result'); c.result = { winner: 'player', points: 1 }; - for (let i = 0; i < 20 && g.closeup; i++) g.update(0.25); - assert.equal(g.closeup, null, 'il risultato si chiude da solo'); + for (let i = 0; i < 20; i++) g.update(0.25); + assert.equal(g.closeup, c, 'il risultato resta a schermo finché non si torna al pallaio'); + assert.ok(g.closeupClose()); const after = settle(g).map(s => `${s.who}: ${s.text}`); assert.ok(after.includes('mariano: Hai vinto, ti ci porto!')); assert.ok(g.state.flags.bocce_vinte);