Fasi del giorno legate alla storia (pomeriggio, tramonto, sera) ed effetti animati: lucine, lampioni, acqua, fumo, bandiere, foglie, pulviscolo, occhi che sbattono

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApqNBvNbNCwXqR2RLo8QJh
This commit is contained in:
2026-10-24 23:13:13 +02:00
co-authored by Claude Opus 5.5
parent 9cb026074b
commit ab2bfff1ad
5 changed files with 177 additions and 8 deletions
+6
View File
@@ -339,6 +339,12 @@ export class Game {
}
// La mappa: i posti già visti, con la posizione sulla pianta, e le strade che li collegano (uscite fra due posti visti).
// La fase del giorno: l'ultima di story.phases la cui condizione è vera (la prima non ne ha: è quella di partenza).
// Segue la storia, non l'orologio: il pomeriggio diventa tramonto con le impronte, e sera con la torcia.
phase() {
const phases = this.story.phases ?? [];
return [...phases].reverse().find(p => check(p.when, this.state)) ?? phases[0] ?? null;
}
// Il taccuino: le tappe fatte e quella di adesso (la prima non fatta).
notebook() {
const steps = this.story.notebook ?? [], at = steps.findIndex(s => !check(s.done, this.state));
+119 -5
View File
@@ -161,7 +161,9 @@ export function render(ctx, game, { hover, spots, debug, time = performance.now(
}
for (const q of queue.sort((a, b) => a.y - b.y)) q.draw();
drawApparitions(ctx, game, time);
drawLights(ctx, scene, time);
drawPhase(ctx, game);
drawEffects(ctx, game, time);
drawLights(ctx, game, time);
for (const shape of art) if (shape.top) drawShape(ctx, shape);
if (spots) drawSpots(ctx, game);
if (debug) drawDebug(ctx, game, debug);
@@ -196,6 +198,117 @@ function drawApparitions(ctx, game, time) {
}
}
// Un effetto o una luce vale adesso? `when` (condizione sui flag) e `phase` (una fase del giorno o un elenco).
function onNow(e, game) {
if (!check(e.when, game.state)) return false;
if (!e.phase) return true;
const now = game.phase()?.id;
return Array.isArray(e.phase) ? e.phase.includes(now) : e.phase === now;
}
// La luce della fase del giorno sulle scene all'aperto: un velo di colore (`tint` della fase), non sugli interni.
function drawPhase(ctx, game) {
const tint = game.phase()?.tint;
if (!tint || game.scene.indoor) return;
ctx.fillStyle = tint;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
}
// Un numero «a caso» ma ripetibile: lo stesso istante dà sempre lo stesso disegno (e i test lo possono controllare).
const hash = n => (((n | 0) * 2654435761) >>> 0) / 4294967296;
// Gli effetti delle scene (`effects`), ognuno col suo `type`: piccole animazioni disegnate sopra lo sfondo.
export const EFFECTS = {
// Lucine dei festoni: ognuna si accende e si spegne per conto suo, coi suoi colori.
bulbs(ctx, e, time) {
const colors = e.colors ?? ['#ff5a5a', '#ffd25a', '#5ad2ff', '#7dff6a'];
e.points.forEach(([x, y], i) => {
const on = hash(Math.floor(time / (e.every ?? 450)) * 7 + i * 13) < 0.7;
if (!on) return;
const c = colors[i % colors.length];
ctx.fillStyle = c;
ctx.globalAlpha = 0.35;
ctx.fillRect(x - 2, y - 2, 5, 5);
ctx.globalAlpha = 1;
ctx.fillRect(x - 1, y - 1, 2, 2);
ctx.fillStyle = '#fff';
ctx.fillRect(x - 1, y - 1, 1, 1);
});
},
// Luccichii sull'acqua: punti chiari che compaiono e spariscono in giro per l'area.
sparkle(ctx, e, time) {
const [ax, ay, aw, ah] = e.area, step = e.every ?? 260;
for (let i = 0; i < (e.count ?? 6); i++) {
const t = Math.floor(time / step + i * 0.37);
const x = ax + Math.floor(hash(t * 31 + i) * aw), y = ay + Math.floor(hash(t * 17 + i * 5) * ah);
ctx.fillStyle = e.fill ?? 'rgba(255, 255, 240, 0.9)';
ctx.fillRect(x, y, hash(t + i) < 0.5 ? 2 : 1, 1);
}
},
// Una bandiera che sventola: a strisce (`colors`, verticali), ogni colonna sale e scende con l'onda.
flag(ctx, e, time) {
const [x0, y0] = e.at, w = e.w ?? 12, h = e.h ?? 8, colors = e.colors;
for (let c = 0; c < w; c++) {
const dy = Math.round(Math.sin(time / 180 - c * 0.7) * (c / w) * 1.6);
ctx.fillStyle = colors[Math.min(colors.length - 1, Math.floor(c * colors.length / w))];
ctx.fillRect(x0 + c, y0 + dy, 1, h);
}
},
// Il fumo da un comignolo: sbuffi che salgono, crescono e svaniscono.
smoke(ctx, e, time) {
const [x, y] = e.at, life = 2600;
for (let i = 0; i < 4; i++) {
const t = ((time + i * life / 4) % life) / life;
const r = 1 + t * 3, px = x + Math.sin(t * 5 + i) * 2 + t * 6, py = y - t * 22;
ctx.fillStyle = `rgba(225, 225, 230, ${0.5 * (1 - t)})`;
ctx.fillRect(Math.round(px - r), Math.round(py - r), Math.round(r * 2), Math.round(r * 2));
}
},
// Foglie che cadono ondeggiando.
leaves(ctx, e, time) {
const [ax, ay, aw, ah] = e.area, colors = e.colors ?? ['#d7b23a', '#b88a2a', '#8fa83a'];
for (let i = 0; i < (e.count ?? 6); i++) {
const fall = 5200 + hash(i * 3) * 3000, t = ((time + hash(i) * fall) % fall) / fall;
const x = ax + hash(i * 7) * aw + Math.sin(t * 9 + i) * 6, y = ay + t * ah;
ctx.fillStyle = colors[i % colors.length];
ctx.fillRect(Math.round(x), Math.round(y), 2, Math.sin(t * 20 + i) > 0 ? 1 : 2);
}
},
// Pulviscolo che galleggia in un fascio di luce.
dust(ctx, e, time) {
const [ax, ay, aw, ah] = e.area;
for (let i = 0; i < (e.count ?? 8); i++) {
const t = time / 3000 + hash(i * 11);
const x = ax + ((hash(i * 5) * aw + Math.sin(t * 2 + i) * 3) % aw + aw) % aw, y = ay + ((hash(i * 9) * ah - t * 4) % ah + ah) % ah;
ctx.fillStyle = 'rgba(255, 245, 200, 0.75)';
ctx.fillRect(Math.round(x), Math.round(y), 1, 1);
}
},
// Qualcosa che sbatte le palpebre (gli occhi del Regolo): aperto, poi chiuso per un attimo, ogni tanto.
blink(ctx, e, time) {
const every = e.every ?? 1400, closed = (time % every) < (e.closed ?? 140);
ctx.fillStyle = e.fill;
for (const [x, y, w, h] of e.rects) closed ? ctx.fillRect(x, y + Math.floor(h / 2), w, 1) : ctx.fillRect(x, y, w, h);
},
// Lampioni: un alone caldo che si accende (di solito solo di sera, con `phase`).
lamps(ctx, e, time) {
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (const [i, [x, y]] of e.points.entries()) {
const r = (e.glow ?? 16) * (0.94 + 0.06 * Math.sin(time / 500 + i));
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, 'rgba(255, 210, 120, 0.55)');
g.addColorStop(1, 'rgba(255, 170, 70, 0)');
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
ctx.restore();
},
};
function drawEffects(ctx, game, time) {
for (const e of game.scene.effects ?? []) if (onNow(e, game)) EFFECTS[e.type]?.(ctx, e, time);
}
// Le scene buie (`shade`: il velo scuro sopra tutto, Andrea compreso) e le fiammelle (`lights`: candele accese).
// Ogni fiamma ha due disegni che si alternano a caso, e intorno un alone caldo che pulsa piano.
const FLAME_STEP = 140;
@@ -203,15 +316,16 @@ export function flameFrame(time, i) {
const n = Math.floor(time / FLAME_STEP) * 31 + i * 17;
return ((n * 2654435761) >>> 0) % 7 < 3 ? 1 : 0;
}
function drawLights(ctx, scene, time) {
function drawLights(ctx, game, time) {
const scene = game.scene, lights = (scene.lights ?? []).filter(l => onNow(l, game));
if (scene.shade) {
ctx.fillStyle = scene.shade;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
}
if (!scene.lights?.length) return;
if (!lights.length) return;
ctx.save();
ctx.globalCompositeOperation = 'lighter';
scene.lights.forEach(({ at: [x, y], glow = 30 }, i) => {
lights.forEach(({ at: [x, y], glow = 30 }, i) => {
const pulse = 0.85 + 0.15 * Math.sin(time / 260 + i * 1.7);
const g = ctx.createRadialGradient(x, y - 2, 0, x, y - 2, glow * pulse);
g.addColorStop(0, 'rgba(255, 170, 70, 0.4)');
@@ -220,7 +334,7 @@ function drawLights(ctx, scene, time) {
ctx.fillRect(x - glow, y - 2 - glow, glow * 2, glow * 2);
});
ctx.restore();
scene.lights.forEach(({ at: [x, y] }, i) => {
lights.forEach(({ at: [x, y] }, i) => {
const tall = flameFrame(time, i);
ctx.fillStyle = '#ff9a2e';
ctx.fillRect(x - 1, y - 3 - tall, 2, 3 + tall);
+27 -3
View File
@@ -44,6 +44,14 @@ export const story = {
// La mappa (pulsante «Mappa»): ogni scena con `map: [x, y]` (percentuali della pianta) ci compare quando è
// stata visitata; le strade sono le uscite fra scene. Un posto nuovo entra con la sua riga `map`.
start: { scene: 'piazza', at: [250, 320] },
// Le fasi del giorno (idea di Andrea, 24 ottobre): seguono la storia, non l'orologio. Il primo capitolo comincia
// di pomeriggio; con le impronte del Regolo viene il tramonto, e quando Andrea ha la torcia è sera. Sulle scene
// all'aperto un velo di colore (`tint`); luci ed effetti possono valere solo in certe fasi (`phase`).
phases: [
{ id: 'pomeriggio' },
{ id: 'tramonto', when: 'indizio_regolo', tint: 'rgba(255, 110, 40, 0.16)' },
{ id: 'sera', when: 'casetta_aperta', tint: 'rgba(24, 28, 90, 0.42)' },
],
// Il taccuino (pulsante «Taccuino», tasto N): le tappe del capitolo in ordine. Una tappa è fatta quando la sua
// condizione `done` è vera; la prima non fatta è l'obiettivo di adesso. Solo testo: non si sente a voce.
notebook: [
@@ -329,6 +337,11 @@ export const story = {
name: 'Sotto le mura della Fortezza',
map: [36, 6],
background: 'assets/scenes/fortezza-esterno.png',
// Nella fessura, con la torcia: il pulviscolo nel fascio di luce, e gli occhi gialli che sbattono.
effects: [
{ type: 'dust', when: 'luce_fessura', area: [494, 278, 14, 22], count: 7 },
{ type: 'blink', when: 'occhi_regolo', rects: [[498, 282, 2, 2], [504, 282, 2, 2]], fill: '#ffe14a', every: 700, closed: 120 },
],
walk: [[110, 284], [300, 280], [330, 272], [348, 274], [400, 292], [442, 294], [640, 352], [640, 360], [0, 360], [0, 315], [60, 310], [105, 295]],
depth: [[272, 0.55], [360, 1.15]],
entry: [175, 352],
@@ -383,8 +396,6 @@ export const story = {
{ when: 'luce_fessura', rect: [497, 291, 9, 8], fill: '#d9d2b0' },
{ when: 'luce_fessura', rect: [499, 293, 1, 3], fill: '#c0392b' },
{ when: 'luce_fessura', rect: [503, 294, 1, 3], fill: '#9bb04a' },
{ when: 'occhi_regolo', rect: [498, 282, 2, 2], fill: '#ffe14a' },
{ when: 'occhi_regolo', rect: [504, 282, 2, 2], fill: '#ffe14a' },
],
actions: {
look: [{ say: 'Ecco dove vanno le impronte: dentro questa fessura, larga quanto un gatto grasso!' }, { say: 'Dentro è buio pesto, e ne esce un\'aria fresca da cantina. Senza luce non vedo niente.' }, { if: '!has:torcia', then: [{ say: 'Ci vuole una torcia. Quelli della festa ne tengono una nella casetta dell\'area feste, con le prolunghe.' }] }],
@@ -507,8 +518,10 @@ export const story = {
map: [56, 39],
background: 'assets/scenes/pieve-interno.png',
// Penombra, e le sei candele dell'altare accese (Andrea, 24 ottobre).
indoor: true,
shade: 'rgba(14, 8, 24, 0.62)',
lights: [{ at: [284, 157] }, { at: [294, 159] }, { at: [304, 155] }, { at: [336, 155] }, { at: [346, 159] }, { at: [356, 157] }],
// Le candele si accendono dal tramonto: di pomeriggio la chiesa è in penombra e basta.
lights: [[284, 157], [294, 159], [304, 155], [336, 155], [346, 159], [356, 157]].map(at => ({ at, phase: ['tramonto', 'sera'] })),
walk: [[262, 360], [290, 256], [350, 256], [380, 360]],
depth: [[256, 0.7], [360, 1.7]],
entry: [320, 352],
@@ -1091,6 +1104,7 @@ export const story = {
name: 'Via Provinciale, il viale alberato',
map: [72, 68],
background: 'assets/scenes/provinciale-viale.png',
effects: [{ type: 'leaves', area: [20, 40, 360, 250], count: 7 }],
walk: [[0, 360], [0, 330], [400, 232], [462, 232], [620, 360]],
depth: [[232, 0.4], [360, 1.7]],
entry: [300, 350],
@@ -1127,6 +1141,7 @@ export const story = {
name: 'La Provinciale lungo la Carza',
map: [66, 73],
background: 'assets/scenes/verso-luna.png',
effects: [{ type: 'sparkle', area: [380, 288, 200, 30], count: 5 }, { type: 'sparkle', area: [430, 210, 40, 30], count: 2 }, { type: 'smoke', at: [113, 116] }, { type: 'lamps', phase: 'sera', points: [[165, 93]] }],
walk: [[0, 360], [0, 200], [168, 190], [182, 196], [330, 360]],
depth: [[192, 0.45], [360, 1.7]],
entry: [150, 206],
@@ -1259,6 +1274,7 @@ export const story = {
name: 'Campomigliaio, il ponte vecchio',
map: [50, 86],
background: 'assets/scenes/campomigliaio-ponte.png',
effects: [{ type: 'sparkle', area: [150, 205, 160, 90], count: 7 }, { type: 'sparkle', area: [330, 300, 110, 40], count: 3 }, { type: 'smoke', at: [605, 50] }],
walk: [[0, 360], [0, 232], [100, 225], [140, 240], [170, 275], [260, 330], [300, 360]],
depth: [[225, 0.6], [360, 1.5]],
entry: [80, 330],
@@ -1372,6 +1388,11 @@ export const story = {
name: 'Il piazzale delle feste',
map: [26, 91],
background: 'assets/scenes/area-feste-festa.png',
// Le lucine dei festoni, che si accendono e si spengono; di sera i lampioni.
effects: [
{ type: 'bulbs', points: [[178, 122], [197, 126], [217, 130], [236, 133], [255, 136], [275, 139], [294, 141], [313, 143], [333, 144], [352, 145], [371, 146], [391, 146], [410, 146], [430, 145], [449, 144], [468, 143], [488, 141], [507, 139], [526, 137], [546, 134], [565, 130], [584, 127], [604, 122], [623, 118]] },
{ type: 'lamps', phase: 'sera', points: [[162, 90], [216, 148], [250, 162], [370, 158], [440, 158], [496, 182], [527, 180], [590, 178], [632, 112]] },
],
walk: [[0, 360], [0, 260], [160, 240], [420, 240], [640, 296], [640, 360]],
depth: [[240, 0.55], [360, 1.15]],
entry: [320, 350],
@@ -1409,6 +1430,7 @@ export const story = {
name: 'Villa Adami',
map: [46, 32],
background: 'assets/scenes/villa-adami.png',
effects: [{ type: 'flag', at: [251, 4], w: 9, h: 6, colors: ['#f4f1e8', '#2a2a2a'] }],
walk: [[0, 272], [30, 246], [60, 244], [110, 256], [190, 268], [360, 296], [440, 288], [520, 276], [560, 268], [612, 262], [614, 272], [520, 288], [500, 292], [500, 360], [0, 360]],
depth: [[244, 0.5], [360, 1.2]],
entry: [300, 345],
@@ -1665,6 +1687,8 @@ export const story = {
name: 'La scuola',
map: [14, 19],
background: 'assets/scenes/scuola.png',
// Il tricolore che sventola in cima all'asta.
effects: [{ type: 'flag', at: [122, 117], w: 13, h: 9, colors: ['#2f9a4a', '#f4f1e8', '#d2393a'] }],
walk: [[110, 360], [118, 226], [140, 222], [268, 220], [290, 232], [470, 330], [620, 300], [640, 300], [640, 360]],
depth: [[220, 0.42], [360, 1.32]],
entry: [300, 350],
+12
View File
@@ -75,3 +75,15 @@ test('le apparizioni (lo spirito del Trebbio) hanno la loro immagine e parlano c
assert.ok(sc.speakers?.[a.who], `${id}: il fumetto di ${a.who} non ha un posto`);
}
});
test('gli effetti delle scene: tipi che il motore conosce, e dentro la scena', async () => {
const { story } = await import('../src/story/story.js');
const { EFFECTS } = await import('../src/engine/render.js');
const phases = new Set((story.phases ?? []).map(p => p.id));
for (const [id, sc] of Object.entries(story.scenes)) for (const e of sc.effects ?? []) {
assert.ok(EFFECTS[e.type], `${id}: effetto sconosciuto ${e.type}`);
for (const ph of [e.phase ?? []].flat()) assert.ok(phases.has(ph), `${id}: fase sconosciuta ${ph}`);
const pts = [...(e.points ?? []), ...(e.at ? [e.at] : []), ...(e.area ? [e.area.slice(0, 2), [e.area[0] + e.area[2], e.area[1] + e.area[3]]] : []), ...(e.rects ?? []).map(r => r.slice(0, 2))];
for (const [x, y] of pts) assert.ok(x >= 0 && x <= 640 && y >= 0 && y <= 360, `${id}: ${e.type} fuori scena`);
}
});
+13
View File
@@ -122,6 +122,19 @@ test('il taccuino: lungo la soluzione le tappe si spuntano in ordine, una alla v
assert.equal(g.notebook().done.length, story.notebook.length);
});
test('le fasi del giorno seguono la storia: pomeriggio, poi tramonto, poi sera, senza tornare indietro', () => {
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];
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);
if (seen.at(-1) !== g.phase().id) seen.push(g.phase().id);
}
assert.deepEqual(seen, ids);
});
test('la ricerca della mappa: pezzi di parola, senza maiuscole, accenti e apostrofi', () => {
assert.ok(matchPlace('Campomigliaio', 'camp'));
assert.ok(matchPlace('Via de\' Medici', 'de medici'));