La campana al campone delle poste col Taglia: il sassolino tirato con mira e forza, i salti a tempo con la lancetta dell'equilibrio, le caselle tinte coi gessi colorati e in cima la CAMPANA, sfondo del vialetto di 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:
2026-10-30 11:20:33 +01:00
co-authored by Claude Opus 5.5
parent 8693bff0c7
commit 5135689293
27 changed files with 675 additions and 8 deletions
+75
View File
@@ -0,0 +1,75 @@
// La campana nel browser vero: dal Taglia al campone delle poste, il tiro col mouse e col dito, i salti.
// npm run browser:campana — schermate in /tmp/regolo-campana.
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-campana';
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('giardini_poste'); settle(game);
game.act('talk', 'taglia'); settle(game);
game.choose(game.options().findIndex(o => o.text.startsWith('E una partita a campana'))); 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/campone-campana.png'));
assert.equal(await page.locator('#bocce-throw').textContent(), 'Tira');
assert.ok(await page.locator('#campana-help').isVisible());
assert.ok(!(await page.locator('#bocce-left').isVisible()), 'niente pulsanti Mira');
const box = await page.locator('#scene').boundingBox(), P = (x, y) => [box.x + x / 640 * box.width, box.y + y / 360 * box.height];
// Mira alla 1, poi la forza giusta: col mouse tenendo premuto, col dito il cursore e Tira.
const one = await page.evaluate(async () => { const m = window.campanaModule = await import('/src/engine/campana.js'); const p = m.center(1); return [p.x, p.y, Math.hypot(p.x - m.FROM.x, p.y - m.FROM.y) / m.REACH]; });
if (mobile) {
await page.touchscreen.tap(...P(one[0], one[1]));
assert.equal(await page.evaluate(() => game.closeup.step), 'aim', 'un tocco mira soltanto');
await page.evaluate(p => { game.closeup.power = p; game.closeup.skill.scatter = 0; }, one[2]);
await page.locator('#bocce-throw').tap();
} else {
await page.mouse.move(...P(one[0], one[1]));
await page.evaluate(() => { game.closeup.skill.scatter = 0; });
await page.mouse.down();
await page.waitForFunction(p => Math.abs(game.closeup.power - p) < 0.02, one[2], { polling: 'raf' });
await page.screenshot({ path: `${out}/${name}-forza.png` });
await page.mouse.up();
// Col mouse il momento giusto dipende da quanto è lesta la pagina: se è andato fuori, si ritira col pulsante.
await page.waitForFunction(() => !['aim', 'flying'].includes(game.closeup.step));
if (await page.evaluate(() => game.closeup.step) !== 'hop') {
await page.evaluate(p => { Object.assign(game.closeup, { step: 'aim', current: 'player', next: null, line: null, power: p }); }, one[2]);
await page.locator('#bocce-throw').click();
}
}
await page.waitForFunction(() => game.closeup.step === 'hop', null, { timeout: 5000 }).catch(async e => { console.log(await page.evaluate(() => JSON.stringify({ ...game.closeup, events: 0 }))); await page.screenshot({ path: `${out}/${name}-errore.png` }); throw e; });
assert.equal(await page.locator('#bocce-throw').textContent(), 'Salta');
// Quanti salti riescono: la lancetta dopo una schermata (lenta, con swiftshader) può già essere scappata.
let hops = 0;
// I salti: si preme quando la lancetta passa nel verde (col dito sul pulsante Salta, col mouse sulla scena).
for (let i = 0; i < 4; i++) {
await page.waitForFunction(() => { const m = window.campanaModule, c = game.closeup; return c.step !== 'hop' || ((!c.hop || c.hop.t >= m.HOP) && c.meter.t > 0 && Math.abs(m.needle(c)) < 0.15); }, null, { polling: 'raf', timeout: 5000 });
if (await page.evaluate(() => game.closeup.step) !== 'hop') break;
if (mobile) await page.locator('#bocce-throw').tap(); else await page.mouse.click(...P(320, 200));
hops = Math.max(hops, await page.evaluate(() => game.closeup.beat + 1));
if (i === 1) await page.screenshot({ path: `${out}/${name}-salti.png` });
}
assert.ok(hops >= 2, `salti riusciti: ${hops}`);
// Poi tocca al Taglia: lo si fa sbagliare per vederlo saltare.
await page.evaluate(() => { const c = game.closeup; c.step = 'rest'; c.t = 0; c.next = 'cpu'; c.line = 'Prova: tocca al Taglia.'; });
await page.waitForFunction(() => game.closeup.step === 'cpuHop' && game.closeup.beat >= 3 || game.closeup.current === 'player' && game.closeup.step === 'aim', null, { timeout: 20000 });
await page.screenshot({ path: `${out}/${name}-taglia.png` });
await page.close();
}
assert.deepEqual(errors, []);
console.log(`ok: schermate in ${out}`);
} finally { await browser?.close(); server.kill(); }
+125
View File
@@ -0,0 +1,125 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as m from '../src/engine/campana.js';
import { drawCampana } from '../src/engine/campana-render.js';
import { Game, MINIGAMES } from '../src/engine/game.js';
import { story } from '../src/story/story.js';
import { settle } from './helpers.mjs';
// Tira senza sparpaglio, con la forza giusta per arrivare al punto, e aspetta che il sassolino cada.
function toss(c, [x, y]) {
c.skill = { ...c.skill, scatter: 0 }; c.aim = { x, y };
c.power = Math.hypot(x - m.FROM.x, y - m.FROM.y) / m.REACH;
assert.ok(m.shoot(c));
for (let i = 0; i < 200 && c.step === 'flying'; i++) m.update(c, 1 / 60);
}
// Salta ogni volta che la lancetta passa nel mezzo.
function hopAll(c) {
for (let i = 0; i < 20000 && c.step === 'hop'; i++) {
m.update(c, 1 / 120);
if (c.step === 'hop' && (!c.hop || c.hop.t >= m.HOP) && Math.abs(m.needle(c)) < 0.03 && c.meter.t > 0) m.press(c, 1);
}
}
const at = id => { const p = m.center(id); return [p.x, p.y]; };
test('le caselle: 1-2-3, la coppia 4-5, la 6, la coppia 7-8 e il cielo; le righe', () => {
for (let n = 1; n <= 8; n++) assert.equal(m.boxAt(at(n), 3).id, n);
assert.equal(m.boxAt(at('cielo')).id, 'cielo');
assert.ok(m.center(4).y < m.center(5).y, 'la 4 a sinistra di chi salta');
const b = m.BOXES[2];
assert.ok(m.boxAt([b.x + 1, m.CY], 3).line, 'sul bordo si tocca la riga');
assert.ok(!m.boxAt([b.x + 1, m.CY], 0).line, 'alla bona le righe non contano');
assert.equal(m.boxAt([20, 20]), null);
});
test('i salti: si scavalca la casella col sassolino, nella coppia un piede solo; tornando lo si raccoglie', () => {
const ids = list => list.map(b => b.pick ? 'prendi' : b.out ? 'fuori' : b.boxes.join('+'));
assert.deepEqual(ids(m.beats(1)), ['2', '3', '4+5', '6', '7+8', 'cielo', '7+8', '6', '4+5', '3', '2', 'prendi', 'fuori']);
assert.deepEqual(ids(m.beats(5)), ['1', '2', '3', '4', '6', '7+8', 'cielo', '7+8', '6', 'prendi', '4', '3', '2', '1', 'fuori']);
assert.deepEqual(ids(m.beats(8)), ['1', '2', '3', '4+5', '6', '7', 'cielo', 'prendi', '7', '6', '4+5', '3', '2', '1', 'fuori']);
assert.ok(m.beats(3).every(b => !b.boxes.includes(3)), 'mai il piede dove sta il sassolino');
});
test('il sassolino nella casella giusta fa saltare; tutta la campana fatta, si passa alla dopo', () => {
const c = m.newCampana(1, { level: 'seria' });
toss(c, at(1));
assert.equal(c.step, 'hop'); assert.match(c.line, /nella 1/);
hopAll(c);
assert.equal(c.step, 'done'); assert.equal(c.target.player, 2); assert.equal(c.stone, null, 'raccolto');
for (let i = 0; i < 120 && c.step !== 'aim'; i++) m.update(c, 1 / 60);
assert.equal(c.current, 'player', 'chi ce la fa ritira');
});
test('fuori, sulla riga o nella casella sbagliata passa la mano al Taglia', () => {
for (const [point, why] of [[at(2), /Nella 2, non nella 1/], [[m.START + 1, m.CY], /riga/], [[60, 40], /fuori/i]]) {
const c = m.newCampana(2, { level: 'seria' });
toss(c, point);
assert.equal(c.step, 'rest'); assert.match(c.line, why);
for (let i = 0; i < 200 && c.step === 'rest'; i++) m.update(c, 1 / 60);
assert.equal(c.current, 'cpu'); assert.equal(c.target.player, 1);
}
});
test('l\'equilibrio: troppo presto o troppo tardi si sbaglia', () => {
const early = m.newCampana(3, { level: 'male' });
toss(early, at(1));
while (early.meter.t < 0.05) m.update(early, 1 / 120);
m.press(early, 1); assert.equal(early.step, 'rest'); assert.match(early.line, /presto/);
const late = m.newCampana(3, { level: 'male' });
toss(late, at(1));
for (let i = 0; i < 1000 && late.step === 'hop'; i++) m.update(late, 1 / 60);
assert.match(late.line, /equilibrio/);
});
test('il tiro col dito: un tocco secco mira soltanto; tenendo premuto la forza va e torna', () => {
const c = m.newCampana(4, { level: 'bona' });
assert.ok(m.aimScreen(c, [300, 150]));
m.press(c, 1); m.update(c, 0.05); m.release(c, 1); assert.equal(c.step, 'aim');
m.press(c, 1);
for (let i = 0; i < 60 * m.CHARGE; i++) m.update(c, 1 / 60);
assert.ok(c.power > 0.95);
for (let i = 0; i < 30 * m.CHARGE; i++) m.update(c, 1 / 60);
assert.ok(c.power < 0.6, 'e poi torna giù');
assert.ok(m.release(c, 1)); assert.equal(c.step, 'flying');
assert.ok(m.key(c, 'ArrowUp', true));
});
test('il Taglia gioca da solo; chi finisce la 8 vince', () => {
const c = m.newCampana(5, { level: 'seria' });
toss(c, [60, 40]);
let turns = 0;
for (let i = 0; i < 60 * 600 && c.step !== 'result'; i++) {
m.update(c, 1 / 60);
if (c.current === 'player' && c.step === 'aim') { turns++; toss(c, [60, 40]); }
}
assert.equal(c.step, 'result'); assert.equal(c.result.winner, 'cpu'); assert.equal(c.target.cpu, 9);
assert.ok(turns > 0, 'il Taglia ogni tanto sbaglia e ripassa la mano');
const w = m.newCampana(6, { level: 'bona' });
w.target.player = 8;
toss(w, at(8)); hopAll(w);
assert.equal(w.step, 'result'); assert.equal(w.result.winner, 'player');
});
test('il disegno della campana 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.newCampana(7, { level: 'bona' }), bg = {};
const draw = () => { const before = structuredClone(c); drawCampana(ctx, c, story.closeups.campana, 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 === 'LA CAMPANA'));
toss(c, at(1)); for (let i = 0; i < 40; i++) m.update(c, 1 / 60); draw();
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'EQUILIBRIO'));
c.step = 'result'; c.result = { winner: 'player' }; calls.length = 0; draw();
assert.ok(calls.some(([op, t]) => op === 'fillText' && t === 'BATTUTO IL TAGLIA!'));
assert.ok(MINIGAMES.campana);
});
test('dal Taglia, al campone delle poste: la campana, il livello e la vittoria', () => {
const g = new Game(story); g.go('giardini_poste'); settle(g);
g.act('talk', 'taglia'); settle(g);
g.choose(g.options().findIndex(o => o.text.startsWith('E una partita a campana'))); settle(g);
g.choose(g.options().findIndex(o => o.text === 'Seria.')); settle(g);
assert.equal(g.closeup?.id, 'campana'); assert.equal(g.closeup.level, 'seria');
g.closeup.step = 'result'; g.closeup.result = { winner: 'player' };
g.closeupClose(); settle(g);
assert.ok(g.state.flags.campana_vinta);
});