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

151 lines
9.6 KiB
JavaScript

import { HALF, CAR, CARS, canThrow, guide, done, overHeld, carImage, choiceAt, why, rank } from './micro.js';
import { text, panel } from './bocce-render.js';
const NAME = { player: 'Andrea', cpu: 'Chiaru' };
// Il gesso: i due bordi della pista e il traguardo, calcolati una volta per pista. Il tratto ha dei buchi, come il
// gesso vero sull'asfalto ruvido (sempre gli stessi, da un piccolo hash: non tremano da un fotogramma all'altro).
const chalks = new WeakMap();
const hash = i => { let h = Math.imul(i ^ 0x9e3779b9, 0x85ebca6b); h ^= h >>> 13; return ((Math.imul(h, 0xc2b2ae35) ^ (h >>> 16)) >>> 0) / 4294967296; };
function chalk(track) {
if (chalks.has(track)) return chalks.get(track);
const edges = [-1, 1].map(side => track.pts.map(([x, y], i) => [x - track.tan[i][1] * HALF * side, y + track.tan[i][0] * HALF * side]));
const [tx, ty] = track.tan[0], [x0, y0] = track.pts[0];
const out = { edges, finish: { x: x0, y: y0, nx: -ty, ny: tx, tx, ty } };
chalks.set(track, out);
return out;
}
function drawChalk(ctx, track) {
const { edges, finish } = chalk(track);
for (const edge of edges) for (const [width, color, jitter] of [[3, 'rgba(236,236,226,0.28)', 0.8], [1.5, 'rgba(246,246,238,0.85)', 0]]) {
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.beginPath();
let open = false;
edge.forEach(([x, y], i) => {
if (hash(i + width * 1000) < 0.06) { open = false; return; } // un buco nel gesso
const j = jitter * (hash(i * 7 + 3) - 0.5);
if (!open) { ctx.moveTo(x + j, y - j); open = true; } else ctx.lineTo(x + j, y - j);
});
ctx.closePath(); ctx.stroke();
}
// Il traguardo: la scacchiera di gesso di traverso alla pista, due file.
const { x, y, nx, ny, tx, ty } = finish, cell = 4;
for (let k = -HALF; k < HALF; k += cell) for (let row = 0; row < 2; row++) {
if ((Math.round(k / cell) + row) % 2) continue;
const cx = x + nx * (k + cell / 2) + tx * (row - 0.5) * cell, cy = y + ny * (k + cell / 2) + ty * (row - 0.5) * cell;
ctx.fillStyle = 'rgba(246,246,238,0.85)'; ctx.fillRect(Math.round(cx - cell / 2), Math.round(cy - cell / 2), cell, cell);
}
}
function car(ctx, img, x, y, heading, alpha = 1, flipped = false) {
ctx.save(); ctx.globalAlpha = alpha; ctx.translate(x, y); ctx.rotate(heading);
if (flipped) ctx.scale(1, -1);
ctx.fillStyle = 'rgba(10,12,16,0.35)'; ctx.fillRect(-CAR.len / 2 + 1, -CAR.wid / 2 + 2, CAR.len, CAR.wid); // l'ombra
if (img) ctx.drawImage(img, -CAR.len / 2, -CAR.wid / 2, CAR.len, CAR.wid);
else { ctx.fillStyle = '#d8402f'; ctx.fillRect(-CAR.len / 2, -CAR.wid / 2, CAR.len, CAR.wid); }
ctx.restore();
}
function dashed(ctx, x0, y0, x1, y1, color) {
ctx.strokeStyle = color; ctx.lineWidth = 1; ctx.setLineDash([3, 3]);
ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke(); ctx.setLineDash([]);
}
// La mira: alla bona tutto il tiro, con la macchinina fantasma dove si ferma (rossa se esce); seria solo la linea; a
// fassi male una freccina. Quando si carica, il dito si tira indietro; se resta troppo alla carica piena diventa rosso
// (sta per essere un accompagnè).
function aim(ctx, c, img) {
const me = c.cars.player, ux = Math.cos(c.angle), uy = Math.sin(c.angle), kind = c.skill.guide;
if (kind === 'full') {
const g = guide(c), color = g.out ? '#ff7a5c' : '#b9f0a8';
dashed(ctx, me.x, me.y, g.x, g.y, color);
car(ctx, img, g.x, g.y, c.angle, 0.35);
ctx.strokeStyle = color; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(g.x, g.y, CAR.r + 3, 0, Math.PI * 2); ctx.stroke();
} else {
const length = kind === 'line' ? 70 : 24;
dashed(ctx, me.x, me.y, me.x + ux * length, me.y + uy * length, '#fff8dc');
const hx = me.x + ux * length, hy = me.y + uy * length;
ctx.fillStyle = '#fff8dc'; ctx.beginPath();
ctx.moveTo(hx + ux * 4, hy + uy * 4); ctx.lineTo(hx - uy * 3, hy + ux * 3); ctx.lineTo(hx + uy * 3, hy - ux * 3); ctx.fill();
}
const back = CAR.len / 2 + 4 + (c.charging ? c.power * 14 : 0), fx = me.x - ux * back, fy = me.y - uy * back;
const over = overHeld(c);
ctx.fillStyle = over ? '#ff6a4a' : '#e8b48f'; ctx.strokeStyle = '#6b3d26'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(fx, fy, 4, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.fillStyle = '#f7d9c4'; ctx.fillRect(Math.round(fx + ux * 1.5) - 1, Math.round(fy + uy * 1.5) - 1, 2, 2); // l'unghia
// La forza che sale mentre si tiene premuto: una barretta sopra la macchinina.
if (c.holding) {
const x = Math.round(me.x - 15), y = Math.round(me.y - 19);
ctx.fillStyle = '#17160fd0'; ctx.fillRect(x - 1, y - 1, 32, 6);
ctx.fillStyle = over ? '#ff6a4a' : c.power > 0.75 ? '#ffd23c' : '#9be07c'; ctx.fillRect(x, y, Math.round(30 * c.power), 4);
}
}
// Dove una macchinina ha fatto il fallo: una croce di gesso rosso che sfuma, e la macchinina (ribaltata, se ha cappottato)
// che sparisce per tornare al suo posto.
function returns(ctx, c, images) {
for (const r of c.returns) {
const age = c.visual.time - r.at;
if (age > 3) continue;
const a = 1 - age / 3;
ctx.strokeStyle = `rgba(255,110,80,${a})`; ctx.lineWidth = 2; ctx.beginPath();
ctx.moveTo(r.x - 5, r.y - 5); ctx.lineTo(r.x + 5, r.y + 5); ctx.moveTo(r.x + 5, r.y - 5); ctx.lineTo(r.x - 5, r.y + 5); ctx.stroke();
const fade = r.fault === 'cappotte' ? 1.2 : 0.6;
if (age < fade) car(ctx, images[r.role], r.x, r.y, r.heading + (r.fault === 'cappotte' ? 0.5 : 0), 0.7 * (1 - age / fade), r.fault === 'cappotte');
}
}
// La scelta della macchinina: le sei in grande sul parcheggio, quella scelta cerchiata di gesso e un po' sollevata.
function choice(ctx, c, image) {
ctx.fillStyle = '#17160fc8'; ctx.fillRect(60, 56, 520, 250);
text(ctx, 'SCEGLI LA TUA MACCHININA', 320, 76, 12, '#efd49e', 'center');
CARS.forEach((car, i) => {
const [x, y] = choiceAt(i), on = i === c.choice, lift = on ? Math.sin(c.visual.time * 5) * 1.5 - 2 : 0, img = image?.(carImage(car.id, true));
if (on) { ctx.strokeStyle = 'rgba(246,246,238,0.9)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.ellipse(x, y, 50, 28, 0, 0, Math.PI * 2); ctx.stroke(); }
ctx.fillStyle = 'rgba(10,12,16,0.35)'; ctx.fillRect(x - 37, y - 16, 78, 38); // l'ombra
if (img) ctx.drawImage(img, Math.round(x - img.width / 2), Math.round(y - img.height / 2 + lift));
text(ctx, car.name, x, y + 34, 8, on ? '#fff0c8' : '#a99d86', 'center');
});
const cpu = CARS[c.choice].id === 'blu' ? 'la rossa' : 'la blu e gialla';
text(ctx, `Il Chiaru prende ${cpu}. Tocca la tua, o frecce e Scegli.`, 320, 292, 8, '#d9ccba', 'center');
}
export function drawMicro(ctx, c, def, bg, image) {
ctx.save(); ctx.imageSmoothingEnabled = false;
if (bg) ctx.drawImage(bg, 0, 0, 640, 360); else { ctx.fillStyle = '#4b4d50'; ctx.fillRect(0, 0, 640, 360); }
drawChalk(ctx, c.track);
if (c.step === 'choose') { choice(ctx, c, image); ctx.restore(); return; }
const images = { player: image?.(carImage(c.colors.player)), cpu: image?.(carImage(c.colors.cpu)) };
if (c.step === 'rolling' && c.trail.length > 1) {
ctx.strokeStyle = 'rgba(255,248,220,0.35)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(...c.trail[0]);
for (const p of c.trail.slice(1)) ctx.lineTo(...p);
ctx.stroke();
}
returns(ctx, c, images);
if (canThrow(c)) aim(ctx, c, images.player);
for (const who of ['cpu', 'player']) { const x = c.cars[who]; car(ctx, images[who], x.x, x.y, x.heading); }
// In alto: quanta pista ha fatto ciascuno (una barra col colore della macchinina) e cosa succede.
ctx.fillStyle = '#17160fe8'; ctx.fillRect(0, 0, 640, 22);
text(ctx, 'LA PISTA COL GESSO', 8, 7, 8, '#efd49e');
const level = { bona: 'alla bona', seria: 'seria', male: 'a fassi male' }[c.level];
text(ctx, `${level} · spinte ${c.flicks.player}`, 8, 16, 7, '#d9ccba');
const tint = id => CARS.find(x => x.id === id).color;
for (const [who, x0, color] of [['player', 200, tint(c.colors.player)], ['cpu', 430, tint(c.colors.cpu)]]) {
// Primo o secondo, mentre si va (Andrea, 29 ottobre).
const place = rank(c, who);
text(ctx, `${place}°`, x0 - 4, 11, 9, place === 1 ? '#ffe28a' : '#a99d86', 'right');
text(ctx, NAME[who], x0, 11, 8, '#fff0c8');
ctx.fillStyle = '#3a372e'; ctx.fillRect(x0 + 52, 7, 140, 8);
ctx.fillStyle = color; ctx.fillRect(x0 + 52, 7, Math.round(140 * done(c, who)), 8);
ctx.strokeStyle = '#897b57'; ctx.lineWidth = 1; ctx.strokeRect(x0 + 52.5, 7.5, 139, 7);
}
const turn = c.step === 'result' ? null : c.current === 'cpu' ? 'Spinge il Chiaru…' : c.step === 'aim' ? 'Tocca a te: mira e spingi' : null;
const line = overHeld(c) ? 'Lascia! Sennò è accompagnè' : c.line ?? turn;
if (line) { ctx.fillStyle = '#17160fd0'; ctx.fillRect(170, 24, 300, 14); text(ctx, line, 320, 31, 8, /^(Fuori|Tagliè|Cappottè|Accompagnè)!/.test(c.line ?? '') ? '#ffbf9f' : '#9fd0ff', 'center'); }
if (c.step === 'result') {
const w = c.result.winner, title = w === 'player' ? 'HAI BATTUTO IL CHIARU!' : w === 'cpu' ? 'HA VINTO IL CHIARU' : 'ARRIVATI PARI';
// Il perché, in due righe: chi è arrivato a quale spinta, e di quanto è andato più in là.
const [first, second] = why(c);
panel(ctx, 130, 132, 380, 92);
text(ctx, title, 320, 150, 12, w === 'player' ? '#b9e5b3' : '#efd49e', 'center');
text(ctx, first, 320, 172, 8, '#efd49e', 'center');
text(ctx, second, 320, 186, 8, '#efd49e', 'center');
text(ctx, 'Torna dal Chiaru per uscire.', 320, 208, 8, '#d9ccba', 'center');
}
ctx.restore();
}