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

781 lines
39 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 } 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 { 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.
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).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() } = {}) {
const scene = game.scene, s = game.state, bg = backgrounds.get(scene.background);
if (!bg && scene.background) loadBackground(scene.background);
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 drawAndreaNow = () => 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);
const queue = [{ 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();
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;
// 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 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.scene.background);
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.scene.background), 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.
cloth(ctx, e, time, game) {
const bg = backgrounds.get(game.scene.background);
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();
},
// 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();
},
};
const BACKGROUND_EFFECTS = new Set(['sway', 'clouds', 'cloth', 'leafshadow', ...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 === '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 === '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;
if (hint && !game.speech && c.age > 0.5) {
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();
}
}