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:
@@ -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.
|
||||
// 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.'}`;
|
||||
}
|
||||
|
||||
+5
-4
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user