diff --git a/assets/closeups/river-flipper-tabellone.png b/assets/closeups/river-flipper-tabellone.png new file mode 100644 index 0000000..76d5587 Binary files /dev/null and b/assets/closeups/river-flipper-tabellone.png differ diff --git a/docs/STATO.md b/docs/STATO.md index c1b6e5c..d8527b5 100644 --- a/docs/STATO.md +++ b/docs/STATO.md @@ -76,6 +76,11 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto, sulla tavola e la scossa del mobile, la scia della pallina; sul tabellone la regia (la corona al traguardo, le bandiere al Palio, i due archi ai ponti) e le fanfare. Ponti, sponde e griglia dei punti spenti in cache su tele a parte (l'arretrato grafico con swiftshader da 8 s a meno di un secondo). + Il tabellone è un vero «backglass» (Andrea: «col Trebbio, la Fortezza e effetti speciali»): fondale di Codex + (`assets/closeups/river-flipper-tabellone.png`, 200×360: il Castello del Trebbio, la Fortezza sotto i fasci di luce, + il Regolo coi fulmini, i fuochi nei colori dei rioni), le cifre nella finestra nera, le lampadine sui bordi, i cinque + dischi in basso (palline e rioni), i ponti ai lati della finestra, il fondale che lampeggia ai colpi grossi. + [DA ANDREA] un record per livello che resti salvato (suggerito da Codex). - **Dedica allo zio Mario (31 ottobre)**: nei Ringraziamenti, sotto quella al Lele e più piccola: «Al mio caro zio Mario, che mi ha fatto vivere tante avventure più belle della fantasia.» - **Il Taglia (31 ottobre)**: tolte le battute sulla sua altezza (Baldoria: non gli fa piacere). diff --git a/src/engine/flipper-render.js b/src/engine/flipper-render.js index be695e0..4e47293 100644 --- a/src/engine/flipper-render.js +++ b/src/engine/flipper-render.js @@ -158,7 +158,11 @@ function dots(ctx, line, x, y, pitch, on, off = 'rgba(255,170,60,0.12)', align = // Il tabellone ricorda i punti mostrati (che corrono dietro a quelli veri) e la scritta che lampeggia: stato di sola // presentazione, fuori dalla partita (così disegnare non cambia niente). const shown = new WeakMap(); -function board(ctx, c, time) { +// Il tabellone è il fondale luminoso di Codex (il «backglass»: il Trebbio, la Fortezza, il Regolo, i fuochi) sul +// fianco sinistro, 200×360: le cifre nella finestra nera, le lampadine sui bordi che girano, i cinque dischi in basso +// per le palline e i rioni, i ponti ai lati della finestra. Senza il fondale (tests), un pannello scuro. +const GLASS = { window: [20, 78, 160, 46], sockets: [[23, 329], [67, 329], [100, 329], [133, 329], [169, 329]], lampX: [8, 192], lampY0: 16, lampStep: 20 }; +function board(ctx, c, time, glass) { let d = shown.get(c); if (!d) { d = { from: c.score, to: c.score, t0: time, flash: 0, lineId: 0 }; shown.set(c, d); } // Le cifre corrono: da dove erano a dove devono arrivare, in mezzo secondo, a scatti di dieci; a partita finita @@ -166,55 +170,54 @@ function board(ctx, c, time) { if (d.to !== c.score) { d.from = shownScore(d, time); d.to = c.score; d.t0 = time; } const score = c.step === 'result' ? c.score : shownScore(d, time), hot = score !== c.score; if (c.lineId !== d.lineId) { d.lineId = c.lineId; d.flash = time; } - const x0 = 16, y0 = 50, w = 172, h = 180; - ctx.fillStyle = '#0c0806'; ctx.fillRect(x0, y0, w, h); - ctx.strokeStyle = '#5a4a30'; ctx.lineWidth = 2; ctx.strokeRect(x0 + 1, y0 + 1, w - 2, h - 2); - // Le lampadine intorno: tranquille di solito, impazzite per due secondi a ogni scritta. - const party = c.line && time - d.flash < 2, beat = time * (party ? 14 : 2); + const party = c.line && time - d.flash < 2, [wx, wy, ww, wh] = GLASS.window; + if (glass) ctx.drawImage(glass, 0, 0, 200, 360); + else { ctx.fillStyle = '#0c0806'; ctx.fillRect(0, 0, 200, 360); ctx.strokeStyle = '#5a4a30'; ctx.lineWidth = 2; ctx.strokeRect(wx - 3, wy - 3, ww + 6, wh + 6); } + // Alla festa il fondale lampeggia (un lampo breve, solo sul fianco: una passata piccola). + if (party && Math.sin(time * 12) > 0.6) { ctx.globalAlpha = 0.25; ctx.fillStyle = '#fff6c8'; ctx.fillRect(0, 0, 200, 360); ctx.globalAlpha = 1; } + // Le lampadine sui bordi: tranquille di solito, impazzite per due secondi a ogni scritta. + const beat = time * (party ? 14 : 2); let n = 0; - const lamp = (x, y) => { const k = (n++ + Math.floor(beat)) % 3; ctx.fillStyle = k === 0 ? '#ffd45a' : k === 1 ? (party ? '#ff5a3a' : '#c8842a') : '#5a3a1e'; ctx.fillRect(x - 1, y - 1, 3, 3); }; - for (let x = x0 + 6; x < x0 + w - 4; x += 8) lamp(x, y0 + 4); - for (let y = y0 + 12; y < y0 + h - 4; y += 8) lamp(x0 + w - 5, y); - for (let x = x0 + w - 10; x > x0 + 4; x -= 8) lamp(x, y0 + h - 5); - for (let y = y0 + h - 12; y > y0 + 4; y -= 8) lamp(x0 + 4, y); - text(ctx, 'FLIPPER DEL RIVER', x0 + w / 2, y0 + 16, 7, '#efd49e', 'center'); - // I punti, a cifre grosse color ambra col bagliore caldo dietro: sei cifre larghe, da un milione in su più fitte. - ctx.save(); ctx.beginPath(); ctx.rect(x0 + 8, y0 + 24, w - 16, h - 32); ctx.clip(); + const lamp = (x, y) => { const k = (n++ + Math.floor(beat)) % 3; ctx.fillStyle = k === 0 ? '#ffd45a' : k === 1 ? (party ? '#ff5a3a' : '#c8842a') : '#5a3a1e'; ctx.fillRect(x - 2, y - 2, 4, 4); }; + for (let y = GLASS.lampY0; y < 300; y += GLASS.lampStep) lamp(GLASS.lampX[0], y); + for (let y = 300 - ((300 - GLASS.lampY0) % GLASS.lampStep); y >= GLASS.lampY0; y -= GLASS.lampStep) lamp(GLASS.lampX[1], y); + // La finestra: le cifre grosse color ambra col bagliore caldo dietro (sei cifre larghe, da un milione in su più + // fitte) e sotto la scritta: l'ultimo evento (lampeggia per due secondi), se no l'esito o il traguardo. + ctx.save(); ctx.beginPath(); ctx.rect(wx, wy, ww, wh); ctx.clip(); + ctx.fillStyle = '#000'; ctx.fillRect(wx, wy, ww, wh); const digits = String(Math.round(score)).padStart(6, '0'), pitch = digits.length > 6 ? 3 : 4; - ctx.fillStyle = hot ? 'rgba(255,120,30,0.22)' : 'rgba(255,120,30,0.12)'; ctx.fillRect(x0 + 10, y0 + 26, w - 20, 34); - dots(ctx, digits, x0 + w / 2, y0 + 29 + (4 - pitch) * 3, pitch, hot ? '#fff0a0' : '#ffb13a', 'rgba(255,170,60,0.1)', 'center'); - // La scritta grossa: l'ultimo evento (lampeggia per due secondi), se no l'esito o il traguardo. + ctx.fillStyle = hot ? 'rgba(255,120,30,0.25)' : 'rgba(255,120,30,0.12)'; ctx.fillRect(wx + 2, wy + 1, ww - 4, 29); + dots(ctx, digits, wx + ww / 2, wy + 1 + (4 - pitch) * 3, pitch, hot ? '#fff0a0' : '#ffb13a', 'rgba(255,170,60,0.1)', 'center'); const won = c.result?.winner === 'player'; const line = c.step === 'result' ? (won ? 'TRAGUARDO!' : 'FINITA') : c.line ?? (won ? 'TRAGUARDO!' : `TRAG. ${c.skill.goal}`); const flashing = c.line && c.step !== 'result' && time - d.flash < 2, visible = !flashing || Math.sin(time * 14) > -0.3; - if (visible) dots(ctx, line.slice(0, 12), x0 + w / 2, y0 + 68, line.length <= 8 ? 3 : 2, flashing ? '#fff6c8' : '#f0a24a', 'rgba(255,170,60,0.08)', 'center'); - if (flashing) regia(ctx, c.line, time - d.flash, x0, y0, w); + if (visible) dots(ctx, line.slice(0, 12), wx + ww / 2, wy + 31, 2, flashing ? '#fff6c8' : '#f0a24a', 'rgba(255,170,60,0.08)', 'center'); + if (flashing) regia(ctx, c.line, time - d.flash, wx, wy, ww, wh); ctx.restore(); - // Palline, salvataggio, rioni e ponti: in chiaro, leggibili anche sul telefono. - text(ctx, `PALLINE ${c.step === 'result' ? 0 : c.balls}`, x0 + 10, y0 + 102, 7, '#f0a24a'); + // I ponti: due lampade ai lati della finestra (accese se fatte; quella da fare lampeggia). + c.ramps.forEach((r, i) => disc(ctx, i ? wx + ww + 8 : wx - 8, wy + wh / 2, 6, r.lit > 0 || c.lastRamp?.id === r.id ? '#ffd45a' : c.lastRamp && Math.sin(time * 6) > 0 ? '#fff6c8' : '#2a2420', '#8a7a5a')); + // I cinque dischi in basso: le palline che restano, e i quattro rioni accesi. + const [[bx, by], ...rioni] = GLASS.sockets; + disc(ctx, bx, by, 11, '#2a2420', '#8a7a5a'); text(ctx, String(c.step === 'result' ? 0 : c.balls), bx, by + 1, 9, '#ffd45a', 'center'); + rioni.forEach(([x, y], i) => { const l = c.lanes[i]; disc(ctx, x, y, 11, l.lit ? COLORS[l.rione] : '#2a2420', l.lit ? '#fff4c8' : '#8a7a5a'); }); + // Sotto, in piccolo: il salvataggio o la combo. const save = saveLeft(c); - if (save > 0) text(ctx, `SALVA ${Math.ceil(save)} s`, x0 + w - 10, y0 + 102, 7, '#9fd27a', 'right'); - else if (c.ball && !c.ball.waiting && c.saved) text(ctx, 'SENZA RETE', x0 + w - 10, y0 + 102, 6, '#a08a60', 'right'); - text(ctx, 'RIONI', x0 + 10, y0 + 124, 7, '#f0a24a'); - c.lanes.forEach((l, i) => disc(ctx, x0 + 86 + i * 22, y0 + 124, 7, l.lit ? COLORS[l.rione] : '#2a2420', l.lit ? '#fff4c8' : '#5a5040')); - text(ctx, 'PONTI', x0 + 10, y0 + 146, 7, '#f0a24a'); - c.ramps.forEach((r, i) => disc(ctx, x0 + 86 + i * 22, y0 + 146, 7, r.lit > 0 || c.lastRamp?.id === r.id ? '#ffd45a' : '#2a2420', r.lit > 0 ? '#fff4c8' : '#5a5040')); - if (c.lastRamp) text(ctx, `L'ALTRO ENTRO ${Math.ceil(12 - (c.sim - c.lastRamp.at))} s`, x0 + w - 10, y0 + 164, 6, '#ffe08c', 'right'); - else text(ctx, 'ANDREA', x0 + 10, y0 + 164, 6, '#a9a08a'); + const note = save > 0 ? `PALLINA SALVA ${Math.ceil(save)} s` : c.lastRamp ? `L'ALTRO PONTE ENTRO ${Math.ceil(12 - (c.sim - c.lastRamp.at))} s` : c.ball && !c.ball.waiting && c.saved ? 'SENZA RETE' : ''; + if (note) text(ctx, note, 100, 352, 6, save > 0 ? '#9fd27a' : '#ffe08c', 'center'); } // La piccola regia del tabellone ai colpi grossi: al traguardo la corona del Regolo che si compone a colonne, al // Palio le quattro bandiere dei rioni che sfilano, ai ponti due archi di punti che si uniscono. const CROWN = ['10001000100010001', '11011101110111011', '11111111111111111', '01111111111111110', '01111111111111110']; -function regia(ctx, line, age, x0, y0, w) { - const cx = x0 + w / 2; +function regia(ctx, line, age, wx, wy, ww, wh) { + const cx = wx + ww / 2; if (line === 'TRAGUARDO!') { const cols = Math.min(17, Math.floor(age * 12)); - for (let row = 0; row < 5; row++) for (let col = 0; col < cols; col++) if (CROWN[row][col] === '1') { ctx.fillStyle = row < 2 ? '#fff6c8' : '#ffd45a'; ctx.fillRect(cx - 34 + col * 4, y0 + 24 + row * 4, 3, 3); } + for (let row = 0; row < 5; row++) for (let col = 0; col < cols; col++) if (CROWN[row][col] === '1') { ctx.fillStyle = row < 2 ? '#fff6c8' : '#ffd45a'; ctx.fillRect(cx - 34 + col * 4, wy + 2 + row * 3, 3, 2); } } else if (line === 'PALIO!') { - ['#d0453a', '#f2eee2', '#3fa34d', '#2f6fd0'].forEach((color, i) => { const x = x0 + 10 + ((age * 140 + i * 40) % (w - 20)); ctx.fillStyle = color; ctx.fillRect(Math.round(x), y0 + 60, 8, 6); ctx.fillRect(Math.round(x), y0 + 88, 8, 6); }); + ['#d0453a', '#f2eee2', '#3fa34d', '#2f6fd0'].forEach((color, i) => { const x = wx + 2 + ((age * 140 + i * 40) % (ww - 4)); ctx.fillStyle = color; ctx.fillRect(Math.round(x), wy + 1, 8, 3); ctx.fillRect(Math.round(x), wy + wh - 4, 8, 3); }); } else if (line === 'I PONTI!') { const k = Math.min(1, age / 0.8); - for (let i = 0; i <= 16 * k; i++) { const a = Math.PI + i / 16 * Math.PI; ctx.fillStyle = i % 2 ? '#ffd45a' : '#fff6c8'; ctx.fillRect(Math.round(cx - 60 + Math.cos(a) * 30 + 30), Math.round(y0 + 94 + Math.sin(a) * 10), 3, 3); ctx.fillRect(Math.round(cx + 60 - Math.cos(a) * 30 - 30), Math.round(y0 + 94 + Math.sin(a) * 10), 3, 3); } + for (let i = 0; i <= 16 * k; i++) { const a = Math.PI + i / 16 * Math.PI; ctx.fillStyle = i % 2 ? '#ffd45a' : '#fff6c8'; ctx.fillRect(Math.round(cx - 60 + Math.cos(a) * 30 + 30), Math.round(wy + wh - 3 + Math.sin(a) * 6), 3, 2); ctx.fillRect(Math.round(cx + 60 - Math.cos(a) * 30 - 30), Math.round(wy + wh - 3 + Math.sin(a) * 6), 3, 2); } } } // I punti mostrati dal tabellone, che corrono da `from` a `to` in mezzo secondo. @@ -280,12 +283,12 @@ function trail(ctx, c) { ctx.globalAlpha = 1; } function result(ctx, c) { - panel(ctx, 16, 238, 172, 56); - text(ctx, c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA', 102, 254, 8, '#efd49e', 'center'); - text(ctx, `${c.result.score} su ${c.result.goal}`, 102, 274, 8, c.result.winner === 'player' ? '#9fd27a' : '#e0b07a', 'center'); + panel(ctx, 14, 236, 172, 56); + text(ctx, c.result.winner === 'player' ? 'TRAGUARDO FATTO!' : 'FINE PARTITA', 100, 252, 8, '#efd49e', 'center'); + text(ctx, `${c.result.score} su ${c.result.goal}`, 100, 272, 8, c.result.winner === 'player' ? '#9fd27a' : '#e0b07a', 'center'); } -export function drawFlipper(ctx, c, def, bg) { +export function drawFlipper(ctx, c, def, bg, glass) { const time = c.visual.time, [sx, sy] = shake(c, time); ctx.save(); if (sx || sy) { ctx.fillStyle = '#0c0806'; ctx.fillRect(0, 0, 640, 360); ctx.translate(sx, sy); } @@ -319,7 +322,7 @@ export function drawFlipper(ctx, c, def, bg) { drawFx(ctx, c, time); // Da fermo, l'invito: la molla da caricare. if (waiting(c) && !c.holding && Math.sin(time * 5) > 0) text(ctx, 'LANCIA', (LANE.x0 + LANE.x1) / 2, 300, 5, '#ffe08c', 'center'); - board(ctx, c, time); + board(ctx, c, time, glass); if (c.step === 'result') result(ctx, c); ctx.restore(); } diff --git a/src/engine/render.js b/src/engine/render.js index d5cd23f..e39d916 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -1057,8 +1057,8 @@ function drawCloseup(ctx, game, time) { return; } if (def.type === 'flipper') { - if (!backgrounds.has(def.picture)) loadBackground(def.picture); - drawFlipper(ctx, c, def, backgrounds.get(def.picture)); + for (const src of [def.picture, def.backglass]) if (src && !backgrounds.has(src)) loadBackground(src); + drawFlipper(ctx, c, def, backgrounds.get(def.picture), def.backglass && backgrounds.get(def.backglass)); return; } if (def.type === 'bruciate') { diff --git a/src/story/story.js b/src/story/story.js index 75c8934..9f96a6a 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -365,6 +365,7 @@ export const story = { flipper: { type: 'flipper', picture: 'assets/closeups/river-flipper.png', + backglass: 'assets/closeups/river-flipper-tabellone.png', // il fondale luminoso del tabellone (Codex: Trebbio, Fortezza, Regolo, fuochi) names: { player: 'Andrea', cpu: 'il flipper' }, win: [{ say: 'Traguardo! Se c\'era la classifica, oggi ci mettevo le mie iniziali.' }, { set: 'flipper_vinto' }], lose: [{ say: 'Tre palline, e tutte nella Sieve. Il flipper del River non perdona.' }], diff --git a/tests/flipper.test.mjs b/tests/flipper.test.mjs index 33f3efa..2ce65b0 100644 --- a/tests/flipper.test.mjs +++ b/tests/flipper.test.mjs @@ -5,6 +5,7 @@ import { drawFlipper } from '../src/engine/flipper-render.js'; import { Game } from '../src/engine/game.js'; import { story } from '../src/story/story.js'; import { settle } from './helpers.mjs'; +import { existsSync } from 'node:fs'; const run = (c, secs) => { for (let i = 0; i < secs * 60; i++) m.update(c, 1 / 60); }; const put = (c, ball) => Object.assign(c.ball, ball); @@ -228,7 +229,8 @@ test('il disegno del flipper non cambia lo stato; il tabellone dice l\'esito giu const c = m.newFlipper(11, { level: 'bona' }), bg = {}; const draw = () => { const before = structuredClone(c); drawFlipper(ctx, c, story.closeups.flipper, bg); assert.deepEqual(c, before); }; draw(); assert.ok(calls.some(([op, img]) => op === 'drawImage' && img === bg)); - assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'FLIPPER DEL RIVER')); + assert.ok(existsSync(new URL('../' + story.closeups.flipper.backglass, import.meta.url)), 'il fondale del tabellone esiste'); + const glass = {}; drawFlipper(ctx, c, story.closeups.flipper, bg, glass); assert.ok(calls.some(([op, img, x, y, w, h]) => op === 'drawImage' && img === glass && w === 200 && h === 360), 'il fondale sul fianco'); m.launch(c, 0.8); run(c, 1); calls.length = 0; draw(); assert.ok(calls.filter(([op]) => op === 'fillRect').length > 300, 'il tabellone a punti luminosi'); c.step = 'result'; c.result = { winner: 'player', score: 70000, goal: 60000 }; calls.length = 0; draw();