Circolo 4 Maggio: dal portone in piazza si entra nella sala dei biliardi (sfondo in stile Lucas fatto da Andrea con Codex) e al primo tavolo si gioca a biliardo coi cinque birilli contro l'Ing. Tozzi (battute provvisorie, sprite da fare). Motore nuovo: fisica delle biglie e delle sponde, birilli che cadono, regole e falli del gioco italiano, avversario che prova i colpi a vuoto un po' per fotogramma; disegno dall'alto con stecca, linea di mira e biglia fantasma; stessi comandi delle bocce (pannello, tastiera, pressione); suoni; test; nelle idee la foto per i rigori
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import { LEN, WID, R, PIN, VIEW, canThrow, predict, lastLine, status } from './biliardo.js';
|
||||
import { text, panel } from './bocce-render.js';
|
||||
import { ambientHash as hash } from './ambient.js';
|
||||
|
||||
const BALL = { player: ['#8f8160', '#f1e6c8', '#fffdf4'], cpu: ['#8a7220', '#ead77a', '#fff9d2'], pallino: ['#5e1612', '#c9372b', '#ffb3a4'] };
|
||||
const sx = x => VIEW.x + x * VIEW.k, sy = y => VIEW.y + y * VIEW.k;
|
||||
// Le biglie si disegnano un po' più grandi del vero (la fisica resta quella), sennò sul telefono spariscono.
|
||||
const rr = R * VIEW.k * 1.3;
|
||||
|
||||
function sphere(ctx, role, x, y, r) {
|
||||
const [dark, mid, light] = BALL[role];
|
||||
ctx.fillStyle = 'rgba(0,20,8,0.45)'; ctx.beginPath(); ctx.ellipse(x + r * 0.35, y + r * 0.45, r * 1.05, r * 0.8, 0, 0, Math.PI * 2); ctx.fill();
|
||||
const g = ctx.createRadialGradient(x - r * 0.35, y - r * 0.4, 0, x, y, r * 1.05);
|
||||
g.addColorStop(0, light); g.addColorStop(0.35, mid); g.addColorStop(1, dark);
|
||||
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = '#ffffff'; ctx.fillRect(Math.round(x - r * 0.4), Math.round(y - r * 0.45), 1, 1);
|
||||
}
|
||||
function pin(ctx, p) {
|
||||
const x = sx(p.x), y = sy(p.y), h = 7, w = 3;
|
||||
const [base, top] = p.red ? ['#8d1f1a', '#e24a3c'] : ['#cfc3a3', '#fff8e4'];
|
||||
if (p.down) {
|
||||
// Birillo a terra, dalla parte in cui è caduto.
|
||||
ctx.strokeStyle = top; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + Math.cos(p.dir) * h, y + Math.sin(p.dir) * h); ctx.stroke();
|
||||
ctx.fillStyle = base; ctx.fillRect(Math.round(x) - 1, Math.round(y) - 1, 2, 2);
|
||||
return;
|
||||
}
|
||||
ctx.fillStyle = 'rgba(0,20,8,0.4)'; ctx.fillRect(Math.round(x) - 1, Math.round(y) + 1, w + 1, 2);
|
||||
ctx.fillStyle = base; ctx.fillRect(Math.round(x - w / 2), Math.round(y - h + 2), w, h - 1);
|
||||
ctx.fillStyle = top; ctx.fillRect(Math.round(x - w / 2), Math.round(y - h), w, 3);
|
||||
}
|
||||
|
||||
function table(ctx) {
|
||||
const x0 = sx(0), y0 = sy(0), w = LEN * VIEW.k, h = WID * VIEW.k;
|
||||
// Cornice di legno con i diamanti, sponda, panno.
|
||||
ctx.fillStyle = '#3a2416'; ctx.fillRect(x0 - 16, y0 - 16, w + 32, h + 32);
|
||||
ctx.fillStyle = '#5a3a22'; ctx.fillRect(x0 - 13, y0 - 13, w + 26, h + 26);
|
||||
ctx.fillStyle = '#6e4a2c'; ctx.fillRect(x0 - 13, y0 - 13, w + 26, 3);
|
||||
ctx.fillStyle = '#e7d7a8';
|
||||
for (let i = 1; i < 8; i++) { const dx = x0 + w * i / 8; ctx.fillRect(Math.round(dx) - 1, y0 - 9, 2, 2); ctx.fillRect(Math.round(dx) - 1, y0 + h + 7, 2, 2); }
|
||||
for (let i = 1; i < 4; i++) { const dy = y0 + h * i / 4; ctx.fillRect(x0 - 9, Math.round(dy) - 1, 2, 2); ctx.fillRect(x0 + w + 7, Math.round(dy) - 1, 2, 2); }
|
||||
ctx.fillStyle = '#1d5a33'; ctx.fillRect(x0 - 5, y0 - 5, w + 10, h + 10);
|
||||
ctx.fillStyle = '#2a7a44'; ctx.fillRect(x0, y0, w, h);
|
||||
for (let i = 0; i < 420; i++) {
|
||||
ctx.fillStyle = i % 2 ? '#2f8349' : '#26713f';
|
||||
ctx.fillRect(Math.round(x0 + hash(i + 11) * w), Math.round(y0 + hash(i + 400) * h), 1, 1);
|
||||
}
|
||||
// La linea di metà tavolo e il punto del castello.
|
||||
ctx.fillStyle = 'rgba(255,255,230,0.25)'; ctx.fillRect(Math.round(sx(LEN / 2)), y0, 1, h);
|
||||
}
|
||||
|
||||
function aimGuide(ctx, c) {
|
||||
const p = predict(c), me = c.balls[c.current];
|
||||
ctx.strokeStyle = 'rgba(255,248,220,0.75)'; ctx.lineWidth = 1; ctx.setLineDash([3, 4]);
|
||||
ctx.beginPath(); ctx.moveTo(sx(p.points[0][0]), sy(p.points[0][1]));
|
||||
for (const [x, y] of p.points.slice(1)) ctx.lineTo(sx(x), sy(y));
|
||||
ctx.stroke(); ctx.setLineDash([]);
|
||||
if (p.ghost) {
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.beginPath(); ctx.arc(sx(p.ghost.x), sy(p.ghost.y), rr, 0, Math.PI * 2); ctx.stroke();
|
||||
if (p.dir) {
|
||||
const o = c.balls[p.hit], L = 22;
|
||||
ctx.strokeStyle = p.hit === 'pallino' ? 'rgba(255,120,100,0.9)' : 'rgba(150,210,255,0.9)';
|
||||
ctx.beginPath(); ctx.moveTo(sx(o.x), sy(o.y)); ctx.lineTo(sx(o.x) + p.dir[0] * L, sy(o.y) + p.dir[1] * L); ctx.stroke();
|
||||
}
|
||||
}
|
||||
// La stecca dietro la biglia, tirata indietro quanto la forza.
|
||||
const a = c.angle, back = rr + 6 + c.power * 26, len = 150;
|
||||
const bx = sx(me.x) - Math.cos(a) * back, by = sy(me.y) - Math.sin(a) * back;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.strokeStyle = '#2b1a10'; ctx.lineWidth = 5;
|
||||
ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx - Math.cos(a) * len, by - Math.sin(a) * len); ctx.stroke();
|
||||
ctx.strokeStyle = '#c99a5b'; ctx.lineWidth = 3;
|
||||
ctx.beginPath(); ctx.moveTo(bx - Math.cos(a) * 6, by - Math.sin(a) * 6); ctx.lineTo(bx - Math.cos(a) * len, by - Math.sin(a) * len); ctx.stroke();
|
||||
ctx.strokeStyle = '#4fb0ff'; ctx.lineWidth = 3;
|
||||
ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx - Math.cos(a) * 5, by - Math.sin(a) * 5); ctx.stroke();
|
||||
ctx.lineCap = 'butt';
|
||||
}
|
||||
|
||||
export function drawBiliardo(ctx, c, def) {
|
||||
ctx.save();
|
||||
// La sala, in penombra, e il lume sopra il tavolo.
|
||||
ctx.fillStyle = '#17110c'; ctx.fillRect(0, 0, 640, 360);
|
||||
const lamp = ctx.createRadialGradient(320, 200, 60, 320, 200, 420);
|
||||
lamp.addColorStop(0, 'rgba(255,220,150,0.16)'); lamp.addColorStop(1, 'rgba(255,220,150,0)');
|
||||
ctx.fillStyle = lamp; ctx.fillRect(0, 0, 640, 360);
|
||||
table(ctx);
|
||||
for (const p of c.pins) pin(ctx, p);
|
||||
if (c.step === 'aim' && (c.current === 'player' || c.plan)) aimGuide(ctx, c);
|
||||
for (const role of ['pallino', 'cpu', 'player']) { const b = c.balls[role]; sphere(ctx, role, sx(b.x), sy(b.y), rr); }
|
||||
// Punteggio: i due nomi, i punti, chi deve tirare.
|
||||
panel(ctx, 18, 8, 604, 62);
|
||||
for (const [i, who] of ['player', 'cpu'].entries()) {
|
||||
const x = 30 + i * 300, y = 24;
|
||||
sphere(ctx, who, x + 4, y, 4);
|
||||
text(ctx, def.names[who], x + 14, y, 7, BALL[who][2]);
|
||||
text(ctx, String(c.points[who]), x + 14, y + 16, 10, c.step === 'aim' && c.current === who ? '#fff2a3' : '#f4e3c2');
|
||||
if (c.step === 'aim' && c.current === who) text(ctx, '< al tiro', x + 44, y + 16, 6, '#fff2a3');
|
||||
}
|
||||
const last = c.last && c.last.age < 6 ? lastLine(c, def.names) : '';
|
||||
if (last) text(ctx, last, 610, 42, 6, c.last.foul ? '#f3b0a0' : '#c9eab3', 'right');
|
||||
text(ctx, c.step === 'aim' ? c.current === 'player' ? 'Tocca il tavolo per mirare; tieni premuto e lascia per tirare.' : `${def.names.cpu} studia il colpo…` : status(c, def.names), 320, 60, 6, '#d7ccb4', 'center');
|
||||
if (canThrow(c)) {
|
||||
panel(ctx, 18, 318, 176, 34);
|
||||
text(ctx, `FORZA ${Math.round(c.power * 100)}%`, 30, 329, 7);
|
||||
ctx.fillStyle = '#3e3e36'; ctx.fillRect(30, 341, 150, 5);
|
||||
ctx.fillStyle = '#b9d392'; ctx.fillRect(30, 341, Math.round(c.power * 150), 5);
|
||||
}
|
||||
if (c.step === 'result') {
|
||||
panel(ctx, 170, 140, 300, 80);
|
||||
text(ctx, 'PARTITA FINITA', 320, 160, 9, '#efd49e', 'center');
|
||||
text(ctx, status(c, def.names), 320, 184, 8, c.result.winner === 'player' ? '#b9e5b3' : '#f1d9be', 'center');
|
||||
text(ctx, 'Il tavolo resta così finché esci.', 320, 204, 6, '#d7ccb4', 'center');
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
// Biliardo all'italiana, cinque birilli, visto dall'alto. Regole da circolo, semplificate:
|
||||
// si colpisce la propria biglia e la si manda sulla biglia avversaria; i birilli abbattuti dalla biglia avversaria
|
||||
// o dal pallino fanno punti a chi tira (bianco 2, rosso 4, il rosso da solo 10), il pallino toccato dalla biglia
|
||||
// avversaria ne vale 3. Fallo, con i punti all'avversario (almeno 2): mancare la biglia avversaria, toccare prima
|
||||
// il pallino, abbattere birilli con la propria biglia. Un colpo a testa; vince chi arriva prima al traguardo.
|
||||
// Unità: centimetri. x lungo il tavolo (0..LEN), y di traverso (0..WID).
|
||||
export const LEN = 284, WID = 142, R = 3.05, PIN = 0.9, GAP = 6.3, TARGET = 20;
|
||||
export const MIN_SPEED = 50, MAX_SPEED = 520, FRICTION = 70, DRAG = 0.35, CUSHION = 0.78;
|
||||
export const SWING = 1.4, FIXED_STEP = 1 / 240;
|
||||
const EPS = 0.5, REST = 0.6, CPU_THINK = 0.2, CPU_SHOT = 1.5;
|
||||
export const OTHER = { player: 'cpu', cpu: 'player' };
|
||||
// Dove sta il tavolo nel primo piano 640×360: angolo in alto a sinistra del panno e pixel per centimetro.
|
||||
export const VIEW = { x: 57, y: 78, k: 1.85 };
|
||||
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
|
||||
const deviation = c => random(c) + random(c) + random(c) - 1.5;
|
||||
const ball = (role, x, y) => ({ role, x, y, vx: 0, vy: 0 });
|
||||
const list = c => [c.balls.player, c.balls.cpu, c.balls.pallino];
|
||||
const angleTo = (a, b) => Math.atan2(b.y - a.y, b.x - a.x);
|
||||
|
||||
// Il castello: il rosso in mezzo al tavolo, i quattro bianchi a croce intorno.
|
||||
export function castle() {
|
||||
const cx = LEN / 2, cy = WID / 2;
|
||||
return [[0, 0, true], [-GAP, 0], [GAP, 0], [0, -GAP], [0, GAP]].map(([dx, dy, red]) => ({ x: cx + dx, y: cy + dy, red: !!red, down: false }));
|
||||
}
|
||||
|
||||
export const powerAt = time => clamp(time / SWING, 0, 1);
|
||||
export const speedFor = power => MIN_SPEED + (MAX_SPEED - MIN_SPEED) * power;
|
||||
// Quanta strada fa una biglia lanciata a velocità v, senza urti (attrito costante più un po' di resistenza).
|
||||
export function reach(v) {
|
||||
const k = FRICTION / DRAG, t = Math.log((v + k) / k) / DRAG;
|
||||
return (v + k) / DRAG * (1 - Math.exp(-DRAG * t)) - k * t;
|
||||
}
|
||||
export const canThrow = c => c.step === 'aim' && c.current === 'player';
|
||||
export const moving = c => list(c).some(b => b.vx || b.vy);
|
||||
|
||||
export function newBiliardo(seed = Date.now() >>> 0, def = {}) {
|
||||
const c = { step: 'aim', t: 0, seed, current: 'player', target: def.target ?? TARGET, power: 0.45, angle: 0,
|
||||
holding: false, holdTime: 0, accumulator: 0, events: [], points: { player: 0, cpu: 0 }, last: null, shot: null, plan: null };
|
||||
c.balls = { player: ball('player', LEN * 0.18, WID * 0.5), cpu: ball('cpu', LEN * 0.81, WID * 0.25), pallino: ball('pallino', LEN * 0.88, WID * 0.7) };
|
||||
c.pins = castle();
|
||||
c.angle = angleTo(c.balls.player, c.balls.cpu);
|
||||
return c;
|
||||
}
|
||||
|
||||
// Valore dei birilli abbattuti in un colpo.
|
||||
export function value(pins) {
|
||||
if (pins.length === 1 && pins[0].red) return 10;
|
||||
return pins.reduce((s, p) => s + (p.red ? 4 : 2), 0);
|
||||
}
|
||||
// Chi prende i punti di un colpo, e quanti.
|
||||
export function judge(shot) {
|
||||
const shooter = shot.shooter, other = OTHER[shooter];
|
||||
const own = shot.pins.filter(p => p.by === shooter);
|
||||
const foul = shot.first === null ? 'miss' : shot.first === 'pallino' ? 'pallino' : own.length ? 'own' : null;
|
||||
if (foul) return { shooter, to: other, points: Math.max(2, value(shot.pins)), foul };
|
||||
return { shooter, to: shooter, points: value(shot.pins) + (shot.pallino ? 3 : 0), foul: null };
|
||||
}
|
||||
|
||||
function cushions(b) {
|
||||
let impact = 0;
|
||||
for (const [p, v, hi] of [['x', 'vx', LEN - R], ['y', 'vy', WID - R]]) {
|
||||
if (b[p] < R || b[p] > hi) {
|
||||
const out = b[p] < R ? b[v] < 0 : b[v] > 0;
|
||||
b[p] = clamp(b[p], R, hi);
|
||||
if (out) { impact = Math.max(impact, Math.abs(b[v])); b[v] *= -CUSHION; }
|
||||
}
|
||||
}
|
||||
return impact;
|
||||
}
|
||||
// Urto fra due biglie uguali, quasi elastico; separa anche quelle ferme e sovrapposte.
|
||||
export function collide(a, b) {
|
||||
const dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy);
|
||||
if (d >= 2 * R) return 0;
|
||||
const nx = d > 1e-9 ? dx / d : 1, ny = d > 1e-9 ? dy / d : 0, overlap = (2 * R - d) / 2;
|
||||
a.x -= nx * overlap; a.y -= ny * overlap; b.x += nx * overlap; b.y += ny * overlap;
|
||||
const closing = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
|
||||
if (closing <= 0) return 0;
|
||||
const j = closing * 0.975;
|
||||
a.vx -= j * nx; a.vy -= j * ny; b.vx += j * nx; b.vy += j * ny;
|
||||
return closing;
|
||||
}
|
||||
function touch(shot, a, b) {
|
||||
if (!shot) return;
|
||||
const roles = [a.role, b.role], other = OTHER[shot.shooter];
|
||||
if (shot.first === null && roles.includes(shot.shooter)) shot.first = roles.find(r => r !== shot.shooter);
|
||||
if (roles.includes(other) && roles.includes('pallino')) shot.pallino = true;
|
||||
}
|
||||
function step(w, dt) {
|
||||
let hit = 0, rail = 0;
|
||||
for (const b of w.balls) {
|
||||
const v = Math.hypot(b.vx, b.vy);
|
||||
if (v < EPS) { b.vx = b.vy = 0; continue; }
|
||||
const nv = Math.max(0, v - (FRICTION + DRAG * v) * dt), travel = (v + nv) / 2 * dt;
|
||||
b.x += b.vx / v * travel; b.y += b.vy / v * travel;
|
||||
b.vx *= nv / v; b.vy *= nv / v;
|
||||
rail = Math.max(rail, cushions(b));
|
||||
for (const p of w.pins) {
|
||||
if (p.down || Math.hypot(b.x - p.x, b.y - p.y) >= R + PIN) continue;
|
||||
// Il birillo cade dalla parte opposta a chi lo colpisce; la biglia perde un po' di slancio.
|
||||
Object.assign(p, { down: true, by: b.role, dir: Math.atan2(p.y - b.y, p.x - b.x) });
|
||||
b.vx *= 0.88; b.vy *= 0.88;
|
||||
w.shot?.pins.push(p);
|
||||
if (w.events && w.events.length < 16) w.events.push({ kind: 'biliardo-pin', power: Math.min(1, v / 300) });
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < w.balls.length; i++) for (let j = i + 1; j < w.balls.length; j++) {
|
||||
const s = collide(w.balls[i], w.balls[j]);
|
||||
if (s > 0) { touch(w.shot, w.balls[i], w.balls[j]); hit = Math.max(hit, s); }
|
||||
}
|
||||
if (w.events && w.events.length < 16 && (hit > 12 || rail > 12))
|
||||
w.events.push({ kind: hit >= rail ? 'biliardo-hit' : 'biliardo-rail', power: Math.min(1, Math.max(hit, rail) / 400) });
|
||||
}
|
||||
|
||||
function strike(c, who, angle, power) {
|
||||
const b = c.balls[who], v = speedFor(power);
|
||||
b.vx = Math.cos(angle) * v; b.vy = Math.sin(angle) * v;
|
||||
c.shot = { shooter: who, first: null, pallino: false, pins: [] };
|
||||
c.step = 'rolling'; c.t = c.accumulator = 0; c.angle = angle; c.power = power; c.plan = null;
|
||||
c.events.push({ kind: 'biliardo-cue', power });
|
||||
return true;
|
||||
}
|
||||
// Il colpo provato a vuoto, su una copia del tavolo: serve all'avversario per scegliere.
|
||||
export function simulate(c, who, angle, power) {
|
||||
const balls = list(c).map(b => ({ ...b })), pins = c.pins.map(p => ({ ...p }));
|
||||
const w = { balls, pins, shot: { shooter: who, first: null, pallino: false, pins: [] } };
|
||||
const b = balls.find(x => x.role === who), v = speedFor(power);
|
||||
b.vx = Math.cos(angle) * v; b.vy = Math.sin(angle) * v;
|
||||
for (let i = 0; i < 15 / FIXED_STEP && balls.some(x => x.vx || x.vy); i++) step(w, FIXED_STEP);
|
||||
return judge(w.shot);
|
||||
}
|
||||
|
||||
// I birilli si rialzano; una biglia ferma sopra il posto di un birillo si sposta appena quanto basta.
|
||||
function reset(c) {
|
||||
for (const p of c.pins) { p.down = false; delete p.by; delete p.dir; }
|
||||
for (let pass = 0; pass < 4; pass++) {
|
||||
for (const b of list(c)) for (const p of c.pins) {
|
||||
const d = Math.hypot(b.x - p.x, b.y - p.y), need = R + PIN + 0.3;
|
||||
if (d >= need) continue;
|
||||
const a = d > 1e-6 ? Math.atan2(b.y - p.y, b.x - p.x) : -Math.PI / 2;
|
||||
b.x = p.x + Math.cos(a) * need; b.y = p.y + Math.sin(a) * need;
|
||||
}
|
||||
const all = list(c);
|
||||
for (let i = 0; i < 3; i++) for (let j = i + 1; j < 3; j++) collide(all[i], all[j]);
|
||||
all.forEach(cushions);
|
||||
}
|
||||
}
|
||||
function finish(c) {
|
||||
const j = judge(c.shot);
|
||||
c.points[j.to] += j.points;
|
||||
c.last = { ...j, age: 0 };
|
||||
c.shot = null;
|
||||
reset(c);
|
||||
c.t = 0;
|
||||
if (c.points[j.to] >= c.target) { c.step = 'result'; c.result = { winner: j.to, points: { ...c.points } }; return; }
|
||||
c.current = OTHER[j.shooter];
|
||||
c.step = 'aim';
|
||||
c.angle = angleTo(c.balls[c.current], c.balls[OTHER[c.current]]);
|
||||
}
|
||||
|
||||
// L'avversario prova tanti colpi a vuoto e sceglie quello che rende di più; poi la mano gli trema un poco.
|
||||
// Le prove si fanno a pezzi, un po' per fotogramma: tutte insieme fermerebbero il gioco per un decimo di secondo.
|
||||
function cpuCandidates(c) {
|
||||
const base = angleTo(c.balls.cpu, c.balls.player), angles = [];
|
||||
for (let i = 0; i < 64; i++) angles.push(i / 64 * Math.PI * 2);
|
||||
for (let d = -0.3; d <= 0.3; d += 0.025) angles.push(base + d);
|
||||
return angles.flatMap(angle => [0.3, 0.5, 0.7, 0.9].map(power => ({ angle, power })));
|
||||
}
|
||||
function cpuThink(c, budget = 12) {
|
||||
const t = c.thinking ??= { todo: cpuCandidates(c), best: null };
|
||||
for (let i = 0; i < budget && t.todo.length; i++) {
|
||||
const { angle, power } = t.todo.pop(), j = simulate(c, 'cpu', angle, power), gain = j.to === 'cpu' ? j.points : -j.points;
|
||||
if (!t.best || gain > t.best.gain) t.best = { angle, power, gain };
|
||||
}
|
||||
if (t.todo.length) return;
|
||||
c.plan = { angle: t.best.angle + deviation(c) * (c.wobble ?? 0.03), power: clamp(t.best.power + deviation(c) * 0.05, 0.05, 1) };
|
||||
c.angle = c.plan.angle; c.power = c.plan.power; c.thinking = null;
|
||||
}
|
||||
|
||||
export function update(c, dt) {
|
||||
if (!Number.isFinite(dt) || dt <= 0) return;
|
||||
dt = Math.min(dt, 0.25); c.t += dt;
|
||||
if (c.last) c.last.age += dt;
|
||||
if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); }
|
||||
if (c.step === 'rolling') {
|
||||
c.accumulator += dt;
|
||||
const w = { balls: list(c), pins: c.pins, shot: c.shot, events: c.events };
|
||||
while (c.accumulator >= FIXED_STEP) { step(w, FIXED_STEP); c.accumulator -= FIXED_STEP; }
|
||||
if (!moving(c)) { c.step = 'settle'; c.t = 0; }
|
||||
} else if (c.step === 'settle' && c.t >= REST) finish(c);
|
||||
else if (c.step === 'aim' && c.current === 'cpu') {
|
||||
if (!c.plan && c.t >= CPU_THINK) cpuThink(c);
|
||||
if (c.plan && c.t >= CPU_SHOT) strike(c, 'cpu', c.plan.angle, c.plan.power);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Comandi del giocatore: gli stessi delle bocce.
|
||||
export function aimAt(c, x, y) {
|
||||
if (!canThrow(c) || !Number.isFinite(x) || !Number.isFinite(y)) return false;
|
||||
const b = c.balls.player;
|
||||
if (Math.hypot(x - b.x, y - b.y) < R) return false;
|
||||
c.angle = Math.atan2(y - b.y, x - b.x); return true;
|
||||
}
|
||||
export function aimScreen(c, [sx, sy]) { return aimAt(c, (sx - VIEW.x) / VIEW.k, (sy - VIEW.y) / VIEW.k); }
|
||||
export function nudge(c, dir) { if (!canThrow(c) || c.holding) return false; c.angle += dir * 0.01; return true; }
|
||||
export function setPower(c, value) {
|
||||
if (!canThrow(c) || c.holding || !Number.isFinite(value)) return false;
|
||||
c.power = clamp(value, 0, 1); return true;
|
||||
}
|
||||
export function shoot(c) { return canThrow(c) && !c.holding && strike(c, 'player', c.angle, c.power); }
|
||||
export function press(c) {
|
||||
if (!canThrow(c) || c.holding) return false;
|
||||
c.previousPower = c.power; c.holding = true; c.holdTime = c.power = 0; return true;
|
||||
}
|
||||
export function cancel(c) {
|
||||
if (!c.holding) return false;
|
||||
c.holding = false; c.power = c.previousPower; c.holdTime = 0; return true;
|
||||
}
|
||||
export function release(c) {
|
||||
if (!c.holding) return false;
|
||||
if (c.holdTime < 0.14) return cancel(c); // un tocco breve mira soltanto
|
||||
c.holding = false; return strike(c, 'player', c.angle, c.power);
|
||||
}
|
||||
|
||||
// Dove andrebbe la biglia: la linea fino al primo urto (con le sponde, fino a dove arriva con quella forza),
|
||||
// la biglia fantasma dove tocca, e verso dove partirebbe quella colpita.
|
||||
export function predict(c) {
|
||||
const me = c.balls[c.current], others = list(c).filter(b => b !== me);
|
||||
let x = me.x, y = me.y, dx = Math.cos(c.angle), dy = Math.sin(c.angle), left = reach(speedFor(c.power));
|
||||
const points = [[x, y]], STEP = 0.5;
|
||||
for (let n = 0; left > 0 && n < 6000; n++) {
|
||||
x += dx * STEP; y += dy * STEP; left -= STEP;
|
||||
if (x < R || x > LEN - R) { x = clamp(x, R, LEN - R); dx = -dx; left *= 0.6; points.push([x, y]); }
|
||||
if (y < R || y > WID - R) { y = clamp(y, R, WID - R); dy = -dy; left *= 0.6; points.push([x, y]); }
|
||||
for (const o of others) {
|
||||
const d = Math.hypot(o.x - x, o.y - y);
|
||||
if (d < 2 * R) { points.push([x, y]); return { points, ghost: { x, y }, hit: o.role, dir: [(o.x - x) / d, (o.y - y) / d] }; }
|
||||
}
|
||||
for (const p of c.pins) if (!p.down && Math.hypot(p.x - x, p.y - y) < R + PIN) { points.push([x, y]); return { points, ghost: { x, y }, hit: 'pin', dir: null }; }
|
||||
}
|
||||
points.push([x, y]);
|
||||
return { points, ghost: null, hit: null, dir: null };
|
||||
}
|
||||
|
||||
const FOULS = { miss: 'biglia avversaria mancata', pallino: 'prima il pallino', own: 'birilli con la propria biglia' };
|
||||
export function lastLine(c, names) {
|
||||
const l = c.last;
|
||||
if (!l) return '';
|
||||
if (l.foul) return `Fallo di ${names[l.shooter]} (${FOULS[l.foul]}): ${l.points} a ${names[l.to]}.`;
|
||||
return l.points ? `${names[l.to]} fa ${l.points} punti!` : `${names[l.shooter]}: nessun punto.`;
|
||||
}
|
||||
export function status(c, names) {
|
||||
if (c.step === 'result') return `Partita a ${names[c.result.winner]}: ${c.points.player} a ${c.points.cpu}.`;
|
||||
if (c.step === 'rolling') return 'Biglie in movimento…';
|
||||
if (c.step === 'settle') return 'Si contano i birilli…';
|
||||
const last = lastLine(c, names);
|
||||
return `${last ? last + ' ' : ''}${names[c.current]} al tiro. (${c.points.player} a ${c.points.cpu}, si va a ${c.target})`;
|
||||
}
|
||||
@@ -3,10 +3,10 @@ import { ambientHash as hash } from './ambient.js';
|
||||
|
||||
const FONT = '"Press Start 2P", monospace';
|
||||
const COLORS = { player: ['#172e58', '#3975c4', '#d9ecff'], cpu: ['#531d1b', '#c24e3b', '#ffe0bd'], jack: ['#70531b', '#e2b743', '#fff8cd'] };
|
||||
function text(ctx, line, x, y, size = 7, color = '#f4e3c2', align = 'left') {
|
||||
export function text(ctx, line, x, y, size = 7, color = '#f4e3c2', align = 'left') {
|
||||
ctx.font = `${size}px ${FONT}`; ctx.textAlign = align; ctx.textBaseline = 'middle'; ctx.fillStyle = color; ctx.fillText(line, x, y);
|
||||
}
|
||||
function panel(ctx, x, y, w, h) {
|
||||
export function panel(ctx, x, y, w, h) {
|
||||
ctx.fillStyle = 'rgba(22,25,23,0.92)'; ctx.fillRect(x, y, w, h);
|
||||
ctx.strokeStyle = '#897b57'; ctx.lineWidth = 1; ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
|
||||
}
|
||||
|
||||
+23
-13
@@ -1,7 +1,14 @@
|
||||
// Il cuore dell'avventura, senza DOM: stato, comandi, coda delle azioni, dialoghi.
|
||||
// La storia (scene, oggetti, dialoghi) arriva tutta dai dati: qui non c'è nessun enigma scritto a mano.
|
||||
import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js';
|
||||
import { newBocce, update as bocceUpdate, press as boccePress, release as bocceRelease, cancel as bocceCancel, aimScreen } from './bocce.js';
|
||||
import * as bocce from './bocce.js';
|
||||
import * as biliardo from './biliardo.js';
|
||||
|
||||
// I minigiochi in primo piano: stessi comandi (mira, pressione, rilascio), motori diversi.
|
||||
export const MINIGAMES = {
|
||||
bocce: { create: seed => bocce.newBocce(seed), update: bocce.update, press: bocce.press, release: bocce.release, cancel: bocce.cancel, aim: bocce.aimScreen },
|
||||
biliardo: { create: seed => biliardo.newBiliardo(seed), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.aimScreen },
|
||||
};
|
||||
|
||||
export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give', 'drop'];
|
||||
export const VERB_LABELS = { walk: 'Vai a', look: 'Guarda', take: 'Prendi', use: 'Usa', talk: 'Parla con', open: 'Apri', give: 'Dai', drop: 'Butta' };
|
||||
@@ -230,7 +237,7 @@ export class Game {
|
||||
closeupTap() {
|
||||
const c = this.closeup;
|
||||
if (!c || this.speech) return false;
|
||||
if (this.story.closeups[c.id].type) return false; // torcia e bocce non si sfogliano
|
||||
if (this.story.closeups[c.id].type) return false; // torcia e minigiochi non si sfogliano
|
||||
if (c.step === 'cover') Object.assign(c, { step: 'title', t: 0 });
|
||||
else if (c.step === 'title') Object.assign(c, { step: 'turn', t: 0 });
|
||||
else return false;
|
||||
@@ -248,8 +255,8 @@ export class Game {
|
||||
if (c.step === 'done') this.queue.unshift(...def.complete);
|
||||
return true;
|
||||
}
|
||||
// Le bocce: a mano finita, la battuta di chi ha vinto; uscendo prima, quella di chi lascia.
|
||||
if (def?.type === 'bocce') {
|
||||
// Bocce e biliardo: a partita finita, la battuta di chi ha vinto; uscendo prima, quella di chi lascia.
|
||||
if (MINIGAMES[def?.type]) {
|
||||
this.queue.unshift(...(c.step === 'result' ? def[c.result?.winner === 'player' ? 'win' : c.result?.winner === 'cpu' ? 'lose' : 'draw'] ?? [] : def.quit ?? []));
|
||||
return true;
|
||||
}
|
||||
@@ -258,16 +265,18 @@ export class Game {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Le bocce: si tiene premuto per caricare la forza e si lascia per tirare (mouse, dito o spazio).
|
||||
closeupPress() { const c = this.closeup; return !!c && this.story.closeups[c.id].type === 'bocce' && !this.speech && boccePress(c); }
|
||||
closeupRelease() { const c = this.closeup; return !!c && this.story.closeups[c.id].type === 'bocce' && bocceRelease(c); }
|
||||
closeupCancel() { const c = this.closeup; return !!c && this.story.closeups[c.id].type === 'bocce' && bocceCancel(c); }
|
||||
// Bocce e biliardo: si tiene premuto per caricare la forza e si lascia per tirare (mouse, dito o spazio).
|
||||
minigame() { const c = this.closeup; return c ? MINIGAMES[this.story.closeups[c.id].type] ?? null : null; }
|
||||
closeupPress() { const m = this.minigame(); return !!m && !this.speech && m.press(this.closeup); }
|
||||
closeupRelease() { const m = this.minigame(); return !!m && m.release(this.closeup); }
|
||||
closeupCancel() { const m = this.minigame(); return !!m && m.cancel(this.closeup); }
|
||||
|
||||
// Solo luce, nessun clic sugli oggetti dietro al primo piano. Il progresso è transitorio:
|
||||
// uscire o ricaricare durante la ricerca permette di ripeterla, senza anticipare il finale.
|
||||
closeupAim([x, y]) {
|
||||
const c = this.closeup;
|
||||
if (c && this.story.closeups[c.id].type === 'bocce') return aimScreen(c, [x, y]);
|
||||
const m = this.minigame();
|
||||
if (m) return m.aim(c, [x, y]);
|
||||
if (!c || this.story.closeups[c.id].type !== 'torch' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
|
||||
c.beam = [Math.max(70, Math.min(570, x)), Math.max(65, Math.min(295, y))];
|
||||
return true;
|
||||
@@ -390,9 +399,10 @@ export class Game {
|
||||
const c = this.closeup;
|
||||
if (c) {
|
||||
if (this.story.closeups[c.id].type === 'torch') { this.updateTorch(dt); return; }
|
||||
if (this.story.closeups[c.id].type === 'bocce') {
|
||||
bocceUpdate(c, dt);
|
||||
// Il risultato resta leggibile finché il giocatore torna al pallaio.
|
||||
const m = this.minigame();
|
||||
if (m) {
|
||||
m.update(c, dt);
|
||||
// Il risultato resta leggibile finché il giocatore non esce.
|
||||
return;
|
||||
}
|
||||
c.t += dt;
|
||||
@@ -429,7 +439,7 @@ export class Game {
|
||||
else if (e.closeup) {
|
||||
const type = this.story.closeups[e.closeup].type;
|
||||
this.closeup = type === 'torch' ? { id: e.closeup, step: 'search', t: 0, age: 0, found: 0, dwell: 0, beam: [320, 180] }
|
||||
: type === 'bocce' ? { id: e.closeup, ...newBocce(Math.floor(Math.random() * 4294967296)) }
|
||||
: MINIGAMES[type] ? { id: e.closeup, ...MINIGAMES[type].create(Math.floor(Math.random() * 4294967296)) }
|
||||
: { id: e.closeup, step: 'cover', t: 0, age: 0 };
|
||||
}
|
||||
else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e));
|
||||
|
||||
@@ -4,6 +4,7 @@ import { check, PAGE_TURN } from './game.js';
|
||||
import { ambientEffects, ambientHash } from './ambient.js';
|
||||
import { drawTorch } from './torch.js';
|
||||
import { drawBocce } from './bocce-render.js';
|
||||
import { drawBiliardo } from './biliardo-render.js';
|
||||
import { versioned } from '../version.js';
|
||||
export const WIDTH = 640, HEIGHT = 360;
|
||||
|
||||
@@ -675,6 +676,7 @@ function drawCloseup(ctx, game, time) {
|
||||
drawBocce(ctx, c, def, backgrounds.get(def.picture), time);
|
||||
return;
|
||||
}
|
||||
if (def.type === 'biliardo') { drawBiliardo(ctx, c, def); return; }
|
||||
ctx.save();
|
||||
ctx.fillStyle = `rgba(8, 5, 12, ${Math.min(0.78, c.age / 0.35 * 0.78)})`;
|
||||
ctx.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
|
||||
@@ -104,6 +104,10 @@ export class Soundscape {
|
||||
else if (kind === 'bocce-roll') this.noiseSound(0.7, 650, 0.06 + strength * 0.08, { pan });
|
||||
else if (kind === 'bocce-hit') { this.tone(760, 320, 0.075, 0.04 + strength * 0.07, 0, pan); this.noiseSound(0.09, 1600, 0.04, { pan }); }
|
||||
else if (kind === 'bocce-rail') this.noiseSound(0.16, 380, 0.04 + strength * 0.08, { pan });
|
||||
else if (kind === 'biliardo-cue') { this.tone(1900, 900, 0.05, 0.03 + strength * 0.05, 0, pan); this.noiseSound(0.05, 2400, 0.03, { pan }); }
|
||||
else if (kind === 'biliardo-hit') { this.tone(2600, 1500, 0.045, 0.03 + strength * 0.08, 0, pan); this.noiseSound(0.04, 3200, 0.03, { pan }); }
|
||||
else if (kind === 'biliardo-rail') this.noiseSound(0.12, 520, 0.03 + strength * 0.06, { pan });
|
||||
else if (kind === 'biliardo-pin') { this.tone(3200, 2200, 0.03, 0.04 + strength * 0.05, 0, pan); this.noiseSound(0.08, 1800, 0.035, { pan }); }
|
||||
else if (kind === 'wood') this.noiseSound(0.3, 750, 0.13, { pan });
|
||||
else if (kind === 'drip') this.tone(1150, 540, 0.18, 0.055, 0, pan);
|
||||
else if (kind === 'bird') {
|
||||
|
||||
Reference in New Issue
Block a user