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
+8
View File
@@ -349,6 +349,14 @@ versione nuova.
`REGOLO_GPU=0/1` forza. Con 6 o 8 prove insieme il giro dura uguale: il limite è la partita intera (243 s, il tempo
del gioco, non della grafica). La pipeline dà la GPU ai job (`--gpus all`, runtime NVIDIA di Docker); runner a 6 job.
- **Schermo intero e flipper (1 novembre sera, Andrea):** pulsante ⛶ accanto ai volumi e tasto F; il gioco non si ferma
più a 1280 px (fino a 2560: a 1920×1080 la scena è 1553 px e prende tutta l'altezza). Nei minigiochi la scena lascia
sotto lo spazio vero dei comandi (`fitControls` in main.js, `--below`; cresce subito, cala solo oltre 24 px, «Come si
gioca» aperto non conta: una frase che va a capo faceva saltare il piano). Al flipper «Solo tavola» nasconde la
pulsantiera (ricordato nel browser); sul telefono in orizzontale è nascosta sempre e «Lascia stare» sta in alto. La
molla si carica toccando al centro (fascia 256-384), ai lati le palette anche prima del lancio; a pallina ferma la
tavola dice «TIENI PREMUTO QUI». Ponti rifatti da Codex (parapetti, portali, luci che seguono la pallina).
- **La prova del peso (1 novembre, Andrea: «senza GPU scoprivamo anche gli effetti troppo pesanti»):** con la scheda
video le prove non vedono più un effetto pesante. `tests/peso.browser.mjs` usa sempre swiftshader e, nel gioco vero,
in ogni scena nelle tre fasi e in ogni primo piano (il flipper anche in gioco e nel Regolo del futuro) misura
+2
View File
@@ -23,12 +23,14 @@
<button id="notebook-button" hidden title="Taccuino (N)">Taccuino</button>
<button id="spots-button" hidden title="Mostra cosa si può toccare (H)">?</button>
<button id="back-button" hidden></button>
<button id="table-only" hidden title="Nasconde la pulsantiera: il flipper a tutta altezza (si gioca con mouse e tastiera)">Solo tavola</button>
</div>
<div id="sound">
<button id="mute" aria-label="Togli l'audio" title="Audio (M)">♪</button>
<input id="volume" type="range" min="0" max="100" step="5" aria-label="Volume della musica" title="Volume della musica">
<label for="voice-volume" class="sound-label" title="Volume delle voci">bla</label>
<input id="voice-volume" type="range" min="0" max="100" step="5" aria-label="Volume delle voci" title="Volume delle voci">
<button id="fullscreen-hud" aria-label="Schermo intero" title="Schermo intero (F)">⛶</button>
</div>
<section id="splash" class="overlay" aria-label="Manzolo (Poca) Art Presents">
<p class="splash-name">Manzolo</p>
+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;
+24 -6
View File
@@ -20,15 +20,25 @@ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink);
button, input { font-family: var(--font); }
body { line-height: 1.5; }
body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-highlight-color: transparent; }
/* Il gioco è largo quanto può: tutta la finestra fino a 1280px, oppure quanto ci sta in altezza con il pannello sotto la
/* Il gioco è largo quanto può: tutta la finestra (fino a 2560px: a schermo intero su un monitor grande), oppure quanto ci sta in altezza con il pannello sotto la
scena 16:9. Il pannello (frase, poi verbi e inventario o le risposte di un dialogo) è alto --side-h unità --u, dove --u
è un centesimo della larghezza del gioco (1cqw) fra 8,75px e 12,8px: tutto il pannello cresce e cala col gioco, non con
è un centesimo della larghezza del gioco (1cqw) fra 8,75px e 25,6px: tutto il pannello cresce e cala col gioco, non con
la finestra, e la riserva in altezza è esatta (prima erano 250px fissi, un centinaio di troppo, che in una finestra
1040×590 costavano un terzo della scena). I due calc sono i due regimi di --u: proporzionale, e al minimo. */
#app { --u: clamp(8.75px, 1cqw, 12.8px); --side-h: 13.3; container-type: inline-size;
width: min(100vw, 1280px, calc(100dvh / (9 / 16 + var(--side-h) / 100)), calc((100dvh - var(--side-h) * 8.75px) * 16 / 9));
#app { --u: clamp(8.75px, 1cqw, 25.6px); --side-h: 13.3; container-type: inline-size;
width: min(100vw, 2560px, calc(100dvh / (9 / 16 + var(--side-h) / 100)), calc((100dvh - var(--side-h) * 8.75px) * 16 / 9));
min-width: min(100vw, 320px); margin-block: auto; display: flex; flex-direction: column; padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom); }
/* Coi comandi di un minigioco aperti (main.js, fitControls) la scena lascia intorno il loro spazio vero, --below; ma non
scende sotto i 700px (nelle finestre basse si scorre un poco per arrivare ai comandi, come prima). */
@media (min-width: 601px) and (min-height: 521px) {
#app.controls { width: min(100vw, 2560px, calc(100dvh / (9 / 16 + var(--side-h) / 100)), calc((100dvh - var(--side-h) * 8.75px) * 16 / 9),
max(calc((100dvh - var(--below, 0px)) * 16 / 9), 700px)); }
/* «Solo tavola» (Andrea): la pulsantiera del flipper nascosta, la tavola a tutta altezza. */
#app.controls.table-only { width: min(100vw, 2560px, calc((100dvh - var(--below, 88px)) * 16 / 9)); }
#app.table-only #side { display: none; }
}
#stage { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000; user-select: none; touch-action: manipulation; }
#scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; }
#scene.pinball-hd { image-rendering: auto; }
@@ -54,7 +64,7 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
#poker-controls summary, #briscola-controls summary { cursor: pointer; padding: 10px 0; }
#poker-controls p, #briscola-controls p { max-width: 76em; }
#bocce-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; }
#bocce-status { color: #f3dcac; margin: 0 0 10px; line-height: 1.5; min-height: 1.5em; }
#bocce-status { color: #f3dcac; margin: 0 0 10px; line-height: 1.5; min-height: 3em; } /* due righe: la scena non salta quando va a capo */
.bocce-adjust { display: flex; align-items: center; justify-content: center; gap: 12px; }
.bocce-adjust label { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; min-width: 120px; max-width: 280px; flex: 1; }
#bocce-power { width: 100%; accent-color: #b9d392; min-height: 32px; margin: 0; }
@@ -113,6 +123,8 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
#mute[aria-pressed="true"] { color: var(--dim); text-decoration: line-through; }
#volume, #voice-volume { width: calc(7.8 * var(--u)); accent-color: var(--accent); cursor: pointer; }
.sound-label { margin-left: 4px; font-size: 9px; color: var(--accent); }
#fullscreen-hud { width: 30px; height: 30px; padding: 0; font: bold 17px system-ui, sans-serif; line-height: 1; color: var(--accent); background: none; border: 0; cursor: pointer; }
#fullscreen-hud[aria-pressed="true"] { color: var(--blue); }
/* La frase: una riga sola, alta sempre uguale (2,5 --u); una frase lunghissima finisce coi puntini invece di spostare la scena. */
#sentence { height: calc(2.5 * var(--u)); line-height: 1.5; padding: .25em .6em; text-align: center; color: var(--blue); font-size: calc(1.2375 * var(--u)); font-weight: bold; background: var(--panel);
@@ -239,7 +251,7 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
/* Mappa: la pianta del paese a gradini, i posti visti come cartellini, le strade come linee. */
/* In alto a sinistra: Pausa e Mappa. */
#corner { position: absolute; top: 6px; left: 6px; z-index: 2; display: flex; gap: 6px; }
#map-button, #pause-button, #notebook-button, #spots-button, #back-button { font: bold calc(.9 * var(--u)) var(--font); padding: .35em .8em;
#map-button, #pause-button, #notebook-button, #spots-button, #back-button, #table-only { font: bold calc(.9 * var(--u)) var(--font); padding: .35em .8em;
color: var(--accent); background: rgba(27, 20, 32, .62); border: 0; border-radius: 18px; cursor: pointer; }
#back-button { color: #1b1420; background: #9fd0ff; }
/* Con molti pulsanti la Pausa può essere più alta della scena: «safe» la fa partire dall'alto e scorrere, invece di tagliare il titolo. */
@@ -355,6 +367,12 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
#stage.biliardo { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
#app.presenta { justify-content: center; }
#app.presenta #stage.biliardo { width: min(calc((100dvh - 44px) * 16 / 9), 100vw); }
/* Il flipper sul telefono (Andrea, 1 novembre): pollice sinistro sulla metà sinistra della scena, destro sulla destra,
al centro la molla. La scena prende tutta la larghezza e la pulsantiera sparisce: punti e palline sono sulla tavola. */
#app.flipper { justify-content: center; }
#app.flipper #side, #app.flipper #table-only { display: none; }
#app.flipper #stage.biliardo { width: min(calc((100dvh - 44px) * 16 / 9), 100vw); }
#app.flipper #stage.biliardo #closeup-close { position: absolute; top: 6px; bottom: auto; left: 86px; right: auto; margin: 0; min-height: 0; height: 30px; padding: 0 .9em; font-size: 10px; }
#stage.briscola { width: min(calc((100dvh - 90px) * 16 / 9), 68vw); }
#briscola-controls, #tressette-controls, #scopa-controls, #poker-controls { overflow-y: auto; min-height: 0; padding: 8px; font-size: 9px; }
/* Nella colonna stretta accanto al tavolo: carte e segni su due colonne, parole che vanno a capo. */
+16
View File
@@ -508,6 +508,22 @@ try {
assert.ok(stage0.width >= 1200, `a 1280×860 la scena è larga ${stage0.width} px`);
assert.ok(await page.evaluate(() => document.documentElement.scrollHeight <= innerHeight), 'la pagina scorre in verticale a 1280×860');
assert.ok(await page.evaluate(() => [...document.querySelectorAll('#verbs button')].every(b => b.scrollWidth <= b.clientWidth + 1 && b.scrollHeight <= b.clientHeight + 1)), 'verbi tagliati a 1280×860');
// Su uno schermo grande (1920×1080, a schermo intero) il gioco non si ferma più a 1280 px: prende tutta l'altezza.
await page.setViewportSize({ width: 1920, height: 1080 });
await page.waitForFunction(() => document.getElementById('stage').getBoundingClientRect().width > 1500);
assert.ok(await page.evaluate(() => document.documentElement.scrollHeight <= innerHeight && document.getElementById('app').getBoundingClientRect().height >= innerHeight - 2), 'a 1920×1080 il gioco prende tutta l\'altezza, senza scorrere');
await page.setViewportSize({ width: 1280, height: 860 });
// Schermo intero: il pulsante accanto ai volumi e il tasto F, per entrare e per uscire.
if (await page.evaluate(() => document.fullscreenEnabled)) {
await page.click('#fullscreen-hud');
await page.waitForFunction(() => !!document.fullscreenElement && document.getElementById('fullscreen-hud').getAttribute('aria-pressed') === 'true');
await page.keyboard.press('f');
await page.waitForFunction(() => !document.fullscreenElement);
await page.keyboard.press('F');
await page.waitForFunction(() => !!document.fullscreenElement);
await page.click('#fullscreen-hud');
await page.waitForFunction(() => !document.fullscreenElement && document.getElementById('fullscreen-hud').getAttribute('aria-pressed') === 'false');
} else assert.ok(await page.evaluate(() => document.getElementById('fullscreen-hud').hidden), 'senza schermo intero il pulsante sparisce');
// Pausa: Esc o il pulsante fermano il gioco; Riprendi torna dov'eri; il titolo offre «Continua».
await page.keyboard.press('Escape');
await page.waitForSelector('#pause:not([hidden])');
+20 -11
View File
@@ -25,16 +25,31 @@ try {
game.act('use', 'river'); settle(game);
game.choose(game.options().findIndex(o => o.text.startsWith('Una partita alla bona'))); settle(game);
});
await page.waitForSelector('#bocce-controls:not([hidden])');
await page.waitForSelector('#bocce-controls:not([hidden])', { state: 'attached' });
await page.waitForFunction(async () => (await import('/src/engine/render.js')).hasBackground('assets/closeups/river-flipper.png'));
// Aspetta che finisca la dissolvenza d'ingresso: le immagini devono mostrare i colori della tavola.
const readyAt = await page.evaluate(() => game.closeup.visual.time);
await page.waitForFunction(t => game.closeup.visual.time > t + 1, readyAt);
assert.equal(await page.locator('#scene').getAttribute('width'), '1280', 'il flipper usa la risoluzione doppia');
assert.equal(await page.locator('#bocce-throw').textContent(), 'Lancia');
assert.ok(await page.locator('#flipper-help').isVisible());
assert.equal(await page.locator('#bocce-left').textContent(), '← Flipper');
assert.ok(!(await page.locator('#bocce-left').isDisabled()), 'i pulsanti dei flipper sono attivi anche prima del lancio');
if (!mobile) {
// «Solo tavola»: la pulsantiera sparisce e la tavola si allarga; ripremuto, torna tutto com'era.
const before = (await page.locator('#scene').boundingBox()).width;
await page.click('#table-only');
await page.waitForFunction(() => getComputedStyle(document.getElementById('side')).display === 'none');
await page.waitForFunction(w => document.getElementById('scene').getBoundingClientRect().width > w + 50, before);
assert.equal(await page.locator('#table-only').textContent(), 'Pulsantiera');
await page.click('#table-only');
await page.waitForFunction(() => getComputedStyle(document.getElementById('side')).display !== 'none');
assert.ok(await page.locator('#flipper-help').isVisible());
assert.equal(await page.locator('#bocce-left').textContent(), '← Flipper');
assert.ok(!(await page.locator('#bocce-left').isDisabled()), 'i pulsanti dei flipper sono attivi anche prima del lancio');
} else {
// Sul telefono in orizzontale (Andrea): niente pulsantiera, la scena larga sotto i due pollici.
assert.ok(!(await page.locator('#side').isVisible()), 'sul telefono la pulsantiera del flipper sparisce');
const w = (await page.locator('#scene').boundingBox()).width;
assert.ok(w > viewport.width * 0.6, `sul telefono la scena del flipper è larga ${w} px`);
}
await page.screenshot({ path: `${out}/${name}-tavola.png` });
const box = await page.locator('#scene').boundingBox(), P = (x, y) => [box.x + x / 640 * box.width, box.y + y / 360 * box.height];
if (!mobile) await page.screenshot({ path: `${out}/desktop-dettaglio.png`, clip: { x: P(200, 0)[0], y: box.y, width: 248 / 640 * box.width, height: box.height } });
@@ -42,7 +57,7 @@ try {
const touch = (type, points) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints: points.map(([x, y]) => ({ x, y })) });
// La molla: tenendo premuto sulla scena, col mouse e col dito.
if (mobile) {
await touch('touchStart', [P(440, 320)]);
await touch('touchStart', [P(320, 320)]); // al centro: la molla
await page.waitForFunction(() => game.closeup.holding && game.closeup.power > 0.3, null, { polling: 'raf' });
await touch('touchEnd', []);
} else {
@@ -63,12 +78,6 @@ try {
await page.waitForFunction(() => game.closeup.flippers.left.held.length === 0 && game.closeup.flippers.right.held.length > 0, null, { timeout: 3000 });
await touch('touchEnd', []);
await page.waitForFunction(() => game.closeup.flippers.right.held.length === 0);
// I pulsanti tenuti premuti.
const b = await page.locator('#bocce-left').boundingBox();
await touch('touchStart', [[b.x + b.width / 2, b.y + b.height / 2]]);
await page.waitForFunction(() => game.closeup.flippers.left.held.length > 0);
await touch('touchEnd', []);
await page.waitForFunction(() => game.closeup.flippers.left.held.length === 0);
} else {
await page.mouse.move(...P(400, 200)); await page.mouse.down();
await page.waitForFunction(() => game.closeup.flippers.right.held.length > 0);
+7 -3
View File
@@ -130,13 +130,17 @@ test('funghi, bersagli, Regolo e corsie fanno punti solo a un urto nuovo; le qua
assert.ok(!c.lanes[1].lit);
});
test('comandi: la molla si carica e si lancia lasciando; annullare non lancia; due dita, pulsanti e tasti', () => {
test('comandi: la molla si carica al centro e si lancia lasciando, ai lati le palette anche prima del lancio; annullare non lancia; due dita, pulsanti e tasti', () => {
const c = m.newFlipper(8, { level: 'bona' });
m.aimScreen(c, [440, 320]); assert.ok(m.press(c, 1)); run(c, 0.6);
// A pallina ferma i lati alzano le palette, il centro carica la molla.
m.aimScreen(c, [120, 300]); assert.ok(m.press(c, 5)); assert.ok(c.flippers.left.held.includes(5) && !c.holding, 'a sinistra, la paletta');
m.aimScreen(c, [520, 300]); assert.ok(m.press(c, 6)); assert.ok(c.flippers.right.held.includes(6) && !c.holding, 'a destra, la paletta');
m.release(c, 5); m.release(c, 6); assert.ok(m.waiting(c));
m.aimScreen(c, [320, 320]); assert.ok(m.press(c, 1)); run(c, 0.6);
assert.ok(c.holding && c.power > 0.4 && c.power < 0.6, `molla a ${c.power.toFixed(2)}`);
assert.ok(!m.release(c, 2), 'un altro dito non lancia');
m.cancel(c, 1); assert.ok(m.waiting(c) && !c.holding, 'annullata la carica: niente lancio');
m.aimScreen(c, [440, 320]); m.press(c, 1); run(c, 0.6); assert.ok(m.release(c, 1)); assert.ok(!m.waiting(c));
m.aimScreen(c, [300, 320]); m.press(c, 1); run(c, 0.6); assert.ok(m.release(c, 1)); assert.ok(!m.waiting(c));
m.aimScreen(c, [250, 200]); assert.ok(m.press(c, 3)); assert.ok(c.flippers.left.held.includes(3));
m.aimScreen(c, [400, 200]); assert.ok(m.press(c, 4)); assert.ok(c.flippers.right.held.includes(4));
assert.ok(m.release(c, 3)); assert.ok(!c.flippers.left.held.length && c.flippers.right.held.includes(4));
+19 -5
View File
@@ -13,18 +13,32 @@ async function tap(page, mode, selector) {
if (mode === 'touch') { const r = await page.locator(selector).boundingBox(); await page.touchscreen.tap(r.x + r.width / 2, r.y + r.height / 2); }
else await page.click(selector);
}
// Una battuta che compare proprio in quell'istante (le battute del giocatore partono dal ciclo del gioco) si prende il
// tasto e la salta, come nel gioco: allora, come un giocatore, si ripreme. Al più tre volte.
async function pressUntil(page, key, done, arg) {
for (let i = 0; i < 3; i++) {
await page.keyboard.press(key);
if (await page.evaluate(done, arg)) return;
await page.waitForFunction(() => !game.speech);
}
}
async function playCard(page, mode, index, signal) {
// Un tasto premuto mentre qualcuno parla salta la battuta e basta (come nel gioco): si aspetta che abbia finito.
if (mode === 'keyboard') await page.waitForFunction(() => !game.speech);
// Un tasto premuto mentre qualcuno parla salta la battuta e basta, e il segnale si sceglie solo quando tocca a noi
// (come nel gioco): si aspetta che la battuta sia finita e che si possa giocare.
if (mode === 'keyboard') {
await page.evaluate(async () => { window.tressetteRules ??= await import('/src/engine/tressette.js'); });
await page.waitForFunction(() => !game.speech && tressetteRules.canPlay(game.closeup));
}
if (signal) {
if (mode === 'keyboard') await page.keyboard.press(signal[0]);
if (mode === 'keyboard') await pressUntil(page, signal[0], s => game.closeup.signal === s, signal);
else await tap(page, mode, `#tressette-${signal}`);
assert.equal(await page.evaluate(() => game.closeup.signal), signal);
}
if (mode === 'keyboard') {
await page.keyboard.press(String((index + 1) % 10));
await pressUntil(page, String((index + 1) % 10), i => game.closeup.selected === i, index);
assert.equal(await page.evaluate(() => game.closeup.selected), index);
await page.keyboard.press('Enter');
const held = await page.evaluate(() => game.closeup.hands[0].length);
await pressUntil(page, 'Enter', n => game.closeup.hands[0].length < n || !!game.closeup.refused, held);
return;
}
const at = await page.evaluate(async i => {