Files
leggenda-del-regolo/src/engine/render.js
T

1169 lines
62 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Disegno della scena su un canvas 640×360, ingrandito dal CSS senza sfocatura.
// Le forme provvisorie (`art`) verranno sostituite da sfondi e sprite in pixel art.
import { check, PAGE_TURN, POSTER_READY } from './game.js';
import { ambientEffects, ambientHash } from './ambient.js';
import { drawTorch } from './torch.js';
import { drawBocce } from './bocce-render.js';
import { drawBiliardo } from './biliardo-render.js';
import { drawRinquarto } from './rinquarto-render.js';
import { drawBasket } from './basket-render.js';
import { drawPesca } from './pesca-render.js';
import { drawGara } from './gara-render.js';
import { drawMicro } from './micro-render.js';
import { drawCerbottana } from './cerbottana-render.js';
import { drawCorsa } from './corsa.js';
import { drawCondor } from './condor-render.js';
import { drawCampana } from './campana-render.js';
import { drawBruciate } from './bruciate-render.js';
import { drawFlipper } from './flipper-render.js';
import { drawPoker } from './poker-render.js';
import { drawViaggio } from './viaggio.js';
import { drawIncanto } from './incanto.js';
import { drawRisveglio } from './risveglio.js';
import { drawTelefono, telefonoHint } from './telefono.js';
import { drawBriscola } from './briscola-render.js';
import { drawTressette } from './tressette-render.js';
import { drawScopa } from './scopa-render.js';
import { versioned } from '../version.js';
export const WIDTH = 640, HEIGHT = 360;
function drawShape(ctx, s) {
if (s.rect) {
ctx.fillStyle = s.fill;
ctx.fillRect(...s.rect);
} else if (s.poly) {
ctx.fillStyle = s.fill;
ctx.beginPath();
s.poly.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
ctx.closePath();
ctx.fill();
} else if (s.lines) {
// Righe di riempimento: fughe del selciato o sbarre di un cancello.
const { from: [x0, y0], to: [x1, y1], step, vertical } = s.lines;
ctx.fillStyle = s.stroke;
if (vertical) for (let x = x0; x <= x1; x += step) ctx.fillRect(x, y0, 2, y1 - y0);
else for (let y = y0; y <= y1; y += step) ctx.fillRect(x0, y, x1 - x0, 1);
} else if (s.text) {
ctx.fillStyle = s.fill;
ctx.font = s.size ? `${s.size}px "Press Start 2P", monospace` : 'bold 11px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(s.text, ...s.at);
}
}
// Andrea provvisorio, disegnato a blocchi: maglia blu (il rione di Campomigliaio), gambe che si alternano.
const BODY = [
// [x, y, w, h, colore] con l'origine ai piedi, rivolto a destra.
[-6, -60, 12, 4, '#4a2e1c'], [-6, -56, 12, 12, '#e8b48c'], [3, -53, 2, 2, '#2a1a10'], [-7, -60, 4, 10, '#4a2e1c'],
[-8, -44, 16, 20, '#2f5fb3'], [-4, -42, 8, 3, '#f4efe4'],
];
export function drawAndrea(ctx, x, y, scale, facing, walkPhase) {
ctx.save();
ctx.translate(Math.round(x), Math.round(y));
ctx.scale(scale * facing, scale);
ctx.fillStyle = 'rgba(0,0,0,0.22)';
ctx.fillRect(-10, -2, 20, 4);
const swing = walkPhase == null ? 0 : Math.round(Math.sin(walkPhase) * 5);
ctx.fillStyle = '#3a3f52';
ctx.fillRect(-6 + swing, -24, 5, 22);
ctx.fillRect(1 - swing, -24, 5, 22);
ctx.fillStyle = '#e8e3d6';
ctx.fillRect(-7 + swing, -3, 7, 3);
ctx.fillRect(0 - swing, -3, 7, 3);
for (const [bx, by, w, h, c] of BODY) {
ctx.fillStyle = c;
ctx.fillRect(bx, by, w, h);
}
ctx.fillStyle = '#e8b48c';
ctx.fillRect(-2 - swing, -40, 4, 16);
ctx.restore();
}
// Sfondi e fogli dei personaggi, caricati una volta sola. Finché un'immagine non c'è si vedono i segnaposto.
const backgrounds = new Map(), sprites = new Map();
const loadImage = src => new Promise(resolve => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
img.src = versioned(src);
});
export const hasBackground = src => backgrounds.has(src);
// Uno sfondo si chiede una volta sola: la stessa promessa serve a chi lo vuole subito e al caricamento di fondo.
// Se fallisce (rete ballerina) si riprova, ma non a ogni fotogramma: dopo 2, 4, 8 secondi, poi basta fino al
// ricaricamento della pagina.
const pending = new Map(), failed = new Map();
export const RETRIES = 3, RETRY_WAIT = 2000;
export function loadBackground(src, now = Date.now()) {
if (backgrounds.has(src)) return Promise.resolve(true);
if (pending.has(src)) return pending.get(src);
const f = failed.get(src);
if (f && (f.count > RETRIES || now < f.until)) return Promise.resolve(false);
const p = loadImage(src).then(img => {
pending.delete(src);
if (img) { backgrounds.set(src, img); failed.delete(src); return true; }
const count = (failed.get(src)?.count ?? 0) + 1;
failed.set(src, { count, until: Date.now() + RETRY_WAIT * 2 ** (count - 1) });
return false;
});
pending.set(src, p);
return p;
}
// Prima i personaggi e la scena dove si parte, poi le scene vicine (le uscite), poi tutte le altre poche alla volta:
// la prima scena compare subito invece di aspettare in fila con i 10 MB degli altri sfondi.
// Una scena dove si arriva prima del suo turno passa avanti (render la chiede da sé).
export async function loadAssets(story, first = story.start.scene, { together = 3 } = {}) {
const people = Object.entries(story.characters).filter(([, c]) => c.sprite).map(async ([id, c]) => {
try {
const atlas = await (await fetch(versioned(c.sprite))).json();
const img = await loadImage(new URL(atlas.image, new URL(c.sprite, location.href)).href);
if (img) sprites.set(id, { atlas, img });
return !!img;
} catch {
return false;
}
});
const order = backgroundOrder(story, story.scenes[first] ? first : story.start.scene), results = [];
results.push(...await Promise.all([...people, ...order.slice(0, 1).map(loadBackground)]));
let next = 1;
const worker = async () => { while (next < order.length) results.push(await loadBackground(order[next++])); };
await Promise.all(Array.from({ length: together }, worker));
return results;
}
// Gli sfondi in ordine di distanza dalla scena di partenza, seguendo le uscite; in fondo quelle non raggiungibili.
// Prima tutti gli sfondi veri, poi le varianti (`backdrops`, come la notte dell'Ingorgo): quando servono prima, la
// scena se le carica da sé.
export function backgroundOrder(story, first) {
const seen = new Set([first]), queue = [first];
for (let i = 0; i < queue.length; i++)
for (const h of story.scenes[queue[i]]?.hotspots ?? [])
if (h.exit && story.scenes[h.exit.scene] && !seen.has(h.exit.scene)) { seen.add(h.exit.scene); queue.push(h.exit.scene); }
for (const id of Object.keys(story.scenes)) if (!seen.has(id)) queue.push(id);
return [...new Set([...queue.map(id => story.scenes[id].background), ...queue.flatMap(id => (story.scenes[id].backdrops ?? []).map(b => b.src))].filter(Boolean))];
}
// Il personaggio alto PERSON pixel a grandezza 1 (per `size` 1), con i piedi in (x, y); rivolto a sinistra si specchia.
const PERSON = 62;
function drawSprite(ctx, { atlas, img }, frame, x, y, scale, facing, size = 1) {
const f = atlas.frames[frame] ?? atlas.frames.stand ?? Object.values(atlas.frames)[0], k = PERSON * scale * size / atlas.height;
ctx.save();
ctx.translate(Math.round(x), Math.round(y));
ctx.fillStyle = 'rgba(0,0,0,0.22)';
ctx.beginPath();
ctx.ellipse(0, 0, 13 * scale, 3 * scale, 0, 0, Math.PI * 2);
ctx.fill();
// La pagina è ingrandita a pixel netti: qui invece si rimpicciolisce, e la media dei pixel evita lo sfarfallio.
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
if (frame !== 'back') ctx.scale(facing, 1);
ctx.drawImage(img, f.x, f.y, f.w, f.h, -f.ax * k, -f.ay * k, f.w * k, f.h * k);
ctx.restore();
}
export function render(ctx, game, { hover, spots, debug, time = performance.now() } = {}) {
// Il flipper copre tutto il canvas: evitare di dipingere e illuminare anche la strada nascosta sotto.
if (game.closeup && game.story.closeups?.[game.closeup.id]?.type === 'flipper') {
drawCloseup(ctx, game, time);
if (debug) drawDebug(ctx, game, debug);
return;
}
const scene = game.scene, s = game.state, backdrop = game.backdrop(), bg = backgrounds.get(backdrop);
if (!bg && backdrop) loadBackground(backdrop);
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, WIDTH, HEIGHT);
if (bg) ctx.drawImage(bg, 0, 0, WIDTH, HEIGHT);
// Gli effetti che muovono pezzi di sfondo (i cipressi al vento) vanno subito sopra lo sfondo, sotto tutto il resto.
drawEffects(ctx, game, time, true);
for (const shape of scene.art ?? []) drawShape(ctx, shape);
// Una forma può avere la sua condizione (`when`): la chiavetta nella griglia sparisce quando la si prende.
// I primi piani (`top`) invece vanno sopra a tutto, anche al buio e alle candele: si disegnano dopo.
const art = game.hotspots().flatMap(h => (h.art ?? []).filter(shape => check(shape.when, game.state)));
for (const shape of art) if (!shape.top) drawShape(ctx, shape);
// Andrea e i personaggi della scena, dal più lontano al più vicino.
const andrea = sprites.get('andrea');
const party = festa(game, time), drawAndreaNow = () => party ? drawSprite(ctx, party.sheet, party.pose, s.x, s.y, game.scaleAt(s.y), s.facing)
: andrea ? drawSprite(ctx, andrea, game.pose(), s.x, s.y, game.scaleAt(s.y), s.facing)
: drawAndrea(ctx, s.x, s.y, game.scaleAt(s.y), s.facing, game.walking ? game.stride / 6 : null);
// Durante il risveglio si guarda dagli occhi di Andrea: lui non si vede.
const pov = game.closeup && game.story.closeups?.[game.closeup.id]?.type === 'risveglio';
const queue = pov ? [] : [{ y: s.y, draw: drawAndreaNow }];
for (const p of game.people()) {
const sheet = sprites.get(p.who);
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();
if (!pov) crowdFront(ctx, game, time);
drawApparitions(ctx, game, 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 (game.closeup) drawCloseup(ctx, game, time);
if (debug) drawDebug(ctx, game, debug);
if (hover) {
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
ctx.setLineDash([3, 3]);
ctx.strokeRect(...hover.box);
ctx.setLineDash([]);
}
}
// Le apparizioni (`apparitions`): uno spirito che compare solo mentre parla (un dialogo suo, o una sua battuta),
// semitrasparente, che ondeggia piano, con un alone freddo intorno e il caldo della lanterna.
function drawApparitions(ctx, game, time) {
for (const a of game.scene.apparitions ?? []) {
if (game.dialogue?.id !== a.who && game.speech?.who !== a.who) continue;
const img = backgrounds.get(a.src);
if (!img) { loadBackground(a.src); continue; }
const h = a.height, w = Math.round(img.width * h / img.height), bob = Math.round(Math.sin(time / 600) * 2);
const x = Math.round(a.at[0] - w / 2), y = a.at[1] - h + bob;
ctx.save();
const halo = ctx.createRadialGradient(a.at[0], y + h * 0.45, 0, a.at[0], y + h * 0.45, h * 0.7);
halo.addColorStop(0, 'rgba(180, 230, 255, 0.28)');
halo.addColorStop(1, 'rgba(180, 230, 255, 0)');
ctx.globalCompositeOperation = 'lighter';
ctx.fillStyle = halo;
ctx.fillRect(a.at[0] - h, y - h * 0.3, h * 2, h * 1.6);
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 0.82 + 0.08 * Math.sin(time / 380);
ctx.drawImage(img, x, y, w, h);
ctx.restore();
}
}
// Il cielo di uno sfondo: i pixel azzurri (blu ben più forte del rosso), come maschera; si calcola una volta sola.
const skyMasks = new WeakMap();
function skyMask(bg) {
if (skyMasks.has(bg)) return skyMasks.get(bg);
let mask = null;
try {
const c = document.createElement('canvas');
c.width = WIDTH; c.height = HEIGHT;
const x = c.getContext('2d');
x.drawImage(bg, 0, 0, WIDTH, HEIGHT);
const img = x.getImageData(0, 0, WIDTH, HEIGHT), d = img.data, W = WIDTH, H = HEIGHT;
const sky = new Uint8Array(W * H);
for (let i = 0; i < W * H; i++) sky[i] = d[i * 4 + 2] > 150 && d[i * 4 + 2] - d[i * 4] > 60 && d[i * 4 + 2] - d[i * 4 + 1] > 15 ? 1 : 0;
// Le nuvole dipinte nello sfondo sono cielo anche loro (sennò le nuvole nuove ci spariscono dietro): pixel chiari
// con del cielo azzurro vicino a sinistra, a destra, sopra e sotto. Un muro chiaro sotto non ha cielo: resta fuori.
const near = (i, step, n) => { for (let k = 1, j = i; k <= n; k++) { j += step; if (j < 0 || j >= W * H) return false; if (sky[j]) return true; } return false; };
const out = sky.slice();
for (let i = 0; i < W * H; i++) {
if (sky[i]) continue;
const r = d[i * 4], g = d[i * 4 + 1], bl = d[i * 4 + 2], col = i % W;
if (r + g + bl < 540 || bl < 140) continue;
if (near(i, -1, Math.min(40, col)) && near(i, 1, Math.min(40, W - 1 - col)) && near(i, -W, 24) && near(i, W, 24)) out[i] = 1;
}
for (let i = 0; i < W * H; i++) d[i * 4 + 3] = out[i] ? 255 : 0;
x.putImageData(img, 0, 0);
mask = c;
} catch { /* senza canvas (test) niente nuvole */ }
skyMasks.set(bg, mask);
return mask;
}
let scratchCanvas = null;
const scratch = () => {
if (!scratchCanvas) { scratchCanvas = document.createElement('canvas'); scratchCanvas.width = WIDTH; scratchCanvas.height = HEIGHT; }
return scratchCanvas;
};
const CLOUDS = Array.from({ length: 8 }, (_, i) => `assets/effects/nuvola-${i + 1}.png`);
// 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;
// Fasci della festa: alone e nucleo sulla griglia dei pixel, attenuati in tre fasce nette lungo il percorso.
// Bastano sei poligoni per fascio, senza sfocature o letture del canvas.
function discoCone(ctx, x, y, angle, len, width, color, power) {
for (let layer = 0; layer < 2; layer++) {
const half = width * (layer ? 0.32 : 1);
const lx = Math.cos(angle - half), ly = Math.sin(angle - half), rx = Math.cos(angle + half), ry = Math.sin(angle + half);
for (let band = 0; band < 3; band++) {
const near = len * band / 3, far = len * (band + 1) / 3;
ctx.fillStyle = `rgba(${color},${power * (layer ? 0.8 : 0.45) * (1 - band * 0.36)})`;
ctx.beginPath();
ctx.moveTo(Math.round(x + lx * near), Math.round(y + ly * near));
ctx.lineTo(Math.round(x + lx * far), Math.round(y + ly * far));
ctx.lineTo(Math.round(x + rx * far), Math.round(y + ry * far));
ctx.lineTo(Math.round(x + rx * near), Math.round(y + ry * near));
ctx.closePath(); ctx.fill();
}
}
}
// Una chiazza schiacciata a scalini: cinque strisce bastano, anche sulla folla in primo piano.
function discoPool(ctx, x, y, rx, ry, color, power) {
ctx.fillStyle = `rgba(${color},${power})`;
for (let row = -2; row <= 2; row++) {
const w = Math.round(rx * (Math.abs(row) === 2 ? 0.6 : Math.abs(row) === 1 ? 0.88 : 1));
const top = Math.round(y + (row - 0.5) * ry * 0.4), bottom = Math.round(y + (row + 0.5) * ry * 0.4);
ctx.fillRect(Math.round(x) - w, top, w * 2, bottom - top);
}
}
// Gli effetti delle scene (`effects`), ognuno col suo `type`: piccole animazioni disegnate sopra lo sfondo.
export const EFFECTS = {
...ambientEffects,
// 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 fuoco di legna (il braciere delle bruciate alla Smarronata, Andrea 31 ottobre): lingue che guizzano a scatti
// sulla base `area` [x, y, w, h], rosse fuori e gialle dentro, e un bagliore caldo che pulsa.
fire(ctx, e, time) {
const [x, y, w, h] = e.area, step = e.every ?? 110, n = Math.max(3, Math.round(w / 4));
ctx.save();
ctx.globalCompositeOperation = 'lighter';
const g = ctx.createRadialGradient(x + w / 2, y + h / 2, 0, x + w / 2, y + h / 2, w * 0.9);
g.addColorStop(0, `rgba(255, 140, 50, ${0.28 + 0.08 * Math.sin(time / 170)})`);
g.addColorStop(1, 'rgba(255, 90, 30, 0)');
ctx.fillStyle = g;
ctx.fillRect(x - w, y - w, w * 3, w * 2.4);
ctx.restore();
// Ogni lingua: righe che si stringono salendo, gialla alla base, arancio a metà, rossa in punta, e ondeggia.
for (let i = 0; i < n; i++) {
const t = Math.floor(time / step) * 13 + i * 7, cx = x + (i + 0.5) * w / n;
const tall = Math.round(h * (0.5 + 0.8 * hash(t))), base = y + h, lean = (hash(t + 1) - 0.5) * 3;
for (let r = 0; r < tall; r++) {
const k = r / tall, half = Math.max(0.5, 2.2 * (1 - k)), px = cx + lean * k;
ctx.fillStyle = k < 0.3 ? '#ffe27a' : k < 0.65 ? '#ff9a2e' : '#d8401c';
ctx.fillRect(Math.round(px - half), base - r - 1, Math.max(1, Math.round(half * 2)), 1);
}
// Una scintilla che sale, ogni tanto.
if (hash(t + 3) < 0.25) { ctx.fillStyle = '#ffd25a'; ctx.fillRect(Math.round(cx + lean), base - tall - 2 - Math.round(hash(t + 9) * 5), 1, 1); }
}
},
// 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);
},
// Lucciole di sera: puntini gialli che vagano piano e si accendono e si spengono.
fireflies(ctx, e, time) {
const [ax, ay, aw, ah] = e.area;
for (let i = 0; i < (e.count ?? 8); i++) {
const t = time / 1000, glow = Math.sin(t * (1.3 + hash(i) * 1.2) + i * 2);
if (glow < 0.15) continue;
const x = ax + aw * (0.5 + 0.45 * Math.sin(t * 0.21 + i * 2.3) * Math.cos(t * 0.13 + i)), y = ay + ah * (0.5 + 0.45 * Math.sin(t * 0.17 + i * 1.7));
ctx.fillStyle = `rgba(255, 240, 120, ${0.25 * glow})`;
ctx.fillRect(Math.round(x) - 1, Math.round(y) - 1, 3, 3);
ctx.fillStyle = `rgba(230, 255, 140, ${glow})`;
ctx.fillRect(Math.round(x), Math.round(y), 1, 1);
}
},
// Le finestre che si accendono di sera, una dopo l'altra, appena si entra nella scena.
windows(ctx, e, time, game) {
const since = ((game.time ?? 0) - (game.enteredAt ?? 0)) * 1000;
ctx.globalAlpha = e.opacity ?? 1;
ctx.globalCompositeOperation = e.blend ?? 'source-over';
e.rects.forEach(([x, y, w, h], i) => {
if (since < 400 + hash(i * 7 + 3) * (e.over ?? 3500)) return;
ctx.fillStyle = 'rgba(255, 196, 100, 0.72)';
ctx.fillRect(x, y, w, h);
ctx.fillStyle = 'rgba(255, 236, 170, 0.5)';
ctx.fillRect(x + 1, y + 1, Math.max(1, w - 2), Math.max(1, Math.floor(h / 3)));
});
},
// Rondini: ogni pochi secondi un gruppetto attraversa il cielo in diagonale, a «V» con le ali che battono.
birds(ctx, e, time) {
const [ax, ay, aw, ah] = e.area, every = e.every ?? 6000, duration = e.duration ?? every * 0.6;
const elapsed = (time + (e.offset ?? 0)) % every, run = Math.floor((time + (e.offset ?? 0)) / every);
if (elapsed >= duration) return;
const k = elapsed / duration, rtl = hash(run * 13) < 0.5;
for (let i = 0; i < (e.count ?? 4); i++) {
const lag = i * 0.05, kk = k - lag;
if (kk < 0) continue;
const x0 = rtl ? ax + aw + 10 - kk * (aw + 30) : ax - 10 + kk * (aw + 30);
const x = Math.round(x0 + (hash(run * 5 + i) - 0.5) * 16);
const y = Math.round(ay + hash(run * 3 + i) * ah - kk * 20 + Math.sin(kk * 14 + i) * 4);
const up = Math.floor(time / 90 + i * 2) % 3;
ctx.fillStyle = '#1b1722';
ctx.fillRect(x, y, 2, 2);
const wy = up === 0 ? -2 : up === 1 ? -1 : 1;
ctx.fillRect(x - 2, y + (wy < 0 ? -1 : 0), 2, 1);
ctx.fillRect(x - 4, y + wy, 2, 1);
ctx.fillRect(x + 2, y + (wy < 0 ? -1 : 0), 2, 1);
ctx.fillRect(x + 4, y + wy, 2, 1);
}
},
// Alberi al vento (i cipressi): il pezzo di sfondo si piega tutto insieme dalla base, di frazioni di pixel e con
// due onde lente sovrapposte, così il movimento è morbido e non a scatti.
sway(ctx, e, time, game) {
const bg = backgrounds.get(game.backdrop());
if (!bg) return;
const k = bg.width / WIDTH, amount = e.amount ?? 3.2;
for (const [i, [x, y, w, h]] of e.rects.entries()) {
const a = amount * (0.65 * Math.sin(time / 1900 + i * 1.7) + 0.35 * Math.sin(time / 830 + i * 2.9));
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.translate(x, y + h);
ctx.transform(1, 0, -a / h, 1, 0, 0);
ctx.drawImage(bg, x * k, y * k, w * k, h * k, 0, -h, w, h);
ctx.restore();
}
},
// Nuvole che attraversano il cielo, lentissime (disegnate da Codex nello stile degli sfondi, assets/effects/):
// ognuna a una sua altezza e velocità, uscita da un lato rientra dall'altro. Si vedono solo dove lo sfondo è
// cielo (skyMask): passano dietro alberi, torri e tetti. Le due grandi solo coi cieli larghi (`big`).
clouds(ctx, e, time, game) {
const bg = backgrounds.get(game.backdrop()), mask = bg && skyMask(bg);
if (!mask) return;
const layer = scratch(), lc = layer.getContext('2d'), [ax, ay, aw, ah] = e.area;
const pool = e.big ? CLOUDS : CLOUDS.slice(2);
lc.clearRect(0, 0, WIDTH, HEIGHT);
for (let i = 0; i < (e.count ?? 3); i++) {
const src = pool[Math.floor(hash(i * 19 + (e.seed ?? 0) + game.state.scene.length) * pool.length)], img = backgrounds.get(src);
if (!img) { loadBackground(src); continue; }
const speed = (e.speed ?? 2.5) * (0.6 + hash(i * 7) * 0.8), span = aw + img.width + 20;
const x = ax - img.width - 10 + ((hash(i * 11) * span + time / 1000 * speed) % span);
lc.drawImage(img, Math.round(x), ay + Math.floor(hash(i * 5 + 2) * ah));
}
lc.globalCompositeOperation = 'destination-in';
lc.drawImage(mask, 0, 0);
lc.globalCompositeOperation = 'source-over';
ctx.drawImage(layer, 0, 0);
},
// Ombre di foglie in piccoli gruppi irregolari. Il seme mescolato evita file di
// ovali equidistanti (che sembravano sassi); niente rettangoli di sfondo deformati.
leafshadow(ctx, e, time) {
const [ax, ay, aw, ah] = e.area, t = time / 1000;
ctx.globalAlpha = e.opacity ?? 1;
for (let i = 0; i < (e.count ?? 14); i++) {
const bx = ax + ambientHash(i * 7 + ax) * aw, by = ay + ambientHash(i * 13 + ay) * ah;
const x = bx + Math.sin(t * 0.9 + i) * 2 + Math.sin(t * 0.37 + i * 2), y = by + Math.sin(t * 0.7 + i * 3);
for (let leaf = 0; leaf < 4; leaf++) {
const n = i * 31 + leaf * 7, dx = (ambientHash(n + 3) - 0.5) * 22, dy = (ambientHash(n + 9) - 0.5) * 9;
const w = 3 + ambientHash(n + 21) * (e.size ?? 7), h = 1.5 + ambientHash(n + 4) * 2.5;
const px = x + dx, py = y + dy;
ctx.fillStyle = leaf === 3 ? 'rgba(255,235,170,0.10)' : 'rgba(30,29,40,0.15)';
ctx.beginPath();
ctx.moveTo(px - w, py); ctx.lineTo(px - w * 0.2, py - h);
ctx.lineTo(px + w * 0.6, py - h * 0.5); ctx.lineTo(px + w, py + h * 0.3);
ctx.lineTo(px, py + h); ctx.closePath(); ctx.fill();
}
}
},
// Una bandiera dipinta nello sfondo che sventola: a strisce verticali che salgono e scendono, ferme dalla parte
// dell'asta (`pole`: 'left' o 'right') e sempre più mosse verso il lembo libero.
// La folla dell'Ingorgo che salta a tempo (Andrea, 31 ottobre): la fascia di sfondo `area` tagliata in strisce
// verticali larghe `step`, ognuna che sobbalza sul battito (`bpm`) con una fase sua, come teste che vanno su e giù;
// ogni striscia salta un battito sì e uno no, a turno, con un colpo secco in su e una ricaduta.
crowd(ctx, e, time, game) {
const bg = backgrounds.get(game.backdrop());
if (!bg) return;
const k = bg.width / WIDTH, [ax, ay, aw, ah] = e.area, step = e.step ?? 5, amount = e.amount ?? 3;
const beats = time / 60000 * (e.bpm ?? 120);
for (let x = ax; x < ax + aw; x += step) {
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);
if (!dy) continue;
const w = Math.min(step, ax + aw - x);
ctx.drawImage(bg, x * k, ay * k, w * k, ah * k, x, ay - dy, w, ah);
}
},
cloth(ctx, e, time, game) {
const bg = backgrounds.get(game.backdrop());
if (!bg) return;
const k = bg.width / WIDTH;
for (const [i, [x, y, w, h]] of e.rects.entries()) {
for (let c = 0; c < w; c++) {
const free = e.pole === 'left' ? c / w : 1 - c / w;
const dy = Math.sin(time / 260 - free * 5 + i) * free * (e.amount ?? 2.2);
ctx.drawImage(bg, (x + c) * k, y * k, k, h * k, x + c, y + dy, 1, h);
}
}
},
// Un fascio di luce da una finestra: un cono chiaro che sfuma fino al pavimento, appena pulsante.
beam(ctx, e, time) {
const [[x1, y1], [x2, y2], [x3, y3], [x4, y4]] = e.poly, pulse = 0.85 + 0.15 * Math.sin(time / 2300);
const g = ctx.createLinearGradient(x1, y1, (x3 + x4) / 2, (y3 + y4) / 2);
g.addColorStop(0, `rgba(255, 236, 180, ${0.42 * pulse})`);
g.addColorStop(1, 'rgba(255, 236, 180, 0)');
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.lineTo(x3, y3); ctx.lineTo(x4, y4);
ctx.closePath();
ctx.fill();
ctx.restore();
},
// Le luci da palco dell'Ingorgo (Andrea, 30 ottobre): dalle teste mobili (`from`) i fasci colorati che spazzano il
// cielo; sul palco un bagliore che pulsa a tempo (`bpm`) e cambia colore a ogni battuta; ogni tanto lo strobo.
stagelights(ctx, e, time) {
const colors = e.colors ?? ['255,60,200', '60,220,255', '120,255,90', '255,190,40'], beat = time / (60000 / (e.bpm ?? 124));
const hit = 1 - (beat % 1), bar = Math.floor(beat / 4);
ctx.save();
ctx.globalCompositeOperation = 'lighter';
if (e.wash) {
const [x, y, r] = e.wash, c = colors[bar % colors.length];
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, `rgba(${c},${0.16 + 0.3 * hit ** 2})`);
g.addColorStop(1, `rgba(${c},0)`);
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
for (const [k, [x, y]] of (e.from ?? []).entries()) {
for (let i = 0; i < (e.beams ?? 3); i++) {
const n = k * 3 + i, swing = Math.sin(time / (700 + n * 150) + n * 1.9) * (e.spread ?? 0.85);
const a = -Math.PI / 2 + swing + (k % 2 ? 0.25 : -0.25), len = e.len ?? 340, half = 0.055;
const c = colors[(n + bar) % colors.length], tipX = x + Math.cos(a) * len, tipY = y + Math.sin(a) * len;
const g = ctx.createLinearGradient(x, y, tipX, tipY);
g.addColorStop(0, `rgba(${c},${0.38 + 0.2 * hit})`);
g.addColorStop(1, `rgba(${c},0)`);
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + Math.cos(a - half) * len, y + Math.sin(a - half) * len);
ctx.lineTo(x + Math.cos(a + half) * len, y + Math.sin(a + half) * len);
ctx.closePath();
ctx.fill();
ctx.fillStyle = `rgba(${c},0.9)`;
ctx.fillRect(Math.round(x) - 1, Math.round(y) - 1, 3, 3);
}
}
// Il colpo di colore su tutta la scena a ogni battuta (`pulse`: quanto forte).
if (e.pulse) {
ctx.fillStyle = `rgba(${colors[Math.floor(beat) % colors.length]},${e.pulse * hit ** 3})`;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
}
ctx.restore();
// Lo strobo: un lampo bianco brevissimo ogni `strobe` battute, a raffica nell'ultima battuta di ogni frase.
const roll = Math.floor(beat) % 16 === 15 && (beat * 4) % 1 < 0.3;
if (e.strobe && ((Math.floor(beat) % e.strobe === 0 && beat % 1 < 0.12) || roll)) {
ctx.fillStyle = 'rgba(255,255,255,0.18)';
ctx.fillRect(0, 0, WIDTH, HEIGHT);
}
},
// Le macchie di luce che corrono sulla folla (`area`), colorate, a tempo: la pista da ballo.
floorspots(ctx, e, time) {
const [ax, ay, aw, ah] = e.area, colors = e.colors ?? ['255,60,200', '60,220,255', '120,255,90', '255,190,40', '170,90,255'];
const beat = time / (60000 / (e.bpm ?? 124)), hit = 1 - (beat % 1);
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (let i = 0; i < (e.count ?? 6); i++) {
const u = 0.5 + 0.5 * Math.sin(time / (1300 + i * 230) + i * 2.1), v = 0.5 + 0.5 * Math.sin(time / (1700 + i * 190) + i * 1.3);
const x = ax + u * aw, y = ay + v * ah, r = (e.size ?? 34) * (0.7 + 0.5 * v);
const c = colors[(i + Math.floor(beat / 2)) % colors.length];
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, `rgba(${c},${0.22 + 0.2 * hit})`);
g.addColorStop(1, `rgba(${c},0)`);
ctx.fillStyle = g;
ctx.save();
ctx.translate(x, y); ctx.scale(1, 0.45); ctx.translate(-x, -y);
ctx.fillRect(x - r, y - r, r * 2, r * 2);
ctx.restore();
}
ctx.restore();
},
// I laser della tecno: righe sottili verdi (o del colore dato) che si aprono a ventaglio e si richiudono.
lasers(ctx, e, time) {
const [x, y] = e.at, n = e.count ?? 7, open = 0.35 + 0.35 * Math.sin(time / 1300), tilt = Math.sin(time / 2100) * 0.4;
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.strokeStyle = `rgba(${e.color ?? '90,255,110'},${0.45 + 0.25 * Math.sin(time / 90) ** 2})`;
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i < n; i++) {
const a = -Math.PI / 2 + tilt + (i / (n - 1) - 0.5) * 2 * open * 1.4;
ctx.moveTo(x + 0.5, y + 0.5);
ctx.lineTo(x + Math.cos(a) * (e.len ?? 400), y + Math.sin(a) * (e.len ?? 400));
}
ctx.stroke();
ctx.restore();
},
// La palla a specchi «di Codex» (Andrea, 30 ottobre: «l'effetto qui mi piaceva molto»): appesa al suo filo, un faro
// la colpisce, e i riflessi girano a tempo sui muri e sulla folla (`surfaces`), con qualche scintilla.
mirrorball(ctx, e, time) {
const [bx, by] = e.at, beat = time * (e.bpm ?? 122) / 60000, turn = beat / 32;
const surfaces = e.surfaces ?? [e.area];
ctx.save();
ctx.fillStyle = '#71718d'; ctx.fillRect(bx, e.hang ?? by - 26, 1, by - (e.hang ?? by - 26) - 7);
ctx.globalCompositeOperation = 'lighter';
if (e.from) {
const [x, y] = e.from, dx = bx - x, dy = by - y;
discoCone(ctx, x, y, Math.atan2(dy, dx), Math.hypot(dx, dy), 0.035, '195,215,255', 0.32);
}
for (let i = 0; i < (e.count ?? 60); i++) {
const [ax, ay, aw, ah] = surfaces[i % surfaces.length];
const u = (hash(i * 13) + turn) % 1, v = 0.5 + 0.42 * Math.sin(hash(i * 7 + 3) * Math.PI * 2 + turn * Math.PI * 2);
const x = ax + Math.floor(u * (aw - 4)), y = ay + Math.floor(v * (ah - 3));
const glint = Math.max(0, Math.sin(turn * Math.PI * 4 + i * 2.3)) ** 4;
ctx.fillStyle = `rgba(${e.color ?? '200,215,255'},${0.13 + 0.65 * glint})`;
ctx.fillRect(x, y, glint > 0.7 ? 3 : 2, 2);
if (glint > 0.95) ctx.fillRect(x + 1, y - 1, 1, 4);
}
ctx.globalCompositeOperation = 'source-over';
for (let row = -3; row <= 3; row++) {
for (let col = -3; col <= 3; col++) {
if (row * row + col * col > 11) continue;
const light = Math.sin(col * 0.8 + turn * Math.PI * 2) * 0.65 - row * 0.12;
ctx.fillStyle = light > 0.55 ? '#f3f5ff' : light > 0.1 ? '#b9bcdf' : light > -0.4 ? '#6e78ab' : '#343856';
ctx.fillRect(bx + col * 2, by + row * 2, 2, 2);
}
}
ctx.fillStyle = '#ffffff'; ctx.fillRect(bx - 3, by - 4, 3, 1); ctx.fillRect(bx - 2, by - 5, 1, 3);
ctx.restore();
},
// Le teste mobili «di Codex» (30 ottobre, usate all'underground): incrocio, ventaglio, passaggio parallelo. Il programma cambia ogni otto battiti,
// sempre ricavato dal tempo: anche tornando indietro a un istante si ritrova esattamente la stessa luce.
pixelbeams(ctx, e, time) {
const colors = e.colors ?? ['255,45,170', '45,190,255'], beat = time * (e.bpm ?? 124) / 60000;
const hit = (1 - beat % 1) ** 3, bar = Math.floor(beat / 4), cue = Math.floor(beat / 8) % 3;
const from = e.from ?? [], swing = Math.sin(beat * Math.PI / (e.sweep ?? 4));
ctx.save();
ctx.globalCompositeOperation = 'lighter';
if (e.wash) {
const [x, y, r] = e.wash, c = colors[bar % colors.length];
discoPool(ctx, x, y, r, r * 0.32, c, 0.045 + 0.045 * hit);
discoPool(ctx, x, y, r * 0.55, r * 0.18, c, 0.04 + 0.07 * hit);
}
for (let k = 0; k < from.length; k++) {
const [x, y] = from[k], side = k % 2 ? 1 : -1;
const fan = from.length > 1 ? k / (from.length - 1) * 2 - 1 : 0;
const pan = cue === 0 ? side * swing : cue === 1 ? fan * (0.6 + 0.25 * swing) : swing;
const angle = -Math.PI / 2 + (e.aim ?? 0) + pan * (e.spread ?? 0.7);
const color = colors[(k + bar) % colors.length];
// Un breve buio di fine frase rende leggibile il rientro della cassa; il latino resta più continuo.
const power = (e.power ?? 0.38) * (0.65 + 0.35 * hit) * (e.shutter && beat % 16 > 15.5 ? 0.15 : 1);
for (let i = 0; i < (e.beams ?? 1); i++) {
const offset = (i - ((e.beams ?? 1) - 1) / 2) * 0.15;
discoCone(ctx, x, y, angle + offset, e.len ?? 270, e.width ?? 0.055, color, power);
}
ctx.fillStyle = `rgb(${color})`;
ctx.fillRect(x - 2, y - 1, 5, 3);
ctx.fillStyle = '#fff5df'; ctx.fillRect(x, y, 1, 1);
}
ctx.restore();
},
// Lampade sulla traversa: inseguimento a ottavi, poi accensione insieme sul primo colpo della frase.
// I blinder illuminano solo davanti al palco: nessun velo bianco su tutta la scena.
stagechase(ctx, e, time) {
const beat = time * (e.bpm ?? 124) / 60000, step = Math.floor(beat * 2), points = e.points;
const colors = e.colors ?? ['255,45,170', '45,190,255'];
const flash = e.blinder && (beat % 16 < 0.14 || (Math.floor(beat) % 16 === 15 && beat * 2 % 1 < 0.16));
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (let i = 0; i < points.length; i++) {
const [x, y] = points[i], on = step % points.length === i || beat % 8 < 0.22;
const color = flash ? '255,240,210' : colors[(i + Math.floor(beat / 4)) % colors.length];
if (on || flash) {
ctx.fillStyle = `rgba(${color},0.18)`; ctx.fillRect(x - 4, y - 3, 9, 7);
if (flash) discoCone(ctx, x, y, Math.PI / 2, e.len ?? 65, 0.3, color, 0.2);
}
ctx.fillStyle = `rgba(${color},${on || flash ? 0.95 : 0.15})`;
ctx.fillRect(x - 2, y - 1, 5, 3);
if (on || flash) { ctx.fillStyle = '#fff5df'; ctx.fillRect(x, y, 1, 1); }
}
ctx.restore();
},
// Gobo sulla folla «di Codex»: macchie a gradini, con tagli scuri, più grandi verso il primo piano. L'area le contiene tutte.
pixelspots(ctx, e, time) {
const [ax, ay, aw, ah] = e.area, colors = e.colors ?? ['255,45,170', '45,190,255'];
const beat = time * (e.bpm ?? 124) / 60000, hit = (1 - beat % 1) ** 2;
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (let i = 0; i < (e.count ?? 6); i++) {
const phase = beat * Math.PI / 8 + i * 2.4;
const v = 0.5 + 0.5 * Math.sin(phase * 0.7 + i), u = 0.5 + 0.5 * Math.sin(phase);
const rx = Math.min((e.size ?? 24) * (0.55 + 0.45 * v), aw / 2), ry = Math.min(rx * 0.3, ah / 2);
const x = ax + rx + u * (aw - rx * 2), y = ay + ry + v * (ah - ry * 2);
const c = colors[(i + Math.floor(beat / 4)) % colors.length];
// Le tre impronte separate ricordano un disco sagomato davanti al proiettore.
for (let part = -1; part <= 1; part++)
discoPool(ctx, x + part * rx * 0.65, y, rx * 0.25, ry * (part ? 0.7 : 1), c, 0.12 + 0.16 * hit);
}
ctx.restore();
},
// 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();
},
};
// All'Ingorgo Andrea ha la sua birra (Andrea, 31 ottobre): camminando la tiene davanti al petto, parlando pure; fermo
// alza il braccio col bicchiere e lo muove a tempo con la folla. Prendere e voltarsi restano quelli di sempre.
const CHEER = ['cheer_1', 'cheer_2', 'cheer_1', 'cheer_3'];
export function festa(game, time) {
const sheet = sprites.get('andrea_birra'), band = (game.scene.effects ?? []).find(e => e.type === 'crowd' && onNow(e, game));
if (!sheet || !band) return null;
const pose = game.pose(), beat = Math.floor(time / 60000 * (band.bpm ?? 120)) % CHEER.length;
const mine = pose === 'stand' ? CHEER[beat] : pose;
return sheet.atlas.frames[mine] ? { sheet, pose: mine } : null;
}
// 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);
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)]);
function drawEffects(ctx, game, time, background = false) {
for (const e of game.scene.effects ?? []) {
if (BACKGROUND_EFFECTS.has(e.type) !== background || !onNow(e, game)) continue;
ctx.save();
// Acqua solo nell'acqua, tende e sagome solo dietro il vetro; il ritaglio può
// seguire una riva obliqua senza toccare il terreno o gli oggetti degli enigmi.
if (e.clip) {
ctx.beginPath();
e.clip.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
ctx.closePath();
ctx.clip();
}
// Gli eventi nuovi seguono il tempo giocato: pausa e scheda nascosta non li fanno saltare.
const at = ambientEffects[e.type] ? (game.time ?? time / 1000) * 1000 : time;
EFFECTS[e.type]?.(ctx, e, at, game);
ctx.restore();
}
}
// 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;
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, 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 (!lights.length) return;
ctx.save();
ctx.globalCompositeOperation = 'lighter';
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)');
g.addColorStop(1, 'rgba(255, 120, 40, 0)');
ctx.fillStyle = g;
ctx.fillRect(x - glow, y - 2 - glow, glow * 2, glow * 2);
});
ctx.restore();
lights.forEach(({ at: [x, y] }, i) => {
const tall = flameFrame(time, i);
ctx.fillStyle = '#ff9a2e';
ctx.fillRect(x - 1, y - 3 - tall, 2, 3 + tall);
ctx.fillStyle = '#ffe9a0';
ctx.fillRect(tall ? x - 1 : x, y - 2 - tall, 1, 2);
});
}
// Il pulsante «?»: un segno su ogni cosa che si può toccare, col suo nome. Le uscite in giallo, il resto in bianco.
// I nomi non si sovrappongono: placeLabels cerca per ognuno un posto libero vicino al suo segno.
function drawSpots(ctx, game) {
ctx.save();
ctx.font = '6px "Press Start 2P", monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
const spots = game.hotspots().map(h => {
const [x, y, w, hh] = h.box;
return { h, x: Math.round(x + w / 2), y: Math.round(y + hh / 2), w: ctx.measureText(h.name).width };
});
const labels = placeLabels(spots);
spots.forEach(({ h, x, y }, i) => {
const color = h.exit ? '#ffd27a' : '#ffffff', l = labels[i];
// Nome spostato lontano dal segno: una lineetta li collega.
if (Math.abs(l.y - LABEL_H / 2 - y) > LABEL_H * 1.6 || Math.abs(l.x - x) > 4) {
ctx.strokeStyle = 'rgba(0,0,0,0.6)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(l.x, l.y - LABEL_H / 2);
ctx.stroke();
}
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.fillRect(x - 4, y - 4, 9, 9);
ctx.fillStyle = color;
ctx.fillRect(x - 3, y - 3, 7, 7);
ctx.lineWidth = 3;
ctx.strokeStyle = '#000';
ctx.strokeText(h.name, l.x, l.y);
ctx.fillText(h.name, l.x, l.y);
});
ctx.restore();
}
// Dove scrivere i nomi del «?»: per ogni segno { x, y, w } (centro e larghezza del testo) il punto { x, y } della base
// del testo, centrato. Si prova sopra il segno, sotto, poi sempre più lontano e di lato; vince il primo posto che non
// tocca né i nomi già messi né i segni, e sta dentro la scena. Prima i nomi dei segni più in alto.
const LABEL_H = 8;
export function placeLabels(spots, { width = WIDTH, height = HEIGHT } = {}) {
const boxes = spots.map(s => ({ l: s.x - 5, r: s.x + 5, t: s.y - 5, b: s.y + 5 }));
const hit = (a, b) => a.l < b.r && b.l < a.r && a.t < b.b && b.t < a.b;
const out = new Array(spots.length);
const order = spots.map((s, i) => i).sort((a, b) => spots[a].y - spots[b].y || spots[a].x - spots[b].x);
for (const i of order) {
const s = spots[i], half = s.w / 2 + 1;
const tries = [];
for (const dy of [-6, 13, -15, 22, -24, 31, -33, 40]) for (const dx of [0, -half - 6, half + 6]) tries.push([dx, dy]);
let best = null;
for (const [dx, dy] of tries) {
const x = Math.max(half + 2, Math.min(width - half - 2, s.x + dx)), y = Math.max(LABEL_H + 2, Math.min(height - 2, s.y + dy));
const box = { l: x - half - 3, r: x + half + 3, t: y - LABEL_H - 1, b: y + 1 }; // con un po' d'aria intorno
if (!boxes.some(b => hit(b, box))) { best = { x, y, box }; break; }
best ??= { x, y, box }; // tutto pieno: almeno il primo
}
out[i] = { x: best.x, y: best.y };
boxes.push(best.box);
}
return out;
}
// Modalità debug (?debug nell'indirizzo): area calpestabile coi vertici numerati, tutti gli hotspot col loro id
// (tratteggiati quelli spenti dal loro `when`), punti dove si va (`at`), dove si parla e dove stanno i personaggi.
// In basso le coordinate del puntatore e i punti segnati con Maiusc+clic, per scrivere scene a mano.
function drawDebug(ctx, game, { mouse, points = [], help }) {
const scene = game.scene, active = new Set(game.hotspots());
ctx.save();
ctx.font = '6px "Press Start 2P", monospace';
ctx.lineWidth = 1;
const label = (text, x, y, color) => {
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(x - 1, y - 7, ctx.measureText(text).width + 2, 8);
ctx.fillStyle = color;
ctx.fillText(text, x, y);
};
const cross = ([x, y], color) => { ctx.strokeStyle = color; ctx.beginPath(); ctx.moveTo(x - 3, y); ctx.lineTo(x + 3, y); ctx.moveTo(x, y - 3); ctx.lineTo(x, y + 3); ctx.stroke(); };
if (scene.walk) {
ctx.fillStyle = 'rgba(80,220,120,0.18)';
ctx.strokeStyle = '#50dc78';
ctx.beginPath();
scene.walk.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
ctx.closePath();
ctx.fill();
ctx.stroke();
scene.walk.forEach(([x, y], i) => label(String(i), Math.min(WIDTH - 12, x + 2), Math.max(8, y - 2), '#50dc78'));
}
for (const h of scene.hotspots ?? []) {
const on = active.has(h), color = h.exit ? '#ffd27a' : on ? '#7ad7ff' : '#999';
ctx.setLineDash(on ? [] : [2, 2]);
ctx.strokeStyle = color;
ctx.strokeRect(...h.box);
ctx.setLineDash([]);
label(h.id, Math.max(1, Math.min(WIDTH - ctx.measureText(h.id).width - 2, h.box[0] + 1)), Math.max(8, h.box[1] + 8), color);
if (on) cross(h.at ?? [h.box[0] + h.box[2] / 2, h.box[1] + h.box[3]], color);
if (h.talkAt) cross(h.talkAt, '#ff8ad8');
}
for (const [who, p] of Object.entries(scene.speakers ?? {})) { cross(p, '#ff8ad8'); label(who, p[0] + 4, p[1], '#ff8ad8'); }
for (const p of game.people()) { cross(p.at, '#ffffff'); label(p.who, p.at[0] + 4, p.at[1] + 8, '#fff'); }
if (points.length) {
ctx.strokeStyle = '#ff5050';
ctx.beginPath();
points.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
ctx.stroke();
points.forEach(p => cross(p, '#ff5050'));
}
const where = mouse ? `${Math.round(mouse[0])}, ${Math.round(mouse[1])}` : '-';
label(`${game.state.scene} ${where} ${help ?? ''}`, 2, HEIGHT - 3, '#fff');
ctx.restore();
}
// --- Il primo piano di un libro (story.closeups, game.closeup): la scena si oscura, le mani alzano il libro dal basso.
// Le immagini hanno etichetta e pagine vuote: i testi si scrivono qui, nitidi. Una pagina sola gira, a fisarmonica
// sulla piega; sotto c'è già l'ultima pagina.
const ink = '#3a1f12', FONT = '"Press Start 2P", monospace';
function pageText(ctx, lines, [x, y, w, h], size, at = 0.5, color = ink) {
ctx.font = `${size}px ${FONT}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = color;
const gap = size * 1.7, top = y + h * at - (lines.length - 1) * gap / 2;
lines.forEach((line, i) => ctx.fillText(line, Math.round(x + w / 2), Math.round(top + i * gap)));
}
const twoLines = title => { const i = title.lastIndexOf(' '); return i > 0 ? [title.slice(0, i), title.slice(i + 1)] : [title]; };
function drawCloseup(ctx, game, time) {
const c = game.closeup, def = game.story.closeups?.[c.id];
if (!def) return;
if (def.type === 'briscola') { drawBriscola(ctx, c, def); return; }
if (def.type === 'tressette') { drawTressette(ctx, c, def); return; }
if (def.type === 'scopa') { drawScopa(ctx, c, def); return; }
if (def.type === 'viaggio') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawViaggio(ctx, c, def, backgrounds.get(def.picture));
return;
}
// La macchina del tempo da fuori: lo sfondo di adesso della scena, con la luce della fase del giorno.
if (def.type === 'corsa') {
const bg = game.backdrop(game.story.scenes[def.scene]), frames = {};
for (const [k, src] of [[bg, bg], ...Object.entries(def.frames)]) {
if (!backgrounds.has(src)) loadBackground(src);
if (k !== bg) frames[k] = backgrounds.get(src);
}
drawCorsa(ctx, c, def, { bg: backgrounds.get(bg), frames, tint: game.phase()?.tint });
return;
}
// L'incantesimo va sopra la scena (con Andrea dentro), già disegnata.
if (def.type === 'incanto') { drawIncanto(ctx, c); return; }
// Il risveglio, visto da dentro gli occhi: anche lui sopra la scena già disegnata.
if (def.type === 'risveglio') { drawRisveglio(ctx, c, backgrounds.get(game.backdrop())); return; }
// Il telefonino del Barone: sale come il libro, e il sito nello schermo si allarga a tutta la scena.
if (def.type === 'telefono') {
const pic = src => { if (!backgrounds.has(src)) loadBackground(src); return backgrounds.get(src); };
drawTelefono(ctx, c, def, { phone: pic(def.phone), site: pic(def.site) }, time);
closeupHint(ctx, game.speech ? null : telefonoHint(c), time);
return;
}
// Il manifesto ingrandito: la scena si scurisce e lui viene avanti, poi l'invito a toccarlo.
if (def.type === 'poster') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
const img = backgrounds.get(def.picture), k = Math.min(1, c.t / POSTER_READY), ease = 1 - (1 - k) ** 3;
ctx.fillStyle = `rgba(8, 4, 16, ${0.75 * ease})`;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
if (img) {
const fit = Math.min(600 / img.width, 300 / img.height) * (0.55 + 0.45 * ease), w = img.width * fit, h = img.height * fit;
ctx.globalAlpha = ease;
ctx.drawImage(img, Math.round((WIDTH - w) / 2), Math.round((HEIGHT - 26 - h) / 2), Math.round(w), Math.round(h));
ctx.globalAlpha = 1;
}
if (k >= 1 && Math.floor(time / 600) % 2 === 0) {
ctx.font = '8px "Press Start 2P", monospace';
ctx.textAlign = 'center';
ctx.fillStyle = '#ffe9b0';
ctx.fillText(def.hint ?? 'Tocca il manifesto', WIDTH / 2, HEIGHT - 12);
ctx.textAlign = 'left';
}
return;
}
if (def.type === 'torch') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawTorch(ctx, c, def, backgrounds.get(def.picture));
return;
}
if (def.type === 'bocce') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawBocce(ctx, c, def, backgrounds.get(def.picture), time);
return;
}
if (def.type === 'poker') { drawPoker(ctx, c); return; }
if (def.type === 'cerbottana') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawCerbottana(ctx, c, def, backgrounds.get(def.picture));
return;
}
if (def.type === 'condor') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawCondor(ctx, c, def, backgrounds.get(def.picture));
return;
}
if (def.type === 'campana') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawCampana(ctx, c, def, backgrounds.get(def.picture));
return;
}
if (def.type === 'flipper') {
for (const src of [def.picture, def.backglass]) if (src && !backgrounds.has(src)) loadBackground(src);
drawFlipper(ctx, c, def, backgrounds.get(def.picture), def.backglass && backgrounds.get(def.backglass));
return;
}
if (def.type === 'bruciate') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawBruciate(ctx, c, def, backgrounds.get(def.picture));
return;
}
if (def.type === 'micro') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawMicro(ctx, c, def, backgrounds.get(def.picture), src => { if (!backgrounds.has(src)) loadBackground(src); return backgrounds.get(src); });
return;
}
if (def.type === 'gara') {
if (def.picture && !backgrounds.has(def.picture)) loadBackground(def.picture);
drawGara(ctx, c, def, def.picture ? backgrounds.get(def.picture) : null,
src => { if (!backgrounds.has(src)) loadBackground(src); return backgrounds.get(src); }, id => sprites.get(id));
return;
}
if (def.type === 'pesca') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawPesca(ctx, c, def, backgrounds.get(def.picture), sprites.get('andrea_pesca'), sprites.get('pescatori'));
return;
}
if (def.type === 'basket') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawBasket(ctx, c, def, backgrounds.get(def.picture), sprites.get('andrea_basket'));
return;
}
if (def.type === 'rinquarto') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawRinquarto(ctx, c, def, backgrounds.get(def.picture));
return;
}
if (def.type === 'biliardo') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawBiliardo(ctx, c, def, backgrounds.get(def.picture), sprites.get('ing_tozzi'));
return;
}
ctx.save();
ctx.fillStyle = `rgba(8, 5, 12, ${Math.min(0.78, c.age / 0.35 * 0.78)})`;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
const src = c.step === 'cover' ? def.closed : def.open, img = backgrounds.get(src);
if (!img) { loadBackground(src); loadBackground(c.step === 'cover' ? def.open : def.closed); ctx.restore(); return; }
// Il libro sale dal basso (solo all'inizio), con le braccia che entrano dal bordo.
const rise = 1 - Math.min(1, c.age / 0.45), ox = Math.round((WIDTH - img.width) / 2);
const oy = Math.round(HEIGHT - img.height + 140 * rise * rise);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(img, ox, oy);
ctx.translate(ox, oy);
const lines = twoLines(def.title), { left, right, spine } = def.pages;
if (c.step === 'cover') pageText(ctx, lines, def.label, 8);
else {
const flip = c.step === 'turn' ? Math.min(1, c.t / PAGE_TURN) : c.step === 'end' ? 1 : 0;
// Sotto la pagina che gira c'è già l'ultima: «FINE», e nient'altro.
if (flip > 0) pageText(ctx, [def.last], right, 20, 0.46);
else pageText(ctx, lines, right, 8, 0.32);
if (flip > 0 && flip < 1) {
// La pagina che gira: si stringe sulla piega e passa a sinistra, col dorso un po' più scuro.
const e = (1 - Math.cos(Math.PI * flip)) / 2, k = Math.cos(Math.PI * e);
const lift = Math.round(Math.sin(Math.PI * e) * 10), [, y, , h] = right, pw = right[0] + right[2] - spine;
ctx.save();
ctx.translate(spine, 0);
ctx.scale(k || 0.001, 1);
ctx.fillStyle = k > 0 ? def.paper : '#e6cc9c';
ctx.beginPath();
ctx.moveTo(0, y); ctx.lineTo(pw, y - lift); ctx.lineTo(pw, y + h - lift); ctx.lineTo(0, y + h);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = 'rgba(58, 31, 18, 0.55)';
ctx.lineWidth = 1 / Math.max(0.2, Math.abs(k));
ctx.stroke();
if (k > 0) pageText(ctx, lines, [right[0] - spine, y - lift, right[2], h], 8, 0.32);
ctx.restore();
}
// Dove toccare per sfogliare: l'angolo della pagina che pulsa.
if (c.step === 'title' && !game.speech) {
const pulse = 0.5 + 0.5 * Math.sin(time / 260), cx = right[0] + right[2] - 2, cy = right[1] + right[3] - 2, d = 12 + 3 * pulse;
ctx.fillStyle = `rgba(232, 194, 90, ${0.55 + 0.4 * pulse})`;
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx - d, cy); ctx.lineTo(cx, cy - d); ctx.closePath(); ctx.fill();
}
}
ctx.restore();
// Il suggerimento in alto, finché c'è qualcosa da sfogliare.
const hint = c.step === 'cover' ? 'Clic o tocco: apri il libro' : c.step === 'title' ? 'Clic o tocco: gira la pagina' : null;
closeupHint(ctx, !game.speech && c.age > 0.5 ? hint : null, time);
}
function closeupHint(ctx, hint, time) {
if (hint) {
ctx.save();
ctx.globalAlpha = 0.65 + 0.35 * Math.sin(time / 300);
ctx.font = `7px ${FONT}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
const tw = ctx.measureText(hint).width;
ctx.fillRect(WIDTH / 2 - tw / 2 - 6, 8, tw + 12, 14);
ctx.fillStyle = '#ffd27a';
ctx.fillText(hint, WIDTH / 2, 15);
ctx.restore();
}
}