diff --git a/src/engine/game.js b/src/engine/game.js index 6370677..13b0b96 100644 --- a/src/engine/game.js +++ b/src/engine/game.js @@ -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)); diff --git a/src/engine/render.js b/src/engine/render.js index 051cfd5..8fd4a14 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -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); diff --git a/src/story/story.js b/src/story/story.js index 6f5aef4..4f77cb2 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -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], diff --git a/tests/render.test.mjs b/tests/render.test.mjs index 825f17a..cdb4b2f 100644 --- a/tests/render.test.mjs +++ b/tests/render.test.mjs @@ -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`); + } +}); diff --git a/tests/story.test.mjs b/tests/story.test.mjs index 301edec..c2290db 100644 --- a/tests/story.test.mjs +++ b/tests/story.test.mjs @@ -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'));