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

163 lines
9.4 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.
// Le bruciate della Smarronata (Andrea, 31 ottobre: «a manovella, a mano, fallo così»): coi volontari della
// Misericordia si gira la manovella del cilindro a rete sopra il fuoco. Il gesto: tenendo premuto si fanno dei giri,
// grandi o piccoli, dove si vuole (conta quanto gira la direzione del dito, non dove sta), come la manovella vera.
// Le castagne stanno in fondo al cilindro: girando, la rete le alza e loro ricascano e si rimescolano. Troppo piano,
// quelle di sotto restano sulla brace e si carbonizzano mentre quelle di sopra restano crude; troppo forte, la rete
// le porta in cima e qualcuna salta fuori nel fuoco («mica si fa la centrifuga!»). Quando paiono cotte si rovesciano
// nel sacco: contano quelle buone, né crude né bruciate. Per chi non può fare il gesto: Spazio, le frecce o il
// pulsante Gira danno una spinta a ogni colpo. Unità: i pixel del primo piano 640×360, visto di fronte (sfondo di
// Codex: assets/closeups/smarronata-bruciatore.png).
// Il cilindro (fra le due forcelle dello sfondo) e la manovella, a destra: un giro visto di sbieco, un'ellisse.
export const DRUM = { x0: 182, x1: 456, y: 190, r: 46 }, CRANK = { x: 506, y: 190, rx: 14, ry: 30 }, BAR = 92;
export const N = 18, BOTTOM = Math.PI / 2;
// Tempi in secondi; velocità in radianti al secondo.
export const MIN_POUR = 4, POUR = 1.4, SPILL = 0.45, PUSH = 2.6, FRICTION = 1.4, LINE = 2.4, MAX_SPEED = 22;
// Quando una castagna è cotta e quando è carbonizzata.
export const COOKED = 0.85, BURNT = 1.6;
// I livelli: sotto `lo` il cilindro gira troppo piano (le castagne non si rimescolano abbastanza), sopra `hi` le
// castagne volano fuori; `time`, in quanti secondi cuociono girando bene; `goal`, quante buone ci vogliono.
export const LEVELS = {
bona: { lo: 1.6, hi: 10, time: 20, goal: 10 },
seria: { lo: 2.2, hi: 8, time: 17, goal: 15 },
male: { lo: 2.8, hi: 6.5, time: 14, goal: 16 },
};
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const wrap = a => Math.atan2(Math.sin(a), Math.cos(a));
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
const between = (c, lo, hi) => lo + (hi - lo) * random(c);
// Dove ricasca una castagna: di nuovo in fondo, a un'altezza a caso nel mucchio, con la sua voglia di farsi alzare.
function drop(c, k, side = 1) { Object.assign(k, { a: BOTTOM - side * between(c, 0, 0.55), r: between(c, 0.5, 0.93), lift: between(c, 0.55, 1.5) }); }
export function newBruciate(seed = Date.now() >>> 0, def = {}) {
const level = LEVELS[def.level] ? def.level : 'seria';
const c = { step: 'turn', t: 0, seed, level, skill: LEVELS[level], current: 'player', power: 0, drum: 0, speed: 0,
pending: 0, grab: null, cursor: null, spin: 0, chestnuts: [], flying: [], line: null, lineT: 0, events: [], visual: { time: 0 } };
for (let i = 0; i < N; i++) { const k = { x: between(c, 0.06, 0.94), cook: between(c, 0, 0.08), out: false }; drop(c, k, random(c) < 0.5 ? 1 : -1); c.chestnuts.push(k); }
return c;
}
export const inside = c => c.chestnuts.filter(k => !k.out);
// Quanto stanno a contatto con la rete sopra la brace: le castagne in fondo e di fuori; quelle di sopra quasi niente.
export function contact(k) { return Math.max(0, Math.cos(k.a - BOTTOM)) ** 3 * clamp((k.r - 0.62) / 0.3, 0, 1); }
// La cottura media delle castagne ancora nel cilindro, da 0 (crude) in su (1: cotte).
export const doneness = c => { const ks = inside(c); return ks.length ? ks.reduce((s, k) => s + k.cook, 0) / ks.length : 0; };
export const tally = c => {
const t = { good: 0, raw: 0, burnt: 0, spilled: 0 };
for (const k of c.chestnuts) t[k.out ? 'spilled' : k.cook < COOKED ? 'raw' : k.cook > BURNT ? 'burnt' : 'good']++;
return t;
};
function say(c, line) { c.line = line; c.lineT = LINE; }
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.lineT > 0 && (c.lineT -= dt) <= 0) c.line = null;
for (const f of c.flying) f.t += dt;
c.flying = c.flying.filter(f => f.t < 1.2);
if (c.step === 'pour') { if (c.t >= POUR) finish(c); return; }
if (c.step !== 'turn') return;
// La velocità: tenendo la manovella è quella del gesto (smussata); lasciata, il cilindro rallenta da sé.
if (c.grab) c.speed += (c.pending / dt - c.speed) * (1 - Math.exp(-dt * 10));
else c.speed *= Math.exp(-FRICTION * dt);
c.pending = 0;
c.speed = clamp(c.speed, -MAX_SPEED, MAX_SPEED);
const w = c.speed, fast = Math.abs(w) > c.skill.hi, side = Math.sign(w) || 1, before = c.drum;
c.drum += w * dt;
// Il cigolio della manovella, a ogni mezzo giro.
if (Math.floor(before / Math.PI) !== Math.floor(c.drum / Math.PI)) c.events.push({ kind: 'bruciate-manovella', power: clamp(Math.abs(w) / 8, 0.3, 1) });
const rate = 1 / (c.skill.time * 0.45);
for (const k of inside(c)) {
// La rete se le porta dietro finché non ricascano in fondo; troppo forte le porta fino in cima e oltre.
k.a += w * dt * k.r;
if (!fast && (k.a - BOTTOM) * side > k.lift) drop(c, k, side);
const before = k.cook;
k.cook += dt * rate * (0.15 + 1.9 * contact(k));
if (before < COOKED && k.cook >= COOKED) c.events.push({ kind: 'bruciate-scoppio', power: 0.5 + 0.5 * random(c) });
}
// La centrifuga: ogni tanto ne vola fuori una, nel fuoco.
if (fast) {
c.spin += dt;
if (c.spin >= SPILL) {
c.spin = 0;
const k = inside(c).sort((p, q) => Math.cos(p.a - BOTTOM) - Math.cos(q.a - BOTTOM))[0];
if (k) { k.out = true; c.flying.push({ x: DRUM.x0 + k.x * (DRUM.x1 - DRUM.x0), y: DRUM.y + Math.sin(k.a) * DRUM.r * k.r, dir: side, t: 0 }); c.events.push({ kind: 'bruciate-cade', power: 0.8 }); }
say(c, random(c) < 0.5 ? 'Oh! Mica si fa la centrifuga!' : 'Piano, che le si butta nel fuoco!');
}
} else c.spin = Math.max(0, c.spin - dt);
if (!c.line && Math.abs(w) < c.skill.lo && c.t > 3 && inside(c).some(k => k.cook > BURNT - 0.3)) say(c, 'Gira, che sotto si bruciano!');
// Tutte nel fuoco o tutte carbone: si rovescia comunque.
if (!inside(c).length || inside(c).every(k => k.cook > BURNT)) pour(c);
}
function finish(c) {
const t = tally(c);
c.step = 'result'; c.speed = 0;
c.result = { winner: t.good >= c.skill.goal ? 'player' : 'cpu', ...t, goal: c.skill.goal };
}
export function pour(c) {
if (c.step !== 'turn') return false;
Object.assign(c, { step: 'pour', t: 0, grab: null });
c.events.push({ kind: 'bruciate-sacco', power: 0.8 });
return true;
}
// --- Comandi. Il gesto: la direzione del movimento, a ogni tratto, quanto ha girato rispetto al tratto prima.
const STROKE = 3;
export function aimScreen(c, [x, y]) {
if (!Number.isFinite(x) || !Number.isFinite(y)) return false;
const g = c.grab;
if (g && c.step === 'turn') {
const [px, py] = g.last ?? [x, y], dx = x - px, dy = y - py;
if (Math.hypot(dx, dy) >= STROKE) {
const heading = Math.atan2(dy, dx);
if (g.heading !== null) c.pending += clamp(wrap(heading - g.heading), -1.2, 1.2);
g.heading = heading; g.last = [x, y];
} else if (!g.last) g.last = [x, y];
}
c.cursor = [x, y];
return true;
}
export function press(c, pointerId) {
if (c.step !== 'turn' || c.grab) return false;
c.grab = { pointerId, heading: null, last: c.cursor };
return true;
}
export function release(c, pointerId) {
if (!c.grab || c.grab.pointerId !== pointerId) return false;
c.grab = null; return true;
}
export const cancel = (c, pointerId) => (pointerId === undefined || c.grab?.pointerId === pointerId) && release(c, c.grab?.pointerId);
// Una spinta alla manovella (Spazio, frecce): per chi non fa il gesto.
export function push(c) {
if (c.step !== 'turn' || c.grab) return false;
c.speed = (Math.sign(c.speed) || 1) * (Math.abs(c.speed) + PUSH);
return true;
}
export function key(c, k, down) {
if (k === 'Enter') { if (down) shoot(c); return true; }
if (![' ', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(k)) return false;
if (down) push(c);
return true;
}
// Il pulsante: rovescia nel sacco (dopo i primi secondi).
export const canThrow = c => c.step === 'turn' && c.t >= MIN_POUR;
export const canAdjust = () => false;
export const aimFree = () => true;
export const shoot = c => canThrow(c) && pour(c);
export const button = c => c.step === 'result' ? 'Finita' : c.step === 'pour' ? 'Nel sacco…' : 'Rovescia nel sacco';
export function speedWord(c) {
const w = Math.abs(c.speed);
return w < 0.3 ? 'ferma' : w < c.skill.lo ? 'troppo piano' : w > c.skill.hi ? 'troppo forte' : 'giusta';
}
export function status(c) {
if (c.step === 'result') {
const r = c.result, extra = [r.raw && `${r.raw} crude`, r.burnt && `${r.burnt} carbonizzate`, r.spilled && `${r.spilled} nel fuoco`].filter(Boolean).join(', ');
return `${r.good} bruciate buone su ${N}${extra ? ` (${extra})` : ''}. ${r.winner === 'player' ? 'I volontari approvano!' : `Ne servivano ${r.goal}.`}`;
}
if (c.step === 'pour') return 'Nel sacco di iuta, a sudare...';
const cooked = `Cottura ${Math.round(doneness(c) * 100)}%.`;
if (c.line) return `${c.line} ${cooked}`;
const hint = c.t < 3 && Math.abs(c.speed) < 0.3 ? 'Tieni premuto e fai dei giri per girare la manovella.' : `Manovella ${speedWord(c)}.`;
return `${hint} ${cooked} Quando son cotte: Rovescia nel sacco.`;
}