L'Ingorgo Sonoro: dal manifesto in piazza, ingrandito e toccato, «Quanto darei per essere all'Ingorgo stasera?!?» e l'effetto speciale; il centro di notte pieno zeppo di gente (sfondi di Codex), le console di Andrea (Suno) che si mescolano camminando, Andrea più grande alla festa, uscendo si torna a maggio. Baldoria al campo sportivo col bandierone della Sampdoria (Codex) (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GiECd9Quo9nEdXyjXYF8ma
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { Game } from '../src/engine/game.js';
|
||||
import { story } from '../src/story/story.js';
|
||||
import { settle } from './helpers.mjs';
|
||||
|
||||
test('Baldoria al campo principale: ogni tanto sventola il bandierone, sennò sta fermo', () => {
|
||||
const g = new Game(story); g.go('polisportiva_principale'); settle(g);
|
||||
const p = g.people().find(p => p.who === 'baldoria');
|
||||
assert.ok(p, 'Baldoria è in scena');
|
||||
const poses = new Set();
|
||||
for (let t = 0; t < 9; t += 0.05) { g.time = t; poses.add(g.personPose(p)); }
|
||||
assert.ok(poses.has('wave') && poses.has('wave2') && poses.has('stand') && poses.has('blink'));
|
||||
g.time = 3; assert.equal(g.personPose(p), 'stand', 'fuori dallo sventolio, fermo');
|
||||
const sheet = JSON.parse(readFileSync(new URL('../assets/characters/baldoria.json', import.meta.url)));
|
||||
for (const f of ['stand', 'blink', 'talk', 'explain', 'wave', 'wave2', 'cheer', 'laugh']) assert.ok(sheet.frames[f], f);
|
||||
assert.ok(existsSync(new URL('../assets/characters/baldoria.png', import.meta.url)));
|
||||
});
|
||||
|
||||
test('si parla con Baldoria: le risposte e il saluto', () => {
|
||||
const g = new Game(story); g.go('polisportiva_principale'); settle(g);
|
||||
g.act('talk', 'baldoria'); settle(g);
|
||||
assert.equal(g.dialogue?.id, 'baldoria');
|
||||
const said = [];
|
||||
for (const text of ['Baldoria! Ma che ci fai col bandierone al campo?', 'Guarda che qui la Sampdoria non ci gioca.', 'Hai visto passare il palio, per caso?']) {
|
||||
g.choose(g.options().findIndex(o => o.text === text));
|
||||
said.push(...settle(g).filter(s => s.who === 'baldoria').map(s => s.text));
|
||||
}
|
||||
assert.ok(said.includes('Forza Doria!'));
|
||||
assert.equal(said.length, 4);
|
||||
g.choose(g.options().findIndex(o => o.text === 'Ciao, Baldoria.')); settle(g);
|
||||
assert.equal(g.dialogue, null);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// L'Ingorgo Sonoro nel browser vero: il manifesto in piazza, l'effetto speciale, la festa nelle cinque scene con le
|
||||
// console che si mescolano, e il ritorno a maggio uscendo dal centro.
|
||||
// npm run browser:ingorgo — schermate in /tmp/regolo-ingorgo.
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { staticServer } from './helpers.mjs';
|
||||
|
||||
const out = process.env.OUT ?? '/tmp/regolo-ingorgo';
|
||||
mkdirSync(out, { recursive: true });
|
||||
const server = await staticServer();
|
||||
let browser;
|
||||
const errors = [];
|
||||
try {
|
||||
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 860 } });
|
||||
page.on('pageerror', e => { errors.push(e.message); console.log('ERR', e.message); });
|
||||
await page.goto(`http://127.0.0.1:${server.port}/`);
|
||||
await page.click('#splash'); await page.click('#new-game'); await page.click('#rione [data-rione="campomigliaio"]'); await page.click('#skip-intro');
|
||||
await page.evaluate(async () => {
|
||||
const { settle } = await import('/tests/helpers.mjs');
|
||||
window.settle = settle;
|
||||
settle(game); game.go('piazza', [250, 300]); settle(game);
|
||||
game.act('look', 'manifesto_ingorgo');
|
||||
});
|
||||
// Il manifesto si apre ingrandito; un clic sopra, e parte il desiderio.
|
||||
await page.waitForFunction(() => game.closeup?.id === 'manifesto_ingorgo' && game.closeup.t > 0.6, null, { timeout: 15000 }).catch(async e => { console.log(await page.evaluate(() => JSON.stringify({ c: game.closeup, sp: game.speech, q: game.queue, task: game.task, walking: game.walking, xy: [game.state.x, game.state.y] }))); throw e; });
|
||||
await page.waitForFunction(() => game.closeup.t > 1.4, null, { timeout: 5000 });
|
||||
await page.screenshot({ path: `${out}/0-manifesto.png` });
|
||||
const box = await page.locator('#scene').boundingBox();
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
||||
// Le battute davanti al manifesto, poi l'effetto: a metà i fasci, poi il lampo, poi i coriandoli sulla festa.
|
||||
await page.waitForFunction(() => game.speech?.text.startsWith('Quanto darei'), null, { timeout: 15000 });
|
||||
await page.screenshot({ path: `${out}/1-desiderio.png` });
|
||||
await page.waitForFunction(() => game.closeup?.id === 'ingorgo' && game.closeup.t > 1.2, null, { timeout: 15000 });
|
||||
await page.screenshot({ path: `${out}/2-fasci.png` });
|
||||
await page.waitForFunction(() => game.closeup?.t > 2.5, null, { timeout: 5000 });
|
||||
await page.screenshot({ path: `${out}/3-coriandoli.png` });
|
||||
await page.waitForFunction(() => !game.closeup && game.state.flags.ingorgo && game.speech?.text.includes('Ingorgo'), null, { timeout: 5000 });
|
||||
await page.screenshot({ path: `${out}/3b-festa.png` });
|
||||
await page.evaluate(() => settle(game));
|
||||
// La festa, scena per scena: lo sfondo di notte, e la musica di più console insieme.
|
||||
for (const [scene, at] of [['piazza', [250, 300]], ['via_antica_posta', [300, 300]], ['piazzetta_comune', [300, 330]], ['antica_posta_alta', [330, 300]], ['piazzetta', [330, 320]]]) {
|
||||
await page.evaluate(([s, a]) => { game.go(s, a); settle(game); }, [scene, at]);
|
||||
await page.waitForFunction(async () => (await import('/src/engine/render.js')).hasBackground(game.backdrop()), null, { timeout: 10000 }).catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
const mixNow = await page.evaluate(() => [...music.tracks.values()].map(t => [t.src.split('/').pop(), Math.round(t.gain * 100)]));
|
||||
console.log(scene, JSON.stringify(mixNow));
|
||||
assert.ok(mixNow.length >= 2, `${scene}: più console insieme`);
|
||||
await page.screenshot({ path: `${out}/4-${scene}.png` });
|
||||
}
|
||||
// Uscendo dal centro si torna a maggio.
|
||||
await page.evaluate(() => { game.go('via_de_medici'); });
|
||||
await page.waitForFunction(() => !game.state.flags.ingorgo && game.speech?.text.startsWith('Mi stropiccio'), null, { timeout: 5000 });
|
||||
await page.screenshot({ path: `${out}/5-risveglio.png` });
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(`ok: schermate in ${out}`);
|
||||
} finally { await browser?.close(); server.kill(); }
|
||||
@@ -0,0 +1,115 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { trackFor, mix, Music } from '../src/engine/music.js';
|
||||
import { newIncanto, updateIncanto, drawIncanto, whiteness, T } from '../src/engine/incanto.js';
|
||||
import { Game, check, POSTER_READY } from '../src/engine/game.js';
|
||||
import { story } from '../src/story/story.js';
|
||||
import { settle } from './helpers.mjs';
|
||||
|
||||
const ZONE = story.zones.find(z => z.flag === 'ingorgo');
|
||||
// Fa scorrere il tempo finché l'incantesimo non è finito.
|
||||
const spell = g => { for (let i = 0; i < 600 && g.closeup?.id === 'ingorgo'; i++) g.update(1 / 60); settle(g); };
|
||||
const fromPiazza = () => {
|
||||
const g = new Game(story); g.go('piazza'); settle(g);
|
||||
g.act('look', 'manifesto_ingorgo'); settle(g);
|
||||
assert.equal(g.closeup?.id, 'manifesto_ingorgo', 'il manifesto ingrandito');
|
||||
assert.ok(!g.closeupTap(), 'mentre si apre, un tocco non conta');
|
||||
g.update(POSTER_READY); assert.ok(g.closeupTap()); settle(g);
|
||||
assert.equal(g.closeup?.id, 'ingorgo', 'l\'effetto speciale');
|
||||
spell(g);
|
||||
return g;
|
||||
};
|
||||
|
||||
test('la miscela delle console: vicino a una si sente quella, fra due tutte e due, mai il silenzio', () => {
|
||||
const stages = [{ at: [100, 300], music: 'a.mp3' }, { at: [600, 300], music: 'b.mp3' }];
|
||||
const near = mix(stages, [110, 300]);
|
||||
assert.equal(near[0].src, 'a.mp3'); assert.equal(near[0].gain, 1);
|
||||
assert.ok(near[1].gain < 0.3 && near[1].gain >= 0.08, 'l\'altra arriva da lontano, ma c\'è');
|
||||
const mid = mix(stages, [350, 300]);
|
||||
assert.ok(Math.abs(mid[0].gain - mid[1].gain) < 1e-9, 'a metà strada, pari');
|
||||
// Le console contano solo quando vale la loro condizione; spente, la scena torna al suo brano.
|
||||
const s = { music: { game: 'g.mp3' }, scenes: { p: { stages: stages.map(st => ({ ...st, when: 'festa' })) } } };
|
||||
assert.equal(trackFor(s, { screen: 'game', scene: 'p', at: [110, 300] }), 'g.mp3');
|
||||
const on = trackFor(s, { screen: 'game', scene: 'p', at: [110, 300], ok: cond => cond === 'festa' });
|
||||
assert.deepEqual(on.map(t => t.src), ['a.mp3', 'b.mp3']);
|
||||
});
|
||||
|
||||
test('la musica suona più brani insieme e li fa sfumare quando si cambia piazza', () => {
|
||||
globalThis.Audio = class { constructor(src) { this.src = src; this.volume = 0; this.paused = true; } play() { this.paused = false; return Promise.resolve(); } pause() { this.paused = true; } };
|
||||
const m = new Music({ volume: 0.5 });
|
||||
for (let i = 0; i < 40; i++) m.update([{ src: 'a.mp3', gain: 1 }, { src: 'b.mp3', gain: 0.2 }], 0.1);
|
||||
const [a, b] = [m.tracks.get('a.mp3').audio, m.tracks.get('b.mp3').audio];
|
||||
assert.equal(a.volume, 0.5); assert.ok(Math.abs(b.volume - 0.1) < 1e-9);
|
||||
assert.equal(m.current.src, 'a.mp3', 'la più forte è quella principale');
|
||||
for (let i = 0; i < 40; i++) m.update('c.mp3', 0.1);
|
||||
assert.ok(a.paused && b.paused, 'le console lasciate sfumano e si fermano');
|
||||
assert.deepEqual([...m.tracks.keys()], ['c.mp3']);
|
||||
delete globalThis.Audio;
|
||||
});
|
||||
|
||||
test('l\'incantesimo: i battiti accelerano, il lampo, il cambio a bianco pieno, i coriandoli', () => {
|
||||
const c = newIncanto('ingorgo'), seen = [];
|
||||
let swapAt = null;
|
||||
for (let i = 0; i < 400; i++) {
|
||||
const now = updateIncanto(c, 1 / 60);
|
||||
if (now === 'swap') swapAt = c.t;
|
||||
if (now) seen.push(now);
|
||||
if (now === 'end') break;
|
||||
}
|
||||
assert.deepEqual(seen, ['swap', 'end']);
|
||||
assert.ok(Math.abs(swapAt - T.swap) < 0.02);
|
||||
assert.equal(whiteness(T.swap), 1, 'si cambia quando non si vede niente');
|
||||
assert.equal(whiteness(T.end), 0);
|
||||
const kicks = c.events.filter(e => e.kind === 'incanto-cassa');
|
||||
assert.ok(kicks.length >= 6 && c.events.some(e => e.kind === 'incanto-lampo'));
|
||||
const calls = [], ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => calls.push([k, ...args]), set: (o, k, v) => { o[k] = v; return true; } });
|
||||
for (const t of [0.5, 2.0, 3.2]) { calls.length = 0; drawIncanto(ctx, { ...c, t }); assert.ok(calls.some(([op]) => op === 'fillRect')); }
|
||||
});
|
||||
|
||||
test('dal manifesto in piazza all\'Ingorgo: la battuta, l\'effetto, la festa; uscendo dalla zona si torna a maggio', () => {
|
||||
const g = fromPiazza();
|
||||
assert.ok(g.state.flags.ingorgo, 'la festa c\'è');
|
||||
assert.equal(g.phase().id, 'ingorgo');
|
||||
assert.notEqual(g.backdrop(), story.scenes.piazza.background, 'la piazza della festa');
|
||||
assert.ok(!g.hotspots().some(h => h.id === 'manifesto_ingorgo'), 'il manifesto, alla festa, non serve');
|
||||
// Si gira la festa: in ogni posto della zona la festa resta, la musica è la miscela delle console.
|
||||
for (const scene of ZONE.scenes) {
|
||||
g.go(scene); settle(g);
|
||||
assert.ok(g.state.flags.ingorgo, scene);
|
||||
assert.notEqual(g.backdrop(), story.scenes[scene].background, `${scene}: lo sfondo della festa`);
|
||||
const track = trackFor(story, { screen: 'game', scene, at: [g.state.x, g.state.y], ok: c => check(c, g.state) });
|
||||
assert.ok(Array.isArray(track) && track.length >= 2, `${scene}: più console insieme`);
|
||||
}
|
||||
g.go('via_cipriani'); settle(g);
|
||||
assert.ok(!g.state.flags.ingorgo, 'fuori dalla zona la festa sparisce');
|
||||
assert.equal(g.backdrop(), story.scenes.via_cipriani.background);
|
||||
});
|
||||
|
||||
test('chiudendo l\'effetto prima del lampo, la festa arriva lo stesso', () => {
|
||||
const g = new Game(story); g.go('piazza'); settle(g);
|
||||
g.act('look', 'manifesto_ingorgo'); settle(g);
|
||||
g.update(POSTER_READY); g.closeupTap(); settle(g);
|
||||
assert.equal(g.closeup?.id, 'ingorgo');
|
||||
g.closeupClose(); settle(g);
|
||||
assert.ok(g.state.flags.ingorgo);
|
||||
});
|
||||
|
||||
test('il manifesto ingrandito si chiude senza fare niente: niente festa', () => {
|
||||
const g = new Game(story); g.go('piazza'); settle(g);
|
||||
g.act('look', 'manifesto_ingorgo'); settle(g);
|
||||
g.update(1); g.closeupClose(); settle(g);
|
||||
assert.ok(!g.state.flags.ingorgo && !g.closeup);
|
||||
});
|
||||
|
||||
test('le musiche e gli sfondi della festa esistono', () => {
|
||||
const files = new Set();
|
||||
for (const id of ZONE.scenes) {
|
||||
const sc = story.scenes[id];
|
||||
for (const st of sc.stages ?? []) files.add(st.music);
|
||||
for (const b of sc.backdrops ?? []) if (b.when === 'ingorgo') files.add(b.src);
|
||||
}
|
||||
files.add(story.closeups.manifesto_ingorgo.picture);
|
||||
assert.ok(files.size >= 10);
|
||||
for (const f of files) assert.ok(existsSync(new URL('../' + f, import.meta.url)), `manca ${f}`);
|
||||
});
|
||||
@@ -135,7 +135,8 @@ test('le fasi del giorno seguono la storia: pomeriggio, poi tramonto, poi sera,
|
||||
const g = new Game(story);
|
||||
g.go(story.start.scene, story.start.at);
|
||||
settle(g);
|
||||
const ids = story.phases.map(p => p.id), seen = [g.phase().id];
|
||||
// La notte dell'Ingorgo è un extra fuori dalla soluzione: la storia non ci passa.
|
||||
const ids = story.phases.map(p => p.id).filter(id => id !== 'ingorgo'), seen = [g.phase().id];
|
||||
for (const [verb, target, item] of solution) {
|
||||
if (verb === 'choose') g.choose(g.options().findIndex(o => o.text === target)); else g.act(verb, target, item);
|
||||
settle(g, { explore: true });
|
||||
|
||||
Reference in New Issue
Block a user