Lavori in corso (non pubblicati): Bertovo al tavolo di mezzo coi rinquarti (Codex), Bini, Yuri, il Gianno e la Brauda in gara, l'Audi «Let it be», sensibilità e riquadro dello swipe, pulsanti su iPhone (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 23:36:05 +01:00
co-authored by Claude Opus 5.5
parent 6585c9ffcb
commit bf22dc2511
54 changed files with 954 additions and 117 deletions
+105 -60
View File
@@ -10,6 +10,7 @@ import { Soundscape } from './engine/soundscape.js';
import { soundscapeFor } from './story/soundscapes.js';
import * as bocce from './engine/bocce.js';
import * as biliardo from './engine/biliardo.js';
import * as rinquarto from './engine/rinquarto.js';
import * as basket from './engine/basket.js';
import * as pesca from './engine/pesca.js';
import * as gara from './engine/gara.js';
@@ -507,7 +508,7 @@ canvas.addEventListener('pointerdown', e => {
return;
}
const aimed = game.closeupAim(scenePoint(e));
if (['biliardo', 'basket'].includes(story.closeups[game.closeup.id].type)) {
if (['biliardo', 'rinquarto', 'basket'].includes(story.closeups[game.closeup.id].type)) {
if (!aimed && !game.speech) return; // dove non si può mirare (dietro Andrea) non si carica nemmeno
if (aimed) game.closeup.aimLocked = true; // il clic fissa la mira
}
@@ -569,12 +570,19 @@ canvas.addEventListener('pointermove', e => {
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);
const aiming = game.closeup && ['biliardo', 'rinquarto', '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));
});
canvas.addEventListener('pointerleave', () => { mouse = null; });
// Il pannello si aggiorna a ogni fotogramma: si scrive nel DOM solo quando qualcosa cambia davvero. Su iPhone
// Safari, se la pagina cambia subito dopo un tocco, il tocco vale come «passaggio» e il clic non arriva: i pulsanti
// dei minigiochi, riscritti a ogni fotogramma, non rispondevano (un amico di Andrea, 28 ottobre).
const setText = (el, text) => { if (el.textContent !== text) el.textContent = text; };
const setAttr = (el, name, value) => { if (el.getAttribute(name) !== value) el.setAttribute(name, value); };
const setValue = (el, value) => { if (el.value !== String(value)) el.value = value; };
const setProp = (el, name, value) => { if (typeof el[name] === 'boolean') value = !!value; if (el[name] !== value) el[name] = value; };
// Comandi dei minigiochi separati dai verbi: carte oppure mira, forza e lancio.
const MINI = {
scopa: { ...scopa, back: 'Torna all’oratorio', label: 'Scopa: tocca una carta per giocarla. Se le prese sono più d’una, tocca una carta in tavola o usa le frecce, Invio prende. Esc esce.' },
@@ -582,6 +590,7 @@ const MINI = {
briscola: { ...briscola, back: 'Torna all’oratorio', label: 'Briscola: tocca una carta per giocarla. Frecce o 1, 2, 3 scelgono; Invio gioca. Esc esce.' },
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.' },
rinquarto: { ...rinquarto, back: 'Torna da Bertovo', label: 'I rinquarti di Bertovo: 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 sulla scena per il gas e trascina verso la direzione in cui vuoi andare sullo schermo; alza il dito per mollare. Anche col mouse. Oppure Spazio, Invio o ↑ per il gas, ← → per sterzare, ↓ per frenare; disponibili anche i pulsanti Gas, Sterza e Frena.' },
@@ -589,7 +598,29 @@ const MINI = {
};
const miniState = () => running && game?.closeup && MINI[story.closeups[game.closeup.id].type] ? game.closeup : null;
const mini = () => MINI[story.closeups[game.closeup.id].type];
$('bocce-power').addEventListener('input', e => { const c = miniState(); if (c) mini().setPower?.(c, Number(e.target.value) / 100); });
$('bocce-power').addEventListener('input', e => {
const c = miniState(); if (!c) return;
mini().setPower?.(c, Number(e.target.value) / 100);
// Nel GP di Fortezza il cursore è la sensibilità dello sterzo: il gioco se la ricorda.
if (story.closeups[c.id].type === 'gara') try { localStorage.setItem(GARA_SENS, e.target.value); } catch { /* senza memoria, pazienza */ }
});
const GARA_SENS = 'regolo-gara-sensibilita';
// Il riquadro dello swipe sotto la pista: come il dito sulla scena, con le distanze in pixel della scena.
const padPoint = e => { const r = canvas.getBoundingClientRect(), k = 640 / r.width; return [(e.clientX - r.left) * k, (e.clientY - r.top) * k]; };
let padPointer = null;
$('gara-pad').addEventListener('pointerdown', e => {
const c = miniState(); if (!c || padPointer !== null || game.skip()) return;
e.preventDefault();
gara.aimScreen(c, padPoint(e), 'pad' + e.pointerId);
if (gara.press(c, 'pad' + e.pointerId)) { padPointer = e.pointerId; $('gara-pad').setPointerCapture(e.pointerId); $('gara-pad').classList.add('active'); }
});
$('gara-pad').addEventListener('pointermove', e => { const c = miniState(); if (c && e.pointerId === padPointer) gara.aimScreen(c, padPoint(e), 'pad' + e.pointerId); });
for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) $('gara-pad').addEventListener(ev, e => {
if (e.pointerId !== padPointer) return;
const c = miniState(); if (c) gara.release(c, 'pad' + e.pointerId);
padPointer = null; $('gara-pad').classList.remove('active');
});
$('gara-pad').addEventListener('contextmenu', e => e.preventDefault());
$('bocce-throw').addEventListener('click', () => { const c = miniState(); if (c && !game.speech) mini().shoot?.(c); });
// Le carte da gioco: dove si tocca la carta sul tavolo, gioco per gioco.
const CARD_AT = { briscola: cardAt, tressette: tressetteAt, scopa: scopaAt };
@@ -680,8 +711,11 @@ function updateInventory() {
let choicesKey = '';
function updateChoices() {
const options = game.options(), inDialogue = !!game.dialogue;
$('panel').hidden = inDialogue;
$('choices').hidden = !inDialogue;
// Con un minigioco aperto il suo pannello prende il posto di verbi e scelte: si decide qui, una volta sola, senza
// mostrarli e nasconderli a ogni fotogramma.
const covered = !!miniState();
setProp($('panel'), 'hidden', inDialogue || covered);
setProp($('choices'), 'hidden', !inDialogue || covered);
const key = inDialogue + '|' + options.map(o => o.text).join('|');
if (key === choicesKey) return;
choicesKey = key;
@@ -853,21 +887,22 @@ function updateSpeech() {
if (!sp) { if (box.textContent) box.textContent = ''; return; }
const [x, y] = game.speakerPoint(sp.who);
if (box.textContent !== sp.text) box.textContent = sp.text;
box.style.color = story.characters[sp.who]?.color ?? '#fff';
const color = story.characters[sp.who]?.color ?? '#fff';
if (box.style.color !== color && box.dataset.color !== color) { box.style.color = color; box.dataset.color = color; }
// Sopra chi parla, ma sempre dentro la scena e mai sotto i pulsanti in alto (Pausa, volume): sul telefono un
// fumetto lungo è alto quasi quanto la scena, e si misura in pixel.
const stage = $('stage').getBoundingClientRect(), w = box.offsetWidth, h = box.offsetHeight;
const roof = Math.max(...['sound', 'corner'].map(id => $(id).getBoundingClientRect().bottom - stage.top)) + 4;
const px = Math.min(stage.width - w / 2 - 4, Math.max(w / 2 + 4, x / WIDTH * stage.width));
const py = Math.min(stage.height - 4, Math.max(roof + h, y / HEIGHT * stage.height));
box.style.left = px + 'px';
box.style.top = py + 'px';
if (box.style.left !== px + 'px') box.style.left = px + 'px';
if (box.style.top !== py + 'px') box.style.top = py + 'px';
}
function updateUi() {
// Il bersaglio si ricalcola a ogni fotogramma: la scena cambia anche se il mouse sta fermo.
hoverTarget = (game.state.inventory.includes(hoverItem) && hoverItem) || (mouse && game.hotspotAt(mouse)?.id) || null;
for (const b of $('verbs').children) b.setAttribute('aria-pressed', String(command.verb === b.dataset.verb));
for (const b of $('verbs').children) setAttr(b, 'aria-pressed', String(command.verb === b.dataset.verb));
const c = game.closeup, torch = c && story.closeups[c.id].type === 'torch';
const text = torch ? c.step === 'done' ? 'Gli occhi sono spariti. Continua o premi Invio.'
: `${story.closeups[c.id].targets[c.found]?.hint ?? ''} Sposta la luce; Invio per cercare.`
@@ -878,22 +913,22 @@ function updateUi() {
updateSpeech();
const mg = miniState() && mini(), def = mg && story.closeups[c.id];
const cards = ['briscola', 'tressette', 'scopa'].includes(def?.type);
$('bocce-controls').hidden = !mg || cards;
$('bocce-controls').inert = !running;
$('briscola-controls').hidden = def?.type !== 'briscola';
$('briscola-controls').inert = !running;
$('scopa-controls').hidden = def?.type !== 'scopa';
$('scopa-controls').inert = !running;
setProp($('bocce-controls'), 'hidden', !mg || cards);
setProp($('bocce-controls'), 'inert', !running);
setProp($('briscola-controls'), 'hidden', def?.type !== 'briscola');
setProp($('briscola-controls'), 'inert', !running);
setProp($('scopa-controls'), 'hidden', def?.type !== 'scopa');
setProp($('scopa-controls'), 'inert', !running);
if (def?.type === 'scopa') {
$('panel').hidden = $('choices').hidden = true;
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
const line = scopa.status(c, def.names);
if ($('scopa-status').textContent !== line) $('scopa-status').textContent = line;
for (let i = 0; i < 3; i++) {
const button = $('scopa-card-' + i), card = c.hands.player[i];
const label = card ? `${i + 1}. ${scopa.cardName(card)}` : `${i + 1}. —`;
if (button.textContent !== label) button.textContent = label;
button.disabled = !scopa.canPlay(c) || !card || !!game.speech;
button.setAttribute('aria-pressed', String(c.selected === i && !!card && c.step === 'play'));
setProp(button, 'disabled', !scopa.canPlay(c) || !card || !!game.speech);
setAttr(button, 'aria-pressed', String(c.selected === i && !!card && c.step === 'play'));
}
// Le prese fra cui scegliere, una per pulsante.
const options = c.step === 'choose' ? c.choice.options.map(o => o.map(scopa.cardName).join(' + ')) : [];
@@ -905,14 +940,14 @@ function updateUi() {
b.dataset.option = k; return b;
}));
}
box.hidden = !options.length;
[...box.children].forEach((b, k) => b.setAttribute('aria-pressed', String(c.choice?.selected === k)));
$('scopa-next').hidden = c.step !== 'smazzata';
setProp(box, 'hidden', !options.length);
[...box.children].forEach((b, k) => setAttr(b, 'aria-pressed', String(c.choice?.selected === k)));
setProp($('scopa-next'), 'hidden', c.step !== 'smazzata');
}
$('tressette-controls').hidden = def?.type !== 'tressette';
$('tressette-controls').inert = !running;
setProp($('tressette-controls'), 'hidden', def?.type !== 'tressette');
setProp($('tressette-controls'), 'inert', !running);
if (def?.type === 'tressette') {
$('panel').hidden = $('choices').hidden = true;
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
const line = tressette.status(c, def.seats);
if ($('tressette-status').textContent !== line) $('tressette-status').textContent = line;
const mine = tressette.canPlay(c) && !game.speech, ok = mine ? tressette.legal(c.hands[0], c.table) : [];
@@ -920,66 +955,76 @@ function updateUi() {
const button = $('tressette-card-' + i), card = c.hands[0][i];
const label = card ? `${(i + 1) % 10}. ${tressette.cardName(card)}` : '—';
if (button.textContent !== label) button.textContent = label;
button.hidden = !card;
button.disabled = !ok.includes(i);
button.setAttribute('aria-pressed', String(c.selected === i && !!card));
setProp(button, 'hidden', !card);
setProp(button, 'disabled', !ok.includes(i));
setAttr(button, 'aria-pressed', String(c.selected === i && !!card));
}
for (const signal of tressette.SIGNALS) {
$('tressette-' + signal).disabled = !mine || c.table.length > 0;
$('tressette-' + signal).setAttribute('aria-pressed', String(c.signal === signal));
setProp($('tressette-' + signal), 'disabled', !mine || c.table.length > 0);
setAttr($('tressette-' + signal), 'aria-pressed', String(c.signal === signal));
}
$('tressette-next').hidden = c.step !== 'smazzata';
setProp($('tressette-next'), 'hidden', c.step !== 'smazzata');
}
if (def?.type === 'briscola') {
$('panel').hidden = $('choices').hidden = true;
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
const line = briscola.status(c, def.names);
if ($('briscola-status').textContent !== line) $('briscola-status').textContent = line;
for (let i = 0; i < 3; i++) {
const button = $('briscola-card-' + i), card = c.hands.player[i];
const label = card ? `${i + 1}. ${briscola.cardName(card)}` : `${i + 1}. —`;
if (button.textContent !== label) button.textContent = label;
button.disabled = !briscola.canPlay(c) || !card || !!game.speech;
button.setAttribute('aria-pressed', String(c.selected === i && !!card));
setProp(button, 'disabled', !briscola.canPlay(c) || !card || !!game.speech);
setAttr(button, 'aria-pressed', String(c.selected === i && !!card));
}
}
if (mg && !cards) {
$('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;
setAttr($('bocce-controls'), 'aria-label', { rinquarto: 'Comandi dei rinquarti', biliardo: 'Comandi del biliardo', basket: 'Comandi del basket', pesca: 'Comandi della pesca', gara: 'Comandi della gara' }[def.type] ?? 'Comandi delle bocce');
setProp($('panel'), 'hidden', true); setProp($('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 && !mg.toggle) || !!game.speech;
$('bocce-throw').disabled = !mg.canThrow(c) || (c.holding && !mg.toggle) || !!game.speech;
setValue($('bocce-power'), Math.round(c.power * 100));
setValue($('bocce-power-value'), `${Math.round(c.power * 100)}%`);
for (const id of ['bocce-power', 'bocce-left', 'bocce-right']) setProp($(id), 'disabled', !(mg.canAdjust?.(c) ?? mg.canThrow(c)) || (c.holding && !mg.toggle) || !!game.speech);
setProp($('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';
// Nel GP di Fortezza il cursore della forza diventa la sensibilità dello sterzo col dito (ricordata).
const powerLabel = $('bocce-power').closest('label');
const powerName = def.type === 'gara' ? 'Sensibilità ' : 'Forza ';
if (powerLabel.firstChild.nodeValue !== powerName) powerLabel.firstChild.nodeValue = powerName;
setAttr($('bocce-power'), 'aria-label', def.type === 'gara' ? 'Sensibilità dello sterzo col dito' : 'Forza del lancio');
setProp($('gara-pad'), 'hidden', def.type !== 'gara');
if (def.type === 'gara' && c.sensLoaded === undefined) {
c.sensLoaded = true;
try { const v = localStorage.getItem(GARA_SENS); if (v !== null) gara.setPower(c, Number(v) / 100); } catch { /* si resta al valore di partenza */ }
}
setText($('bocce-left'), def.type === 'gara' ? '← Sterza' : '← Mira');
setText($('bocce-right'), def.type === 'gara' ? 'Sterza →' : 'Mira →');
setProp($('gara-brake'), 'hidden', def.type !== 'gara');
setProp($('gara-brake'), 'disabled', !(mg.canAdjust?.(c) ?? false) || !!game.speech);
setText($('bocce-throw'), mg.button?.(c) ?? (c.step === 'result' ? 'Finita' : c.step === 'rolling' ? 'In movimento…' : c.current === 'cpu' ? `Tira ${def.names.cpu}…` : def.type === 'bocce' ? 'Lancia' : 'Tira'));
setProp($('bocce-help'), 'hidden', def.type !== 'bocce');
setProp($('biliardo-help'), 'hidden', def.type !== 'biliardo');
setProp($('rinquarto-help'), 'hidden', def.type !== 'rinquarto');
setProp($('basket-help'), 'hidden', def.type !== 'basket');
setProp($('pesca-help'), 'hidden', def.type !== 'pesca');
setProp($('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 });
// Il pulsante della mappa c'è solo in partita, fuori dai dialoghi, e quando c'è almeno un altro posto dove andare.
$('map-button').hidden = !running || !!game.dialogue || !!game.closeup || game.mapPlaces().length < 2;
$('pause-button').hidden = !running;
$('spots-button').hidden = !running || !!game.closeup;
$('notebook-button').hidden = !running || !story.notebook || !!game.closeup;
$('closeup-close').hidden = !running || !game.closeup;
$('closeup-close').textContent = torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : mg ? c.step === 'result' ? mg.back : 'Lascia stare' : 'Chiudi';
$('panel').inert = !!c;
setProp($('map-button'), 'hidden', !running || !!game.dialogue || !!game.closeup || game.mapPlaces().length < 2);
setProp($('pause-button'), 'hidden', !running);
setProp($('spots-button'), 'hidden', !running || !!game.closeup);
setProp($('notebook-button'), 'hidden', !running || !story.notebook || !!game.closeup);
setProp($('closeup-close'), 'hidden', !running || !game.closeup);
setText($('closeup-close'), torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : mg ? c.step === 'result' ? mg.back : 'Lascia stare' : 'Chiudi');
setProp($('panel'), 'inert', !!c);
canvas.classList.toggle('torch', !!torch);
canvas.classList.toggle('bocce', !!mg);
// 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('biliardo', !!c && ['biliardo', 'rinquarto', '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");
setAttr(canvas, '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.
const step = game.closeup?.step ?? null;
if (step === 'turn' && turning !== 'turn') ambience.play('page');
@@ -989,7 +1034,7 @@ function updateUi() {
reveal = nextReveal;
if (game.state.ended && !game.speech && !game.queue.length) {
running = false;
$('ending').hidden = false;
setProp($('ending'), 'hidden', false);
}
}