diff --git a/assets/closeups/micro/arancio-grande.png b/assets/closeups/micro/arancio-grande.png new file mode 100644 index 0000000..7043dcd Binary files /dev/null and b/assets/closeups/micro/arancio-grande.png differ diff --git a/assets/closeups/micro/azzurra-grande.png b/assets/closeups/micro/azzurra-grande.png new file mode 100644 index 0000000..41217da Binary files /dev/null and b/assets/closeups/micro/azzurra-grande.png differ diff --git a/assets/closeups/micro/blu-grande.png b/assets/closeups/micro/blu-grande.png new file mode 100644 index 0000000..ccdf8b7 Binary files /dev/null and b/assets/closeups/micro/blu-grande.png differ diff --git a/assets/closeups/micro/nera-grande.png b/assets/closeups/micro/nera-grande.png new file mode 100644 index 0000000..8d4f410 Binary files /dev/null and b/assets/closeups/micro/nera-grande.png differ diff --git a/assets/closeups/micro/rossa-grande.png b/assets/closeups/micro/rossa-grande.png new file mode 100644 index 0000000..e57c11a Binary files /dev/null and b/assets/closeups/micro/rossa-grande.png differ diff --git a/assets/closeups/micro/viola-grande.png b/assets/closeups/micro/viola-grande.png new file mode 100644 index 0000000..15168be Binary files /dev/null and b/assets/closeups/micro/viola-grande.png differ diff --git a/assets/voices/andrea-17216997e2.mp3 b/assets/voices/andrea-17216997e2.mp3 new file mode 100644 index 0000000..d3e8373 Binary files /dev/null and b/assets/voices/andrea-17216997e2.mp3 differ diff --git a/assets/voices/andrea-4089f348c4.mp3 b/assets/voices/andrea-4089f348c4.mp3 deleted file mode 100644 index 7f90a5c..0000000 Binary files a/assets/voices/andrea-4089f348c4.mp3 and /dev/null differ diff --git a/assets/voices/andrea-44037129d8.mp3 b/assets/voices/andrea-44037129d8.mp3 new file mode 100644 index 0000000..ee26a28 Binary files /dev/null and b/assets/voices/andrea-44037129d8.mp3 differ diff --git a/assets/voices/andrea-75b58c99e6.mp3 b/assets/voices/andrea-75b58c99e6.mp3 deleted file mode 100644 index c293793..0000000 Binary files a/assets/voices/andrea-75b58c99e6.mp3 and /dev/null differ diff --git a/assets/voices/andrea-8052393bfd.mp3 b/assets/voices/andrea-8052393bfd.mp3 deleted file mode 100644 index 0d9f946..0000000 Binary files a/assets/voices/andrea-8052393bfd.mp3 and /dev/null differ diff --git a/assets/voices/andrea-cb2fa74266.mp3 b/assets/voices/andrea-cb2fa74266.mp3 new file mode 100644 index 0000000..ab464b8 Binary files /dev/null and b/assets/voices/andrea-cb2fa74266.mp3 differ diff --git a/assets/voices/andrea-d48ea04521.mp3 b/assets/voices/andrea-d48ea04521.mp3 new file mode 100644 index 0000000..2f1caf8 Binary files /dev/null and b/assets/voices/andrea-d48ea04521.mp3 differ diff --git a/assets/voices/andrea-da5fb02d21.mp3 b/assets/voices/andrea-da5fb02d21.mp3 deleted file mode 100644 index 2a13c37..0000000 Binary files a/assets/voices/andrea-da5fb02d21.mp3 and /dev/null differ diff --git a/assets/voices/andrea-fc5a08bbd6.mp3 b/assets/voices/andrea-fc5a08bbd6.mp3 new file mode 100644 index 0000000..c557883 Binary files /dev/null and b/assets/voices/andrea-fc5a08bbd6.mp3 differ diff --git a/assets/voices/chiaru-841aa1efd7.mp3 b/assets/voices/chiaru-841aa1efd7.mp3 deleted file mode 100644 index 3ae4331..0000000 Binary files a/assets/voices/chiaru-841aa1efd7.mp3 and /dev/null differ diff --git a/assets/voices/chiaru-c106f9e9c4.mp3 b/assets/voices/chiaru-c106f9e9c4.mp3 new file mode 100644 index 0000000..f9b77df Binary files /dev/null and b/assets/voices/chiaru-c106f9e9c4.mp3 differ diff --git a/assets/voices/index.json b/assets/voices/index.json index 9e7218e..31bb10e 100644 --- a/assets/voices/index.json +++ b/assets/voices/index.json @@ -1639,10 +1639,6 @@ "file": "andrea-3d15dd47bb.mp3", "sec": 4.82 }, - "andrea|Il Chiaru, con le ginocchia bianche di gesso e una Formula 1 blu in mano.": { - "file": "andrea-8d5911c8e7.mp3", - "sec": 4.53 - }, "andrea|Il parcheggio sotto casa. Col gesso ci si disegnava la pista delle macchinine, e si giocava finché non chiamavano per cena.": { "file": "andrea-8670e6ce99.mp3", "sec": 7.6 @@ -1675,6 +1671,10 @@ "file": "andrea-f6c10fd559.mp3", "sec": 6.28 }, + "andrea|Il Chiaru, con le ginocchia bianche di gesso e una Formula 1 blu in mano.": { + "file": "andrea-8d5911c8e7.mp3", + "sec": 4.53 + }, "andrea|Un pino a ombrello enorme. Alla ricreazione, tutti all'ombra lì sotto. Sembra ieri.": { "file": "andrea-0a073729fd.mp3", "sec": 7.3 @@ -2435,9 +2435,9 @@ "file": "mariano-d65fb6e871.mp3", "sec": 3.42 }, - "andrea|Si corre?": { - "file": "andrea-8052393bfd.mp3", - "sec": 0.7 + "andrea|Si fa una gara alle macchinine?!?!": { + "file": "andrea-17216997e2.mp3", + "sec": 2.74 }, "andrea|Che pista hai disegnato?": { "file": "andrea-3dd737ed1b.mp3", @@ -2447,9 +2447,9 @@ "file": "andrea-ee03433b9c.mp3", "sec": 1.6 }, - "andrea|Dopo, Chiaru.": { - "file": "andrea-75b58c99e6.mp3", - "sec": 1.32 + "andrea|Vado a fare un girino alle case rosse.": { + "file": "andrea-cb2fa74266.mp3", + "sec": 1.97 }, "chiaru|Un giro, una spinta a testa, prima te. Tutta fuori dal gesso, si torna indietro. Come la vuoi?": { "file": "chiaru-df4b1e3a0e.mp3", @@ -2475,9 +2475,13 @@ "file": "chiaru-a7ab8d241f.mp3", "sec": 2.01 }, - "chiaru|La pista è qui. Finché non piove.": { - "file": "chiaru-841aa1efd7.mp3", - "sec": 2.39 + "chiaru|Vorrai dire un giochino!": { + "file": "chiaru-c106f9e9c4.mp3", + "sec": 1.14 + }, + "andrea|Ciao Mattè.": { + "file": "andrea-fc5a08bbd6.mp3", + "sec": 0.73 }, "andrea|No, aspetta.": { "file": "andrea-04890c1d8e.mp3", @@ -2499,9 +2503,9 @@ "file": "chiaru-a0d7dbb4c1.mp3", "sec": 2.15 }, - "andrea|Trappizzio Berti! Che fai, giochi da solo?": { - "file": "andrea-da5fb02d21.mp3", - "sec": 3.4 + "andrea|Fabri! Che fai, giochi da solo?": { + "file": "andrea-44037129d8.mp3", + "sec": 2.71 }, "andrea|Come fai a prendere le biglie dove nessuno ci arriva?": { "file": "andrea-c6670ffb48.mp3", @@ -2515,9 +2519,9 @@ "file": "andrea-fe39c8f30c.mp3", "sec": 1.49 }, - "andrea|Ci si vede, Trappizzio Berti.": { - "file": "andrea-4089f348c4.mp3", - "sec": 2.57 + "andrea|Ci si vede, Fabri.": { + "file": "andrea-d48ea04521.mp3", + "sec": 1.37 }, "bertovo|Da solo? Io gioco contro le sponde. E vinco quasi sempre.": { "file": "bertovo-5e771069ff.mp3", diff --git a/docs/STATO.md b/docs/STATO.md index faf2590..e47bc9b 100644 --- a/docs/STATO.md +++ b/docs/STATO.md @@ -26,6 +26,9 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto, cappottè (urto forte), accompagnè (dito tenuto oltre la carica piena). Tre livelli: alla bona la guida mostra dove ci si ferma. Test in `tests/micro.test.mjs` e nel browser (`npm run browser:micro`). Battute del Chiaru provvisorie [DA ANDREA]; il Chiaru ha dato il consenso (29 ottobre). + Poi: si sceglie la macchinina (sei, il Chiaru tiene la blu), la forza parte da zero appena si preme e la spinta + parte quando si lascia; saluto «Vado a fare un girino alle case rosse» / «Vorrai dire un giochino!» / «Ciao Mattè»; + Andrea chiama Bertovo «Fabri». Musica in prestito dal GP (gara.mp3): [DA ANDREA] la sua, con Suno. - **Bertovo ora si chiama «Trappizzio Berti»** in tutte le scritte (Andrea, 29 ottobre); l'id resta `bertovo`. Fabrizio Berti e il suo libro «Memorie di sport e di paese» nei Ringraziamenti; l'idea delle cartoline in `IDEE.md`. - **I rinquarti di Bertovo (28 ottobre, modifiche locali)**: variante del biliardo in `rinquarto.js` e diff --git a/src/engine/micro-render.js b/src/engine/micro-render.js index bb4e590..633b36a 100644 --- a/src/engine/micro-render.js +++ b/src/engine/micro-render.js @@ -1,4 +1,4 @@ -import { HALF, CAR, canThrow, guide, done, overHeld } from './micro.js'; +import { HALF, CAR, CARS, canThrow, guide, done, overHeld, carImage, choiceAt } from './micro.js'; import { text, panel } from './bocce-render.js'; const NAME = { player: 'Andrea', cpu: 'Chiaru' }; @@ -68,6 +68,12 @@ function aim(ctx, c, img) { ctx.fillStyle = over ? '#ff6a4a' : '#e8b48f'; ctx.strokeStyle = '#6b3d26'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(fx, fy, 4, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.fillStyle = '#f7d9c4'; ctx.fillRect(Math.round(fx + ux * 1.5) - 1, Math.round(fy + uy * 1.5) - 1, 2, 2); // l'unghia + // La forza che sale mentre si tiene premuto: una barretta sopra la macchinina. + if (c.holding) { + const x = Math.round(me.x - 15), y = Math.round(me.y - 19); + ctx.fillStyle = '#17160fd0'; ctx.fillRect(x - 1, y - 1, 32, 6); + ctx.fillStyle = over ? '#ff6a4a' : c.power > 0.75 ? '#ffd23c' : '#9be07c'; ctx.fillRect(x, y, Math.round(30 * c.power), 4); + } } // Dove una macchinina ha fatto il fallo: una croce di gesso rosso che sfuma, e la macchinina (ribaltata, se ha cappottato) // che sparisce per tornare al suo posto. @@ -83,11 +89,27 @@ function returns(ctx, c, images) { } } +// La scelta della macchinina: le sei in grande sul parcheggio, quella scelta cerchiata di gesso e un po' sollevata. +function choice(ctx, c, image) { + ctx.fillStyle = '#17160fc8'; ctx.fillRect(60, 56, 520, 250); + text(ctx, 'SCEGLI LA TUA MACCHININA', 320, 76, 12, '#efd49e', 'center'); + CARS.forEach((car, i) => { + const [x, y] = choiceAt(i), on = i === c.choice, lift = on ? Math.sin(c.visual.time * 5) * 1.5 - 2 : 0, img = image?.(carImage(car.id, true)); + if (on) { ctx.strokeStyle = 'rgba(246,246,238,0.9)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.ellipse(x, y, 50, 28, 0, 0, Math.PI * 2); ctx.stroke(); } + ctx.fillStyle = 'rgba(10,12,16,0.35)'; ctx.fillRect(x - 37, y - 16, 78, 38); // l'ombra + if (img) ctx.drawImage(img, Math.round(x - img.width / 2), Math.round(y - img.height / 2 + lift)); + text(ctx, car.name, x, y + 34, 8, on ? '#fff0c8' : '#a99d86', 'center'); + }); + const cpu = CARS[c.choice].id === 'blu' ? 'la rossa' : 'la blu e gialla'; + text(ctx, `Il Chiaru prende ${cpu}. Tocca la tua, o frecce e Scegli.`, 320, 292, 8, '#d9ccba', 'center'); +} + export function drawMicro(ctx, c, def, bg, image) { ctx.save(); ctx.imageSmoothingEnabled = false; if (bg) ctx.drawImage(bg, 0, 0, 640, 360); else { ctx.fillStyle = '#4b4d50'; ctx.fillRect(0, 0, 640, 360); } drawChalk(ctx, c.track); - const images = { player: image?.(def.cars?.player), cpu: image?.(def.cars?.cpu) }; + if (c.step === 'choose') { choice(ctx, c, image); ctx.restore(); return; } + const images = { player: image?.(carImage(c.colors.player)), cpu: image?.(carImage(c.colors.cpu)) }; if (c.step === 'rolling' && c.trail.length > 1) { ctx.strokeStyle = 'rgba(255,248,220,0.35)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(...c.trail[0]); for (const p of c.trail.slice(1)) ctx.lineTo(...p); @@ -101,7 +123,8 @@ export function drawMicro(ctx, c, def, bg, image) { text(ctx, 'LA PISTA COL GESSO', 8, 7, 8, '#efd49e'); const level = { bona: 'alla bona', seria: 'seria', male: 'a fassi male' }[c.level]; text(ctx, `${level} · spinte ${c.flicks.player}`, 8, 16, 7, '#d9ccba'); - for (const [who, x0, color] of [['player', 168, '#e0543c'], ['cpu', 404, '#3c66d8']]) { + const tint = id => CARS.find(x => x.id === id).color; + for (const [who, x0, color] of [['player', 168, tint(c.colors.player)], ['cpu', 404, tint(c.colors.cpu)]]) { text(ctx, NAME[who], x0, 11, 8, '#fff0c8'); ctx.fillStyle = '#3a372e'; ctx.fillRect(x0 + 52, 7, 150, 8); ctx.fillStyle = color; ctx.fillRect(x0 + 52, 7, Math.round(150 * done(c, who)), 8); diff --git a/src/engine/micro.js b/src/engine/micro.js index 9c9df9c..5ea3b0e 100644 --- a/src/engine/micro.js +++ b/src/engine/micro.js @@ -19,7 +19,7 @@ export const CONTROL = [ ]; export const HALF = 22, CAR = { len: 26, wid: 13, r: 6 }, LAPS = 1; // La spinta: velocità di partenza da MIN_SPEED a MAX_SPEED, e l'asfalto che frena di DECEL px/s². -export const MIN_SPEED = 60, MAX_SPEED = 400, DECEL = 420, SWING = 1.2, LONG_PRESS = 0.6, FIXED_STEP = 1 / 120; +export const MIN_SPEED = 60, MAX_SPEED = 400, DECEL = 420, SWING = 1.2, TAP = 0.14, FIXED_STEP = 1 / 120; const REST = 0.7, THINK = 1, RESTITUTION = 0.6, OUT = HALF + CAR.r, WINDOW = 30; // FLIP: la velocità d'urto (px/s) che ribalta la macchinina colpita; ACCOMPAGNA: quanti secondi si può restare alla // carica piena prima che diventi un accompagnè. @@ -144,14 +144,43 @@ function step(track, list, dt, events) { for (const car of list) if (car.vx || car.vy) follow(track, car); } +// Le macchinine fra cui scegliere (Andrea, 29 ottobre: «quando si parte fammi scegliere la macchinina»): il disegno di +// Codex in `assets/closeups/micro/.png` (e `-grande.png` per la scelta) e il colore della barra in alto. +// Il Chiaru tiene la sua blu e gialla; se la prende Andrea, lui prende la rossa. +export const CARS = [ + { id: 'rossa', name: 'Rossa e bianca', color: '#e0543c' }, + { id: 'blu', name: 'Blu e gialla', color: '#3c66d8' }, + { id: 'nera', name: 'Nera e oro', color: '#c9a23a' }, + { id: 'azzurra', name: 'Bianca e azzurra', color: '#6fb7ff' }, + { id: 'arancio', name: 'Arancione', color: '#f08a2c' }, + { id: 'viola', name: 'Viola e argento', color: '#9a5ad8' }, +]; +export const carImage = (id, big = false) => `assets/closeups/micro/${id}${big ? '-grande' : ''}.png`; +// Dove stanno le macchinine nella scelta: due file da tre, al centro del parcheggio. +export const choiceAt = i => [160 + (i % 3) * 160, 132 + Math.floor(i / 3) * 88]; +export function pick(c, i = c.choice) { + if (c.step !== 'choose' || !CARS[i]) return false; + const mine = CARS[i].id; + c.colors = { player: mine, cpu: mine === 'blu' ? 'rossa' : 'blu' }; + c.step = 'aim'; c.t = 0; c.aimLocked = false; // il clic che sceglie non deve fissare anche la mira + return true; +} +export function choose(c, i) { + if (c.step !== 'choose' || !Number.isFinite(i)) return false; + c.choice = (i + CARS.length) % CARS.length; return true; +} +const carUnder = (x, y) => CARS.findIndex((_, i) => { const [cx, cy] = choiceAt(i); return Math.abs(x - cx) < 44 && Math.abs(y - cy) < 30; }); + export function newMicro(seed = Date.now() >>> 0, def = {}) { const level = LEVELS[def.level] ? def.level : 'seria', track = TRACK; - const c = { step: 'aim', t: 0, seed, level, skill: LEVELS[level], current: 'player', power: 0.5, angle: 0, + const c = { step: 'choose', choice: 0, colors: null, t: 0, seed, level, skill: LEVELS[level], current: 'player', power: 0.5, angle: 0, holding: false, holdTime: 0, accumulator: 0, events: [], track, laps: LAPS, flicks: { player: 0, cpu: 0 }, line: null, trail: [], returns: [], before: null, plan: null, visual: { time: 0 } }; // In griglia: Andrea all'esterno, il Chiaru all'interno, poco prima del traguardo. c.cars = { player: newCar(track, 'player', 18, -11), cpu: newCar(track, 'cpu', 18, 11) }; c.angle = c.cars.player.heading; + // Si parte scegliendo la macchinina, se non è già data (`def.car`). + if (def.car) pick(c, CARS.findIndex(x => x.id === def.car)); return c; } @@ -285,7 +314,10 @@ export function aimAt(c, x, y) { if (Math.hypot(x - car.x, y - car.y) < CAR.r) return false; c.angle = Math.atan2(y - car.y, x - car.x); return true; } -export const aimScreen = (c, [x, y]) => x >= 0 && x <= 640 && y >= 24 && y <= 360 && aimAt(c, x, y); +export function aimScreen(c, [x, y]) { + if (c.step === 'choose') { const i = carUnder(x, y); return i >= 0 && choose(c, i); } + return x >= 0 && x <= 640 && y >= 24 && y <= 360 && aimAt(c, x, y); +} export function nudge(c, dir, fine = false) { if (!canThrow(c) || c.holding || !Number.isFinite(dir)) return false; c.angle += dir * (fine ? 0.004 : 0.03); return true; @@ -294,8 +326,9 @@ export function setPower(c, value) { if (!canThrow(c) || c.holding || !Number.isFinite(value)) return false; c.power = clamp(value, 0, 1); return true; } -export function shoot(c) { return canThrow(c) && !c.holding && strike(c, 'player', c.angle, c.power); } +export function shoot(c) { return c.step === 'choose' ? pick(c) : canThrow(c) && !c.holding && strike(c, 'player', c.angle, c.power); } export function turn(c, dir, owner, fine = false) { + if (c.step === 'choose') { choose(c, c.choice + dir); return false; } // nella scelta frecce e pulsanti cambiano macchinina if (!canThrow(c) || c.holding || c.turn) return false; c.turn = { dir, owner, fine, time: 0 }; c.angle += dir * (fine ? 0.004 : 0.03); c.aimLocked = true; return true; @@ -311,10 +344,11 @@ export function key(c, key, down, fine = false) { return true; } export function press(c, pointerId) { + if (c.step === 'choose') return pick(c); if (!canThrow(c) || c.holding) return false; - c.turn = null; c.pointerId = pointerId; c.holdDelay = pointerId === undefined ? 0 : LONG_PRESS; - c.previousPower = c.power; c.holding = true; c.charging = false; c.holdTime = 0; - if (!c.holdDelay) c.power = 0; + // Premuto, la forza parte da zero e sale finché non si lascia (Andrea, 29 ottobre): come il dito che si carica. + c.turn = null; c.pointerId = pointerId; c.holdDelay = 0; + c.previousPower = c.power; c.holding = true; c.charging = false; c.holdTime = 0; c.power = 0; return true; } export function cancel(c, pointerId) { @@ -325,7 +359,7 @@ export function cancel(c, pointerId) { } export function release(c, pointerId) { if (c.pointerId !== pointerId || !c.holding) return false; - if (c.holdTime < Math.max(0.14, c.holdDelay)) return cancel(c, pointerId); // il tocco mira soltanto + if (c.holdTime < TAP) return cancel(c, pointerId); // il tocco secco mira soltanto const accompagne = (c.overHold ?? 0) > ACCOMPAGNA; c.holding = c.charging = false; c.overHold = 0; strike(c, 'player', c.angle, c.power); @@ -346,7 +380,11 @@ export function guide(c) { return { x: car.x + ux * length, y: car.y + uy * length, length, out }; } +// Il pulsante del pannello vale anche nella scelta della macchinina. +export const canPress = c => canThrow(c) || c.step === 'choose'; +export const canAdjust = canPress; export function button(c) { + if (c.step === 'choose') return 'Scegli'; if (c.step === 'result') return 'Finita'; if (c.step === 'rolling' || c.step === 'settle') return 'In movimento…'; return c.current === 'cpu' ? 'Spinge il Chiaru…' : 'Spingi'; @@ -354,6 +392,7 @@ export function button(c) { // Quanta pista manca al traguardo, in percentuale, per il tabellone. export const done = (c, who) => clamp(c.cars[who].progress / (c.laps * c.track.length), 0, 1); export function status(c) { + if (c.step === 'choose') return `Scegli la macchinina: ${CARS[c.choice].name}. Tocca quella che vuoi, o frecce e Scegli.`; const where = `Andrea ${Math.round(done(c, 'player') * 100)}%, Chiaru ${Math.round(done(c, 'cpu') * 100)}% del giro.`; if (c.step === 'result') return `${where} ${c.result.winner === 'player' ? 'Hai battuto il Chiaru!' : c.result.winner === 'cpu' ? 'Ha vinto il Chiaru.' : 'Arrivati pari.'}`; if (c.step === 'rolling' || c.step === 'settle') return `${where} ${c.line ?? ''}`.trim(); diff --git a/src/story/story.js b/src/story/story.js index 6923c54..d666608 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -236,10 +236,9 @@ export const story = { // Le macchinine sulla pista col gesso, sul parcheggio di Cardetole, contro il Chiaru (Andrea, 28 e 29 ottobre). // [DA ANDREA: le battute sono provvisorie] micro: { - type: 'micro', + type: 'micro', music: 'assets/audio/gara.mp3', // in prestito dal GP di Fortezza, finché non c'è la sua picture: 'assets/closeups/micro-parcheggio.png', names: { player: 'Andrea', cpu: 'Chiaru' }, - cars: { player: 'assets/closeups/micro/rossa.png', cpu: 'assets/closeups/micro/blu.png' }, win: [{ say: 'Hai vinto te. Ma la mia aveva le ruote piene di gesso, sia chiaro.', who: 'chiaru', pose: 'shrug' }, { set: 'micro_vinto' }], lose: [{ say: 'Visto? La blu non perde mai. Mai.', who: 'chiaru', pose: 'cheer' }], quit: [{ say: 'Ti ritiri? Allora la vinco a tavolino.', who: 'chiaru', pose: 'laugh' }], @@ -3254,7 +3253,7 @@ export const story = { nodes: { start: { options: [ - { text: 'Si corre?', next: 'livello', then: [{ say: 'Un giro, una spinta a testa, prima te. Tutta fuori dal gesso, si torna indietro. Come la vuoi?', who: 'chiaru', pose: 'explain' }] }, + { text: 'Si fa una gara alle macchinine?!?!', next: 'livello', then: [{ say: 'Un giro, una spinta a testa, prima te. Tutta fuori dal gesso, si torna indietro. Come la vuoi?', who: 'chiaru', pose: 'explain' }] }, { text: 'Che pista hai disegnato?', then: [ { say: 'Rettilineo, curvone, rettilineo, e in fondo la esse. Nella esse chi ha fretta esce.', who: 'chiaru', pose: 'explain' }, { say: 'Il tombino l\'ho lasciato in pista: porta fortuna.', who: 'chiaru', pose: 'talk' }, @@ -3265,7 +3264,8 @@ export const story = { { say: 'Chi taglia la strada: tagliè. Chi si ribalta: cappottè. E chi esce tutto dal gesso, fuori.', who: 'chiaru', pose: 'talk' }, { say: 'In tutti i casi, si torna dov\'eri prima.', who: 'chiaru', pose: 'shrug' }, ] }, - { text: 'Dopo, Chiaru.', next: 'end', then: [{ say: 'La pista è qui. Finché non piove.', who: 'chiaru', pose: 'stand' }] }, + // Il saluto (Andrea, 29 ottobre). + { text: 'Vado a fare un girino alle case rosse.', next: 'end', then: [{ say: 'Vorrai dire un giochino!', who: 'chiaru', pose: 'laugh' }, { say: 'Ciao Mattè.' }] }, ], }, livello: { @@ -3280,11 +3280,12 @@ export const story = { }, // Trappizzio Berti al tavolo di mezzo: le biglie nascoste e «Oh tirami questo rinquarto!?» (Andrea, 28 ottobre). // [DA ANDREA: le battute sono provvisorie, tranne la sua sfida] + // Andrea, parlandogli, lo chiama Fabri (29 ottobre). bertovo: { nodes: { start: { options: [ - { text: 'Trappizzio Berti! Che fai, giochi da solo?', then: [ + { text: 'Fabri! Che fai, giochi da solo?', then: [ { say: 'Da solo? Io gioco contro le sponde. E vinco quasi sempre.', who: 'bertovo', pose: 'explain' }, ] }, { text: 'Come fai a prendere le biglie dove nessuno ci arriva?', then: [ @@ -3299,7 +3300,7 @@ export const story = { { text: 'Si fa una sfida a rinquarti?', next: 'sfida', then: [ { say: 'Le biglie le metto io, nascoste per bene. Tiri te, poi tiro io. Oh tirami questo rinquarto!?', who: 'bertovo', pose: 'explain' }, ] }, - { text: 'Ci si vede, Trappizzio Berti.', next: 'end', then: [{ say: 'Occhio alle sponde, fuori.', who: 'bertovo', pose: 'shrug' }] }, + { text: 'Ci si vede, Fabri.', next: 'end', then: [{ say: 'Occhio alle sponde, fuori.', who: 'bertovo', pose: 'shrug' }] }, ], }, sfida: { diff --git a/tests/micro.browser.mjs b/tests/micro.browser.mjs index 5043f12..1af331b 100644 --- a/tests/micro.browser.mjs +++ b/tests/micro.browser.mjs @@ -34,19 +34,31 @@ try { await page.evaluate(async () => { const { settle } = await import('/tests/helpers.mjs'); game.act('talk', 'chiaru'); settle(game); - game.choose(game.options().findIndex(o => o.text === 'Si corre?')); settle(game); + game.choose(game.options().findIndex(o => o.text === 'Si fa una gara alle macchinine?!?!')); settle(game); game.choose(game.options().findIndex(o => o.text === 'Alla bona.')); settle(game); }); await page.waitForSelector('#bocce-controls:not([hidden])'); await page.waitForFunction(async () => { const { hasBackground } = await import('/src/engine/render.js'); - return hasBackground('assets/closeups/micro-parcheggio.png') && hasBackground('assets/closeups/micro/rossa.png'); + return hasBackground('assets/closeups/micro-parcheggio.png'); }); - assert.equal(await page.locator('#bocce-throw').textContent(), 'Spingi'); + // Prima si sceglie la macchinina: la viola, toccandola. + assert.equal(await page.locator('#bocce-throw').textContent(), 'Scegli'); + await page.waitForFunction(async () => (await import('/src/engine/render.js')).hasBackground('assets/closeups/micro/viola-grande.png')); + if (name === 'desktop') { await page.waitForTimeout(1600); await page.screenshot({ path: `${out}/${name}-sceglie.png` }); } + { + const box = await page.locator('#scene').boundingBox(), [vx, vy] = [480, 220]; + const at = { x: box.x + vx / 640 * box.width, y: box.y + vy / 360 * box.height }; + if (mobile) await page.touchscreen.tap(at.x, at.y); else await page.mouse.click(at.x, at.y); + } + assert.deepEqual(await page.evaluate(() => game.closeup.colors), { player: 'viola', cpu: 'blu' }); + await page.waitForTimeout(1600); + if (name === 'desktop') await page.screenshot({ path: `${out}/${name}-scelta.png` }); + await page.waitForFunction(() => document.querySelector('#bocce-throw').textContent === 'Spingi'); assert.ok(await page.locator('#micro-help').isVisible(), 'il «Come si gioca» delle macchinine'); const exit = await page.locator('#closeup-close').boundingBox(), canvas = await page.locator('#scene').boundingBox(); assert.ok(exit.y >= canvas.y + canvas.height, 'l\'uscita sta fuori dalla pista'); - await page.waitForTimeout(1600); + await page.waitForFunction(async () => (await import('/src/engine/render.js')).hasBackground('assets/closeups/micro/viola.png')); const state = () => page.evaluate(() => ({ angle: game.closeup.angle, power: game.closeup.power, step: game.closeup.step, x: game.closeup.cars.player.x })); // Un punto della pista davanti alla macchinina di Andrea, in pixel della pagina. const ahead = await page.evaluate(() => { const c = game.closeup, i = (c.cars.player.idx + 50) % c.track.n; return c.track.pts[i]; }); diff --git a/tests/micro.test.mjs b/tests/micro.test.mjs index b9c8a63..cd0e92c 100644 --- a/tests/micro.test.mjs +++ b/tests/micro.test.mjs @@ -1,11 +1,16 @@ import test from 'node:test'; +import { existsSync } from 'node:fs'; import assert from 'node:assert/strict'; import * as m from '../src/engine/micro.js'; +import * as micro from '../src/engine/micro.js'; import { drawMicro } from '../src/engine/micro-render.js'; import { Game, MINIGAMES } from '../src/engine/game.js'; import { story } from '../src/story/story.js'; import { settle } from './helpers.mjs'; +// Una gara con la macchinina già scelta (la rossa), per provare il resto. +const newMicro = (seed, def = {}) => micro.newMicro(seed, { car: 'rossa', ...def }); + // Fa andare la spinta in corso (e quella del Chiaru, se tocca a lui) finché non si torna a mirare o è finita. function run(c, limit = 20000) { for (let i = 0; i < limit && !(m.canThrow(c) || c.step === 'result'); i++) m.update(c, 1 / 60); @@ -44,7 +49,7 @@ test('la pista: chiusa, larga uguale, e le macchinine partono in pista poco prim const d = Math.hypot(t.pts[i][0] - t.pts[j][0], t.pts[i][1] - t.pts[j][1]); assert.ok(d > 2 * m.HALF + 2 * m.CAR.r, `tratti troppo vicini: ${i} e ${j} (${d.toFixed(1)} px)`); } - const c = m.newMicro(1); + const c = newMicro(1); for (const car of Object.values(c.cars)) { assert.ok(!m.isOut(m.project(t, car.x, car.y).d)); assert.ok(car.progress < 0 && car.progress > -30, 'prima del traguardo'); @@ -52,10 +57,10 @@ test('la pista: chiusa, larga uguale, e le macchinine partono in pista poco prim }); test('stesso seme: stessa partita; la spinta va lontano quanto dice la guida', () => { - const a = m.newMicro(4, { level: 'male' }), b = m.newMicro(4, { level: 'male' }); + const a = newMicro(4, { level: 'male' }), b = newMicro(4, { level: 'male' }); for (const c of [a, b]) { flick(c); run(c); } assert.deepEqual(a.cars, b.cars); - const c = m.newMicro(2, { level: 'bona' }), start = { ...c.cars.player }; + const c = newMicro(2, { level: 'bona' }), start = { ...c.cars.player }; c.power = 0.6; const g = m.guide(c); assert.ok(m.shoot(c)); run(c); // Il Chiaru ha già spinto: la macchinina di Andrea sta dove diceva la guida (se lui non l'ha urtata). @@ -64,7 +69,7 @@ test('stesso seme: stessa partita; la spinta va lontano quanto dice la guida', ( }); test('tutta fuori: si torna dove si era prima della spinta, e la guida lo diceva', () => { - const c = m.newMicro(3, { level: 'bona' }), start = { x: c.cars.player.x, y: c.cars.player.y }; + const c = newMicro(3, { level: 'bona' }), start = { x: c.cars.player.x, y: c.cars.player.y }; c.angle = -Math.PI / 2; c.power = 1; // dritto verso l'erba in alto assert.equal(m.guide(c).out, true); assert.ok(m.shoot(c)); @@ -78,9 +83,22 @@ test('tutta fuori: si torna dove si era prima della spinta, e la guida lo diceva assert.ok(c.events.some(e => e.kind === 'micro-fuori')); }); +test('si parte scegliendo la macchinina: tocco, frecce, pulsante; il Chiaru tiene la blu, o la rossa se la blu è presa', () => { + const c = micro.newMicro(1); + assert.equal(c.step, 'choose'); assert.equal(m.canThrow(c), false); assert.equal(m.button(c), 'Scegli'); + assert.ok(m.aimScreen(c, m.choiceAt(4))); assert.equal(c.choice, 4); + assert.equal(m.turn(c, 1, 'x'), false); assert.equal(c.choice, 5, 'la freccia passa alla dopo'); + m.turn(c, 1, 'x'); assert.equal(c.choice, 0, 'e ricomincia dalla prima'); + assert.ok(m.shoot(c)); assert.deepEqual(c.colors, { player: 'rossa', cpu: 'blu' }); assert.equal(c.step, 'aim'); + const d = micro.newMicro(1); m.aimScreen(d, m.choiceAt(1)); assert.ok(m.press(d, 7)); + assert.deepEqual(d.colors, { player: 'blu', cpu: 'rossa' }); + assert.equal(d.holding, false, 'il tocco che sceglie non carica la spinta'); + for (const car of m.CARS) assert.ok(existsSync(new URL('../' + m.carImage(car.id, true), import.meta.url)) && existsSync(new URL('../' + m.carImage(car.id), import.meta.url))); +}); + test('le penalità alla francese: tagliè, cappottè, accompagnè; per tutte si torna indietro', () => { // Tagliè: dal tratto di mezzo della esse, dritti in su attraverso l'erba verso il tratto di sopra. - const t = m.newMicro(1, { level: 'bona' }), [sx, sy] = t.track.pts[Math.round(t.track.n * 0.48)]; + const t = newMicro(1, { level: 'bona' }), [sx, sy] = t.track.pts[Math.round(t.track.n * 0.48)]; const p = m.project(t.track, sx, sy); Object.assign(t.cars.player, { x: sx, y: sy, idx: p.i, s: p.s, progress: p.s }); t.angle = -Math.PI / 2; t.power = 0.6; assert.ok(m.shoot(t)); for (let i = 0; i < 2000 && t.step !== 'settle'; i++) m.update(t, 1 / 60); @@ -88,7 +106,7 @@ test('le penalità alla francese: tagliè, cappottè, accompagnè; per tutte si assert.match(t.line, /^Tagliè! Andrea torna dov'era\./); assert.deepEqual([t.cars.player.x, t.cars.player.y], [sx, sy]); // Cappottè: Andrea a tutta forza contro il Chiaru fermo davanti. - const k = m.newMicro(2, { level: 'bona' }), me = k.cars.player, him = k.cars.cpu, before = { x: him.x, y: him.y }; + const k = newMicro(2, { level: 'bona' }), me = k.cars.player, him = k.cars.cpu, before = { x: him.x, y: him.y }; k.angle = Math.atan2(him.y - me.y, him.x - me.x); k.power = 1; assert.ok(m.shoot(k)); for (let i = 0; i < 2000 && k.step !== 'settle'; i++) m.update(k, 1 / 60); assert.equal(him.fault, 'cappotte'); @@ -97,7 +115,7 @@ test('le penalità alla francese: tagliè, cappottè, accompagnè; per tutte si // Torna al suo posto; se lì c'è ora Andrea, si scosta appena quanto basta. assert.ok(Math.hypot(him.x - before.x, him.y - before.y) <= 2 * m.CAR.r + 1e-9); // Accompagnè: tenuto premuto ben oltre la carica piena. - const a = m.newMicro(3), start = { x: a.cars.player.x, y: a.cars.player.y }; + const a = newMicro(3), start = { x: a.cars.player.x, y: a.cars.player.y }; assert.ok(m.press(a)); for (let i = 0; i < 120; i++) m.update(a, 1 / 60); assert.ok(m.overHeld(a)); @@ -106,7 +124,7 @@ test('le penalità alla francese: tagliè, cappottè, accompagnè; per tutte si assert.match(a.line, /^Accompagnè! Andrea torna dov'era\./); assert.deepEqual([a.cars.player.x, a.cars.player.y], [start.x, start.y]); // Lasciando in tempo, la spinta piena vale. - const ok = m.newMicro(3); m.press(ok); + const ok = newMicro(3); m.press(ok); for (let i = 0; i < Math.round((m.SWING + 0.2) * 60); i++) m.update(ok, 1 / 60); m.release(ok); assert.equal(ok.cars.player.fault, null); }); @@ -114,7 +132,7 @@ test('le penalità alla francese: tagliè, cappottè, accompagnè; per tutte si test('una partita intera: stesse spinte per tutti e due, un vincitore, e il Chiaru alla bona perde contro chi usa la guida', () => { const wins = { player: 0, cpu: 0, draw: 0 }; for (const seed of [1, 2, 3]) { - const c = m.newMicro(seed, { level: 'bona' }); + const c = newMicro(seed, { level: 'bona' }); for (let n = 0; n < 80 && c.step !== 'result'; n++) { if (m.canThrow(c)) guided(c); run(c); } assert.equal(c.step, 'result'); assert.equal(c.result.flicks.player, c.result.flicks.cpu); @@ -124,7 +142,7 @@ test('una partita intera: stesse spinte per tutti e due, un vincitore, e il Chia assert.ok(wins.player >= 2, `alla bona Andrea, con la guida, deve vincere quasi sempre (${JSON.stringify(wins)})`); // A fassi male il Chiaru fa il giro con meno spinte che alla bona. const flicks = level => { - const c = m.newMicro(7, { level }); + const c = newMicro(7, { level }); for (let n = 0; n < 80 && c.step !== 'result'; n++) { if (m.canThrow(c)) { c.angle = c.cars.player.heading; c.power = 0; m.shoot(c); } run(c); } return c.flicks.cpu; }; @@ -132,14 +150,18 @@ test('una partita intera: stesse spinte per tutti e due, un vincitore, e il Chia }); test('i comandi: un tocco mira soltanto, tenere premuto carica, lasciare spinge; la mira si gira con le frecce', () => { - const c = m.newMicro(5); + const c = newMicro(5); assert.ok(m.press(c, 1)); m.update(c, 0.05); assert.ok(m.release(c, 1) === true && c.step === 'aim', 'il tocco breve non spinge'); - assert.ok(m.press(c, 1)); for (let i = 0; i < 60; i++) m.update(c, 1 / 60); assert.ok(c.charging); + // Premuto, la forza riparte da zero e sale; un secondo dopo è quasi piena. + assert.ok(m.press(c, 1)); assert.equal(c.power, 0); + m.update(c, 0.3); const early = c.power; assert.ok(c.charging && early > 0.2 && early < 0.3, `forza ${early}`); + for (let i = 0; i < 42; i++) m.update(c, 1 / 60); + assert.ok(c.power > early && c.power < 1); const before = c.cars.player.x; assert.ok(m.release(c, 1)); assert.equal(c.step, 'rolling'); m.update(c, 0.2); assert.notEqual(c.cars.player.x, before); assert.equal(m.press(c, 2), false, 'mentre corre non si spinge'); - const d = m.newMicro(5), a0 = d.angle; + const d = newMicro(5), a0 = d.angle; m.key(d, 'ArrowRight', true); m.update(d, 0.5); m.key(d, 'ArrowRight', false); assert.ok(d.angle > a0); assert.ok(m.aimScreen(d, [320, 200])); assert.equal(m.aimScreen(d, [320, 10]), false, 'il tabellone non si mira'); @@ -147,7 +169,7 @@ test('i comandi: un tocco mira soltanto, tenere premuto carica, lasciare spinge; }); test('il Chiaru pensa a pezzi: nessun fotogramma lo ferma troppo', () => { - const c = m.newMicro(6, { level: 'male' }); + const c = newMicro(6, { level: 'male' }); flick(c); let worst = 0; for (let i = 0; i < 600 && !m.canThrow(c); i++) { const t = performance.now(); m.update(c, 1 / 60); worst = Math.max(worst, performance.now() - t); } @@ -156,7 +178,7 @@ test('il Chiaru pensa a pezzi: nessun fotogramma lo ferma troppo', () => { test('il disegno: gesso, macchinine, guida e risultato; lo stato non cambia', () => { const calls = [], ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => calls.push([k, ...args]) }); - const c = m.newMicro(8, { level: 'bona' }), bg = {}, cars = { [story.closeups.micro.cars.player]: { car: 'player' } }; + const c = newMicro(8, { level: 'bona' }), bg = {}, cars = { [m.carImage('rossa')]: { car: 'player' } }; const draw = () => { const before = structuredClone(c); drawMicro(ctx, c, story.closeups.micro, bg, src => cars[src]); assert.deepEqual(c, before); }; draw(); assert.ok(calls.some(([op, img]) => op === 'drawImage' && img === bg)); @@ -173,10 +195,10 @@ test('il Chiaru al parcheggio di Cardetole: si chiacchiera, si sceglie il livell for (const y of [222, 260, 300, 314]) assert.equal(g.hotspotAt([520, y])?.id, 'chiaru', `clic a y=${y}`); g.act('talk', 'chiaru'); settle(g); assert.ok(!g.options().some(o => o.text === 'Seria.'), 'il livello non si sceglie subito'); - g.choose(g.options().findIndex(o => o.text === 'Si corre?')); settle(g); + g.choose(g.options().findIndex(o => o.text === 'Si fa una gara alle macchinine?!?!')); settle(g); g.choose(g.options().findIndex(o => o.text === 'No, aspetta.')); settle(g); assert.equal(g.closeup, null); - g.choose(g.options().findIndex(o => o.text === 'Si corre?')); settle(g); + g.choose(g.options().findIndex(o => o.text === 'Si fa una gara alle macchinine?!?!')); settle(g); g.choose(g.options().findIndex(o => o.text === 'A fassi male.')); settle(g); assert.equal(g.closeup?.id, 'micro'); assert.equal(g.closeup.level, 'male'); g.closeup.step = 'result'; g.closeup.result = { winner: 'player', flicks: { player: 11, cpu: 11 } };