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

588 lines
29 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 } from './game.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 = 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(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 (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;
for (let p = 0; p < d.length; p += 4) {
const sky = d[p + 2] > 150 && d[p + 2] - d[p] > 60 && d[p + 2] - d[p + 1] > 15;
d[p + 3] = sky ? 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;
};
// Una nuvola a pixel: qualche cerchio pieno uno accanto all'altro, la cima color panna e la pancia azzurrina.
const cloudSprites = new Map();
function cloudSprite(n) {
if (cloudSprites.has(n)) return cloudSprites.get(n);
const w = 34 + Math.floor(hash(n * 13 + 1) * 30), h = 18, c = document.createElement('canvas');
c.width = w; c.height = h;
const x = c.getContext('2d'), blobs = [];
const count = 3 + Math.floor(hash(n * 3) * 3);
for (let b = 0; b < count; b++) {
const r = 4 + hash(n * 17 + b) * 5, cx = 5 + (w - 10) * (b + 0.5) / count + (hash(n + b * 3) - 0.5) * 4;
blobs.push([cx, h - 4 - r * 0.8, r]);
}
for (let py = 0; py < h; py++) for (let px = 0; px < w; px++) {
const inside = blobs.some(([cx, cy, r]) => (px - cx) ** 2 + (py - cy) ** 2 <= r * r) && py <= h - 3;
if (!inside) continue;
const top = !blobs.some(([cx, cy, r]) => (px - cx) ** 2 + (py - 2 - cy) ** 2 <= r * r);
x.fillStyle = py >= h - 6 ? '#c7d4ec' : top ? '#ffffff' : py < h - 9 ? '#fbf3dc' : '#eae6dc';
x.fillRect(px, py, 1, 1);
}
cloudSprites.set(n, c);
return c;
}
// 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);
},
// 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;
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, run = Math.floor(time / every), t = (time % every) / every;
if (t > 0.6) return;
const k = t / 0.6, 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 a pixel che attraversano il cielo, lentissime, 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.
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;
lc.clearRect(0, 0, WIDTH, HEIGHT);
for (let i = 0; i < (e.count ?? 3); i++) {
const sprite = cloudSprite(i + (e.seed ?? 0)), speed = (e.speed ?? 3) * (0.6 + hash(i * 7) * 0.8);
const span = aw + sprite.width + 20, x = ax - sprite.width - 10 + ((hash(i * 11) * span + time / 1000 * speed) % span);
lc.drawImage(sprite, 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.globalAlpha = e.alpha ?? 0.92;
ctx.drawImage(layer, 0, 0);
ctx.globalAlpha = 1;
},
// 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']);
function drawEffects(ctx, game, time, background = false) {
for (const e of game.scene.effects ?? [])
if (BACKGROUND_EFFECTS.has(e.type) === background && onNow(e, game)) EFFECTS[e.type]?.(ctx, e, time, game);
}
// 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();
}