Files
leggenda-del-regolo/src/engine/campana.js
T

221 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// La campana al campone delle poste (idea di Andrea, 30 ottobre): disegnata col gesso sul vialetto, a turni col Taglia.
// Si tira il sassolino nella casella che tocca (dalla 1 alla 8): se cade dentro, senza toccare le righe, si salta tutta
// la campana fino in cima e indietro, su un piede nelle caselle sole e a gambe larghe nelle coppie, senza mai mettere
// il piede dove sta il sassolino; tornando, lo si raccoglie dalla casella prima. Chi sbaglia (sassolino fuori o sulla
// riga, salto fuori tempo) passa la mano; chi ce la fa va avanti alla casella dopo e ritira. Vince chi finisce la 8.
// Il tiro: la mira dà la direzione, tenere premuto carica la forza (va e torna), lasciare tira. I salti: a ogni salto
// la lancetta dell'equilibrio attraversa la barra; si preme quando passa nel verde. Unità: i pixel del primo piano
// 640×360, visto dall'alto (sfondo di Codex: assets/closeups/campone-campana.png).
export const W = 66, CY = 181, START = 80, CIELO = { x: START + 6 * W, r: W }, FROM = { x: 40, y: CY }, REACH = 520;
export const TAP = 0.14, CHARGE = 1.3, FLIGHT = 0.55, HOP = 0.22, REST = 1.6, DONE = 1.1, CPU_WAIT = 0.9, CPU_BEAT = 0.42;
// Le caselle: 1, 2, 3 da sole; 4 e 5 in coppia (la 4 a sinistra di chi salta, cioè in alto); la 6; 7 e 8 in coppia;
// in fondo la campana (il mezzo cerchio dove ci si gira; nel codice «cielo», come la chiamano in altri posti).
export const BOXES = {
1: { x: START, y: CY - W / 2 }, 2: { x: START + W, y: CY - W / 2 }, 3: { x: START + 2 * W, y: CY - W / 2 },
4: { x: START + 3 * W, y: CY - W }, 5: { x: START + 3 * W, y: CY }, 6: { x: START + 4 * W, y: CY - W / 2 },
7: { x: START + 5 * W, y: CY - W }, 8: { x: START + 5 * W, y: CY },
};
export const GROUPS = [[1], [2], [3], [4, 5], [6], [7, 8], ['cielo']];
export const LAST = 8;
// I livelli: quanto si sparpaglia il sassolino (px), se si vede dove cade, quanto è larga la riga che non si può
// toccare, quanto ci mette la lancetta dell'equilibrio e quanto è largo il verde; il Taglia: quante volte il sassolino
// va dove vuole e quanto spesso, a ogni salto, mette giù il piede.
export const LEVELS = {
bona: { scatter: 4, guide: true, line: 0, sweep: 1.25, zone: 0.32, cpu: { throw: 0.5, slip: 0.045 } },
seria: { scatter: 7, guide: false, line: 3, sweep: 1, zone: 0.2, cpu: { throw: 0.7, slip: 0.025 } },
male: { scatter: 9, guide: false, line: 4, sweep: 0.8, zone: 0.15, cpu: { throw: 0.8, slip: 0.018 } },
};
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; }
export function center(id) {
if (id === 'cielo') return { x: CIELO.x + W * 0.42, y: CY };
const b = BOXES[id]; return { x: b.x + W / 2, y: b.y + W / 2 };
}
// In che casella cade un punto, e se tocca la riga (entro `tol` pixel da un bordo).
export function boxAt([x, y], tol = 0) {
for (const [id, b] of Object.entries(BOXES)) {
if (x < b.x || x > b.x + W || y < b.y || y > b.y + W) continue;
return { id: Number(id), line: Math.min(x - b.x, b.x + W - x, y - b.y, b.y + W - y) < tol };
}
const d = Math.hypot(x - CIELO.x, y - CY);
if (x >= CIELO.x && d <= CIELO.r) return { id: 'cielo', line: Math.min(x - CIELO.x, CIELO.r - d) < tol };
return null;
}
// I salti per la casella `n`: all'andata tutte le caselle, saltando quella col sassolino (nella coppia si mette un
// piede solo nell'altra); il cielo; al ritorno, dalla casella prima si raccoglie il sassolino; poi fuori.
export function beats(n) {
const step = (g, back) => g.includes(n) ? (g.length > 1 ? [{ boxes: g.filter(k => k !== n), back }] : []) : [{ boxes: g, back }];
const out = GROUPS.flatMap(g => step(g, false));
for (let i = GROUPS.length - 2; i >= 0; i--) {
if (GROUPS[i].includes(n)) out.push({ ...out[out.length - 1], back: true, pick: true });
out.push(...step(GROUPS[i], true));
}
out.push({ boxes: [], back: true, out: true });
return out;
}
export function newCampana(seed = Date.now() >>> 0, def = {}) {
const level = LEVELS[def.level] ? def.level : 'seria';
return { step: 'aim', t: 0, seed, level, skill: LEVELS[level], current: 'player', target: { player: 1, cpu: 1 },
aim: center(1), power: 0.2, holding: false, holdTime: 0, charging: false, stone: null, shot: null,
beats: null, beat: -1, meter: null, hop: null, line: null, next: null, events: [], visual: { time: 0 } };
}
export const canThrow = c => c.current === 'player' && (c.step === 'aim' || c.step === 'hop');
export const canAdjust = c => c.current === 'player' && c.step === 'aim';
export const aimFree = c => !canAdjust(c);
// Dove cade il sassolino tirato con questa forza, senza lo sparpaglio.
export function landing(c, power = c.power) {
const dx = c.aim.x - FROM.x, dy = c.aim.y - FROM.y, d = Math.hypot(dx, dy) || 1;
return { x: FROM.x + dx / d * power * REACH, y: FROM.y + dy / d * power * REACH };
}
function toss(c, to) {
c.shot = { from: { ...FROM }, to, t: 0 }; c.stone = null;
c.step = 'flying'; c.line = null;
c.events.push({ kind: 'campana-lancio', power: 0.6 });
}
function throwStone(c) {
const p = landing(c), s = c.skill.scatter, spread = () => (random(c) + random(c) + random(c) - 1.5) * s * 1.4;
toss(c, { x: p.x + spread(), y: p.y + spread() });
return true;
}
const who = c => c.current === 'cpu' ? 'Il Taglia: ' : '';
const cap = line => line[0].toUpperCase() + line.slice(1);
// Il sassolino è arrivato: nella casella giusta si salta, se no passa la mano.
function land(c) {
const to = c.shot.to, n = c.target[c.current], hit = boxAt([to.x, to.y], c.skill.line);
c.shot = null; c.stone = to;
c.events.push({ kind: 'campana-sasso', power: 0.8 });
if (hit && hit.id === n && !hit.line) {
c.line = cap(`${who(c)}il sassolino è nella ${n}. Salta!`);
c.beats = beats(n); c.beat = -1; c.hop = null; c.t = 0;
c.step = c.current === 'cpu' ? 'cpuHop' : 'hop';
if (c.step === 'hop') c.meter = { t: -0.5, dir: 1 };
return;
}
fail(c, `${who(c)}${hit?.line ? 'sulla riga!' : hit ? `nella ${hit.id === 'cielo' ? 'campana in cima' : hit.id}, non nella ${n}.` : 'fuori dalla campana.'}`);
}
function fail(c, line) {
c.line = cap(line); c.step = 'rest'; c.t = 0; c.meter = null;
c.next = c.current === 'player' ? 'cpu' : 'player';
c.events.push({ kind: 'campana-riga', power: 0.7 });
}
// Un salto riuscito: la casella dopo, o il sassolino raccolto, o fuori dalla campana (e la casella è fatta).
function advance(c) {
c.beat++;
const b = c.beats[c.beat];
c.hop = { t: 0 };
if (b.pick) { c.stone = null; c.line = cap(`${who(c)}preso il sassolino!`); c.events.push({ kind: 'campana-sasso', power: 0.5 }); }
else c.events.push({ kind: 'campana-salto', power: 0.6 });
if (b.out) {
const n = c.target[c.current]++;
if (n >= LAST) {
c.step = 'result'; c.meter = null;
c.result = { winner: c.current, player: c.target.player - 1, cpu: c.target.cpu - 1 };
return;
}
c.line = cap(`${who(c)}fatta la ${n}! Ora la ${n + 1}.`); c.step = 'done'; c.t = 0; c.meter = null;
return;
}
if (c.meter) c.meter = { t: 0, dir: -c.meter.dir };
}
// La lancetta dell'equilibrio, da -1 a 1 (o al contrario, un salto sì e uno no).
export function needle(c) {
if (!c.meter) return 0;
return c.meter.dir * (-1 + 2 * clamp(c.meter.t, 0, c.skill.sweep) / c.skill.sweep);
}
function jump(c) {
if (c.hop && c.hop.t < HOP) return true; // ancora in aria: il tocco non conta
const u = needle(c);
if (Math.abs(u) <= c.skill.zone) { advance(c); return true; }
const early = u * c.meter.dir < 0;
fail(c, early ? 'Troppo presto: giù l\'altro piede!' : 'Troppo tardi: pestata la riga!');
return true;
}
// Tocca all'altro, o allo stesso per la casella dopo: dalla partenza, senza sassolino.
function turn(c, current) {
Object.assign(c, { current, stone: null, beats: null, beat: -1, hop: null, meter: null, t: 0, next: null,
step: current === 'player' ? 'aim' : 'cpuAim' });
if (current === 'player') c.aim = center(c.target.player);
}
export function update(c, dt) {
if (!Number.isFinite(dt) || dt <= 0) return;
dt = Math.min(dt, 0.25); c.t += dt; c.visual.time += dt;
if (c.hop) c.hop.t += dt;
if (c.holding) {
c.holdTime += dt; c.charging = c.holdTime >= TAP;
const k = (c.holdTime / CHARGE) % 2; c.power = k <= 1 ? k : 2 - k;
}
if (c.step === 'flying') { c.shot.t += dt; if (c.shot.t >= FLIGHT) land(c); }
else if (c.step === 'hop') {
if (!c.hop || c.hop.t >= HOP) c.meter.t += dt;
if (c.meter.t > c.skill.sweep) fail(c, 'Hai perso l\'equilibrio!');
} else if (c.step === 'cpuAim' && c.t >= CPU_WAIT) {
// Il Taglia: o nella casella, ben dentro, o mezza casella più in là (sulla riga o nell'altra).
const n = c.target.cpu, p = center(n), ok = random(c) < c.skill.cpu.throw, j = () => (random(c) - 0.5) * 20;
toss(c, ok ? { x: p.x + j(), y: p.y + j() } : { x: p.x + (random(c) < 0.5 ? -1 : 1) * W * (0.5 + random(c) * 0.3), y: p.y + j() });
} else if (c.step === 'cpuHop' && c.t >= CPU_BEAT) {
c.t = 0;
if (random(c) < c.skill.cpu.slip) fail(c, random(c) < 0.5 ? 'Il Taglia ha pestato la riga!' : 'Il Taglia ha messo giù l\'altro piede!');
else advance(c);
} else if (c.step === 'rest' && c.t >= REST) turn(c, c.next);
else if (c.step === 'done' && c.t >= DONE) turn(c, c.current);
}
// --- Comandi: la mira segue il mouse o il dito; tenere premuto carica la forza, lasciare tira; ai salti si preme.
export function aimScreen(c, [x, y]) {
if (!canAdjust(c) || c.holding || !Number.isFinite(x) || !Number.isFinite(y)) return false;
c.aim = { x: clamp(x, FROM.x + 30, 630), y: clamp(y, 80, 285) }; return true;
}
export function setPower(c, v) { if (!canAdjust(c) || c.holding) return false; c.power = clamp(v, 0, 1); return true; }
export function nudge(c, dir) { if (!canAdjust(c) || c.holding) return false; c.aim.y = clamp(c.aim.y + dir * 3, 80, 285); return true; }
// Frecce: su e giù la mira, destra e sinistra la forza (più in là, più in qua).
export function key(c, key, down) {
const move = { ArrowUp: [0, -3], ArrowDown: [0, 3], ArrowLeft: [-0.01, 0], ArrowRight: [0.01, 0] }[key];
if (!move) return false;
if (down && canAdjust(c) && !c.holding) { c.aim.y = clamp(c.aim.y + move[1], 80, 285); c.power = clamp(c.power + move[0], 0, 1); }
return true;
}
export function press(c, pointerId) {
if (c.current !== 'player') return false;
if (c.step === 'hop') return jump(c);
if (!canAdjust(c) || c.holding) return false;
c.pointerId = pointerId; c.holding = true; c.charging = false; c.holdTime = 0; c.power = 0;
return true;
}
export function cancel(c, pointerId) {
if (pointerId !== undefined && c.pointerId !== pointerId) return false;
if (!c.holding) return false;
c.holding = c.charging = false; c.holdTime = 0; return true;
}
export function release(c, pointerId) {
if (c.pointerId !== pointerId || !c.holding) return false;
if (c.holdTime < TAP) return cancel(c, pointerId); // il tocco secco mira soltanto
c.holding = c.charging = false; c.holdTime = 0;
return throwStone(c);
}
// Il pulsante: tira con la forza del cursore, o salta.
export function shoot(c) {
if (c.current !== 'player' || c.holding) return false;
if (c.step === 'hop') return jump(c);
return canAdjust(c) && throwStone(c);
}
const box = n => n > LAST ? 'finito' : `alla ${n}`;
export function button(c) {
if (c.step === 'result') return 'Finita';
if (c.current === 'cpu') return 'Tocca al Taglia…';
return { aim: 'Tira', flying: 'In volo…', hop: 'Salta' }[c.step] ?? 'Aspetta…';
}
export function status(c) {
const score = `Andrea ${box(c.target.player)}, il Taglia ${box(c.target.cpu)}.`;
if (c.step === 'result') return `${c.result.winner === 'player' ? 'Campana finita: battuto il Taglia! ' : 'Il Taglia ha finito la campana. '}${score}`;
const hint = c.current === 'cpu' ? 'Tocca al Taglia.' : c.step === 'hop' ? 'Salta quando la lancetta è nel verde.'
: `Tira il sassolino nella ${c.target.player}: mira, tieni premuto per la forza e lascia.`;
return `${score} ${c.line ?? hint}`;
}