All'Ingorgo Andrea sta dentro la folla: dalla vita in giù la gente dello sfondo gli passa davanti, a strisce che saltano a tempo con la folla e col bordo a teste (Andrea: «sembra di volare fra le persone»)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
This commit is contained in:
2026-10-31 15:17:01 +01:00
co-authored by Claude Opus 5.5
parent 23aa9c4788
commit 70f4e641dc
3 changed files with 60 additions and 0 deletions
+23
View File
@@ -189,6 +189,7 @@ export function render(ctx, game, { hover, spots, debug, time = performance.now(
if (sheet) queue.push({ y: p.at[1], draw: () => drawSprite(ctx, sheet, game.personPose(p), ...p.at, game.scaleAt(p.at[1]), p.facing ?? 1, p.size) }); if (sheet) queue.push({ y: p.at[1], draw: () => drawSprite(ctx, sheet, game.personPose(p), ...p.at, game.scaleAt(p.at[1]), p.facing ?? 1, p.size) });
} }
for (const q of queue.sort((a, b) => a.y - b.y)) q.draw(); for (const q of queue.sort((a, b) => a.y - b.y)) q.draw();
if (!pov) crowdFront(ctx, game, time);
drawApparitions(ctx, game, time); drawApparitions(ctx, game, time);
drawPhase(ctx, game); drawPhase(ctx, game);
drawEffects(ctx, game, time); drawEffects(ctx, game, time);
@@ -764,6 +765,28 @@ export const EFFECTS = {
ctx.restore(); ctx.restore();
}, },
}; };
// Andrea dentro la folla dell'Ingorgo (Andrea, 31 ottobre: «sembra di volare fra le persone»): sopra di lui, dalla
// vita in giù, si ridisegna la gente dello sfondo che gli sta davanti. A strisce come l'effetto `crowd`, con lo stesso
// sobbalzo a tempo (così le teste davanti saltano con le altre) e il bordo in alto a teste, mai dritto.
export function crowdFront(ctx, game, time, bg = backgrounds.get(game.backdrop())) {
const bands = (game.scene.effects ?? []).filter(e => e.type === 'crowd' && onNow(e, game));
if (!bg || !bands.length) return;
const s = game.state, h = PERSON * game.scaleAt(s.y), k = bg.width / WIDTH, waist = s.y - h * 0.5, half = Math.ceil(h * 0.3);
for (const e of bands) {
const [ax, ay, aw, ah] = e.area, step = e.step ?? 5, amount = e.amount ?? 3, beats = time / 60000 * (e.bpm ?? 120);
for (let x = ax + Math.floor((s.x - half - ax) / step) * step; x < s.x + half; x += step) {
if (x < ax || x >= ax + aw) continue;
const i = (x - ax) / step, b = (beats / 2 + hash(i * 17 + ay)) % 1, jump = b < 0.5 ? Math.sin(b * Math.PI) : 0;
const dy = Math.round(amount * (0.4 + 0.6 * hash(i * 31 + 5)) * jump);
// La testa di chi sta davanti: ogni striscia alla sua altezza, e salta col battito.
const edge = Math.round(waist - h * 0.14 * hash(i * 7 + 3)) - dy;
const from = Math.max(edge, ay), to = Math.min(s.y + 3, ay + ah - dy);
if (to <= from) continue;
const w = Math.min(step, ax + aw - x);
ctx.drawImage(bg, x * k, (from + dy) * k, w * k, (to - from) * k, x, from, w, to - from);
}
}
}
const BACKGROUND_EFFECTS = new Set(['sway', 'clouds', 'cloth', 'leafshadow', 'crowd', ...Object.keys(ambientEffects)]); const BACKGROUND_EFFECTS = new Set(['sway', 'clouds', 'cloth', 'leafshadow', 'crowd', ...Object.keys(ambientEffects)]);
function drawEffects(ctx, game, time, background = false) { function drawEffects(ctx, game, time, background = false) {
for (const e of game.scene.effects ?? []) { for (const e of game.scene.effects ?? []) {
+17
View File
@@ -0,0 +1,17 @@
import { chromium } from 'playwright';
import { staticServer } from './helpers.mjs';
const server = await staticServer();
const browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 1280, height: 860 } });
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');
for (const [scene, at] of JSON.parse(process.env.SPOTS)) {
await page.evaluate(async ([scene, at]) => {
const { settle } = await import('/tests/helpers.mjs');
settle(game); game.state.flags.ingorgo = true;
game.go(scene, at, 'right'); settle(game);
}, [scene, at]);
await page.waitForTimeout(1500);
await page.screenshot({ path: `${process.env.S}/folla-${scene}-${at[1]}.png`, clip: { x: 98, y: 60, width: 1084, height: 610 } });
}
await browser.close(); server.kill();
+20
View File
@@ -168,6 +168,26 @@ test('le musiche e gli sfondi della festa esistono', () => {
for (const f of files) assert.ok(existsSync(new URL('../' + f, import.meta.url)), `manca ${f}`); for (const f of files) assert.ok(existsSync(new URL('../' + f, import.meta.url)), `manca ${f}`);
}); });
test('alla festa Andrea sta dentro la folla: dalla vita in giù la gente dello sfondo gli passa davanti', async () => {
const { crowdFront } = await import('../src/engine/render.js');
const g = new Game(story); g.go('piazzetta', [330, 320]); settle(g);
const calls = [], ctx = { drawImage: (...a) => calls.push(a) }, bg = { width: 1280 };
crowdFront(ctx, g, 0, bg);
assert.equal(calls.length, 0, 'di giorno, senza la festa, niente');
g.state.flags.ingorgo = true;
const { x, y } = g.state, h = 62 * g.scaleAt(y);
for (const time of [0, 250, 500]) {
calls.length = 0; crowdFront(ctx, g, time, bg);
assert.ok(calls.length > 3, 'strisce di folla sopra Andrea');
for (const [img, , , , , dx, dy, dw, dh] of calls) {
assert.equal(img, bg);
assert.ok(dx + dw > x - h * 0.4 && dx < x + h * 0.4, 'solo davanti a lui');
assert.ok(dy >= y - h * 0.75 && dy + dh <= y + 3, 'dalla vita in giù, fino ai piedi');
}
assert.ok(new Set(calls.map(c => c[6])).size > 1, 'il bordo in alto a teste, mai dritto');
}
});
test('alla festa la folla dipinta salta a tempo: l\'effetto crowd in tutte e cinque le scene, dietro piano e davanti forte', async () => { test('alla festa la folla dipinta salta a tempo: l\'effetto crowd in tutte e cinque le scene, dietro piano e davanti forte', async () => {
const { EFFECTS } = await import('../src/engine/render.js'); const { EFFECTS } = await import('../src/engine/render.js');
assert.equal(typeof EFFECTS.crowd, 'function'); assert.equal(typeof EFFECTS.crowd, 'function');