Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
1163 lines
61 KiB
JavaScript
1163 lines
61 KiB
JavaScript
// 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() } = {}) {
|
||
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') {
|
||
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
||
drawFlipper(ctx, c, def, backgrounds.get(def.picture));
|
||
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();
|
||
}
|
||
}
|