Il minigioco delle bruciate alla Smarronata: coi volontari della Misericordia si gira a mano la manovella del cilindro sul fuoco, facendo giri col dito o col mouse; troppo piano si carbonizzano sotto e restano crude sopra, troppo forte volano nel fuoco; cotte, nel sacco (Andrea)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
// Le bruciate nel browser vero: dai volontari della Smarronata, la manovella girata col mouse e col dito, il sacco.
|
||||
// npm run browser:bruciate — schermate in /tmp/regolo-bruciate.
|
||||
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-bruciate';
|
||||
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'] });
|
||||
for (const [name, mobile, viewport] of [['desktop', false, { width: 1280, height: 860 }], ['touch', true, { width: 844, height: 390 }]]) {
|
||||
const page = await browser.newPage({ viewport, hasTouch: mobile, isMobile: mobile });
|
||||
page.on('pageerror', e => errors.push(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');
|
||||
settle(game); game.go('smarronata_1993'); settle(game);
|
||||
game.act('talk', 'volontari'); settle(game);
|
||||
game.choose(game.options().findIndex(o => o.text === 'Vi do una mano alla manovella?')); 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 () => (await import('/src/engine/render.js')).hasBackground('assets/closeups/smarronata-bruciatore.png'));
|
||||
assert.equal(await page.locator('#bocce-throw').textContent(), 'Rovescia nel sacco');
|
||||
assert.ok(await page.locator('#bruciate-help').isVisible());
|
||||
assert.ok(!(await page.locator('#bocce-left').isVisible()), 'niente pulsanti Mira');
|
||||
assert.ok(!(await page.locator('#bocce-power').isVisible()), 'niente forza');
|
||||
await page.screenshot({ path: `${out}/${name}-ferma.png` });
|
||||
const box = await page.locator('#scene').boundingBox(), P = (x, y) => [box.x + x / 640 * box.width, box.y + y / 360 * box.height];
|
||||
// I giri attorno alla manovella: col mouse tenendo premuto, col dito trascinando (eventi di puntatore veri).
|
||||
const loops = async (turns, steps = 16) => {
|
||||
const at = i => { const a = i / steps * Math.PI * 2; return P(506 + Math.cos(a) * 40, 190 + Math.sin(a) * 40); };
|
||||
if (mobile) {
|
||||
const cdp = await page.context().newCDPSession(page), touch = (type, [x, y]) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints: type === 'touchEnd' ? [] : [{ x, y }] });
|
||||
await touch('touchStart', at(0));
|
||||
for (let i = 1; i <= turns * steps; i++) await touch('touchMove', at(i));
|
||||
await touch('touchEnd', at(0));
|
||||
} else {
|
||||
await page.mouse.move(...at(0)); await page.mouse.down();
|
||||
for (let i = 1; i <= turns * steps; i++) await page.mouse.move(...at(i));
|
||||
await page.mouse.up();
|
||||
}
|
||||
};
|
||||
await loops(3);
|
||||
const turned = await page.evaluate(() => Math.abs(game.closeup.drum));
|
||||
assert.ok(turned > 6, `il cilindro ha girato: ${turned.toFixed(1)} rad`);
|
||||
await page.screenshot({ path: `${out}/${name}-gira.png` });
|
||||
// Il resto della cottura lo si fa fare al gioco a velocità giusta (le schermate con swiftshader sono lente).
|
||||
await page.evaluate(() => { const c = game.closeup; c.speed = 4.5; for (const k of c.chestnuts) k.cook = 1.05; c.t = Math.max(c.t, 5); });
|
||||
await page.waitForFunction(() => !document.querySelector('#bocce-throw').disabled);
|
||||
await page.screenshot({ path: `${out}/${name}-cotte.png` });
|
||||
if (mobile) await page.locator('#bocce-throw').tap(); else await page.locator('#bocce-throw').click();
|
||||
await page.waitForFunction(() => game.closeup?.step === 'result', null, { timeout: 10000 });
|
||||
assert.match(await page.locator('#bocce-status').textContent(), /bruciate buone su 18/);
|
||||
await page.screenshot({ path: `${out}/${name}-sacco.png` });
|
||||
await page.locator('#closeup-close').click();
|
||||
await page.waitForFunction(() => game.state.flags.bruciate_fatte);
|
||||
await page.close();
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(`ok: schermate in ${out}`);
|
||||
} finally { await browser?.close(); server.kill(); }
|
||||
@@ -0,0 +1,130 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as m from '../src/engine/bruciate.js';
|
||||
import { drawBruciate } from '../src/engine/bruciate-render.js';
|
||||
import { Game } from '../src/engine/game.js';
|
||||
import { story } from '../src/story/story.js';
|
||||
import { settle } from './helpers.mjs';
|
||||
|
||||
// Gira la manovella tenendola, a `w` radianti al secondo, finché le castagne non paiono cotte (o per `secs` secondi).
|
||||
function crank(c, w, { secs = 60, until = 1.05 } = {}) {
|
||||
c.grab ??= { pointerId: 1, heading: null, last: null };
|
||||
for (let i = 0; i < secs * 60 && c.step === 'turn' && m.doneness(c) < until; i++) { c.pending = w / 60; m.update(c, 1 / 60); }
|
||||
}
|
||||
function pourAndWait(c) {
|
||||
assert.ok(m.shoot(c), 'si rovescia nel sacco');
|
||||
for (let i = 0; i < 200 && c.step === 'pour'; i++) m.update(c, 1 / 60);
|
||||
assert.equal(c.step, 'result');
|
||||
return c.result;
|
||||
}
|
||||
|
||||
test('girando a modo le bruciate vengono tutte buone, nel tempo del livello', () => {
|
||||
for (const level of ['bona', 'seria', 'male']) {
|
||||
const c = m.newBruciate(42, { level });
|
||||
crank(c, 4.5);
|
||||
assert.ok(c.t > 6 && c.t < m.LEVELS[level].time, `${level}: cotte in ${c.t.toFixed(1)} s`);
|
||||
const r = pourAndWait(c);
|
||||
assert.equal(r.winner, 'player', `${level}: ${JSON.stringify(r)}`);
|
||||
assert.equal(r.spilled, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('ferme sul fuoco: quelle di sotto carbone, quelle di sopra crude', () => {
|
||||
const c = m.newBruciate(42, { level: 'seria' });
|
||||
crank(c, 0);
|
||||
const r = pourAndWait(c);
|
||||
assert.equal(r.winner, 'cpu');
|
||||
assert.ok(r.burnt > 0 && r.raw > 0, JSON.stringify(r));
|
||||
assert.match(m.status(c), /Ne servivano 15/);
|
||||
});
|
||||
|
||||
test('troppo forte è una centrifuga: le castagne volano nel fuoco', () => {
|
||||
const c = m.newBruciate(42, { level: 'seria' });
|
||||
crank(c, 12, { secs: 4, until: 9 });
|
||||
assert.ok(c.chestnuts.some(k => k.out), 'qualcuna è volata fuori');
|
||||
assert.ok(c.flying.length > 0);
|
||||
assert.match(c.line, /centrifuga|fuoco/);
|
||||
assert.ok(c.events.some(e => e.kind === 'bruciate-cade'));
|
||||
assert.equal(m.speedWord(c), 'troppo forte');
|
||||
});
|
||||
|
||||
test('rovesciarle troppo presto: crude; e il pulsante aspetta i primi secondi', () => {
|
||||
const c = m.newBruciate(42, { level: 'bona' });
|
||||
assert.ok(!m.canThrow(c) && !m.shoot(c), 'appena cominciato non si rovescia');
|
||||
crank(c, 4.5, { until: 0.4 });
|
||||
const r = pourAndWait(c);
|
||||
assert.equal(r.winner, 'cpu'); assert.ok(r.raw > 10);
|
||||
});
|
||||
|
||||
test('il gesto: i giri, grandi o piccoli e dovunque, fanno girare il cilindro; una riga dritta no', () => {
|
||||
const circles = (c, cx, cy, radius, turns, secs) => {
|
||||
assert.ok(m.press(c, 7));
|
||||
const steps = Math.round(secs * 60);
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const a = i / steps * turns * Math.PI * 2;
|
||||
m.aimScreen(c, [cx + Math.cos(a) * radius, cy + Math.sin(a) * radius]);
|
||||
m.update(c, 1 / 60);
|
||||
}
|
||||
};
|
||||
for (const [x, y, r] of [[506, 190, 30], [200, 300, 80], [320, 180, 15]]) {
|
||||
const c = m.newBruciate(1);
|
||||
m.aimScreen(c, [x + r, y]);
|
||||
circles(c, x, y, r, 2, 2);
|
||||
assert.ok(Math.abs(c.speed) > 4 && Math.abs(c.speed) < 9, `giri di raggio ${r}: ${c.speed.toFixed(2)} rad/s`);
|
||||
assert.ok(Math.abs(c.drum) > 6);
|
||||
// Lasciata la manovella, il cilindro rallenta da sé.
|
||||
assert.ok(m.release(c, 7));
|
||||
const w = Math.abs(c.speed);
|
||||
for (let i = 0; i < 60; i++) m.update(c, 1 / 60);
|
||||
assert.ok(Math.abs(c.speed) < w * 0.5);
|
||||
}
|
||||
const c = m.newBruciate(1);
|
||||
m.aimScreen(c, [100, 200]); m.press(c, 1);
|
||||
for (let i = 0; i < 120; i++) { m.aimScreen(c, [100 + i * 3, 200]); m.update(c, 1 / 60); }
|
||||
assert.ok(Math.abs(c.speed) < 0.2, 'trascinare dritto non gira');
|
||||
assert.ok(!m.release(c, 99), 'un altro dito non lascia la manovella');
|
||||
});
|
||||
|
||||
test('senza gesto: Spazio e le frecce danno una spinta, Invio rovescia', () => {
|
||||
const c = m.newBruciate(3);
|
||||
assert.ok(m.key(c, ' ', true));
|
||||
assert.ok(Math.abs(c.speed) >= m.PUSH);
|
||||
assert.ok(m.key(c, 'ArrowRight', true)); assert.ok(Math.abs(c.speed) >= m.PUSH * 1.5);
|
||||
assert.ok(m.key(c, ' ', false), 'anche lasciando il tasto, il tasto è suo');
|
||||
assert.ok(!m.key(c, 'x', true));
|
||||
for (let i = 0; i < 60 * m.MIN_POUR + 2; i++) m.update(c, 1 / 60);
|
||||
m.key(c, 'Enter', true);
|
||||
assert.equal(c.step, 'pour');
|
||||
});
|
||||
|
||||
test('il disegno delle bruciate non cambia lo stato', () => {
|
||||
const calls = [], ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => calls.push([k, ...args]) });
|
||||
const c = m.newBruciate(7, { level: 'bona' }), bg = {};
|
||||
const draw = () => { const before = structuredClone(c); drawBruciate(ctx, c, story.closeups.bruciate, 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 === 'GIRA!'), 'da fermo, l\'invito a girare');
|
||||
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'COTTURA'));
|
||||
crank(c, 12, { secs: 2, until: 9 }); draw();
|
||||
crank(c, 4.5); pourAndWait(c); calls.length = 0; draw();
|
||||
assert.ok(calls.some(([op, t]) => op === 'fillText' && /BRUCIATE/.test(t)));
|
||||
});
|
||||
|
||||
test('alla Smarronata: dai volontari, «Vi do una mano alla manovella?», il livello e le bruciate', () => {
|
||||
const g = new Game(story); g.go('smarronata_1993'); settle(g);
|
||||
g.act('talk', 'volontari'); settle(g);
|
||||
assert.ok(g.options().some(o => o.text.startsWith('Che Iddio ve ne renda merito')), 'il ringraziamento resta');
|
||||
g.choose(g.options().findIndex(o => o.text === 'Vi do una mano alla manovella?')); settle(g);
|
||||
g.choose(g.options().findIndex(o => o.text === 'Seria.')); settle(g);
|
||||
assert.equal(g.closeup?.id, 'bruciate'); assert.equal(g.closeup.level, 'seria');
|
||||
crank(g.closeup, 4.5); pourAndWait(g.closeup);
|
||||
g.closeupClose(); settle(g);
|
||||
assert.ok(g.state.flags.bruciate_fatte);
|
||||
// Anche usando il cilindro si chiede la manovella; uscendo prima, la battuta di chi lascia.
|
||||
g.act('use', 'bruciatore'); settle(g);
|
||||
g.choose(g.options().findIndex(o => o.text === 'Vi do una mano alla manovella?')); settle(g);
|
||||
g.choose(g.options().findIndex(o => o.text === 'Alla bona.')); settle(g);
|
||||
assert.equal(g.closeup?.id, 'bruciate');
|
||||
g.closeupClose();
|
||||
assert.equal(g.queue[0]?.say, story.closeups.bruciate.quit[0].say);
|
||||
});
|
||||
Reference in New Issue
Block a user