Il GP di Fortezza: gara dei cinquantini attorno alle mura (pista di Codex dalla planimetria, sterzo, acceleratori, miscela, buche, presentazione dei piloti coi motorini dalle foto); Via Liguri Magelli col Trappola, la targa della Ghiacciomobile e il Betino in cima alla salita; l'Arcu lancia la sfida dal Trappola e ha la voce nuova (Andrea)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECdCPptNRwbVzjzHbohi
This commit is contained in:
2026-10-28 19:55:16 +01:00
co-authored by Claude Opus 5.5
parent c96dec4692
commit 800a82045d
79 changed files with 1205 additions and 67 deletions
+5 -2
View File
@@ -2,6 +2,9 @@
// Senza verbo scelto vale il clic intelligente: l'azione più sensata per quel punto.
import { VERB_LABELS } from './game.js';
// «Vai a» davanti a un nome che comincia con «dal», «dalla»…: «Vai dal Trappola», non «Vai a dal Trappola».
const verbLabel = (verb, name) => verb === 'walk' && /^da(l|lla|i|gli|ll')?\b/i.test(name ?? '') ? 'Vai' : VERB_LABELS[verb];
export class Command {
constructor(game) {
this.game = game;
@@ -21,10 +24,10 @@ export class Command {
const g = this.game, name = target && g.targetName(target);
if (this.item && target === this.item && this.verb === 'use') return `${VERB_LABELS.use} ${g.itemName(this.item)}`;
if (this.item) return `${VERB_LABELS[this.verb]} ${g.itemName(this.item)} ${this.verb === 'give' ? 'a' : 'con'}${name ? ' ' + name : '…'}`;
if (this.verb) return `${VERB_LABELS[this.verb]}${name ? ' ' + name : '…'}`;
if (this.verb) return `${verbLabel(this.verb, name)}${name ? ' ' + name : '…'}`;
if (!target) return '';
const isItem = !!g.story.items[target];
return isItem ? `Usa ${name} con…` : `${VERB_LABELS[g.defaultVerb(target)]} ${name}`;
return isItem ? `Usa ${name} con…` : `${verbLabel(g.defaultVerb(target), name)} ${name}`;
}
// Clic sulla scena: `look` per il clic destro o la pressione lunga, `hurry` per il doppio clic (passo doppio),
+3 -1
View File
@@ -8,6 +8,7 @@ import * as pesca from './pesca.js';
import * as briscola from './briscola.js';
import * as tressette from './tressette.js';
import * as scopa from './scopa.js';
import * as gara from './gara.js';
// I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta.
export const MINIGAMES = {
@@ -18,6 +19,7 @@ export const MINIGAMES = {
biliardo: { create: (seed, level) => biliardo.newBiliardo(seed, { level }), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.aimScreen },
basket: { create: (seed, level) => basket.newBasket(seed, { level }), update: basket.update, press: basket.press, release: basket.release, cancel: basket.cancel, aim: basket.aimScreen },
pesca: { create: (seed, level) => pesca.newPesca(seed, { level }), update: pesca.update, press: pesca.press, release: pesca.release, cancel: pesca.cancel, aim: pesca.aimScreen },
gara: { create: (seed, level, deck, def) => gara.newGara(seed, { level, riders: def.riders }), update: gara.update, press: gara.press, release: gara.release, cancel: gara.cancel, aim: gara.aimScreen },
};
export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give', 'drop'];
@@ -486,7 +488,7 @@ export class Game {
// `level`: «Alla bona», «Seria» o «A fassi male», scelto nel dialogo con l'avversario.
// `decks`: il mazzo da usare quando il suo flag è acceso (briscola: le napoletane al posto delle toscane).
: MINIGAMES[type] ? { id: e.closeup, ...MINIGAMES[type].create(Math.floor(Math.random() * 4294967296), e.level,
Object.keys(this.story.closeups[e.closeup].decks ?? {}).find(d => s.flags[this.story.closeups[e.closeup].decks[d]])) }
Object.keys(this.story.closeups[e.closeup].decks ?? {}).find(d => s.flags[this.story.closeups[e.closeup].decks[d]]), this.story.closeups[e.closeup]) }
: { id: e.closeup, step: 'cover', t: 0, age: 0 };
}
else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e));
+215
View File
@@ -0,0 +1,215 @@
import { TRACK, CURVES, PICKUPS, BRAKE, LAPS, COUNTDOWN, ROAD, VERGE, CAN, pointAt, standings, player, lapOf, presenting, placeOf, INTRO } from './gara.js';
import { text, panel } from './bocce-render.js';
import { ambientHash as hash } from './ambient.js';
// Il GP di Fortezza visto dall'alto. Col quadro di Codex (def.picture) la strada e la Fortezza sono già dipinte; senza,
// si disegnano qui dai dati del tracciato. Sopra, sempre: la guida, gli acceleratori, le buche, le lattine, i motorini.
function stroke(ctx, from, to, width, color, lane = 0) {
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round';
ctx.beginPath();
for (let s = from; s <= to; s += 2) { const p = pointAt(s, lane); s === from ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y); }
ctx.stroke();
}
function loop(ctx, width, color) {
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineJoin = 'round';
ctx.beginPath();
TRACK.pts.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
ctx.closePath(); ctx.stroke();
}
function ground(ctx) {
ctx.fillStyle = '#6f8f3e'; ctx.fillRect(0, 0, 640, 360);
for (let i = 0; i < 260; i++) {
ctx.fillStyle = hash(i * 13 + 1) > 0.5 ? '#7c9b47' : '#638236';
ctx.fillRect(Math.floor(hash(i * 13 + 2) * 640), Math.floor(hash(i * 13 + 3) * 360), 2 + Math.floor(hash(i * 13 + 4) * 6), 2);
}
// Dentro il giro, le mura della Fortezza e il prato dei bastioni.
ctx.beginPath();
TRACK.pts.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
ctx.closePath();
ctx.fillStyle = '#8a7a5a'; ctx.fill();
loop(ctx, VERGE * 2 + 8, '#6f8f3e');
loop(ctx, VERGE * 2, '#7c9b47');
loop(ctx, ROAD * 2 + 2, '#7a6446');
loop(ctx, ROAD * 2, '#b59b72');
for (let i = 0; i < TRACK.pts.length; i += 3) {
const [x, y] = TRACK.pts[i], o = (hash(i) - 0.5) * ROAD * 1.6;
ctx.fillStyle = hash(i + 7) > 0.5 ? '#a88d64' : '#c2a87e'; ctx.fillRect(Math.round(x + o * 0.5), Math.round(y + o * 0.5), 2, 1);
}
// La paglia fuori dalle curve.
for (const k of CURVES) for (let s = k.start; s <= k.end; s += 8) {
const p = pointAt(s, -(VERGE + 3) * k.side);
ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.heading);
ctx.fillStyle = '#d9b75a'; ctx.fillRect(-3.5, -2.5, 7, 5); ctx.fillStyle = '#b8953f'; ctx.fillRect(-3.5, -0.5, 7, 1);
ctx.restore();
}
const p = pointAt(0);
ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.heading);
for (let i = -6; i < 6; i++) for (let j = 0; j < 2; j++) { ctx.fillStyle = (i + j) % 2 ? '#111' : '#f4f4f4'; ctx.fillRect(j * 2 - 2, i * 2, 2, 2); }
ctx.restore();
}
function guide(ctx, c) {
const v = player(c).vmax;
// Alla bona, in rosso dove si frena per arrivare giusti in curva; Seria, un cono all'inizio della frenata.
for (const k of CURVES) {
const from = k.apex - (v * v - k.speed * k.speed * 0.7) / (2 * BRAKE);
if (c.skill.guide === 'zone') stroke(ctx, from, k.apex, 4, 'rgba(214,64,47,0.6)');
if (c.skill.guide === 'coni') {
const p = pointAt(from, (ROAD + 3) * k.side);
ctx.fillStyle = '#f07a1a'; ctx.beginPath(); ctx.moveTo(p.x, p.y - 5); ctx.lineTo(p.x + 3, p.y + 2); ctx.lineTo(p.x - 3, p.y + 2); ctx.fill();
ctx.fillStyle = '#fff'; ctx.fillRect(p.x - 1.5, p.y - 2, 3, 1);
}
}
}
function things(ctx, c) {
for (const p of PICKUPS) {
ctx.save(); ctx.translate(Math.round(p.x), Math.round(p.y)); ctx.rotate(p.heading);
if (p.kind === 'boost') {
// L'acceleratore: tre frecce gialle che lampeggiano in fila.
for (let k = 0; k < 3; k++) {
ctx.fillStyle = Math.floor(c.age * 6 - k) % 3 === 0 ? '#fff27a' : '#e0b020';
ctx.beginPath(); ctx.moveTo(-6 + k * 5, -5); ctx.lineTo(-2 + k * 5, 0); ctx.lineTo(-6 + k * 5, 5); ctx.lineTo(-4 + k * 5, 0); ctx.fill();
}
} else if (p.kind === 'buca') {
ctx.fillStyle = '#5a4630'; ctx.beginPath(); ctx.ellipse(0, 0, 6, 4.5, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#2e2418'; ctx.beginPath(); ctx.ellipse(0.5, 0.5, 4.5, 3, 0, 0, Math.PI * 2); ctx.fill();
} else if ((c.taken[p.id] ?? -Infinity) <= c.t - CAN.respawn) {
// La lattina della miscela, che saltella.
const y = Math.round(Math.sin(c.age * 5 + p.id) * 1);
ctx.rotate(-p.heading);
ctx.fillStyle = '#f4e3c2'; ctx.fillRect(-3, -4 + y, 6, 8);
ctx.fillStyle = '#c8321e'; ctx.fillRect(-2, -3 + y, 4, 6);
ctx.fillStyle = '#f0c23a'; ctx.fillRect(-2, -1 + y, 4, 1); ctx.fillRect(-1, -5 + y, 2, 1);
}
ctx.restore();
}
}
function moped(ctx, r, c) {
const out = r.out > 0;
ctx.save(); ctx.translate(Math.round(r.x), Math.round(r.y)); ctx.rotate(r.a + (out ? Math.sin(c.age * 22) * 0.6 : 0));
ctx.fillStyle = 'rgba(0,0,0,0.3)'; ctx.fillRect(-6, -1, 13, 4);
ctx.fillStyle = '#151515'; ctx.fillRect(-7, -1, 3, 2); ctx.fillRect(5, -1, 3, 2); // le ruote
ctx.fillStyle = '#f4e3c2'; ctx.fillRect(-5, -3, 11, 6); // il bordo chiaro: anche i motorini scuri si vedono
ctx.fillStyle = r.color; ctx.fillRect(-4, -2, 9, 4);
ctx.fillStyle = r.trim ?? '#1c1c1c'; ctx.fillRect(-4, -0.5, 9, 1);
ctx.fillStyle = '#2a2a2a'; ctx.fillRect(4, -3, 1, 6); // il manubrio
ctx.fillStyle = r.id === 'andrea' ? '#f4e3c2' : '#2a2a2a'; ctx.beginPath(); ctx.arc(-1, 0, 2, 0, Math.PI * 2); ctx.fill(); // il casco
// La miscela: una fiammata dietro.
if (r.boost > 0 && Math.floor(c.age * 20) % 2) { ctx.fillStyle = '#ffb030'; ctx.fillRect(-10, -1, 3, 2); }
ctx.restore();
if (r.id === 'andrea') { ctx.strokeStyle = '#f4e3c2'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(r.x, r.y, 10, 0, Math.PI * 2); ctx.stroke(); }
}
// In basso a sinistra, lontano dalla pausa e dal pulsante per uscire: il giro, la classifica e il tachimetro.
function hud(ctx, c) {
const me = player(c), order = standings(c), h = 34 + order.length * 11, y = 354 - h;
panel(ctx, 4, y, 116, h);
text(ctx, `GIRO ${lapOf(me)}/${LAPS}`, 10, y + 9, 7);
order.forEach((r, i) => {
ctx.fillStyle = '#f4e3c2'; ctx.fillRect(9, y + 18 + i * 11, 8, 8);
ctx.fillStyle = r.color; ctx.fillRect(10, y + 19 + i * 11, 6, 6);
text(ctx, `${i + 1}. ${r.name}`, 21, y + 23 + i * 11, 6, r.id === 'andrea' ? '#ffe28a' : '#f4e3c2');
});
const k = Math.min(1, me.v / me.vmax), bar = y + h - 11;
ctx.fillStyle = '#2a2f2c'; ctx.fillRect(10, bar, 50, 5);
ctx.fillStyle = me.boost > 0 ? '#ffb030' : k > 0.9 ? '#e0583a' : '#b9d392'; ctx.fillRect(10, bar, Math.round(50 * k), 5);
text(ctx, `${Math.round(me.v * 0.5)} km/h`, 116, bar + 3, 6, '#f4e3c2', 'right');
}
// La presentazione: il cartellino di chi arriva. A sinistra il personaggio (lo sprite del gioco, o il ritratto), a
// destra il suo motorino dipinto, sotto quanto va (motore) e quanto rischia (testa). `look(src)` dà l'immagine caricata,
// `sheet(id)` lo sprite di un personaggio.
function figure(ctx, r, x, y, look, sheet) {
const s = r.sprite && sheet(r.sprite);
if (s) {
const f = s.atlas.frames.stand ?? Object.values(s.atlas.frames)[0], k = Math.min(1, 84 / f.h);
ctx.drawImage(s.img, f.x, f.y, f.w, f.h, Math.round(x - f.w * k / 2), Math.round(y + 88 - f.h * k), Math.round(f.w * k), Math.round(f.h * k));
return;
}
const img = r.face && look(r.face);
ctx.fillStyle = '#f4e3c2'; ctx.fillRect(x - 31, y + 13, 62, 62);
if (img) { ctx.drawImage(img, x - 30, y + 14, 60, 60); return; }
// Nessun ritratto (ancora): la sagoma.
ctx.fillStyle = '#2a2f2c'; ctx.fillRect(x - 30, y + 14, 60, 60);
ctx.fillStyle = '#4a504c'; ctx.beginPath(); ctx.arc(x, y + 36, 11, 0, Math.PI * 2); ctx.fill();
ctx.fillRect(x - 18, y + 52, 36, 22);
text(ctx, '?', x, y + 37, 10, '#8f9488', 'center');
}
function card(ctx, c, look, sheet) {
const r = presenting(c);
if (!r) { panel(ctx, 220, 130, 200, 40); text(ctx, 'GP DI FORTEZZA', 320, 142, 7, '#ffe28a', 'center'); text(ctx, 'TUTTI IN GRIGLIA!', 320, 158, 7, '#f4e3c2', 'center'); return; }
const x = 150, y = 92, w = 340, h = 150;
panel(ctx, x, y, w, h);
text(ctx, 'GP DI FORTEZZA', x + w / 2, y + 10, 6, '#ffe28a', 'center');
figure(ctx, r, x + 46, y + 14, look, sheet);
// Il motorino, entra da sinistra e si ferma (come in pista).
const moto = r.moto && look(r.moto), t = Math.min(1, (c.t % INTRO.each) / 0.6), e = 1 - (1 - t) ** 3;
if (moto) {
const k = Math.min(150 / moto.width, 80 / moto.height), mw = moto.width * k, mh = moto.height * k;
ctx.save(); ctx.beginPath(); ctx.rect(x + 92, y + 18, w - 100, 92); ctx.clip();
ctx.drawImage(moto, Math.round(x + 92 + (w - 100 - mw) / 2 - (1 - e) * 120), Math.round(y + 104 - mh), Math.round(mw), Math.round(mh));
ctx.restore();
} else {
ctx.fillStyle = r.color; ctx.fillRect(x + 160, y + 70, 100, 22);
ctx.fillStyle = r.trim ?? '#1c1c1c'; ctx.fillRect(x + 160, y + 80, 100, 3);
}
text(ctx, r.name, x + 100, y + 118, 8, r.id === 'andrea' ? '#ffe28a' : '#f4e3c2');
text(ctx, r.bike, x + 100, y + 130, 6, '#c5c6b6');
for (const [k, [label, value]] of [['MOTORE', r.motore], ['TESTA', r.testa]].entries()) {
const yy = y + 116 + k * 12;
text(ctx, label, x + 222, yy, 5, '#c5c6b6');
for (let i = 0; i < 5; i++) { ctx.fillStyle = i < value ? (k ? '#b9d392' : '#e0583a') : '#3a3f3c'; ctx.fillRect(x + 262 + i * 13, yy - 3, 10, 6); }
}
text(ctx, 'TOCCA: IL PROSSIMO', x + w / 2, y + h - 6, 5, '#8f9488', 'center');
}
// La posizione in corsa, grande in alto a sinistra, e un cartellino sopra il Fifty.
function position(ctx, c) {
const me = player(c), place = placeOf(c), n = c.riders.length, first = place === 1;
panel(ctx, 4, 4, 70, 40);
text(ctx, 'POSIZIONE', 39, 11, 5, '#c5c6b6', 'center');
text(ctx, `${place}°`, 11, 29, 14, first ? '#ffe28a' : '#f4e3c2');
text(ctx, `su ${n}`, 68, 33, 6, '#c5c6b6', 'right');
if (me.shown === false) return;
const x = Math.round(me.x), y = Math.round(me.y) - 17;
ctx.fillStyle = 'rgba(22,25,23,0.85)'; ctx.fillRect(x - 8, y - 5, 16, 10);
ctx.fillStyle = first ? '#ffe28a' : '#f4e3c2'; ctx.fillRect(x - 1, y + 5, 2, 2);
text(ctx, `${place}°`, x, y + 0.5, 6, first ? '#ffe28a' : '#f4e3c2', 'center');
}
function lights(ctx, c) {
const lit = Math.min(3, Math.floor(c.t) + 1);
panel(ctx, 270, 140, 100, 50);
text(ctx, 'GP DI FORTEZZA', 320, 150, 6, '#ffe28a', 'center');
for (let i = 0; i < 3; i++) {
ctx.fillStyle = i < lit ? '#e0402a' : '#3a2a28';
ctx.beginPath(); ctx.arc(300 + i * 20, 170, 7, 0, Math.PI * 2); ctx.fill();
}
if (c.t >= COUNTDOWN - 0.01) text(ctx, 'VIA!', 320, 200, 10, '#b9d392', 'center');
}
function result(ctx, c) {
const order = c.result.order.map(id => c.riders.find(r => r.id === id));
panel(ctx, 200, 96, 240, 44 + order.length * 14);
text(ctx, 'GP DI FORTEZZA', 320, 108, 6, '#c5c6b6', 'center');
text(ctx, c.result.place === 1 ? 'PRIMO!' : `${c.result.place}° POSTO`, 320, 122, 10, c.result.place === 1 ? '#ffe28a' : '#f4e3c2', 'center');
order.forEach((r, i) => {
ctx.fillStyle = '#f4e3c2'; ctx.fillRect(213, 137 + i * 14, 10, 10);
ctx.fillStyle = r.color; ctx.fillRect(214, 138 + i * 14, 8, 8);
text(ctx, `${i + 1}. ${r.name}`, 228, 142 + i * 14, 7, r.id === 'andrea' ? '#ffe28a' : '#f4e3c2');
text(ctx, `${c.result.times[r.id].toFixed(1)}s`, 428, 142 + i * 14, 7, '#c5c6b6', 'right');
});
}
export function drawGara(ctx, c, def, bg, look = () => null, sheet = () => null) {
ctx.save();
if (bg) ctx.drawImage(bg, 0, 0, 640, 360);
else ground(ctx);
guide(ctx, c);
things(ctx, c);
// Prima chi è dietro, così chi è davanti resta sopra; Andrea sempre sopra a tutti.
for (const r of [...standings(c)].reverse().sort((a, b) => (a.id === 'andrea') - (b.id === 'andrea'))) if (r.shown !== false) moped(ctx, r, c);
if (c.step === 'intro') { card(ctx, c, look, sheet); ctx.restore(); return; }
hud(ctx, c);
if (c.step === 'race') position(ctx, c);
if (c.step === 'start') lights(ctx, c);
const me = player(c);
if (c.step === 'race' && me.out > 0) text(ctx, me.lastHole !== undefined ? 'NELLA BUCA!' : 'NELLA PAGLIA!', 320, 180, 10, '#ffe28a', 'center');
if (c.step === 'race' && me.boost > 0 && me.out <= 0) text(ctx, 'MISCELA!', 320, 180, 8, '#ffb030', 'center');
if (c.step === 'result') result(ctx, c);
ctx.restore();
}
+398
View File
@@ -0,0 +1,398 @@
// Il GP di Fortezza (idea di Andrea, 28 ottobre): la gara dei cinquantini sul giro delle mura della Fortezza di San
// Martino, vista dall'alto come le vecchie gare da sala giochi con tutta la pista in una schermata. Il tracciato segue
// la planimetria generale 1:500 che ha mandato Andrea (`assets/raw/fortezza-planimetria.png`), schiacciata nel
// formato del gioco (640x360): x = 90 + (x0 - 215)·0,7, y = 20 + (y0 - 42)·0,49.
//
// Si tiene premuto per il gas (mouse, dito, Spazio o ↑), si sterza con ← → (o trascinando di lato col dito, o coi
// pulsanti), ↓ frena. Sulla pista: acceleratori, buche (andandoci forte si casca) e lattine di miscela da raccogliere.
// Fuori strada c'è l'erba che frena, e poi la paglia: chi ci arriva forte si ferma.
// La linea di mezzo della strada, in senso orario a partire dal traguardo sul rettilineo delle mura a levante: prima
// tracciata sulla planimetria, poi ricalcata sulla strada del quadro di Codex (assets/closeups/gp-fortezza.png).
export const CONTROL = [
[570, 210], [572, 222], [574, 233], [577, 245], [580, 256], [583, 268], [585, 280], [589, 291], [589, 303], [589, 315],
[585, 327], [576, 335], [565, 340], [553, 341], [541, 340], [529, 339], [518, 336], [506, 334], [495, 330], [483, 327],
[471, 324], [460, 321], [448, 318], [436, 316], [424, 315], [412, 314], [400, 314], [389, 317], [378, 320], [372, 331],
[363, 339], [352, 345], [340, 344], [328, 341], [317, 337], [305, 334], [294, 329], [284, 324], [273, 319], [262, 314],
[251, 308], [241, 303], [231, 296], [220, 291], [210, 284], [199, 280], [188, 274], [178, 269], [167, 263], [157, 256],
[148, 248], [143, 237], [147, 225], [149, 214], [148, 203], [142, 192], [135, 182], [127, 174], [118, 166], [110, 156],
[104, 146], [100, 135], [98, 123], [101, 112], [106, 100], [113, 91], [124, 86], [136, 81], [147, 77], [157, 70],
[165, 61], [173, 53], [185, 49], [197, 47], [208, 45], [220, 41], [232, 39], [243, 36], [255, 33], [266, 30],
[278, 26], [290, 24], [301, 22], [313, 20], [325, 20], [337, 19], [349, 20], [361, 23], [372, 26], [384, 29],
[396, 32], [407, 36], [418, 40], [430, 43], [441, 47], [453, 50], [464, 55], [475, 59], [486, 63], [498, 67],
[509, 71], [521, 74], [532, 76], [544, 75], [557, 77], [566, 85], [570, 96], [572, 108], [574, 120], [574, 132],
[574, 144], [573, 156], [573, 168], [572, 180], [571, 192], [570, 204],
];
export const LAPS = 3, STEP = 2, ROAD = 9, VERGE = 15, COUNTDOWN = 3, FIXED_STEP = 1 / 120;
// La presentazione prima del semaforo: uno alla volta i piloti arrivano in griglia col loro motorino (INTRO.each
// secondi ciascuno, da INTRO.from pixel di strada più indietro, in INTRO.ride secondi); un tocco passa al prossimo.
export const INTRO = { each: 4, ride: 1.8, from: 110, after: 1 };
// Sterzo (rad/s a velocità piena di sterzo), freno, rallentamento senza gas, e quanto si scivola di lato in curva.
export const TURN = 3.4, BRAKE = 150, COAST = 45, GRIP = 9, SLIDE = 0.5;
export const BOOST = { speed: 1.35, time: 1.2 }, CAN = { time: 1.6, respawn: 6 }, HOLE = { fast: 45, out: 1.1 };
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const wrapAngle = a => Math.atan2(Math.sin(a), Math.cos(a));
// I piloti sono dati (story.closeups.gara.riders, da piloti.js): `motore` da 1 (di serie) a 5 (truccato da far
// paura), `testa` da 1 (matto) a 5 (prudente). Ne vengono velocità massima e accelerazione in px/s, tenuta, e per gli
// altri quanto si fidano delle curve (`caution`) e quanto spesso rischiano troppo (`slip`).
export function rider({ motore = 3, testa = 3, ...r }) {
const m = clamp(motore, 1, 5), t = clamp(testa, 1, 5);
return { ...r, motore: m, testa: t, vmax: 74 + 6.5 * m, accel: 40 + 5 * m, grip: 1.1 - 0.03 * m, caution: 1.0025 - 0.0125 * t, slip: 0.4275 - 0.0775 * t };
}
// Il gruppo di prova, quando la storia non ne dà uno.
export const RIDERS = [
{ id: 'andrea', name: 'Andrea', bike: 'il Fifty', motore: 3, testa: 3, color: '#d8402f', trim: '#1c1c1c' },
{ id: 'arcu', name: 'L\'Arcu', bike: 'il Betino', motore: 1, testa: 5, color: '#2f7a3a', trim: '#e8d9a0' },
];
// I livelli: quanto vanno gli altri, quanto si resta fermi nella paglia, e la guida disegnata in pista.
export const LEVELS = {
bona: { rivals: 0.78, stun: 0.4, guide: 'zone' },
seria: { rivals: 0.84, stun: 0.9, guide: 'coni' },
male: { rivals: 0.93, stun: 1.4, guide: null },
};
function catmull(p0, p1, p2, p3, t) {
const t2 = t * t, t3 = t2 * t;
return [0, 1].map(k => 0.5 * (2 * p1[k] + (-p0[k] + p2[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (-p0[k] + 3 * p1[k] - 3 * p2[k] + p3[k]) * t3));
}
// Il tracciato campionato ogni STEP pixel: posizione, direzione, raggio di curva e velocità massima di ogni punto.
function buildTrack() {
const dense = [], n = CONTROL.length;
for (let i = 0; i < n; i++) {
const p = [CONTROL[(i - 1 + n) % n], CONTROL[i], CONTROL[(i + 1) % n], CONTROL[(i + 2) % n]];
for (let k = 0; k < 40; k++) dense.push(catmull(...p, k / 40));
}
const pts = [dense[0]];
let carry = 0;
for (let i = 1; i <= dense.length; i++) {
const a = dense[i - 1], b = dense[i % dense.length];
let seg = Math.hypot(b[0] - a[0], b[1] - a[1]), t = 0;
while (carry + seg >= STEP) {
const need = STEP - carry; t += need / Math.hypot(b[0] - a[0], b[1] - a[1]); seg -= need; carry = 0;
pts.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
}
carry += seg;
}
if (Math.hypot(pts.at(-1)[0] - pts[0][0], pts.at(-1)[1] - pts[0][1]) < STEP * 0.5) pts.pop();
const m = pts.length, heading = pts.map((p, i) => { const q = pts[(i + 1) % m]; return Math.atan2(q[1] - p[1], q[0] - p[0]); });
const W = 6, radius = pts.map((_, i) => (2 * W * STEP) / Math.max(Math.abs(wrapAngle(heading[(i + W) % m] - heading[(i - W + m) % m])), 1e-6));
// A sterzo pieno si gira di TURN rad/s: in una curva di raggio R si passa al massimo a TURN·R.
const limit = radius.map(r => TURN * r);
// Da che parte gira (+1 destra, -1 sinistra).
const side = pts.map((_, i) => Math.sign(wrapAngle(heading[(i + W) % m] - heading[(i - W + m) % m])) || 1);
return { pts, heading, radius, limit, side, length: m * STEP };
}
export const TRACK = buildTrack();
const N = TRACK.pts.length;
const at = s => Math.floor((((s % TRACK.length) + TRACK.length) % TRACK.length) / STEP) % N;
export const limitAt = s => TRACK.limit[at(s)];
// Il punto dello schermo a distanza s dalla partenza, spostato di lato (positivo: a destra della direzione di marcia).
export function pointAt(s, lane = 0) {
const f = (((s % TRACK.length) + TRACK.length) % TRACK.length) / STEP, i = Math.floor(f) % N, t = f - Math.floor(f);
const a = TRACK.pts[i], b = TRACK.pts[(i + 1) % N], h = TRACK.heading[i];
return { x: a[0] + (b[0] - a[0]) * t - Math.sin(h) * lane, y: a[1] + (b[1] - a[1]) * t + Math.cos(h) * lane, heading: h };
}
// Le curve vere (dove il Fifty a tutto gas non passa): per la guida, i coni e la paglia.
export const CURVES = (() => {
const out = [], v = rider({ motore: 3 }).vmax * 0.85;
let inside = false, start = 0, min = Infinity, apex = 0;
TRACK.limit.forEach((l, i) => {
if (l < v && !inside) { inside = true; start = i; min = l; apex = i; }
else if (l < v) { if (l < min) { min = l; apex = i; } }
else if (inside) { inside = false; out.push({ start: start * STEP, apex: apex * STEP, end: i * STEP, speed: min, side: TRACK.side[apex] }); }
});
if (inside) out.push({ start: start * STEP, apex: apex * STEP, end: TRACK.length, speed: min, side: TRACK.side[apex] });
return out;
})();
// Le cose in pista, a distanza dal traguardo (s) e di lato (lane): acceleratori e buche fissi, lattine che ricompaiono.
// Sui rettilinei, lontano dalle curve, perché si vedano arrivare.
function straights() {
const out = [];
let from = 0;
for (const k of [...CURVES, { start: TRACK.length + (CURVES[0]?.start ?? 0) }]) { if (k.start - from > 70) out.push([from + 20, k.start - 20]); from = k.end ?? from; }
return out;
}
export const PICKUPS = (() => {
const list = [], parts = straights();
parts.forEach(([a, b], i) => {
const mid = (a + b) / 2, len = b - a;
if (i % 3 === 0) list.push({ kind: 'boost', s: a + len * 0.3, lane: 0 });
else if (i % 3 === 1) list.push({ kind: 'buca', s: mid, lane: i % 2 ? 4 : -4 });
else list.push({ kind: 'miscela', s: mid, lane: i % 2 ? -4 : 4 });
if (len > 160) list.push({ kind: i % 2 ? 'buca' : 'miscela', s: a + len * 0.75, lane: i % 2 ? -4 : 4 });
});
return list.map((p, k) => ({ ...p, s: ((p.s % TRACK.length) + TRACK.length) % TRACK.length, id: k, ...pointAt(p.s, p.lane) }));
})();
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
// La posizione sulla strada: il punto del tracciato più vicino (cercato vicino all'ultimo, così non si salta di lato).
function locate(r) {
let best = r.idx, bd = Infinity;
for (let d = -12; d <= 24; d++) {
const i = (r.idx + d + N) % N, p = TRACK.pts[i], q = (p[0] - r.x) ** 2 + (p[1] - r.y) ** 2;
if (q < bd) { bd = q; best = i; }
}
let delta = best - r.idx; if (delta > N / 2) delta -= N; if (delta < -N / 2) delta += N;
r.idx = best; r.s += delta * STEP;
const h = TRACK.heading[best], p = TRACK.pts[best];
r.lat = -Math.sin(h) * (r.x - p[0]) + Math.cos(h) * (r.y - p[1]);
}
export function newGara(seed = Date.now() >>> 0, def = {}) {
const level = LEVELS[def.level] ? def.level : 'seria', skill = LEVELS[level];
// In griglia nell'ordine della lista, due per fila, dietro il traguardo.
const riders = (def.riders ?? RIDERS).map((r, k) => {
const base = rider(r), factor = r.id === 'andrea' ? 1 : skill.rivals, s = -16 - 14 * Math.floor(k / 2), lane = k % 2 ? 4 : -4, p = pointAt(s, lane);
return { ...base, vmax: base.vmax * factor, accel: base.accel * factor, lane, x: p.x, y: p.y, a: p.heading, v: 0, u: 0, s, idx: at(s), lat: lane,
gas: false, brake: false, steer: 0, out: 0, boost: 0, laps: 0, finished: null, offs: 0, falls: 0, risk: 1 };
});
if (!riders.some(r => r.id === 'andrea')) throw new Error('Nella gara manca Andrea');
for (const r of riders) { r.grid = r.s; r.shown = false; }
return { step: 'intro', t: 0, age: 0, seed, level, skill, riders, holding: false, braking: false, keys: { left: false, right: false, gas: new Set(), brake: false },
pointer: null, toggle: false, brakeButton: false, drag: 0, buttons: 0, anchor: null,
power: 0, accumulator: 0, events: [], result: null, order: riders.map(r => r.id), taken: {} };
}
export const player = c => c.riders.find(r => r.id === 'andrea');
export const lapOf = r => clamp(Math.floor(Math.max(0, r.s) / TRACK.length) + 1, 1, LAPS);
export const canThrow = c => c.step === 'intro' || c.step === 'start' || c.step === 'race';
export const canAdjust = c => c.step === 'race'; // lo sterzo del pannello
export const aimFree = () => true;
export const nudge = () => false;
export const setPower = () => false;
// Il gas ha più fonti, ognuna con la sua: i tasti tenuti giù (Spazio, Invio, ↑), il dito o il mouse premuto sulla
// scena (uno solo, col suo pointerId) e il pulsante Gas del pannello, che resta acceso finché non lo si ritocca.
// Lasciarne una non spegne le altre. `holding` è la loro somma.
function sync(c) {
c.holding = c.keys.gas.size > 0 || c.pointer !== null || c.toggle;
c.braking = c.keys.brake || c.brakeButton;
if (c.pointer === null) c.drag = 0;
}
// Il primo tocco (o tasto) salta la presentazione, senza dare gas.
export function press(c, id = 'press') {
if (!canThrow(c)) return false;
if (c.step === 'intro') return nextIntro(c);
if (c.pointer !== null && c.pointer !== id) return false; // un secondo dito sulla scena non conta
c.pointer = id; sync(c);
return true;
}
export function release(c, id) {
if (c.pointer === null || (id !== undefined && id !== c.pointer)) return false;
c.pointer = null; sync(c);
return true;
}
export function cancel(c) {
Object.assign(c, { pointer: null, toggle: false, brakeButton: false, drag: 0, buttons: 0 });
Object.assign(c.keys, { left: false, right: false, brake: false }); c.keys.gas.clear();
sync(c);
return true;
}
// Un tocco durante la presentazione: chi arrivava è già al suo posto, e tocca al prossimo (dopo l'ultimo, il semaforo).
function nextIntro(c) {
const k = Math.floor(c.t / INTRO.each) + 1;
if (k >= c.riders.length) return skipIntro(c);
c.t = k * INTRO.each;
return true;
}
// Finita (o saltata) la presentazione tutti sono in griglia, e si va al semaforo.
function skipIntro(c) {
for (const r of c.riders) place(r, r.grid, true);
c.step = 'start'; c.t = 0;
return true;
}
function place(r, s, shown) {
const p = pointAt(s, r.lane);
Object.assign(r, { s, x: p.x, y: p.y, a: p.heading, idx: at(s), lat: r.lane, shown });
}
// Chi si sta presentando adesso (o null fra uno e l'altro, e alla fine).
export const presenting = c => c.step === 'intro' ? c.riders[Math.floor(c.t / INTRO.each)] ?? null : null;
// Il pulsante Gas del pannello: un tocco lo accende, un altro lo spegne (per chi non può tenere premuto).
export function shoot(c) { if (!canThrow(c)) return false; if (c.step === 'intro') return nextIntro(c); c.toggle = !c.toggle; sync(c); return true; }
// Il dito o il mouse premuto sulla scena: quanto ci si sposta di lato da dove si è premuto è lo sterzo. Il mouse che
// passa senza premere, o col gas dato da tastiera o dal pannello, non sterza.
export function aimScreen(c, [x], id = 'press') {
if (!Number.isFinite(x)) return false;
if (c.pointer === null) c.anchor = x;
else if (c.pointer === id) c.drag = clamp((x - c.anchor) / 28, -1, 1);
return false;
}
// La tastiera: ← → sterzano, ↑ Spazio e Invio sono il gas, ↓ il freno; finché si tengono giù.
const GAS_KEYS = ['ArrowUp', ' ', 'Enter'];
export function key(c, name, down) {
if (name === 'ArrowLeft') c.keys.left = down;
else if (name === 'ArrowRight') c.keys.right = down;
else if (name === 'ArrowDown') c.keys.brake = down;
else if (GAS_KEYS.includes(name)) {
if (down && c.step === 'intro') { nextIntro(c); return true; }
if (down && canThrow(c)) c.keys.gas.add(name); else c.keys.gas.delete(name);
} else return false;
sync(c);
return true;
}
// I pulsanti del pannello tenuti premuti: Sterza (-1, 0, 1) e Frena.
export function steerButton(c, dir) { c.buttons = dir; return true; }
export function brakeButton(c, down) { c.brakeButton = down; sync(c); return true; }
export const steering = c => clamp((c.keys.right ? 1 : 0) - (c.keys.left ? 1 : 0) + c.buttons + c.drag, -1, 1);
// La distanza davanti, sul giro, da s a un punto della pista (sempre fra 0 e la lunghezza del giro).
export const ahead = (from, to) => ((((to - from) % TRACK.length) + TRACK.length) % TRACK.length);
// Gli avversari: guardano un punto più avanti sulla loro corsia e ci sterzano; danno gas finché, frenando da qui,
// arriverebbero a ogni curva davanti sotto il limite (un po' meno, quanto si fidano). Chi ha poca testa ogni tanto
// si fida troppo.
function drive(c, r) {
const aim = pointAt(r.s + 14 + r.v * 0.18, r.lane * 0.6);
r.steer = clamp(wrapAngle(Math.atan2(aim.y - r.y, aim.x - r.x) - r.a) * 2.6, -1, 1);
const curve = CURVES.find(k => { const s = ((r.s % TRACK.length) + TRACK.length) % TRACK.length; return s >= k.start - 60 && s < k.start - 57; });
if (curve && r.lastCurve !== curve) { r.lastCurve = curve; r.risk = random(c) < r.slip ? 1.15 : 1; }
const horizon = (r.v * r.v) / (2 * BRAKE) + 20;
r.gas = true; r.brake = false;
for (let d = 0; d <= horizon; d += STEP * 2) {
const allowed = limitAt(r.s + d) * 0.82 * r.grip * r.caution * r.risk;
if (r.v * r.v > allowed * allowed + 2 * BRAKE * d) { r.gas = false; r.brake = d < 30; break; }
}
// Le buche si scansano (chi ha testa); una lattina ogni tanto la vanno a prendere (deciso una volta per lattina).
const look = 30 + r.v * 0.6, next = PICKUPS.find(p => p.kind !== 'boost' && ahead(r.s, p.s) < look);
if (next && r.seen !== next.id) { r.seen = next.id; r.wants = random(c) < 0.35; }
// Chi ha davanti uno più lento lo passa dall'altra parte.
const slow = c.riders.find(o => o !== r && o.s - r.s > 0 && o.s - r.s < 22 && Math.abs(o.lat - r.lat) < 7 && o.v < r.v + 5);
if (slow) r.lane = slow.lat > 0 ? -5 : 5;
if (next?.kind === 'buca' && r.testa >= 3) r.lane = next.lane > 0 ? -5 : 5;
else if (next?.kind === 'miscela' && r.wants) r.lane = next.lane;
}
function stepRider(c, r, dt) {
if (r.finished !== null) { r.gas = false; r.brake = false; drive(c, r); r.gas = false; }
else if (r.out > 0) { r.out = Math.max(0, r.out - dt); r.v = 0; r.u = 0; return; }
else if (r.id === 'andrea') { r.gas = c.holding; r.brake = c.braking; r.steer = steering(c); }
else drive(c, r);
if (r.finished !== null) r.gas = false;
const grass = Math.abs(r.lat) > ROAD, top = r.vmax * (r.boost > 0 ? BOOST.speed : 1) * (grass ? 0.5 : 1);
// Il freno vince sul gas: chi li tiene tutti e due frena.
if (r.brake) r.v -= BRAKE * dt;
else if (r.gas) r.v += r.accel * dt * (1 - 0.4 * r.v / top) * (r.boost > 0 ? 1.6 : 1);
else r.v -= COAST * dt;
if (r.v > top) r.v = Math.max(top, r.v - 120 * dt);
r.v = Math.max(0, r.v);
r.boost = Math.max(0, r.boost - dt);
// Lo sterzo: da fermi non si gira; in curva una parte della velocità resta per traverso e si scivola fuori.
const turn = r.steer * TURN * Math.min(1, r.v / 22) * dt;
r.a = wrapAngle(r.a + turn);
r.u -= r.v * Math.sin(turn) * SLIDE * (r.v / r.vmax) / r.grip;
r.u *= Math.exp(-GRIP * r.grip * dt);
r.x += (Math.cos(r.a) * r.v - Math.sin(r.a) * r.u) * dt;
r.y += (Math.sin(r.a) * r.v + Math.cos(r.a) * r.u) * dt;
locate(r);
// La paglia (fuori) e le mura (dentro): si rimbalza in strada; arrivandoci forte ci si ferma.
if (Math.abs(r.lat) > VERGE) {
const h = TRACK.heading[r.idx], p = TRACK.pts[r.idx], side = Math.sign(r.lat), back = side * (VERGE - 1);
r.x = p[0] - Math.sin(h) * back; r.y = p[1] + Math.cos(h) * back; r.lat = back; r.u = 0;
if (r.v > 40 && r.finished === null) {
r.out = c.skill.stun * (r.id === 'andrea' ? 1 : 0.7); r.offs++; r.risk = 1;
c.events.push({ kind: 'gara-fuori', who: r.id, power: r.id === 'andrea' ? 1 : 0.4 });
}
r.v *= 0.35; r.a = wrapAngle(h + clamp(wrapAngle(r.a - h), -0.5, 0.5));
}
if (r.finished === null) pickups(c, r);
const laps = Math.floor(Math.max(0, r.s) / TRACK.length);
if (laps > r.laps && r.finished === null) {
r.laps = laps;
if (laps >= LAPS) { r.finished = c.t; c.events.push({ kind: 'gara-arrivo', who: r.id, power: r.id === 'andrea' ? 1 : 0.5 }); }
else if (r.id === 'andrea') c.events.push({ kind: 'gara-giro', who: r.id, power: 0.6 });
}
}
// Acceleratori (una spinta), lattine di miscela (spinta più lunga, poi la lattina ricompare) e buche (forte si casca).
function pickups(c, r) {
for (const p of PICKUPS) {
if (Math.hypot(p.x - r.x, p.y - r.y) > 6) continue;
if (p.kind === 'boost') { if (r.boost < BOOST.time * 0.5) { r.boost = BOOST.time; c.events.push({ kind: 'gara-turbo', who: r.id, power: r.id === 'andrea' ? 1 : 0.3 }); } }
else if (p.kind === 'miscela') {
if ((c.taken[p.id] ?? -Infinity) > c.t - CAN.respawn) continue;
c.taken[p.id] = c.t; r.boost = CAN.time;
c.events.push({ kind: 'gara-miscela', who: r.id, power: r.id === 'andrea' ? 1 : 0.3 });
} else if (p.kind === 'buca' && r.lastHole !== p.id) {
r.lastHole = p.id;
if (r.v > HOLE.fast) { r.out = HOLE.out; r.falls++; r.v = 0; c.events.push({ kind: 'gara-buca', who: r.id, power: r.id === 'andrea' ? 1 : 0.4 }); }
else { r.v *= 0.7; c.events.push({ kind: 'gara-sobbalzo', who: r.id, power: 0.3 }); }
}
}
if (r.lastHole !== undefined && Math.hypot(PICKUPS[r.lastHole].x - r.x, PICKUPS[r.lastHole].y - r.y) > 14) r.lastHole = undefined;
}
// I motorini si urtano: si allontanano un po', e chi tampona perde velocità.
function bump(c) {
for (let i = 0; i < c.riders.length; i++) for (let j = i + 1; j < c.riders.length; j++) {
const a = c.riders[i], b = c.riders[j], dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy);
if (d >= 7 || d < 1e-6) continue;
const push = (7 - d) / 2, nx = dx / d, ny = dy / d;
a.x -= nx * push; a.y -= ny * push; b.x += nx * push; b.y += ny * push;
const behind = a.s < b.s ? a : b; behind.v *= 0.97;
}
}
// La classifica: chi è arrivato, in ordine d'arrivo; poi chi corre ancora, per strada fatta.
export function standings(c) {
return [...c.riders].sort((a, b) => (a.finished ?? Infinity) - (b.finished ?? Infinity) || b.s - a.s);
}
export const placeOf = (c, id = 'andrea') => standings(c).findIndex(r => r.id === id) + 1;
export function update(c, dt) {
c.age += dt;
if (c.step === 'intro') {
c.t += dt;
c.riders.forEach((r, k) => {
const t = (c.t - k * INTRO.each) / INTRO.ride;
if (t < 0) return;
if (!r.shown) c.events.push({ kind: 'gara-motore', who: r.id, power: 0.3 + 0.14 * r.motore });
const e = 1 - (1 - Math.min(1, t)) ** 2; // arriva forte e rallenta
place(r, r.grid - INTRO.from * (1 - e), true);
r.v = t < 1 ? INTRO.from * 2 * (1 - Math.min(1, t)) / INTRO.ride : 0;
});
if (c.t >= c.riders.length * INTRO.each + INTRO.after) { skipIntro(c); for (const r of c.riders) r.v = 0; }
return;
}
if (c.step === 'start') {
const before = Math.ceil(COUNTDOWN - c.t);
c.t += dt;
const now = Math.ceil(COUNTDOWN - c.t);
if (now !== before && now > 0) c.events.push({ kind: 'gara-semaforo', power: 0.4 });
if (c.t >= COUNTDOWN) { c.step = 'race'; c.t = 0; c.events.push({ kind: 'gara-via', power: 1 }); }
return;
}
if (c.step !== 'race') return;
c.accumulator += Math.min(dt, 0.1);
while (c.accumulator >= FIXED_STEP) {
c.accumulator -= FIXED_STEP;
c.t += FIXED_STEP;
for (const r of c.riders) stepRider(c, r, FIXED_STEP);
bump(c);
for (const r of c.riders) locate(r);
if (player(c).finished !== null) { finish(c); return; }
}
c.order = standings(c).map(r => r.id);
}
function finish(c) {
const order = standings(c);
// Chi non è ancora arrivato: il tempo stimato con la sua media fin qui.
const times = Object.fromEntries(order.map(r => [r.id, r.finished ?? c.t + (LAPS * TRACK.length - r.s) / Math.max(20, r.s / Math.max(c.t, 1))]));
const ranked = [...order].sort((a, b) => times[a.id] - times[b.id]);
const place = ranked.findIndex(r => r.id === 'andrea') + 1;
c.step = 'result';
cancel(c);
c.order = ranked.map(r => r.id);
c.result = { winner: place === 1 ? 'player' : 'cpu', place, times, order: c.order, offs: player(c).offs, falls: player(c).falls };
}
export function status(c) {
if (c.step === 'intro') {
const r = presenting(c);
return r ? `${r.name} con ${r.bike}. (Un tocco: il prossimo.)` : 'Tutti in griglia!';
}
if (c.step === 'start') return c.t < COUNTDOWN - 0.01 ? `GP di Fortezza: si parte fra ${Math.ceil(COUNTDOWN - c.t)}… Tieni premuto per il gas, sterza con ← →.` : 'Via!';
if (c.step === 'result') {
const who = c.riders.find(r => r.id === c.result.order[0]).name;
return c.result.place === 1 ? `Primo al GP di Fortezza! ${LAPS} giri in ${c.result.times.andrea.toFixed(1)} secondi.` : `${c.result.place}° posto. Ha vinto ${who}.`;
}
const me = player(c), place = placeOf(c);
if (me.out > 0) return me.lastHole !== undefined ? 'Nella buca! Ci si rialza…' : 'Nella paglia! Si riparte…';
if (me.boost > 0) return `Giro ${lapOf(me)} di ${LAPS}, ${place}° posto. Miscela!`;
return `Giro ${lapOf(me)} di ${LAPS}, ${place}° posto. ${c.braking ? 'Freno!' : c.holding ? 'Gas!' : 'Senza gas.'}`;
}
export const button = c => c.step === 'intro' ? 'Avanti' : c.step === 'result' ? 'Finita' : c.holding ? 'Molla' : 'Gas';
+7
View File
@@ -7,6 +7,7 @@ import { drawBocce } from './bocce-render.js';
import { drawBiliardo } from './biliardo-render.js';
import { drawBasket } from './basket-render.js';
import { drawPesca } from './pesca-render.js';
import { drawGara } from './gara-render.js';
import { drawBriscola } from './briscola-render.js';
import { drawTressette } from './tressette-render.js';
import { drawScopa } from './scopa-render.js';
@@ -684,6 +685,12 @@ function drawCloseup(ctx, game, time) {
drawBocce(ctx, c, def, backgrounds.get(def.picture), time);
return;
}
if (def.type === 'gara') {
if (def.picture && !backgrounds.has(def.picture)) loadBackground(def.picture);
drawGara(ctx, c, def, def.picture ? backgrounds.get(def.picture) : null,
src => { if (!backgrounds.has(src)) loadBackground(src); return backgrounds.get(src); }, id => sprites.get(id));
return;
}
if (def.type === 'pesca') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawPesca(ctx, c, def, backgrounds.get(def.picture), sprites.get('andrea_pesca'), sprites.get('pescatori'));
+11
View File
@@ -125,6 +125,17 @@ export class Soundscape {
else if (kind === 'basket-board') { this.tone(280, 170, 0.09, 0.05 + strength * 0.08, 0, pan); this.noiseSound(0.07, 900, 0.07, { pan }); }
else if (kind === 'basket-swish') this.noiseSound(0.28, 3200, 0.1, { paper: true, pan });
else if (kind === 'basket-in') { this.tone(1300, 1000, 0.08, 0.04, 0, pan); this.noiseSound(0.22, 2800, 0.08, { paper: true, pan }); }
// Il GP di Fortezza: il semaforo, il via, la paglia, acceleratori, miscela, buche, il giro, il traguardo.
else if (kind === 'gara-semaforo') this.tone(440, 440, 0.18, 0.06, 0, pan);
else if (kind === 'gara-via') this.tone(880, 880, 0.4, 0.08, 0, pan);
else if (kind === 'gara-fuori') { this.noiseSound(0.35, 700, 0.1, { pan }); this.tone(300, 120, 0.2, 0.05, 0, pan); }
else if (kind === 'gara-motore') { this.tone(110, 190 + strength * 160, 0.9, 0.03 + strength * 0.03, 0, pan); this.tone(220, 380 + strength * 320, 0.9, 0.015 + strength * 0.015, 0, pan); }
else if (kind === 'gara-turbo') this.tone(300, 900, 0.25, 0.04 + strength * 0.04, 0, pan);
else if (kind === 'gara-miscela') { this.tone(500, 1200, 0.2, 0.04 + strength * 0.04, 0, pan); this.tone(800, 1600, 0.2, 0.03 * strength, 0.12, pan); }
else if (kind === 'gara-buca') { this.tone(220, 60, 0.3, 0.05 + strength * 0.07, 0, pan); this.noiseSound(0.3, 500, 0.08 * strength + 0.02, { pan }); }
else if (kind === 'gara-sobbalzo') this.tone(160, 90, 0.1, 0.05, 0, pan);
else if (kind === 'gara-giro') this.tone(660, 990, 0.15, 0.05, 0, pan);
else if (kind === 'gara-arrivo') { this.tone(660, 660, 0.12, 0.06 * strength, 0, pan); this.tone(990, 990, 0.3, 0.06 * strength, 0.14, pan); }
// La pesca: il lancio, il tuffo del galleggiante, il tocco, l'abboccata, la ferrata, la lenza che tira, lo strappo, il pesce a riva.
else if (kind === 'pesca-lancio') this.noiseSound(0.22, 1800, 0.05 + strength * 0.06, { pan });
else if (kind === 'pesca-tuffo') { this.tone(420, 180, 0.07, 0.07, 0, pan); this.noiseSound(0.12, 900, 0.06, { pan }); }
+51 -8
View File
@@ -12,6 +12,7 @@ import * as bocce from './engine/bocce.js';
import * as biliardo from './engine/biliardo.js';
import * as basket from './engine/basket.js';
import * as pesca from './engine/pesca.js';
import * as gara from './engine/gara.js';
import * as briscola from './engine/briscola.js';
import { cardAt } from './engine/briscola-render.js';
import * as tressette from './engine/tressette.js';
@@ -497,6 +498,14 @@ canvas.addEventListener('pointerdown', e => {
canvas.setPointerCapture(e.pointerId);
return;
}
// Il GP di Fortezza: un dito solo sulla scena dà gas e sterza trascinando; gli altri tocchi non contano
// (sterzo e freno hanno i loro pulsanti, che si tengono insieme al gas).
if (type === 'gara') {
if (press || game.skip()) return;
gara.aimScreen(game.closeup, scenePoint(e), e.pointerId);
if (gara.press(game.closeup, e.pointerId)) { press = { gara: e.pointerId }; canvas.setPointerCapture(e.pointerId); }
return;
}
const aimed = game.closeupAim(scenePoint(e));
if (['biliardo', 'basket'].includes(story.closeups[game.closeup.id].type)) {
if (!aimed && !game.speech) return; // dove non si può mirare (dietro Andrea) non si carica nemmeno
@@ -529,6 +538,10 @@ canvas.addEventListener('pointerup', e => {
if (running && game.closeup === p.state && p.card >= 0 && p.at(game.closeup, scenePoint(e)) === p.card) game.closeupPlay(p.card);
return;
}
if (press.gara !== undefined) {
if (e.pointerId === press.gara) { if (game?.closeup) gara.release(game.closeup, e.pointerId); press = null; }
return;
}
if (press.closeup) { press = null; if (running && !game.closeupRelease() && !game.skip()) game.closeupTap(); return; }
clearTimeout(press.timer);
// Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità.
@@ -541,7 +554,7 @@ canvas.addEventListener('pointerup', e => {
press = null;
});
canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; game?.closeupCancel(); });
canvas.addEventListener('lostpointercapture', () => { if (press?.closeup || press?.card !== undefined || press?.table !== undefined) { press = null; game?.closeupCancel(); } });
canvas.addEventListener('lostpointercapture', () => { if (press?.closeup || press?.gara !== undefined || press?.card !== undefined || press?.table !== undefined) { press = null; game?.closeupCancel(); } });
canvas.addEventListener('contextmenu', e => {
e.preventDefault();
if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
@@ -552,6 +565,10 @@ canvas.addEventListener('pointermove', e => {
// Al biliardo e alle bocce la mira segue il mouse finché un clic (o una regolazione fine) non la fissa: dopo,
// attraversare il campo per raggiungere la forza, «Tira» o «Lancia» non cambia il colpo. Al tiro dopo torna libera.
// (Bocce: scoperto girando il video «Come si gioca», la mira finiva a due passi andando verso il cursore.)
if (game.closeup && story.closeups[game.closeup.id].type === 'gara') {
if (press?.gara === e.pointerId) gara.aimScreen(game.closeup, scenePoint(e), e.pointerId);
return;
}
const aiming = game.closeup && ['biliardo', 'bocce', 'basket', 'pesca'].includes(story.closeups[game.closeup.id].type);
if (aiming) { if (e.pointerType === 'mouse' && !press && !game.closeup.aimLocked) game.closeupAim(scenePoint(e)); }
else if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e));
@@ -566,6 +583,8 @@ const MINI = {
bocce: { ...bocce, nudge: (c, dir) => bocce.aim(c, c.aim.u, c.aim.w + dir * 0.3), back: 'Torna al pallaio', label: 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' },
biliardo: { ...biliardo, back: 'Torna al bar', label: 'Biliardo: clic o tocco fissa la mira. Frecce per affinarla, Maiusc per passi più piccoli. Regola la forza e premi Tira.' },
basket: { ...basket, back: 'Torna in strada', label: 'Fai due tiri: muovi il mouse o tocca per mirare, tieni premuto per caricare la forza, lascia per tirare.' },
// La gara: niente mira né forza, il pulsante alterna gas e freno (chi non può tenere premuto).
gara: { ...gara, toggle: true, back: 'Torna dal Trappola', label: 'GP di Fortezza: tieni premuto (mouse, dito, Spazio o ↑) per il gas, sterza con ← → o trascinando di lato; ↓ frena.' },
pesca: { ...pesca, back: 'Torna in Fortezza', label: 'La pesca: tieni premuto per lanciare, premi quando il galleggiante va sotto, tieni premuto per recuperare.' },
};
const miniState = () => running && game?.closeup && MINI[story.closeups[game.closeup.id].type] ? game.closeup : null;
@@ -594,8 +613,15 @@ for (let i = 0; i < 3; i++) {
button.addEventListener('click', () => game?.closeupPlay(i));
button.addEventListener('focus', () => { if (game?.closeup?.id === 'briscola') briscola.select(game.closeup, i); });
}
for (const [id, dir] of [['bocce-left', -1], ['bocce-right', 1]])
for (const [id, dir] of [['bocce-left', -1], ['bocce-right', 1]]) {
$(id).addEventListener('click', () => { const c = miniState(); if (c && mini().nudge(c, dir)) c.aimLocked = true; });
// Al GP di Fortezza si sterza finché il pulsante resta premuto (anche col dito, insieme a Gas).
$(id).addEventListener('pointerdown', () => { const c = miniState(); if (c) mini().steerButton?.(c, dir); });
for (const ev of ['pointerup', 'pointerleave', 'pointercancel']) $(id).addEventListener(ev, () => { const c = miniState(); if (c) mini().steerButton?.(c, 0); });
}
// Il freno del GP, tenuto premuto (sul telefono, insieme a Gas e Sterza).
$('gara-brake').addEventListener('pointerdown', () => { const c = miniState(); if (c) mini().brakeButton?.(c, true); });
for (const ev of ['pointerup', 'pointerleave', 'pointercancel']) $('gara-brake').addEventListener(ev, () => { const c = miniState(); if (c) mini().brakeButton?.(c, false); });
addEventListener('blur', () => { if (press?.card !== undefined) press = null; game?.closeupCancel(); });
// --- Verbi.
@@ -757,7 +783,12 @@ window.addEventListener('keydown', e => {
return;
}
if (miniState()) {
if (e.target.closest?.('#bocce-controls') && ['BUTTON', 'SUMMARY'].includes(e.target.tagName)) return;
// Esc esce anche col fuoco sui pulsanti del pannello.
if (e.key === 'Escape') { game.closeupClose(); return; }
const onButton = ['BUTTON', 'SUMMARY'].includes(e.target.tagName), onControl = onButton && e.target.closest?.('#bocce-controls');
// Il GP di Fortezza: frecce, Spazio e Invio finché si tengono giù (su un pulsante Spazio e Invio lo premono).
if (!(onButton && [' ', 'Enter'].includes(e.key)) && mini().key?.(game.closeup, e.key, true)) { e.preventDefault(); return; }
if (onControl) return;
const c = game.closeup, m = mini();
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
e.preventDefault();
@@ -801,7 +832,11 @@ window.addEventListener('keydown', e => {
});
// Alle bocce e al biliardo, lasciando spazio o Invio parte il tiro.
window.addEventListener('keyup', e => { if (running && [' ', 'Enter'].includes(e.key)) game?.closeupRelease(); });
window.addEventListener('keyup', e => {
// Il GP di Fortezza tiene conto di ogni tasto per conto suo: lasciarne uno non toglie il gas del dito o degli altri.
if (miniState() && mini().key) { mini().key(game.closeup, e.key, false); return; }
if (running && [' ', 'Enter'].includes(e.key)) game?.closeupRelease();
});
// --- Il primo piano: si chiude col pulsante; la pagina che gira fruscia (rumore filtrato, niente file audio).
$('closeup-close').addEventListener('click', () => game?.closeupClose());
@@ -908,19 +943,26 @@ function updateUi() {
}
}
if (mg && !cards) {
$('bocce-controls').setAttribute('aria-label', { biliardo: 'Comandi del biliardo', basket: 'Comandi del basket', pesca: 'Comandi della pesca' }[def.type] ?? 'Comandi delle bocce');
$('bocce-controls').setAttribute('aria-label', { biliardo: 'Comandi del biliardo', basket: 'Comandi del basket', pesca: 'Comandi della pesca', gara: 'Comandi della gara' }[def.type] ?? 'Comandi delle bocce');
$('panel').hidden = $('choices').hidden = true;
const line = mg.status(c, def.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']) $(id).disabled = !(mg.canAdjust?.(c) ?? mg.canThrow(c)) || c.holding || !!game.speech;
$('bocce-throw').disabled = !mg.canThrow(c) || c.holding || !!game.speech;
for (const id of ['bocce-power', 'bocce-left', 'bocce-right']) $(id).disabled = !(mg.canAdjust?.(c) ?? mg.canThrow(c)) || (c.holding && !mg.toggle) || !!game.speech;
$('bocce-throw').disabled = !mg.canThrow(c) || (c.holding && !mg.toggle) || !!game.speech;
// Il GP di Fortezza: niente forza, e i due pulsanti di mira diventano lo sterzo (si tengono premuti).
$('bocce-power').closest('label').hidden = def.type === 'gara';
$('bocce-left').textContent = def.type === 'gara' ? '← Sterza' : '← Mira';
$('bocce-right').textContent = def.type === 'gara' ? 'Sterza →' : 'Mira →';
$('gara-brake').hidden = def.type !== 'gara';
$('gara-brake').disabled = !(mg.canAdjust?.(c) ?? false) || !!game.speech;
$('bocce-throw').textContent = mg.button?.(c) ?? (c.step === 'result' ? 'Finita' : c.step === 'rolling' ? 'In movimento…' : c.current === 'cpu' ? `Tira ${def.names.cpu}…` : def.type === 'bocce' ? 'Lancia' : 'Tira');
$('bocce-help').hidden = def.type !== 'bocce';
$('biliardo-help').hidden = def.type !== 'biliardo';
$('basket-help').hidden = def.type !== 'basket';
$('pesca-help').hidden = def.type !== 'pesca';
$('gara-help').hidden = def.type !== 'gara';
if (mg.aimFree?.(c) ?? !mg.canThrow(c)) c.aimLocked = false; // a ogni nuovo tiro la mira torna libera
}
if (mg) for (const event of c.events.splice(0)) ambience.play(event.kind, { pan: 0, strength: event.power });
@@ -934,7 +976,8 @@ function updateUi() {
$('panel').inert = !!c;
canvas.classList.toggle('torch', !!torch);
canvas.classList.toggle('bocce', !!mg);
$('stage').classList.toggle('biliardo', !!c && story.closeups[c.id].type === 'biliardo');
// Biliardo e GP di Fortezza: audio, pausa e uscita fuori dal quadro (al GP la pista passa negli angoli).
$('stage').classList.toggle('biliardo', !!c && ['biliardo', 'gara'].includes(story.closeups[c.id].type));
$('stage').classList.toggle('briscola', !!cards);
canvas.setAttribute('aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : mg ? mg.label : "La scena dell'avventura");
// La pagina che comincia a girare fa il suo fruscio.
+18
View File
@@ -0,0 +1,18 @@
// La gara dei cinquantini (idea di Andrea, 28 ottobre): chi corre, in ordine di griglia (due per fila).
// Ogni amico che manda la foto del suo motorino si aggiunge con una riga. `motore`: da 1 (di serie) a 5 (truccato da
// far paura); `testa`: da 1 (matto, in curva rischia) a 5 (prudente). `color` e `trim`: i colori del motorino in pista.
// Per la presentazione: `moto`, il disegno del motorino (Codex, dalla foto); `sprite`, il personaggio del gioco (un
// nome di story.characters), oppure `face`, un ritratto quadrato; senza nessuno dei due, una sagoma.
// Persone vere solo col loro consenso (`consenso`: chi l'ha dato e quando).
export const piloti = [
// Lo SR di Puntella era il più truccato di tutti; per ora i colori dell'Aprilia SR 50 da GP (foto di Andrea).
{ id: 'puntella', name: 'Puntella', bike: 'lo SR', motore: 5, testa: 1, color: '#5a3fb5', trim: '#e8442e', moto: 'assets/closeups/gp/sr.png', consenso: null }, // [DA ANDREA: consenso di Marco Villani]
// Cistola (Davide Poggi) col suo CR 50 Z, blu notte e argento, dalla foto che ha mandato; il volto dal suo murale.
{ id: 'cistola', name: 'Cistola', bike: 'il CR 50 Z', motore: 4, testa: 3, color: '#1d2440', trim: '#a9adb5', moto: 'assets/closeups/gp/cr50z.png', face: 'assets/closeups/gp/cistola.png', consenso: '2026-10-27' },
// Il Malaguti Fifty di Andrea: rosso con le rifiniture nere.
{ id: 'andrea', name: 'Andrea', bike: 'il Fifty', motore: 3, testa: 3, color: '#d8402f', trim: '#1c1c1c', moto: 'assets/closeups/gp/fifty.png', sprite: 'andrea' },
// Tozzino (Francesco Tozzi) col suo Booster blu scuro, di serie, dalle foto che ha mandato (l'adesivo sul manubrio compreso).
{ id: 'tozzino', name: 'Tozzino', bike: 'il Booster', motore: 1, testa: 3, color: '#1a2a52', trim: '#111318', moto: 'assets/closeups/gp/booster.png', sprite: 'tozzino' },
// Il Betino dell'Arcu: verde e crema, lento ma non cade mai.
{ id: 'arcu', name: 'L\'Arcu', bike: 'il Betino', motore: 1, testa: 5, color: '#2f7a3a', trim: '#e8d9a0', moto: 'assets/closeups/gp/betino.png', sprite: 'arcu' },
];
+108 -10
View File
@@ -2,10 +2,13 @@
// Testi, personaggi ed enigmi sono provvisori e verranno sostituiti con i ricordi di Andrea (docs/PROGETTO.md).
// Le forme in `art` sono segnaposto disegnati sopra lo sfondo (vecchietti, squama, catena) in attesa degli sprite.
import { ambience } from './ambience.js';
import { piloti } from './piloti.js';
// Gli indizi del Trebbio per la catena della torcia (dopo le impronte, finché la torcia non c'è): uno per tappa,
// dalla più avanti alla prima.
const castello = say => [{ say, who: 'castello' }];
// Prima del GP di Fortezza l'Arcu va a prendere il Betino in cima alla salita (Andrea, 28 ottobre).
const rincorsa = level => [{ say: 'Aspettate: prendo la rincorsa, che il Betino s\'accende solo in discesa.', who: 'arcu', pose: 'point' }, { closeup: 'gara', level }];
const torchHints = [{
if: 'has:chiavetta',
then: castello('Una chiavetta piccola apre una cosa piccola. All\'area feste c\'è una casetta che aspetta.'),
@@ -184,6 +187,26 @@ export const story = {
quit: [{ say: 'Lascio la canna ai ragazzi. Il lago non scappa, il palio sì.' }],
draw: [],
},
// Il GP di Fortezza (idea di Andrea, 28 ottobre): tre giri coi cinquantini attorno alle mura, gas e sterzo come
// nelle vecchie gare da sala giochi viste dall'alto. Chi corre sta in piloti.js. [DA ANDREA: le battute; la musica]
gara: {
type: 'gara', music: 'assets/audio/gara.mp3', // di Andrea (Suno)
picture: 'assets/closeups/gp-fortezza.png', // Codex, dalla planimetria di Andrea; la strada è il tracciato
// Corre solo chi ha dato il consenso (`consenso: null` vuol dire «da chiedere»: per ora Puntella).
riders: piloti.filter(p => p.consenso !== null),
names: { player: 'Andrea', cpu: 'gli altri' },
win: [
{ say: 'Primo! E col Fifty di serie, o quasi.' },
{ say: 'Si rifà! Questa un vale, avevo la catena lenta.', who: 'arcu', pose: 'shrug' },
{ set: 'gara_vinta' },
],
lose: [
{ say: 'Ho preso tutta la polvere di quelli davanti.' },
{ say: 'Il Fifty è bono. È chi ci sta sopra.', who: 'arcu', pose: 'laugh' },
],
quit: [{ say: 'Lascio stare. Il Fifty mi serve intero, per il palio.' }],
draw: [],
},
// Il biliardo del Circolo 4 Maggio contro l'Ing. Tozzi: cinque birilli, un colpo a testa, si va a 20. [DA ANDREA: le sue battute]
biliardo: {
type: 'biliardo',
@@ -2059,19 +2082,11 @@ export const story = {
people: [
{ who: 'arcu', at: [418, 292], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.2 },
// Oggetti statici: ordinati per profondità come la macchina del tempo, senza voce.
// Con la depth della scena misurano circa 78 e 160 px; restano sopra il bordo di walk.
{ who: 'betino', at: [480, 263], size: 0.767, idle: 'idle', talk: [] },
// Con la depth della scena misura circa 160 px; resta sopra il bordo di walk. (Il Betino dell'Arcu sta in
// cima alla salita di Via Liguri Magelli, Andrea 28 ottobre.)
{ who: 'ghiacciomobile', at: [176, 286], size: 0.986, idle: 'idle', talk: [] },
],
hotspots: [
{
id: 'betino', name: 'il Betino', box: [441, 212, 78, 51], at: [480, 282], face: 'up', default: 'look',
// Il motorino di Andrea (sprite di Codex, 26 ottobre); la battuta è sua.
actions: {
look: [{ say: 'Nessuno ha mai capito davvero il tuo immenso valore.' }],
use: [{ say: 'O via, lascialo riposare. A pedalare oggi ci penso io.' }],
},
},
{
id: 'ghiacciomobile', name: 'la Ghiacciomobile', box: [96, 215, 160, 71], at: [240, 308], face: 'up', default: 'look',
// La Corsa nera degli amici (sprite di Codex, 26 ottobre). Parlandole, Andrea ricorda; lei non risponde.
@@ -2630,6 +2645,60 @@ export const story = {
{
id: 'al_ponticino', name: 'verso il ponticino della Carza', box: [440, 150, 90, 60], at: [480, 216], exit: { scene: 'ponticino', at: [444, 204], face: 'right' },
},
{
id: 'via_liguri_magelli', name: 'Via Liguri Magelli', box: [0, 100, 70, 260], at: [30, 260], exit: { scene: 'via_liguri_magelli', at: [320, 340], face: 'up' },
},
],
},
// Via Liguri Magelli (Street View mandato da Andrea, 28 ottobre): a sinistra dal campo del Giannini. A destra la
// casa salmone con la tenda del Trappola (il Barrettino), sul muro giallo la targa della Ghiacciomobile con la sua
// fotina, e in cima alla salita il Betino dell'Arcu (dipinto da Codex nel quadro), di traverso verso la discesa:
// per partire ne ha bisogno, e intanto ferma il traffico.
via_liguri_magelli: {
name: 'Via Liguri Magelli',
map: [76, 97],
background: 'assets/scenes/via-liguri-magelli.png',
walk: [[0, 360], [0, 320], [140, 262], [226, 198], [272, 198], [400, 250], [580, 318], [640, 330], [640, 360]],
depth: [[198, 0.3], [360, 1.1]],
entry: [320, 340],
speakers: { arcu: [374, 176] },
people: [
// L'Arcu sotto la tenda del Trappola: due minuti fa era a Campomigliaio (Andrea, 28 ottobre).
{ who: 'arcu', at: [374, 248], facing: -1, size: 1.3, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.2 },
],
hotspots: [
{
id: 'arcu', name: 'l\'Arcu', box: [362, 180, 26, 68], at: [352, 256], face: 'right', default: 'talk',
actions: {
look: [{ say: 'L\'Arcu, sotto la tenda del Trappola. Due minuti fa era a Campomigliaio.' }],
talk: [{ dialogue: 'arcu_trappola' }],
},
},
{
// «Vai dal Trappola, la tenda è il Trappola» (Andrea). [DA ANDREA: com'è dentro, e chi c'è]
id: 'trappola', name: 'dal Trappola', box: [316, 150, 46, 72], at: [340, 238], face: 'right', default: 'walk',
actions: {
look: [{ say: 'La tenda è il Trappola: sotto c\'è il Barrettino.' }],
walk: [{ say: 'Dal Trappola ci si passa dopo. Prima il palio.' }],
},
},
{
id: 'targa', name: 'targa della Ghiacciomobile', box: [570, 130, 62, 78], at: [565, 336], face: 'right', default: 'look',
actions: {
look: [{ say: '«Qui riposa la Ghiacciomobile, che si è schiantata lì.»' }, { say: 'C\'è anche la fotina. Pareva sempre nuova.' }],
},
},
{
// La prima battuta è di Andrea (dal circolino, 26 ottobre).
id: 'betino', name: 'il Betino dell\'Arcu', box: [232, 172, 40, 24], at: [250, 206], face: 'up', default: 'look',
actions: {
look: [{ say: 'Nessuno ha mai capito davvero il tuo immenso valore.' }, { say: 'Lassù in cima, di traverso: per partire ha bisogno della discesa. E intanto ferma il traffico.' }],
use: [{ say: 'È il Betino dell\'Arcu. Senza una discesa davanti, un si muove.' }],
},
},
{
id: 'al_giannini', name: 'verso il campo del Giannini', box: [180, 330, 280, 30], at: [320, 345], exit: { scene: 'campo_giannini', at: [60, 250], face: 'right' },
},
],
},
// Cardetole, il rione dei verdi, dove abita Andrea: giù dalla Pieve.
@@ -3009,6 +3078,35 @@ export const story = {
},
},
},
// L'Arcu dal Trappola (Andrea, 28 ottobre): lo scherzo sulla velocità del Betino, poi la sfida: «il giro della Fortezza» (Andrea), cioè il GP di Fortezza;
// prima del via prende la rincorsa per accenderlo in discesa. [DA ANDREA: le battute sono provvisorie]
arcu_trappola: {
nodes: {
start: {
options: [
{ text: 'Ma ti ho visto du\' minuti fa a Campomigliaio!', then: [
{ say: 'E allora? Col Betino ci metto un attimo.', who: 'arcu', pose: 'explain' },
{ say: 'In discesa.', who: 'arcu', pose: 'laugh' },
{ say: 'E in salita?' },
{ say: 'In salita si spinge.', who: 'arcu', pose: 'shrug' },
] },
{ text: 'Si fa una gara coi motorini? Il giro della Fortezza!', next: 'gara', then: [
{ say: 'Col Betino? Io un perdo mai: un vo mai forte abbastanza da cadere.', who: 'arcu', pose: 'explain' },
{ say: 'Come la si fa?', who: 'arcu', pose: 'talk' },
] },
{ text: 'Ci si vede dopo, Arcu.', next: 'end' },
],
},
gara: {
options: [
{ text: 'Alla bona, un giro di paese.', next: 'end', then: [{ say: 'Alla bona. Chi arriva ultimo paga le paste.', who: 'arcu', pose: 'laugh' }, ...rincorsa('bona')] },
{ text: 'Seria.', next: 'end', then: [{ say: 'Seria. Tre giri, e la paglia in curva è vera.', who: 'arcu', pose: 'explain' }, ...rincorsa('seria')] },
{ text: 'Ginocchio in terra.', next: 'end', then: [{ say: 'Ginocchio in terra? Puntella un vede l\'ora.', who: 'arcu', pose: 'shrug' }, ...rincorsa('male')] },
{ text: 'No, lascia perdere.', next: 'start' },
],
},
},
},
// Da solo al campo del Circolo: Andrea decide quanto farsi male.
basket: {
nodes: {