Schermo intero (pulsante accanto ai volumi e tasto F) e gioco fino a 2560 px; nei minigiochi la scena lascia lo spazio vero dei comandi; flipper: «Solo tavola», sul telefono senza pulsantiera e «Lascia stare» in alto, molla toccando al centro con «TIENI PREMUTO QUI», ponti rifatti da Codex; prove più salde (tressette)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
This commit is contained in:
2026-11-01 21:38:44 +01:00
co-authored by Claude Opus 5.5
parent 428fb4bbec
commit 531aee18bf
10 changed files with 234 additions and 50 deletions
+78 -16
View File
@@ -1,6 +1,6 @@
// Il Regolo / River: tavolo in lacca scura, metallo e inserti luminosi.
// Le coordinate sono quelle della fisica. Le superfici statiche e i bagliori sono in cache a risoluzione doppia.
import { R, SCORE, RAMP, FUTURE, layout, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft, activeBalls, multiball, rampOpen } from './flipper.js';
import { R, SCORE, RAMP, FUTURE, PLUNGER_ZONE, layout, flipperSeg, alongRamp, rampHeight, waiting, canThrow, saveLeft, activeBalls, multiball, rampOpen } from './flipper.js';
import { ambientHash as hash } from './ambient.js';
const INK = '#071018', WHITE = '#eff9ff', MUTED = '#7b99aa';
@@ -151,7 +151,8 @@ function paintTrack(g, r) {
line(g, [[p.x + 5, p.y], [p.x + 7, p.y + 10]], '#050c13', 3);
screw(g, p.x + 7, p.y + 10);
}
const track = (color, width) => {
const track = (color, width, points = pts) => {
const pts = points;
g.beginPath(); g.moveTo(...pts[0]);
for (let i = 1; i < pts.length - 1; i++) {
const [x, y] = pts[i], a = pts[i - 1], b = pts[i + 1];
@@ -165,7 +166,24 @@ function paintTrack(g, r) {
track(color + '18', 20); track('#02080e', 16); track(color, 14);
track(metal(g, 240, 45, 156, 205), 12); track('#07141e', 10);
track(gradient(g, 240, 40, 165, 220, [[0, '#386274'], [0.35, '#152a3b'], [0.7, '#385266'], [1, '#142c3b']]), 8);
track(color + '24', 4);
track(color + '38', 4);
// Parapetti rialzati: montanti e tiranti triangolari fanno leggere il ponte anche da fermo.
for (const side of [-1, 1]) {
let previous;
for (let s = 34; s < r.len - 10; s += 18) {
const p = rampPoint(r, s), foot = [p.x + p.dy * 6.5 * side, p.y - p.dx * 6.5 * side];
const top = [foot[0], foot[1] - 5];
line(g, [foot, top], '#a6c6d2', 0.8);
if (previous) line(g, [previous, foot], color + '88', 0.65);
disc(g, ...top, 0.9, color); previous = top;
}
const rail = pts.map(([x, y], i) => {
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(pts.length - 1, i + 1)];
const len = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
return [x + (b[1] - a[1]) / len * 6.5 * side, y - (b[0] - a[0]) / len * 6.5 * side - 5];
});
track('#06121d', 2.6, rail); track(color, 1.3, rail); track('#ffffff99', 0.45, rail);
}
for (let s = 12; s < r.len - 4; s += 22) {
const p = rampPoint(r, s);
line(g, [[p.x - p.dy * 4, p.y + p.dx * 4], [p.x + p.dy * 4, p.y - p.dx * 4]], '#7595a344', 0.7);
@@ -178,18 +196,50 @@ function paintTrack(g, r) {
const pts = [[cx + side * 6, y - 30], [cx + side * (x1 - x0) / 2, y]];
line(g, pts, '#07121c', 3); line(g, pts, color, 1); screw(g, cx + side * (x1 - x0) / 2, y);
}
text(g, r.points ? number(r.points) : r.id === 'super' ? 'SUPER' : '15.000', cx, y - 12, 5, '#c5e0e7', 'center');
// Portale d'ingresso in metallo: l'arco e le torrette incorniciano la bocca fisica della rampa.
const arch = [[x0 + 2, y - 2], [x0 + 2, y - 17], [cx - 8, y - 27], [cx + 8, y - 27], [x1 - 2, y - 17], [x1 - 2, y - 2]];
line(g, arch, '#020910', 5); line(g, arch, metal(g, x0, y - 27, x1 - x0, 27), 3.2); line(g, arch, color, 1);
for (const x of [x0 + 2, x1 - 2]) {
box(g, x - 2.5, y - 16, 5, 16, '#112735', '#8aaab7', 1);
box(g, x - 0.8, y - 13, 1.6, 9, color, null, 0.5);
}
box(g, cx - 10, y - 31, 20, 8, '#0a1b28', color, 2);
crown(g, cx, y - 27, 2.3, color);
for (let i = 0; i < 2; i++) line(g, [[cx - 3, y - 5 - i * 5], [cx, y - 8 - i * 5], [cx + 3, y - 5 - i * 5]], color + 'aa', 0.8);
text(g, r.points ? number(r.points) : r.id === 'super' ? 'SUPER' : '15.000', cx, y - 18, 4.5, WHITE, 'center');
}
function rampLights(g, c, r, time) {
function trackEnergy(g, c, r, time, hot, balls) {
const color = TINT[r.id], riders = balls.filter(b => b.ramp?.id === r.id);
const charged = hot || riders.length > 0;
// Luci sui bordi: il centro rimane libero per seguire la pallina.
for (let k = 0; k < (charged ? 7 : 3); k++) {
const p = rampPoint(r, (time * (charged ? 155 : 48) + k * r.len / (charged ? 7 : 3)) % r.len);
for (const side of [-1, 1]) {
const x = p.x + p.dy * 6.5 * side, y = p.y - p.dx * 6.5 * side - 5;
if (charged) glow(g, x, y, 7, color, 0.5);
disc(g, x, y, charged ? 1.1 : 0.8, charged ? WHITE : color);
}
}
for (const b of riders) {
// La scia segue la distanza percorsa, anche quando un tiro debole ricade indietro.
for (let i = 7; i > 0; i--) {
const s = b.ramp.s - Math.sign(b.ramp.v) * i * 4;
if (s < 0 || s > r.len) continue;
const p = rampPoint(r, s);
g.save(); g.globalAlpha *= (1 - i / 8) * 0.65;
line(g, [[p.x - p.dy * 3, p.y + p.dx * 3], [p.x + p.dy * 3, p.y - p.dx * 3]], color, 2);
g.restore();
}
const p = rampPoint(r, b.ramp.s); glow(g, b.x, p.y, 14, color, 0.8);
}
const [[x0, y], [x1]] = r.mouth, pulse = 0.45 + Math.sin(time * (charged ? 7 : 2)) * 0.15;
for (const x of [x0 + 2, x1 - 2]) glow(g, x, y - 10, 11, color, charged ? 0.85 : pulse);
}
function rampLights(g, c, r, time, balls) {
const color = TINT[r.id], locked = c.locks.some(l => l.rampId === r.id);
const combo = !c.locks.length && c.lastRamp && c.lastRamp.id !== r.id && r.id !== 'super';
const invited = c.special === r.id || multiball(c), hot = invited || combo || r.lit > 0;
if (hot) {
for (let k = 0; k < 7; k++) {
const p = rampPoint(r, (time * 140 + k * r.len / 7) % r.len);
glow(g, p.x, p.y, 10, color, 0.7); disc(g, p.x, p.y, 1.1, WHITE);
}
}
trackEnergy(g, c, r, time, hot, balls);
const [[x0, y], [x1]] = r.mouth, x = (x0 + x1) / 2;
for (let i = 0; i < 3; i++) {
const lit = locked || (c.rampRun.id === r.id && c.rampRun.count > i);
@@ -429,9 +479,9 @@ function futureField(g, c, time) {
const balls = activeBalls(c);
for (const b of balls) if (!b.ramp && b.releaseAt <= c.sim) ball(g, b);
for (const r of c.ramps) {
cached(g, `track:future:${r.id}`, layer => paintTrack(layer, r), [207, 5, 221, 250]);
cached(g, `track:future:${r.id}`, layer => paintTrack(layer, r), [202, 0, 232, 258]);
const color = TINT[r.id], x = r.path[0][0], y = r.mouth[0][1];
for (let k = 0; k < 4; k++) { const p = rampPoint(r, (time * 120 + k * r.len / 4) % r.len); disc(g, p.x, p.y, 1.3, color); }
trackEnergy(g, c, r, time, r.lit > 0 || c.future.hits[r.id] === 2, balls);
poly(g, [[x, y + 7], [x - 4, y + 14], [x + 4, y + 14]], color);
text(g, r.id.toUpperCase(), x, y + 21, 5.5, color, 'center');
for (const b of balls) if (b.ramp?.id === r.id) ball(g, b, color);
@@ -505,7 +555,7 @@ export function drawFlipper(g, c, def, bg, glass) {
}
if (future) {
if (cabinetBehind(g, key, paint)) g.clearRect(0, 0, 640, 360); else cached(g, key, paint);
futureField(g, c, time); g.restore(); return;
futureField(g, c, time); plungerHint(g, c, time); g.restore(); return;
}
if (shake > 0) { g.fillStyle = INK; g.fillRect(0, 0, 640, 360); g.translate(Math.sin(time * 75) * shake * 1.3, Math.cos(time * 63) * shake); cached(g, key, paint); }
else if (cabinetBehind(g, key, paint)) g.clearRect(0, 0, 640, 360);
@@ -532,7 +582,7 @@ export function drawFlipper(g, c, def, bg, glass) {
if (!rampOpen(c, r)) continue;
g.save();
if (r.id === 'super') { const rise = clamp((time - c.superOpenedAt) / 0.65, 0, 1); g.globalAlpha = rise; g.translate(0, (1 - rise) * 18); }
cached(g, `track:${r.id}`, layer => paintTrack(layer, r), r.id === 'super' ? [213, 50, 138, 202] : [213, 10, 215, 228]); rampLights(g, c, r, time);
cached(g, `track:${r.id}`, layer => paintTrack(layer, r), r.id === 'super' ? [208, 45, 148, 207] : [208, 0, 225, 238]); rampLights(g, c, r, time, balls);
for (const b of balls) if (b.ramp?.id === r.id) ball(g, b, TINT[r.id]);
g.restore();
}
@@ -542,6 +592,18 @@ export function drawFlipper(g, c, def, bg, glass) {
plunger(g, c); effects(g, c, time);
// Il riflesso bianco rende visibili le palline vicino alle palette anche durante la festa.
for (const b of balls) if (!b.ramp && b.y > 265 && b.releaseAt <= c.sim) ball(g, b);
board(g, c, time); mission(g, c, time);
board(g, c, time); mission(g, c, time); plungerHint(g, c, time);
g.restore();
}
// A pallina ferma, dove toccare per la molla (Andrea, 1 novembre: «sul telefono non è intuitivo premere al centro»): la
// fascia centrale di flipper.js (press), tratteggiata, e la scritta che pulsa; sparisce appena si comincia a caricare.
function plungerHint(g, c, time) {
if (!waiting(c) || c.holding) return;
const x0 = 320 - PLUNGER_ZONE + 4, x1 = 320 + PLUNGER_ZONE - 4, y0 = 196, y1 = 300, pulse = 0.8 + Math.sin(time * 4) * 0.2;
g.save(); g.globalAlpha = pulse; g.setLineDash([4, 3]);
g.strokeStyle = '#ffe08a'; g.lineWidth = 1.2; g.strokeRect(x0, y0, x1 - x0, y1 - y0); g.setLineDash([]);
box(g, 320 - 58, 262, 116, 32, '#07121c', '#ffe08a', 2);
text(g, 'TIENI PREMUTO QUI', 320, 272, 8.5, '#ffe08a', 'center', 'sans-serif', 800);
text(g, 'e lascia: MOLLA', 320, 285, 7, WHITE, 'center', 'sans-serif', 700);
g.restore();
}
+9 -2
View File
@@ -534,10 +534,17 @@ function collideFlipper(c, ball, side) {
// --- Comandi: col mouse o col dito, prima del lancio tenere premuto carica la molla e lasciare lancia; in gioco
// la metà sinistra della scena alza il flipper sinistro, la destra il destro, finché si tiene premuto (anche due dita).
export function aimScreen(c, [x, y]) { if (!Number.isFinite(x) || !Number.isFinite(y)) return false; c.cursor = [x, y]; return true; }
// Un tocco (Andrea, 1 novembre: «il pollice destro sulla metà destra, il sinistro sulla sinistra, al centro per lanciare»):
// al centro della scena, a pallina ferma, carica la molla; ai lati alza la paletta di quel lato, anche prima del lancio.
// La tastiera ('key': Spazio, freccia giù) carica sempre.
export const PLUNGER_ZONE = 64; // mezza larghezza della fascia centrale, in pixel di scena (256-384 su 640)
export function press(c, pointerId = 'ptr') {
if (c.step !== 'play') return false;
if (waiting(c)) { if (c.holding) return false; c.holding = true; c.holdTime = 0; c.pointerId = pointerId; c.power = 0; return true; }
return hold(c, pointerId === 'mouse-right' || (c.cursor?.[0] ?? 320) >= 320 ? 'right' : 'left', pointerId, true);
const x = c.cursor?.[0] ?? 320;
if (waiting(c) && pointerId !== 'mouse-right' && (pointerId === 'key' || Math.abs(x - 320) < PLUNGER_ZONE)) {
if (c.holding) return false; c.holding = true; c.holdTime = 0; c.pointerId = pointerId; c.power = 0; return true;
}
return hold(c, pointerId === 'mouse-right' || x >= 320 ? 'right' : 'left', pointerId, true);
}
export function release(c, pointerId = 'ptr') {
if (c.holding && c.pointerId === pointerId) return launch(c, powerAt(c.holdTime));
+51 -7
View File
@@ -399,16 +399,52 @@ addEventListener('pagehide', () => leave(true));
addEventListener('pageshow', () => leave(document.hidden));
$('resume').addEventListener('click', closePause);
$('pause-map').addEventListener('click', () => { closePause(); openMap(); });
// Schermo intero dalla Pausa, dove il browser lo permette (su iPhone no: il pulsante sparisce); Esc o il pulsante per uscire.
// I comandi di un minigioco (molla, pulsanti, carte, «Come si gioca») sono più alti del pannello dei verbi: con quelli
// aperti la scena lascia intorno lo spazio che occupano davvero, fasce comprese (--below, misurato), così su uno schermo grande o a schermo
// intero non finiscono sotto il bordo. Senza comandi vale la riserva del pannello in unità (style.css, #app).
// La misura cambia la scena solo per differenze grandi: una frase di stato che va a capo (PONTE SX al flipper) non deve
// ridimensionarla a ogni colpo, che a schermo sembra un piano che salta di qualche pixel (Andrea, 1 novembre). E «Come si
// gioca» aperto non conta: se lo si apre, la pagina scorre invece di stringere la scena (e poi non torna larga).
function fitControls() {
const app = $('app'), open = !!document.querySelector('#side > section[id$="-controls"]:not([hidden])');
if (!app.classList.toggle('controls', open)) { app.style.removeProperty('--below'); return; }
// Tutto quello che non è la scena 16:9: le fasce di audio, pausa e uscita e la pulsantiera (dalla larghezza, non
// dalla tela, che mentre cambia risoluzione può avere per un attimo un'altezza qualunque).
const box = app.getBoundingClientRect(), help = document.querySelector('#side details[id$="-help"][open]');
const extra = help ? help.getBoundingClientRect().height - help.querySelector('summary').getBoundingClientRect().height : 0;
const below = Math.ceil(box.height - box.width * 9 / 16 - extra), was = parseFloat(app.style.getPropertyValue('--below'));
// Cresce subito (niente deve finire sotto il bordo), cala solo per differenze grandi (niente salti a ogni frase).
if (!(below <= was + 1 && below >= was - 24)) app.style.setProperty('--below', `${below}px`);
}
// «Solo tavola» al flipper (Andrea, 1 novembre): nasconde la pulsantiera, la tavola prende tutta l'altezza; il browser
// se lo ricorda (sul telefono in orizzontale la pulsantiera del flipper è già nascosta, style.css).
let tableOnly = (() => { try { return storage?.getItem('regolo-solo-tavola') === '1'; } catch { return false; } })();
$('table-only').addEventListener('click', e => {
tableOnly = !tableOnly;
try { storage?.setItem('regolo-solo-tavola', tableOnly ? '1' : '0'); } catch { /* niente memoria: vale per questa volta */ }
e.currentTarget.blur();
});
// Schermo intero, dove il browser lo permette (su iPhone no: i pulsanti spariscono): dalla Pausa, dal pulsante accanto ai
// volumi (anche sul titolo) o col tasto F; Esc, il pulsante o F per uscire.
function toggleFullscreen() {
if (!document.fullscreenEnabled) return false;
if (document.fullscreenElement) document.exitFullscreen();
else document.documentElement.requestFullscreen({ navigationUI: 'hide' }).catch(() => {});
return true;
}
{
const full = $('fullscreen');
if (!document.fullscreenEnabled) full.hidden = true;
const full = $('fullscreen'), hud = $('fullscreen-hud');
if (!document.fullscreenEnabled) full.hidden = hud.hidden = true;
else {
full.addEventListener('click', () => {
if (document.fullscreenElement) document.exitFullscreen();
else document.documentElement.requestFullscreen({ navigationUI: 'hide' }).catch(() => {});
full.addEventListener('click', toggleFullscreen);
hud.addEventListener('click', e => { toggleFullscreen(); e.currentTarget.blur(); });
document.addEventListener('fullscreenchange', () => {
const on = !!document.fullscreenElement;
full.textContent = on ? 'Esci da schermo intero' : 'Schermo intero';
hud.setAttribute('aria-label', on ? 'Esci da schermo intero' : 'Schermo intero');
hud.title = on ? 'Esci da schermo intero (F o Esc)' : 'Schermo intero (F)';
hud.setAttribute('aria-pressed', on);
});
document.addEventListener('fullscreenchange', () => { full.textContent = document.fullscreenElement ? 'Esci da schermo intero' : 'Schermo intero'; });
}
}
$('to-title').addEventListener('click', () => {
@@ -838,6 +874,8 @@ window.addEventListener('keydown', e => {
return;
}
if (debugKey(e)) return;
// F: schermo intero ovunque (nessun gioco usa la F; nella ricerca della mappa si scrive, sopra).
if ((e.key === 'f' || e.key === 'F') && !e.ctrlKey && !e.metaKey && !e.altKey && !e.repeat && toggleFullscreen()) { e.preventDefault(); return; }
if (!$('tutorial').hidden) { if (e.key === 'Escape') closeTutorial(); return; }
if (!$('credits').hidden) {
if (e.key === 'Escape') { if (!$('face').hidden) $('face').hidden = true; else $('credits').hidden = true; }
@@ -1170,6 +1208,12 @@ function updateUi() {
// 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', 'rinquarto', 'gara', 'micro', 'cerbottana', 'condor', 'campana', 'flipper'].includes(story.closeups[c.id].type));
$('stage').classList.toggle('briscola', !!cards);
const pinball = story.closeups[c?.id]?.type === 'flipper';
$('app').classList.toggle('flipper', pinball);
$('app').classList.toggle('table-only', pinball && tableOnly);
setProp($('table-only'), 'hidden', !pinball);
setText($('table-only'), tableOnly ? 'Pulsantiera' : 'Solo tavola');
fitControls();
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;