From 531aee18bf39366aeada6ef023aa194876784aa2 Mon Sep 17 00:00:00 2001 From: Andrea Manzi Date: Sun, 1 Nov 2026 21:38:44 +0100 Subject: [PATCH] =?UTF-8?q?Schermo=20intero=20(pulsante=20accanto=20ai=20v?= =?UTF-8?q?olumi=20e=20tasto=20F)=20e=20gioco=20fino=20a=202560=20px;=20ne?= =?UTF-8?q?i=20minigiochi=20la=20scena=20lascia=20lo=20spazio=20vero=20dei?= =?UTF-8?q?=20comandi;=20flipper:=20=C2=ABSolo=20tavola=C2=BB,=20sul=20tel?= =?UTF-8?q?efono=20senza=20pulsantiera=20e=20=C2=ABLascia=20stare=C2=BB=20?= =?UTF-8?q?in=20alto,=20molla=20toccando=20al=20centro=20con=20=C2=ABTIENI?= =?UTF-8?q?=20PREMUTO=20QUI=C2=BB,=20ponti=20rifatti=20da=20Codex;=20prove?= =?UTF-8?q?=20pi=C3=B9=20salde=20(tressette)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T --- docs/STATO.md | 8 +++ index.html | 2 + src/engine/flipper-render.js | 94 ++++++++++++++++++++++++++++++------ src/engine/flipper.js | 11 ++++- src/main.js | 58 +++++++++++++++++++--- style.css | 30 +++++++++--- tests/browser.mjs | 16 ++++++ tests/flipper.browser.mjs | 31 +++++++----- tests/flipper.test.mjs | 10 ++-- tests/tressette.browser.mjs | 24 +++++++-- 10 files changed, 234 insertions(+), 50 deletions(-) diff --git a/docs/STATO.md b/docs/STATO.md index c7ca505..b54abf4 100644 --- a/docs/STATO.md +++ b/docs/STATO.md @@ -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 diff --git a/index.html b/index.html index 51b02b1..c6e8b9d 100644 --- a/index.html +++ b/index.html @@ -23,12 +23,14 @@ +
+

Manzolo

diff --git a/src/engine/flipper-render.js b/src/engine/flipper-render.js index 7b358f6..46b4bda 100644 --- a/src/engine/flipper-render.js +++ b/src/engine/flipper-render.js @@ -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(); } diff --git a/src/engine/flipper.js b/src/engine/flipper.js index 71d457f..823733f 100644 --- a/src/engine/flipper.js +++ b/src/engine/flipper.js @@ -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)); diff --git a/src/main.js b/src/main.js index 7b518ef..3bcb010 100644 --- a/src/main.js +++ b/src/main.js @@ -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; diff --git a/style.css b/style.css index f58aa29..662940f 100644 --- a/style.css +++ b/style.css @@ -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. */ diff --git a/tests/browser.mjs b/tests/browser.mjs index 33b0573..2d351f1 100644 --- a/tests/browser.mjs +++ b/tests/browser.mjs @@ -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])'); diff --git a/tests/flipper.browser.mjs b/tests/flipper.browser.mjs index 38a4a2b..f78e077 100644 --- a/tests/flipper.browser.mjs +++ b/tests/flipper.browser.mjs @@ -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); diff --git a/tests/flipper.test.mjs b/tests/flipper.test.mjs index 12aa503..e7c2312 100644 --- a/tests/flipper.test.mjs +++ b/tests/flipper.test.mjs @@ -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)); diff --git a/tests/tressette.browser.mjs b/tests/tressette.browser.mjs index b9174a8..e78be3e 100644 --- a/tests/tressette.browser.mjs +++ b/tests/tressette.browser.mjs @@ -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 => {