// 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(); }