Bocce più vere (lavoro di Codex): mira libera sul campo o nel dettaglio del pallino, forza regolabile senza tempismo, urti fra masse diverse, tira chi non ha il punto, distanze in centimetri, suoni di rotolamento e urto, comandi anche a pulsanti e tastiera; test aggiornati alle nuove regole
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
This commit is contained in:
+19
@@ -103,6 +103,25 @@
|
|||||||
<div id="inventory" aria-label="Inventario"></div>
|
<div id="inventory" aria-label="Inventario"></div>
|
||||||
</div>
|
</div>
|
||||||
<ol id="choices" hidden></ol>
|
<ol id="choices" hidden></ol>
|
||||||
|
<section id="bocce-controls" hidden aria-label="Comandi delle bocce">
|
||||||
|
<p id="bocce-status" role="status"></p>
|
||||||
|
<div class="bocce-adjust">
|
||||||
|
<button id="bocce-left" type="button" aria-label="Sposta la mira a sinistra">← Mira</button>
|
||||||
|
<label for="bocce-power">Forza <output id="bocce-power-value">50%</output>
|
||||||
|
<input id="bocce-power" type="range" min="0" max="100" step="1" value="50" aria-label="Forza del lancio">
|
||||||
|
</label>
|
||||||
|
<button id="bocce-right" type="button" aria-label="Sposta la mira a destra">Mira →</button>
|
||||||
|
<button id="bocce-throw" type="button">Lancia</button>
|
||||||
|
</div>
|
||||||
|
<details id="bocce-help"><summary>Come si gioca</summary>
|
||||||
|
<p>Tocca il campo o il dettaglio del pallino per mirare. Regola la forza, poi premi Lancia.
|
||||||
|
Il cerchio azzurro indica dove si fermerebbe la boccia senza urti.</p>
|
||||||
|
<p>Puoi anche tenere premuto sul campo o Spazio e rilasciare per tirare.
|
||||||
|
Frecce ← →: mira; ↑ ↓: forza. Esc esce dalla mano.</p>
|
||||||
|
<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>
|
||||||
|
</details>
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
+157
-139
@@ -1,172 +1,190 @@
|
|||||||
// Le bocce al pallaio (primo piano `type: 'bocce'`): una mano da tre bocce a testa contro il campione.
|
// Mano amichevole da tre bocce: mira libera, forza ripetibile, gioca chi non ha il punto.
|
||||||
// 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.
|
// Il campo usa coordinate u (lunghezza) e w (larghezza). Un'unità rappresenta 20 cm.
|
||||||
// La pista è un piano: `u` lungo la pista (0 dove si tira, 100 la sponda di fondo), `w` di traverso (-10…10).
|
// È una versione da circolo: sponde elastiche, senza falli o dichiarazioni arbitrali.
|
||||||
// Niente fisica vera: attrito costante, urti semplici (chi colpisce cede quasi tutta la spinta), sponde che respingono.
|
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 function newBocce(seed = Date.now() >>> 0) {
|
||||||
export const SWING = 1.6; // secondi per un giro della forza (0 → 1 → 0)
|
const c = { step: 'aim', t: 0, age: 0, seed, balls: [], turn: 0, current: 'player', power: 0.5,
|
||||||
const REST = 0.6; // pausa a bocce ferme prima del tiro seguente
|
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 };
|
||||||
// Numeri «a caso» ripetibili: lo stato del generatore sta nel primo piano, così le prove sono sempre uguali.
|
c.aim = { u: c.jack.u, w: c.jack.w };
|
||||||
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 };
|
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
export function setPower(c, value) {
|
||||||
// Di chi è il tiro: Andrea ai tiri pari, il campione ai dispari.
|
if (!canThrow(c) || c.holding || !Number.isFinite(value)) return false;
|
||||||
export const thrower = c => (c.turn % 2 ? 'cpu' : 'player');
|
c.power = clamp(value, 0, 1); return true;
|
||||||
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 aim(c, u, w) {
|
||||||
// Andrea: la forza decide quanto lontano; la direzione punta al pallino, con un po' di mano che trema.
|
if (!canThrow(c) || c.holding || !Number.isFinite(u) || !Number.isFinite(w)) return false;
|
||||||
export function press(c) {
|
c.aim = { u: clamp(u, 10, LANE - 2), w: clamp(w, -HALF + BALL, HALF - BALL) }; return true;
|
||||||
if (c.step !== 'aim' || thrower(c) !== 'player' || c.holding) return false;
|
}
|
||||||
c.holding = true;
|
export function landing(c) {
|
||||||
c.holdTime = 0;
|
const d = Math.hypot(c.aim.u, c.aim.w), range = rangeFor(c.power);
|
||||||
c.power = 0;
|
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;
|
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) {
|
export function release(c) {
|
||||||
if (!c.holding) return false;
|
if (!c.holding) return false;
|
||||||
c.holding = false;
|
// Un tocco breve serve a mirare, non spreca una boccia.
|
||||||
launch(c, 'player', 8 + c.power * (LANE - 4), c.jack.w + gauss(c) * 0.9);
|
if (c.holdTime < 0.14) return cancel(c);
|
||||||
return true;
|
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) {
|
function step(c, dt) {
|
||||||
const all = [c.jack, ...c.balls];
|
const all = [c.jack, ...c.balls];
|
||||||
|
let hit = 0, rail = 0;
|
||||||
for (const b of all) {
|
for (const b of all) {
|
||||||
const v = Math.hypot(b.vu, b.vw);
|
const v = Math.hypot(b.vu, b.vw);
|
||||||
if (v <= 0.01) { b.vu = b.vw = 0; continue; }
|
if (v <= EPS) { b.vu = b.vw = 0; continue; }
|
||||||
const slow = Math.max(0, v - FRICTION * dt) / v;
|
const stop = Math.min(dt, v / FRICTION), travel = v * stop - 0.5 * FRICTION * stop * stop;
|
||||||
b.vu *= slow; b.vw *= slow;
|
b.u += b.vu / v * travel; b.w += b.vw / v * travel;
|
||||||
b.u += b.vu * dt; b.w += b.vw * dt;
|
b.spin = (b.spin ?? 0) + travel / b.r;
|
||||||
// Le sponde: di lato e in fondo respingono, piano.
|
const next = Math.max(0, v - FRICTION * dt) / v;
|
||||||
if (Math.abs(b.w) > HALF - b.r) { b.w = Math.sign(b.w) * (HALF - b.r); b.vw *= -0.4; }
|
b.vu *= next; b.vw *= next;
|
||||||
if (b.u > LANE - b.r) { b.u = LANE - b.r; b.vu *= -0.3; }
|
rail = Math.max(rail, bounds(b));
|
||||||
}
|
}
|
||||||
// Gli urti: chi va più forte spinge l'altro lungo la congiungente e si tiene poco.
|
for (let pass = 0; pass < 3; pass++) {
|
||||||
for (let i = 0; i < all.length; i++) for (let j = i + 1; j < all.length; j++) {
|
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]));
|
||||||
const a = all[i], b = all[j], d = dist(a, b), min = a.r + b.r;
|
all.forEach(bounds);
|
||||||
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];
|
if (hit > 0.8 || rail > 1) {
|
||||||
const nu = (other.u - hit.u) / d, nw = (other.w - hit.w) / d, push = hit.vu * nu + hit.vw * nw;
|
// Un solo evento per urto, limitato anche se il chiamante non consuma l'audio.
|
||||||
if (push <= 0) continue;
|
if (c.events.length < 12) c.events.push({ kind: hit > rail ? 'bocce-hit' : 'bocce-rail', power: Math.min(1, Math.max(hit, rail) / 30) });
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
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 a = closest(c, 'player'), b = closest(c, 'cpu');
|
||||||
const p = by('player'), q = by('cpu');
|
if (!a && !b) return { winner: null, points: 0 };
|
||||||
if (!p.length || !q.length) return { winner: null, points: 0 };
|
const p = a ? gap(a, c.jack) : Infinity, q = b ? gap(b, c.jack) : Infinity;
|
||||||
const winner = p[0] <= q[0] ? 'player' : 'cpu', [mine, theirs] = winner === 'player' ? [p, q] : [q, p];
|
if (Math.abs(p - q) < EPS) return { winner: null, points: 0 };
|
||||||
return { winner, points: mine.filter(d => d < theirs[0]).length };
|
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) {
|
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.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); }
|
||||||
if (c.step === 'rolling') {
|
if (c.step === 'rolling') {
|
||||||
for (let s = 0; s < 4; s++) step(c, dt / 4);
|
c.accumulator += dt;
|
||||||
if (!moving(c) && c.t > 0.3) { c.step = 'settle'; c.t = 0; }
|
while (c.accumulator >= FIXED_STEP) { step(c, FIXED_STEP); c.accumulator -= FIXED_STEP; }
|
||||||
} else if (c.step === 'settle' && c.t >= REST) {
|
const b = c.balls.at(-1);
|
||||||
c.turn++;
|
if (b && Math.hypot(b.vu, b.vw) > 1) {
|
||||||
c.t = 0;
|
c.trails.push({ u: b.u, w: b.w, age: 0 });
|
||||||
c.step = c.turn >= BALLS * 2 ? 'result' : 'aim';
|
if (c.trails.length > 18) c.trails.shift();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
if (!moving(c)) { c.step = 'settle'; c.t = 0; }
|
||||||
|
} else if (c.step === 'settle' && c.t >= REST) {
|
||||||
|
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 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);
|
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) {
|
export function project(u, w) {
|
||||||
const s = 1 / (1 + Q * u), k = (s - S_FAR) / (1 - S_FAR);
|
const s = 1 / (1 + Q * u), k = (s - S_FAR) / (1 - S_FAR);
|
||||||
const cx = FAR.x + (NEAR.x - FAR.x) * k;
|
return { x: FAR.x + (NEAR.x - FAR.x) * k + w / HALF * NEAR.half * s, y: HORIZON + (NEAR.y - HORIZON) * s, s };
|
||||||
return { x: cx + (w / HALF) * NEAR.half * s, y: HORIZON + (NEAR.y - HORIZON) * s, s };
|
|
||||||
}
|
}
|
||||||
|
export function unproject(x, y) {
|
||||||
const COLORS = { player: ['#1d3f8f', '#3f6fd1', '#a9c4ff'], cpu: ['#7a1f1a', '#c2392d', '#ffb0a3'], jack: ['#a8862a', '#f2d75c', '#fff6c4'] };
|
const s = clamp((y - HORIZON) / (NEAR.y - HORIZON), S_FAR, 1);
|
||||||
// Il disegno ha la sua misura (le bocce vere su una pista di 4 metri), più piccola di quella degli urti; lontano si
|
const u = (1 / s - 1) / Q, k = (s - S_FAR) / (1 - S_FAR);
|
||||||
// ingrandisce un po' (fino a 2,6 volte in fondo), sennò in prospettiva esatta resterebbero puntini.
|
return { u, w: (x - FAR.x - (NEAR.x - FAR.x) * k) / (NEAR.half * s) * HALF };
|
||||||
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 const DETAIL = { x: 18, y: 86, w: 150, h: 170, scale: 11 };
|
||||||
const FONT = '"Press Start 2P", monospace';
|
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 }; }
|
||||||
function label(ctx, text, x, y, size = 7, color = '#ffd27a', align = 'center') {
|
export function aimScreen(c, [x, y]) {
|
||||||
ctx.font = `${size}px ${FONT}`;
|
const d = DETAIL;
|
||||||
ctx.textAlign = align;
|
if (x >= d.x && x <= d.x + d.w && y >= d.y + 24 && y <= d.y + d.h - 18)
|
||||||
ctx.textBaseline = 'middle';
|
return aim(c, c.jack.u + (d.y + 93 - y) / d.scale, c.jack.w + (x - d.x - d.w / 2) / d.scale);
|
||||||
const w = ctx.measureText(text).width, x0 = align === 'center' ? x - w / 2 : align === 'right' ? x - w : x;
|
if (y < 185 || y > 360) return false;
|
||||||
ctx.fillStyle = 'rgba(0,0,0,0.62)';
|
const p = unproject(x, y); return aim(c, p.u, p.w);
|
||||||
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 function status(c, names) {
|
||||||
export function drawBocce(ctx, c, def, bg, time) {
|
if (c.step === 'result') return c.result.winner ? `${names[c.result.winner]}: ${c.result.points} ${c.result.points === 1 ? 'punto' : 'punti'}.` : 'Parità: nessun punto.';
|
||||||
ctx.save();
|
if (c.step === 'rolling') return 'Boccia in movimento…';
|
||||||
if (bg) ctx.drawImage(bg, 0, 0, 640, 360); else { ctx.fillStyle = '#c9b48a'; ctx.fillRect(0, 0, 640, 360); }
|
if (c.step === 'settle') return 'Misuriamo il punto…';
|
||||||
const pieces = [{ b: c.jack, kind: 'jack' }, ...c.balls.map(b => ({ b, kind: b.who }))].sort((a, b) => b.b.u - a.b.u);
|
const leader = score(c).winner;
|
||||||
for (const p of pieces) ball(ctx, p.b, p.kind);
|
return `${names[c.current]} al tiro. ${leader ? `Il punto è di ${names[leader]}.` : 'Il punto è da prendere.'}`;
|
||||||
// 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();
|
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-4
@@ -1,7 +1,7 @@
|
|||||||
// 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 } 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 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' };
|
||||||
@@ -250,7 +250,7 @@ export class Game {
|
|||||||
}
|
}
|
||||||
// Le bocce: a mano finita, la battuta di chi ha vinto; uscendo prima, quella di chi lascia.
|
// Le bocce: a mano finita, la battuta di chi ha vinto; uscendo prima, quella di chi lascia.
|
||||||
if (def?.type === 'bocce') {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
// Chi è arrivato all'ultima pagina, chiudendo, la commenta (`closing`).
|
// 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).
|
// 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); }
|
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); }
|
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:
|
// 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]);
|
||||||
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,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 === 'torch') { this.updateTorch(dt); return; }
|
||||||
if (this.story.closeups[c.id].type === 'bocce') {
|
if (this.story.closeups[c.id].type === 'bocce') {
|
||||||
bocceUpdate(c, dt);
|
bocceUpdate(c, dt);
|
||||||
// Il risultato resta a schermo un momento, poi si torna al pallaio per i commenti.
|
// Il risultato resta leggibile finché il giocatore torna al pallaio.
|
||||||
if (c.step === 'result' && c.t >= 2.4) this.closeupClose();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
c.t += dt;
|
c.t += dt;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { check, PAGE_TURN } from './game.js';
|
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.js';
|
import { drawBocce } from './bocce-render.js';
|
||||||
import { versioned } from '../version.js';
|
import { versioned } from '../version.js';
|
||||||
export const WIDTH = 640, HEIGHT = 360;
|
export const WIDTH = 640, HEIGHT = 360;
|
||||||
|
|
||||||
|
|||||||
@@ -98,9 +98,12 @@ export class Soundscape {
|
|||||||
source.start(at); source.stop(at + duration + 0.03);
|
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 (!this.audible) return;
|
||||||
if (kind === 'page') this.noiseSound(0.42, 2800, 0.24, { paper: true });
|
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 === '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') {
|
||||||
|
|||||||
+41
-4
@@ -8,6 +8,7 @@ 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 { VERSION, versioned } from './version.js';
|
import { VERSION, versioned } from './version.js';
|
||||||
|
|
||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
@@ -265,6 +266,7 @@ $('spots-button').addEventListener('click', showSpots);
|
|||||||
function openPause() {
|
function openPause() {
|
||||||
if (!running) return;
|
if (!running) return;
|
||||||
running = false;
|
running = false;
|
||||||
|
game.closeupCancel();
|
||||||
music.hold = true;
|
music.hold = true;
|
||||||
voices.pause();
|
voices.pause();
|
||||||
ambience.configure({ active: false });
|
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.
|
// Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano.
|
||||||
if (game.closeup) {
|
if (game.closeup) {
|
||||||
press = { closeup: true };
|
press = { closeup: true };
|
||||||
|
game.closeupAim(scenePoint(e));
|
||||||
// Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta).
|
// Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta).
|
||||||
if (!game.skip()) game.closeupPress();
|
if (!game.skip()) game.closeupPress();
|
||||||
game.closeupAim(scenePoint(e));
|
|
||||||
canvas.setPointerCapture(e.pointerId);
|
canvas.setPointerCapture(e.pointerId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -419,7 +421,8 @@ canvas.addEventListener('pointerup', e => {
|
|||||||
}
|
}
|
||||||
press = null;
|
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 => {
|
canvas.addEventListener('contextmenu', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
|
if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
|
||||||
@@ -431,6 +434,15 @@ 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
|
||||||
|
// 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.
|
// --- Verbi.
|
||||||
for (const verb of VERBS) {
|
for (const verb of VERBS) {
|
||||||
const b = document.createElement('button');
|
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; }
|
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 (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)) {
|
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.
|
// Invio cerca il prossimo dettaglio anche per chi non può usare il puntatore.
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -590,16 +613,30 @@ function updateUi() {
|
|||||||
updateInventory();
|
updateInventory();
|
||||||
updateChoices();
|
updateChoices();
|
||||||
updateSpeech();
|
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.
|
// 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;
|
$('map-button').hidden = !running || !!game.dialogue || !!game.closeup || game.mapPlaces().length < 2;
|
||||||
$('pause-button').hidden = !running;
|
$('pause-button').hidden = !running;
|
||||||
$('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' : 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;
|
$('panel').inert = !!c;
|
||||||
canvas.classList.toggle('torch', !!torch);
|
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.
|
// 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');
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export const story = {
|
|||||||
{ say: 'Quando vuoi la rivincita, sono qui.', who: 'mariano' },
|
{ say: 'Quando vuoi la rivincita, sono qui.', who: 'mariano' },
|
||||||
],
|
],
|
||||||
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' }],
|
||||||
},
|
},
|
||||||
fessura: {
|
fessura: {
|
||||||
type: 'torch',
|
type: 'torch',
|
||||||
|
|||||||
@@ -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; }
|
#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 { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; }
|
||||||
#scene.torch { touch-action: none; }
|
#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-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); }
|
#effects-volume { width: 130px; accent-color: var(--accent); }
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ function throwTo(c, u) {
|
|||||||
assert.ok(release(c));
|
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.equal(powerAt(0), 0);
|
||||||
assert.ok(Math.abs(powerAt(SWING / 2) - 1) < 1e-9);
|
assert.ok(Math.abs(powerAt(SWING / 2) - 0.5) < 1e-9);
|
||||||
assert.ok(powerAt(SWING) < 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', () => {
|
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.equal(c.balls.length, BALLS * 2);
|
||||||
assert.ok(['player', 'cpu'].includes(c.result.winner));
|
assert.ok(['player', 'cpu'].includes(c.result.winner));
|
||||||
assert.equal(press(c), false, 'a mano finita non si tira più');
|
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', () => {
|
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');
|
assert.equal(c.step, 'result');
|
||||||
c.result = { winner: 'player', points: 1 };
|
c.result = { winner: 'player', points: 1 };
|
||||||
for (let i = 0; i < 20 && g.closeup; i++) g.update(0.25);
|
for (let i = 0; i < 20; i++) g.update(0.25);
|
||||||
assert.equal(g.closeup, null, 'il risultato si chiude da solo');
|
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}`);
|
const after = settle(g).map(s => `${s.who}: ${s.text}`);
|
||||||
assert.ok(after.includes('mariano: Hai vinto, ti ci porto!'));
|
assert.ok(after.includes('mariano: Hai vinto, ti ci porto!'));
|
||||||
assert.ok(g.state.flags.bocce_vinte);
|
assert.ok(g.state.flags.bocce_vinte);
|
||||||
|
|||||||
Reference in New Issue
Block a user