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

248 lines
15 KiB
JavaScript

import { TRACK, CURVES, PICKUPS, BRAKE, LAPS, COUNTDOWN, ROAD, VERGE, CAN, SWIPE, pointAt, standings, player, lapOf, presenting, placeOf, INTRO, mustLift, PREAVVISO } from './gara.js';
import { text, panel } from './bocce-render.js';
import { ambientHash as hash } from './ambient.js';
// Il GP di Fortezza visto dall'alto. Col quadro di Codex (def.picture) la strada e la Fortezza sono già dipinte; senza,
// si disegnano qui dai dati del tracciato. Sopra, sempre: la guida, gli acceleratori, le buche, le lattine, i motorini.
function stroke(ctx, from, to, width, color, lane = 0) {
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round';
ctx.beginPath();
for (let s = from; s <= to; s += 2) { const p = pointAt(s, lane); s === from ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y); }
ctx.stroke();
}
function loop(ctx, width, color) {
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineJoin = 'round';
ctx.beginPath();
TRACK.pts.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
ctx.closePath(); ctx.stroke();
}
function ground(ctx) {
ctx.fillStyle = '#6f8f3e'; ctx.fillRect(0, 0, 640, 360);
for (let i = 0; i < 260; i++) {
ctx.fillStyle = hash(i * 13 + 1) > 0.5 ? '#7c9b47' : '#638236';
ctx.fillRect(Math.floor(hash(i * 13 + 2) * 640), Math.floor(hash(i * 13 + 3) * 360), 2 + Math.floor(hash(i * 13 + 4) * 6), 2);
}
// Dentro il giro, le mura della Fortezza e il prato dei bastioni.
ctx.beginPath();
TRACK.pts.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
ctx.closePath();
ctx.fillStyle = '#8a7a5a'; ctx.fill();
loop(ctx, VERGE * 2 + 8, '#6f8f3e');
loop(ctx, VERGE * 2, '#7c9b47');
loop(ctx, ROAD * 2 + 2, '#7a6446');
loop(ctx, ROAD * 2, '#b59b72');
for (let i = 0; i < TRACK.pts.length; i += 3) {
const [x, y] = TRACK.pts[i], o = (hash(i) - 0.5) * ROAD * 1.6;
ctx.fillStyle = hash(i + 7) > 0.5 ? '#a88d64' : '#c2a87e'; ctx.fillRect(Math.round(x + o * 0.5), Math.round(y + o * 0.5), 2, 1);
}
// La paglia fuori dalle curve.
for (const k of CURVES) for (let s = k.start; s <= k.end; s += 8) {
const p = pointAt(s, -(VERGE + 3) * k.side);
ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.heading);
ctx.fillStyle = '#d9b75a'; ctx.fillRect(-3.5, -2.5, 7, 5); ctx.fillStyle = '#b8953f'; ctx.fillRect(-3.5, -0.5, 7, 1);
ctx.restore();
}
const p = pointAt(0);
ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.heading);
for (let i = -6; i < 6; i++) for (let j = 0; j < 2; j++) { ctx.fillStyle = (i + j) % 2 ? '#111' : '#f4f4f4'; ctx.fillRect(j * 2 - 2, i * 2, 2, 2); }
ctx.restore();
}
function guide(ctx, c) {
const v = player(c).vmax;
// Alla bona, in rosso dove si frena per arrivare giusti in curva; Seria, un cono all'inizio della frenata.
for (const k of CURVES) {
const from = k.apex - (v * v - k.speed * k.speed * 0.7) / (2 * BRAKE);
if (c.skill.guide === 'zone') stroke(ctx, from, k.apex, 4, 'rgba(214,64,47,0.6)');
if (c.skill.guide === 'coni') {
const p = pointAt(from, (ROAD + 3) * k.side);
ctx.fillStyle = '#f07a1a'; ctx.beginPath(); ctx.moveTo(p.x, p.y - 5); ctx.lineTo(p.x + 3, p.y + 2); ctx.lineTo(p.x - 3, p.y + 2); ctx.fill();
ctx.fillStyle = '#fff'; ctx.fillRect(p.x - 1.5, p.y - 2, 3, 1);
}
}
}
function things(ctx, c) {
for (const p of PICKUPS) {
ctx.save(); ctx.translate(Math.round(p.x), Math.round(p.y)); ctx.rotate(p.heading);
if (p.kind === 'boost') {
// L'acceleratore: tre frecce gialle che lampeggiano in fila.
for (let k = 0; k < 3; k++) {
ctx.fillStyle = Math.floor(c.age * 6 - k) % 3 === 0 ? '#fff27a' : '#e0b020';
ctx.beginPath(); ctx.moveTo(-6 + k * 5, -5); ctx.lineTo(-2 + k * 5, 0); ctx.lineTo(-6 + k * 5, 5); ctx.lineTo(-4 + k * 5, 0); ctx.fill();
}
} else if (p.kind === 'buca') {
ctx.fillStyle = '#5a4630'; ctx.beginPath(); ctx.ellipse(0, 0, 6, 4.5, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#2e2418'; ctx.beginPath(); ctx.ellipse(0.5, 0.5, 4.5, 3, 0, 0, Math.PI * 2); ctx.fill();
} else if ((c.taken[p.id] ?? -Infinity) <= c.t - CAN.respawn) {
// La lattina della miscela, che saltella.
const y = Math.round(Math.sin(c.age * 5 + p.id) * 1);
ctx.rotate(-p.heading);
ctx.fillStyle = '#f4e3c2'; ctx.fillRect(-3, -4 + y, 6, 8);
ctx.fillStyle = '#c8321e'; ctx.fillRect(-2, -3 + y, 4, 6);
ctx.fillStyle = '#f0c23a'; ctx.fillRect(-2, -1 + y, 4, 1); ctx.fillRect(-1, -5 + y, 2, 1);
}
ctx.restore();
}
}
function moped(ctx, r, c) {
const out = r.out > 0;
ctx.save(); ctx.translate(Math.round(r.x), Math.round(r.y)); ctx.rotate(r.a + (out ? Math.sin(c.age * 22) * 0.6 : 0));
ctx.fillStyle = 'rgba(0,0,0,0.3)'; ctx.fillRect(-6, -1, 13, 4);
ctx.fillStyle = '#151515'; ctx.fillRect(-7, -1, 3, 2); ctx.fillRect(5, -1, 3, 2); // le ruote
ctx.fillStyle = '#f4e3c2'; ctx.fillRect(-5, -3, 11, 6); // il bordo chiaro: anche i motorini scuri si vedono
ctx.fillStyle = r.color; ctx.fillRect(-4, -2, 9, 4);
ctx.fillStyle = r.trim ?? '#1c1c1c'; ctx.fillRect(-4, -0.5, 9, 1);
ctx.fillStyle = '#2a2a2a'; ctx.fillRect(4, -3, 1, 6); // il manubrio
ctx.fillStyle = r.helmet ?? (r.id === 'andrea' ? '#f4e3c2' : '#2a2a2a'); ctx.beginPath(); ctx.arc(-1, 0, 2, 0, Math.PI * 2); ctx.fill(); // il casco
if (r.stripe) { ctx.fillStyle = r.stripe; ctx.fillRect(-2, -0.5, 2, 1); }
// La miscela: una fiammata dietro.
if (r.boost > 0 && Math.floor(c.age * 20) % 2) { ctx.fillStyle = '#ffb030'; ctx.fillRect(-10, -1, 3, 2); }
ctx.restore();
if (r.id === 'andrea') { ctx.strokeStyle = '#f4e3c2'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(r.x, r.y, 10, 0, Math.PI * 2); ctx.stroke(); }
}
// In basso a sinistra, lontano dalla pausa e dal pulsante per uscire: il giro, la classifica e il tachimetro.
function hud(ctx, c) {
const me = player(c), order = standings(c), h = 34 + order.length * 11, y = 354 - h;
panel(ctx, 4, y, 116, h);
text(ctx, `GIRO ${lapOf(me)}/${LAPS}`, 10, y + 9, 7);
order.forEach((r, i) => {
ctx.fillStyle = '#f4e3c2'; ctx.fillRect(9, y + 18 + i * 11, 8, 8);
ctx.fillStyle = r.color; ctx.fillRect(10, y + 19 + i * 11, 6, 6);
text(ctx, `${i + 1}. ${r.name}`, 21, y + 23 + i * 11, 6, r.id === 'andrea' ? '#ffe28a' : '#f4e3c2');
});
const k = Math.min(1, me.v / me.vmax), bar = y + h - 11;
ctx.fillStyle = '#2a2f2c'; ctx.fillRect(10, bar, 50, 5);
ctx.fillStyle = me.boost > 0 ? '#ffb030' : k > 0.9 ? '#e0583a' : '#b9d392'; ctx.fillRect(10, bar, Math.round(50 * k), 5);
text(ctx, `${Math.round(me.v * 0.5)} km/h`, 116, bar + 3, 6, '#f4e3c2', 'right');
}
// L'origine mobile dello swipe e la direzione scelta col dito (o col mouse premuto).
function swipe(ctx, c) {
if (c.pointer === null || !c.anchor || !c.finger) return;
const [x, y] = c.anchor, [fx, fy] = c.finger, dx = fx - x, dy = fy - y, length = Math.hypot(dx, dy);
ctx.save();
ctx.strokeStyle = 'rgba(244,227,194,0.55)'; ctx.lineWidth = 1.5; ctx.lineCap = 'round';
ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.stroke();
if (length >= SWIPE.dead) {
const ux = dx / length, uy = dy / length;
ctx.beginPath(); ctx.moveTo(x + ux * 6, y + uy * 6); ctx.lineTo(fx, fy);
ctx.moveTo(fx - ux * 6 - uy * 3, fy - uy * 6 + ux * 3); ctx.lineTo(fx, fy);
ctx.lineTo(fx - ux * 6 + uy * 3, fy - uy * 6 - ux * 3); ctx.stroke();
}
ctx.restore();
}
// La presentazione: il cartellino di chi arriva. A sinistra il personaggio (lo sprite del gioco, o il ritratto), a
// destra il suo motorino dipinto, sotto quanto va (motore) e quanto rischia (testa). `look(src)` dà l'immagine caricata,
// `sheet(id)` lo sprite di un personaggio.
function figure(ctx, r, x, y, look, sheet) {
const s = r.sprite && sheet(r.sprite);
if (s) {
const f = s.atlas.frames.stand ?? Object.values(s.atlas.frames)[0], k = Math.min(1, 84 / f.h);
ctx.drawImage(s.img, f.x, f.y, f.w, f.h, Math.round(x - f.w * k / 2), Math.round(y + 88 - f.h * k), Math.round(f.w * k), Math.round(f.h * k));
return;
}
const img = r.face && look(r.face);
ctx.fillStyle = '#f4e3c2'; ctx.fillRect(x - 31, y + 13, 62, 62);
if (img) { ctx.drawImage(img, x - 30, y + 14, 60, 60); return; }
// Nessun ritratto (ancora): la sagoma.
ctx.fillStyle = '#2a2f2c'; ctx.fillRect(x - 30, y + 14, 60, 60);
ctx.fillStyle = '#4a504c'; ctx.beginPath(); ctx.arc(x, y + 36, 11, 0, Math.PI * 2); ctx.fill();
ctx.fillRect(x - 18, y + 52, 36, 22);
text(ctx, '?', x, y + 37, 10, '#8f9488', 'center');
}
function card(ctx, c, look, sheet) {
const r = presenting(c);
if (!r) { panel(ctx, 220, 130, 200, 40); text(ctx, 'GP DI FORTEZZA', 320, 142, 7, '#ffe28a', 'center'); text(ctx, 'TUTTI IN GRIGLIA!', 320, 158, 7, '#f4e3c2', 'center'); return; }
const x = 150, y = 92, w = 340, h = 150;
panel(ctx, x, y, w, h);
text(ctx, 'GP DI FORTEZZA', x + w / 2, y + 10, 6, '#ffe28a', 'center');
figure(ctx, r, x + 46, y + 14, look, sheet);
// Il casco di chi ce l'ha disegnato, accanto al personaggio.
const casco = r.casco && look(r.casco);
if (casco) { const k = 30 / Math.max(casco.width, casco.height); ctx.drawImage(casco, Math.round(x + 66), Math.round(y + 20), Math.round(casco.width * k), Math.round(casco.height * k)); }
// Il motorino, entra da sinistra e si ferma (come in pista).
const moto = r.moto && look(r.moto), t = Math.min(1, (c.t % INTRO.each) / 0.6), e = 1 - (1 - t) ** 3;
if (moto) {
const k = Math.min(150 / moto.width, 80 / moto.height), mw = moto.width * k, mh = moto.height * k;
ctx.save(); ctx.beginPath(); ctx.rect(x + 92, y + 18, w - 100, 92); ctx.clip();
ctx.drawImage(moto, Math.round(x + 92 + (w - 100 - mw) / 2 - (1 - e) * 120), Math.round(y + 104 - mh), Math.round(mw), Math.round(mh));
ctx.restore();
} else {
ctx.fillStyle = r.color; ctx.fillRect(x + 160, y + 70, 100, 22);
ctx.fillStyle = r.trim ?? '#1c1c1c'; ctx.fillRect(x + 160, y + 80, 100, 3);
}
text(ctx, r.name, x + 100, y + 118, 8, r.id === 'andrea' ? '#ffe28a' : '#f4e3c2');
text(ctx, r.bike, x + 100, y + 130, 6, '#c5c6b6');
for (const [k, [label, value]] of [['MOTORE', r.motore], ['TESTA', r.testa]].entries()) {
const yy = y + 116 + k * 12;
text(ctx, label, x + 222, yy, 5, '#c5c6b6');
// Cinque caselle; il motore truccatissimo (6) ne accende una sesta, gialla. Per qualcuno la testa è una scritta.
if (k && r.testaNota) { text(ctx, r.testaNota, x + 262, yy, 5, '#e0583a'); continue; }
for (let i = 0; i < Math.max(5, value); i++) { ctx.fillStyle = i >= 5 ? '#ffe28a' : i < value ? (k ? '#b9d392' : '#e0583a') : '#3a3f3c'; ctx.fillRect(x + 262 + i * 12, yy - 3, 9, 6); }
}
text(ctx, 'TOCCA: IL PROSSIMO', x + w / 2, y + h - 6, 5, '#8f9488', 'center');
}
// La posizione in corsa, grande in alto a sinistra, e un cartellino sopra il Fifty.
function position(ctx, c) {
const me = player(c), place = placeOf(c), n = c.riders.length, first = place === 1;
panel(ctx, 4, 4, 70, 40);
text(ctx, 'POSIZIONE', 39, 11, 5, '#c5c6b6', 'center');
text(ctx, `${place}°`, 11, 29, 14, first ? '#ffe28a' : '#f4e3c2');
text(ctx, `su ${n}`, 68, 33, 6, '#c5c6b6', 'right');
if (me.shown === false) return;
const x = Math.round(me.x), y = Math.round(me.y) - 17;
ctx.fillStyle = 'rgba(22,25,23,0.85)'; ctx.fillRect(x - 8, y - 5, 16, 10);
ctx.fillStyle = first ? '#ffe28a' : '#f4e3c2'; ctx.fillRect(x - 1, y + 5, 2, 2);
text(ctx, `${place}°`, x, y + 0.5, 6, first ? '#ffe28a' : '#f4e3c2', 'center');
}
function lights(ctx, c) {
const lit = Math.min(3, Math.floor(c.t) + 1);
panel(ctx, 270, 140, 100, 50);
text(ctx, 'GP DI FORTEZZA', 320, 150, 6, '#ffe28a', 'center');
for (let i = 0; i < 3; i++) {
ctx.fillStyle = i < lit ? '#e0402a' : '#3a2a28';
ctx.beginPath(); ctx.arc(300 + i * 20, 170, 7, 0, Math.PI * 2); ctx.fill();
}
if (c.t >= COUNTDOWN - 0.01) text(ctx, 'VIA!', 320, 200, 10, '#b9d392', 'center');
}
function result(ctx, c) {
const order = c.result.order.map(id => c.riders.find(r => r.id === id));
panel(ctx, 200, 96, 240, 44 + order.length * 14);
text(ctx, 'GP DI FORTEZZA', 320, 108, 6, '#c5c6b6', 'center');
text(ctx, c.result.place === 1 ? 'PRIMO!' : `${c.result.place}° POSTO`, 320, 122, 10, c.result.place === 1 ? '#ffe28a' : '#f4e3c2', 'center');
order.forEach((r, i) => {
ctx.fillStyle = '#f4e3c2'; ctx.fillRect(213, 137 + i * 14, 10, 10);
ctx.fillStyle = r.color; ctx.fillRect(214, 138 + i * 14, 8, 8);
text(ctx, `${i + 1}. ${r.name}`, 228, 142 + i * 14, 7, r.id === 'andrea' ? '#ffe28a' : '#f4e3c2');
text(ctx, `${c.result.times[r.id].toFixed(1)}s`, 428, 142 + i * 14, 7, '#c5c6b6', 'right');
});
}
// Una scritta sopra il motorino di Andrea, su un fondo scuro perché si legga anche sullo sterrato e sul telefono.
function tag(ctx, line, r, color) {
const x = Math.round(r.x), y = Math.round(r.y) - 20, w = line.length * 9 + 10;
ctx.fillStyle = 'rgba(23,22,15,0.85)'; ctx.fillRect(x - w / 2, y - 7, w, 14);
text(ctx, line, x, y, 10, color, 'center');
}
export function drawGara(ctx, c, def, bg, look = () => null, sheet = () => null) {
ctx.save();
if (bg) ctx.drawImage(bg, 0, 0, 640, 360);
else ground(ctx);
guide(ctx, c);
things(ctx, c);
// Prima chi è dietro, così chi è davanti resta sopra; Andrea sempre sopra a tutti.
for (const r of [...standings(c)].reverse().sort((a, b) => (a.id === 'andrea') - (b.id === 'andrea'))) if (r.shown !== false) moped(ctx, r, c);
if (c.step === 'intro') { card(ctx, c, look, sheet); ctx.restore(); return; }
if (c.step === 'race' || c.step === 'start') swipe(ctx, c);
hud(ctx, c);
if (c.step === 'race') position(ctx, c);
if (c.step === 'start') lights(ctx, c);
const me = player(c);
if (c.step === 'race' && me.out > 0) text(ctx, me.lastHole !== undefined ? 'NELLA BUCA!' : 'NELLA PAGLIA!', 320, 180, 10, '#ffe28a', 'center');
if (c.step === 'race' && me.boost > 0 && me.out <= 0) text(ctx, 'MISCELA!', 320, 180, 8, '#ffb030', 'center');
// La guida semplice: LASCIA! sopra il motorino quando serve frenare, e la curva pulita.
if (c.step === 'race' && c.holding && mustLift(c)) { if (Math.floor(c.age * 8) % 2) tag(ctx, 'LASCIA!', me, '#ff6a4a'); }
else if (c.step === 'race' && c.holding && mustLift(c, PREAVVISO)) tag(ctx, 'CURVA!', me, '#ffe28a');
if (c.step === 'race' && c.clean !== undefined && c.t - c.clean < 1.2) text(ctx, 'CURVA PULITA!', 320, 180, 9, '#b9e5b3', 'center');
if (c.step === 'result') result(ctx, c);
ctx.restore();
}