Field data (miss log) showed the dominant unfair miss: the assist parks you on the landing spot, the ball spends the whole swing window below minY rising off the bounce at your feet, and becomes hittable one frame after the window closes. Root fix: the below-ankles rejection now only applies to a DROPPING ball — rising post-bounce balls can be scooped at any height (a half-volley). Feet-bounce taps go from ~0 to 19/30 winning frames; 30 simulated real points now log 50 hits, 0 whiffs. Also: swing-log snapshots record the same swing-timer value trySwing tests (removes the one-frame misclassification behind 'unclassified'), and tapping the scoreboard toggles an on-screen dump of the last 9 log entries so live situations can be screenshotted from the phone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
310 lines
16 KiB
JavaScript
310 lines
16 KiB
JavaScript
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
|
|
|
|
import { cv, ctx } from './dom.js';
|
|
import { TUNE, COURT, PLAYER_H, BOY_H, UMP_H, BOUNCE_IDLE, SPRITE_H } from './config.js';
|
|
import { P, O, ball, score, crowd, G } from './state.js';
|
|
import { predictLanding } from './gameplay.js';
|
|
import { zombies, gore, parts } from './zombies.js';
|
|
|
|
// ---------------------------------------------------------------- palette ----
|
|
// tennis palette -> drained splatter palette; the transition lerps between them
|
|
const PAL = {
|
|
tennis: { stands: '#232e42', tierA: '#2a3750', tierB: '#26324a', board: '#0e5c2e',
|
|
out: '#295c2d', lawn: '#4cae52', stripe: '#46a54c' },
|
|
splatter: { stands: '#181426', tierA: '#201a33', tierB: '#1c172e', board: '#3a1212',
|
|
out: '#20331c', lawn: '#5e7747', stripe: '#576f42' },
|
|
};
|
|
const hexLerp = (a, b, t) => {
|
|
const c = h => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
|
|
const [ar, ag, ab] = c(a), [br, bg, bb] = c(b);
|
|
return `rgb(${Math.round(ar + (br - ar) * t)},${Math.round(ag + (bg - ag) * t)},${Math.round(ab + (bb - ab) * t)})`;
|
|
};
|
|
function palette() {
|
|
const t = G.mode === 'tennis' ? 0 : G.mode === 'splatter' ? 1 : Math.min(1, G.transT / 6);
|
|
if (t === 0) return PAL.tennis;
|
|
if (t === 1) return PAL.splatter;
|
|
const m = {};
|
|
for (const k in PAL.tennis) m[k] = hexLerp(PAL.tennis[k], PAL.splatter[k], t);
|
|
return m;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- camera ----
|
|
export const RES = 2; // physical pixels per logical pixel — sprites keep 2x detail
|
|
export let W = 480, H = 270;
|
|
|
|
export function fitViewport() { // landscape 480x270, portrait 270x480 with a longer lens
|
|
const portrait = innerHeight > innerWidth;
|
|
const [w, h] = portrait ? [270, 480] : [480, 270];
|
|
if (cv.width !== w * RES) {
|
|
W = w; H = h;
|
|
cv.width = w * RES; cv.height = h * RES;
|
|
TUNE.camF = portrait ? 400 : 280;
|
|
TUNE.horizon = portrait ? 90 : 40;
|
|
buildCrowd();
|
|
}
|
|
ctx.imageSmoothingEnabled = false; // resets on canvas resize
|
|
const sc = Math.min(innerWidth / w, innerHeight / h);
|
|
cv.style.width = w * sc + 'px'; cv.style.height = h * sc + 'px';
|
|
}
|
|
addEventListener('resize', fitViewport);
|
|
|
|
export function proj(x, y, z) {
|
|
const d = TUNE.camZ - z, s = TUNE.camF / d;
|
|
return { x: W / 2 + x * s, y: TUNE.horizon + (TUNE.camY - y) * s, s };
|
|
}
|
|
|
|
// ---------------------------------------------------------------- sprites ----
|
|
// assets/<name>.png; game falls back to code-drawn figures until sprites load.
|
|
const SPR = {};
|
|
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_swing1','p_swing2',
|
|
'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2',
|
|
'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4',
|
|
'z_a1','z_a2','z_b1','z_b2','z_c1','z_c2']) {
|
|
const img = new Image();
|
|
img.onload = () => SPR[n] = img;
|
|
img.src = 'assets/' + n + '.png';
|
|
}
|
|
|
|
// ------------------------------------------------------------------ crowd ----
|
|
export function buildCrowd() {
|
|
crowd.dots.length = 0;
|
|
const rows = Math.floor((proj(0, 0, 0).y - 26) / 5.5), cols = Math.ceil(W / 8);
|
|
for (let row = 0; row < rows; row++) for (let i = 0; i < cols; i++) crowd.dots.push({
|
|
x: 4 + i * 8 + (row % 2) * 4 + Math.random() * 3, y: 8 + row * 5.5 + Math.random() * 2,
|
|
c: ['#c9b18f','#8f6f4f','#d9c6a5','#5f7f9f','#9f5f5f','#7f9f5f','#e0d0b0','#b0a0c0'][(i * 7 + row) % 8]
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------- primitives ----
|
|
function line(x1, z1, x2, z2, w = 1) {
|
|
const a = proj(x1, 0, z1), b = proj(x2, 0, z2);
|
|
ctx.strokeStyle = '#e8e8dc'; ctx.lineWidth = w;
|
|
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
|
}
|
|
function quadXZ(x1, z1, x2, z2, color) { // filled court-plane rect (screen trapezoid)
|
|
const a = proj(x1, 0, z1), b = proj(x2, 0, z1), c = proj(x2, 0, z2), d = proj(x1, 0, z2);
|
|
ctx.fillStyle = color; ctx.beginPath();
|
|
ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(c.x, c.y); ctx.lineTo(d.x, d.y); ctx.fill();
|
|
}
|
|
function drawShadow(px, py, rx, ry, alpha = 0.28) {
|
|
ctx.fillStyle = `rgba(0,0,0,${alpha})`;
|
|
ctx.beginPath(); ctx.ellipse(px, py, rx, Math.max(1, ry), 0, 0, 7); ctx.fill();
|
|
}
|
|
function drawBall(x, y, z, glint = false) {
|
|
const sh = proj(x, 0, z);
|
|
drawShadow(sh.x, sh.y, Math.max(1.5, sh.s * 0.09), sh.s * 0.04, 0.3);
|
|
const bp = proj(x, y, z);
|
|
const r = Math.max(1.5, bp.s * 0.09);
|
|
ctx.fillStyle = '#e8f03a'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r, 0, 7); ctx.fill();
|
|
if (glint) { ctx.fillStyle = '#f8f8d0'; ctx.fillRect(bp.x - r * 0.3, bp.y - r * 0.5, Math.max(1, r * 0.5), Math.max(1, r * 0.5)); }
|
|
}
|
|
|
|
// ----------------------------------------------------------------- actors ----
|
|
function drawStatic(name, x, z, hMeters, bob = 0) {
|
|
const img = SPR[name]; if (!img) return;
|
|
const p = proj(x, 0, z);
|
|
const h = hMeters * p.s, w = h * img.width / img.height;
|
|
drawShadow(p.x, p.y, w * 0.4, h * 0.04, 0.25);
|
|
ctx.drawImage(img, p.x - w / 2, p.y - h - bob, w, h);
|
|
}
|
|
function drawBoy(b) {
|
|
const img = SPR['x_boyrun' + (Math.floor(b.runT * 14) % 4 + 1)]; if (!img) return;
|
|
const p = proj(b.x, 0, b.z);
|
|
const h = BOY_H * p.s, w = h * img.width / img.height;
|
|
drawShadow(p.x, p.y, w * 0.3, h * 0.04, 0.25);
|
|
ctx.save(); ctx.translate(p.x, p.y - (Math.floor(b.runT * 20) % 2));
|
|
if (b.dir < 0) ctx.scale(-1, 1);
|
|
ctx.drawImage(img, -w / 2, -h, w, h);
|
|
ctx.restore();
|
|
}
|
|
function drawZombie(zb) {
|
|
const frame = Math.floor(zb.walkT * 2.6) % 2 + 1;
|
|
drawStatic('z_' + zb.variant + frame, zb.x, zb.z, 1.8, Math.floor(zb.walkT * 5.2) % 2);
|
|
}
|
|
function drawRing(x, z, color) {
|
|
const p = proj(x, 0, z);
|
|
// integer-snapped so the moving ring steps whole pixels instead of shimmering
|
|
const cx = Math.round(p.x), cy = Math.round(p.y);
|
|
const rx = Math.max(2, Math.round(p.s * 0.3)), ry = Math.max(1, Math.round(p.s * 0.12));
|
|
ctx.strokeStyle = color; ctx.lineWidth = 2;
|
|
ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, 7); ctx.stroke();
|
|
}
|
|
|
|
function drawChar(ch) {
|
|
const receiverWaiting = G.phase === 'pretoss' && ch.side !== score.server && ch.pose === 'idle';
|
|
const sway = receiverWaiting ? Math.sin(G.idleT * 3.2) : 0;
|
|
const p = proj(ch.x + sway * 0.12, 0, ch.z);
|
|
const hpx = PLAYER_H * p.s;
|
|
// pick sprite
|
|
const pre = ch.side === 'P' ? 'p_' : 'o_';
|
|
let name = pre + (Math.floor(G.idleT * 1.6) % 2 ? 'idle1' : 'idle2'); // idle weight shift
|
|
if (G.phase === 'pretoss' && ch.side === score.server && ch.pose === 'idle') {
|
|
const w = G.idleT * BOUNCE_IDLE.tempo; // same phase as the bouncing ball
|
|
const rising = Math.sin(w) * Math.cos(w) > 0;
|
|
name = pre + (rising ? 'bounce2' : 'bounce'); // hand up while the ball rises, pat down exactly at the apex
|
|
}
|
|
if (ch.pose === 'run') name = pre + (Math.floor(ch.runT * 9) % 2 ? 'run1' : 'run2');
|
|
if (receiverWaiting) name = pre + (sway > 0 ? 'run1' : 'run2'); // split-step shuffle
|
|
if (ch.pose === 'swing') name = pre + (ch.poseT < 0.15 ? 'swing1' : 'swing2');
|
|
if (ch.pose === 'serve') name = pre + (G.phase === 'toss' ? 'toss' : 'smash');
|
|
if (ch.pose === 'run') p.y -= Math.floor(ch.runT * 18) % 2; // 1px run bob
|
|
const img = SPR[name];
|
|
drawShadow(p.x, p.y, hpx * 0.22, hpx * 0.06);
|
|
if (img) {
|
|
const k = hpx / SPRITE_H; // sprites share one scale; the idle pose defines it
|
|
const w = img.width * k, h = img.height * k;
|
|
ctx.save();
|
|
ctx.translate(p.x, p.y);
|
|
if (ch.facing < 0) ctx.scale(-1, 1);
|
|
ctx.drawImage(img, -w / 2, -h, w, h);
|
|
ctx.restore();
|
|
} else drawFallbackChar(ch, p, hpx);
|
|
}
|
|
function drawFallbackChar(ch, p, h) { // shown only until sprites load
|
|
const you = ch.side === 'P';
|
|
const u = h / 16, x = p.x, y = p.y;
|
|
const skin = '#e8b88a', shirt = you ? '#e8e8e8' : '#d04848', shorts = you ? '#3858a8' : '#404040';
|
|
ctx.fillStyle = skin; ctx.fillRect(x - 2 * u, y - 16 * u, 4 * u, 4 * u);
|
|
ctx.fillStyle = you ? '#804820' : '#282828'; ctx.fillRect(x - 2 * u, y - 16 * u, 4 * u, 1.2 * u);
|
|
ctx.fillStyle = shirt; ctx.fillRect(x - 2.6 * u, y - 12 * u, 5.2 * u, 6 * u);
|
|
ctx.fillStyle = shorts; ctx.fillRect(x - 2.4 * u, y - 6 * u, 4.8 * u, 3 * u);
|
|
ctx.fillStyle = skin; ctx.fillRect(x - 1.8 * u, y - 3 * u, 1.4 * u, 3 * u); ctx.fillRect(x + 0.4 * u, y - 3 * u, 1.4 * u, 3 * u);
|
|
}
|
|
|
|
// ------------------------------------------------------------------ frame ----
|
|
export function render() {
|
|
ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density
|
|
|
|
const pal = palette();
|
|
// stands: tiered crowd from top of screen down to just above the far court
|
|
const standsBottom = proj(0, 0, 0).y - 12;
|
|
ctx.fillStyle = pal.stands; ctx.fillRect(0, 0, W, standsBottom);
|
|
for (let r = 0; r < 16; r++) {
|
|
ctx.fillStyle = r % 2 ? pal.tierA : pal.tierB;
|
|
ctx.fillRect(0, 6 + r * 5.5, W, 5.5);
|
|
}
|
|
for (const d of crowd.dots) {
|
|
const jx = crowd.cheer > 0 ? (Math.random() * 2 - 1) : 0;
|
|
const jy = crowd.cheer > 0 ? -Math.random() * 1.5 : 0;
|
|
ctx.fillStyle = d.c; ctx.fillRect(d.x + jx | 0, d.y + jy | 0, 2, 2);
|
|
}
|
|
// courtside billboard wall
|
|
ctx.fillStyle = pal.board; ctx.fillRect(0, standsBottom - 10, W, 10);
|
|
ctx.fillStyle = '#dfe8d0'; ctx.font = 'bold 7px monospace'; ctx.textBaseline = 'top'; ctx.textAlign = 'center';
|
|
ctx.fillText(W > 400 ? '· SPLATTER OPEN · NEW BALLS PLEASE · SPLATTER OPEN ·' : '· SPLATTER OPEN ·', W / 2, standsBottom - 9);
|
|
ctx.textAlign = 'left';
|
|
|
|
// ground + court (Super Tennis lawn); everything outside the doubles court is OUT
|
|
const dw = COURT.dblW / 2;
|
|
quadXZ(-14, -6, 14, 30, pal.out); // out area
|
|
quadXZ(-dw - 0.15, -0.15, dw + 0.15, COURT.L + 0.15, '#e8e8dc'); // perimeter border line
|
|
quadXZ(-dw, 0, dw, COURT.L, pal.lawn); // lawn (doubles court)
|
|
for (let z = 0; z < COURT.L; z += 2.97) quadXZ(-dw, z, dw, Math.min(z + 1.49, COURT.L), pal.stripe); // mow stripes
|
|
// lines: baselines, doubles + singles sidelines, service box
|
|
line(-dw, 0, dw, 0, 2); line(-dw, COURT.L, dw, COURT.L, 2);
|
|
line(-dw, 0, -dw, COURT.L, 1.5); line(dw, 0, dw, COURT.L, 1.5);
|
|
line(-COURT.W / 2, 0, -COURT.W / 2, COURT.L, 1.5); line(COURT.W / 2, 0, COURT.W / 2, COURT.L, 1.5);
|
|
line(-COURT.W / 2, COURT.svcFar, COURT.W / 2, COURT.svcFar, 1);
|
|
line(-COURT.W / 2, COURT.svcNear, COURT.W / 2, COURT.svcNear, 1);
|
|
line(0, COURT.svcFar, 0, COURT.svcNear, 1);
|
|
line(0, 0, 0, 0.35, 1); line(0, COURT.L - 0.35, 0, COURT.L, 1); // center marks
|
|
|
|
// gore decals: what remains of the visiting team
|
|
for (const d of gore) {
|
|
const p = proj(d.x, 0, d.z);
|
|
ctx.fillStyle = `rgba(140,18,18,${d.a})`;
|
|
ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * d.r * 0.4, p.s * d.r * 0.16, 0, 0, 7); ctx.fill();
|
|
}
|
|
|
|
// landing rings: white = incoming ball, yellow/red = your shot (red lands out)
|
|
if (G.phase === 'rally' && ball.active && ball.bounces === 0 && ball.lastHit === 'O') {
|
|
const l = predictLanding();
|
|
if (l) drawRing(l.x, l.z, 'rgba(255,255,255,0.85)');
|
|
}
|
|
if (G.outMarker) drawRing(G.outMarker.x, G.outMarker.z, G.outMarker.out ? '#ff5050' : '#f8e048');
|
|
|
|
// far-side drawables in z order (far → near), then the net, then the near side
|
|
const farSide = [
|
|
{ z: COURT.netZ - 0.01, f: () => drawStatic('x_ump', 7.1, COURT.netZ, UMP_H) },
|
|
];
|
|
if (!G.hideO) farSide.push({ z: O.z, f: () => drawChar(O) });
|
|
if (G.boy && G.boy.z <= COURT.netZ) farSide.push({ z: G.boy.z, f: () => drawBoy(G.boy) });
|
|
for (const zb of zombies) if (zb.z <= COURT.netZ) farSide.push({ z: zb.z, f: () => drawZombie(zb) });
|
|
farSide.sort((a, b) => a.z - b.z).forEach(d => d.f());
|
|
|
|
// net
|
|
const nl = proj(-COURT.netW / 2, 0, COURT.netZ), nr = proj(COURT.netW / 2, 0, COURT.netZ);
|
|
const nlt = proj(-COURT.netW / 2, COURT.netH, COURT.netZ), nrt = proj(COURT.netW / 2, COURT.netH, COURT.netZ);
|
|
ctx.fillStyle = 'rgba(20,26,34,0.72)';
|
|
ctx.beginPath(); ctx.moveTo(nl.x, nl.y); ctx.lineTo(nr.x, nr.y); ctx.lineTo(nrt.x, nrt.y); ctx.lineTo(nlt.x, nlt.y); ctx.fill();
|
|
ctx.strokeStyle = '#eeeeee'; ctx.lineWidth = 1.5;
|
|
ctx.beginPath(); ctx.moveTo(nlt.x, nlt.y); ctx.lineTo(nrt.x, nrt.y); ctx.stroke();
|
|
ctx.fillStyle = '#333'; ctx.fillRect(nl.x - 1, nlt.y, 2, nl.y - nlt.y); ctx.fillRect(nr.x - 1, nrt.y, 2, nr.y - nrt.y);
|
|
if (G.boy && G.boy.z > COURT.netZ) drawBoy(G.boy);
|
|
zombies.filter(zb => zb.z > COURT.netZ).sort((a, b) => a.z - b.z).forEach(drawZombie);
|
|
|
|
// server idly bouncing the ball while waiting
|
|
if (G.phase === 'pretoss') {
|
|
const sv = score.server === 'P' ? P : O;
|
|
const bx = sv.x - BOUNCE_IDLE.dx * (sv.side === 'P' ? 1 : -1);
|
|
const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * BOUNCE_IDLE.apex;
|
|
drawBall(bx, by, sv.z);
|
|
}
|
|
// the match ball
|
|
if ((ball.active || G.phase === 'over') && !ball.collected) drawBall(ball.x, ball.y, ball.z, true);
|
|
// splatter particles
|
|
for (const pt of parts) {
|
|
const pp = proj(pt.x, pt.y, pt.z);
|
|
ctx.fillStyle = 'rgba(170,22,22,0.9)';
|
|
ctx.fillRect(pp.x | 0, pp.y | 0, 2, 2);
|
|
}
|
|
if (G.mode !== 'eaten') drawChar(P); // there is no P anymore
|
|
|
|
// HUD — scoreboard
|
|
ctx.fillStyle = 'rgba(12,16,24,0.82)'; ctx.fillRect(6, 6, 132, 30);
|
|
ctx.strokeStyle = '#5a6a85'; ctx.strokeRect(6.5, 6.5, 131, 29);
|
|
ctx.font = 'bold 9px monospace'; ctx.textBaseline = 'top';
|
|
const ptTxt = i => score.pts[i] >= 3 && score.pts[0] >= 3 && score.pts[1] >= 3
|
|
? (score.pts[i] === score.pts[1 - i] ? '40' : (score.pts[i] > score.pts[1 - i] ? 'AD' : '40'))
|
|
: ['0', '15', '30', '40'][Math.min(score.pts[i], 3)];
|
|
ctx.fillStyle = score.server === 'P' ? '#f8e048' : '#c8d0dc';
|
|
ctx.fillText('YOU ' + score.games[0] + ' ' + ptTxt(0), 12, 10);
|
|
ctx.fillStyle = score.server === 'O' ? '#f8e048' : '#c8d0dc';
|
|
ctx.fillText((G.mode === 'tennis' ? 'CPU ' : 'THEM ') + score.games[1] + ' ' + ptTxt(1), 12, 22);
|
|
ctx.textAlign = 'center';
|
|
if (G.mode === 'eaten') {
|
|
ctx.fillStyle = 'rgba(10,4,6,0.72)'; ctx.fillRect(0, 0, W, H);
|
|
ctx.fillStyle = '#c02020'; ctx.font = 'bold 22px monospace';
|
|
ctx.fillText('EATEN.', W / 2, H / 2 - 30);
|
|
ctx.fillStyle = '#c8d0dc'; ctx.font = 'bold 10px monospace';
|
|
ctx.fillText('GAMES YOU ' + score.games[0] + ' — THEM ' + score.games[1], W / 2, H / 2);
|
|
if (G.eatenT > 0.7) { ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; ctx.fillText('TAP TO PLAY AGAIN', W / 2, H / 2 + 20); }
|
|
}
|
|
if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten') {
|
|
ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace';
|
|
const touch = matchMedia('(pointer: coarse)').matches;
|
|
ctx.fillText(G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE')
|
|
: (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE'), W / 2, H - 16);
|
|
}
|
|
if (G.flash) {
|
|
ctx.font = 'bold 14px monospace'; ctx.fillStyle = '#ffffff';
|
|
ctx.fillText(G.flash.text, W / 2, 60 + Math.sin(G.flash.t * 20) * 1);
|
|
}
|
|
if (TUNE.missHud && G.lastMiss && G.idleT - G.lastMiss.at < 3.5) { // last miss reason, live
|
|
ctx.font = 'bold 8px monospace'; ctx.fillStyle = '#ff9060';
|
|
ctx.fillText(G.lastMiss.text.toUpperCase(), W / 2, H - 28);
|
|
}
|
|
if (G.logShow) { // scoreboard tap: recent swing diagnostics, screenshot-friendly
|
|
const list = G.missLog.slice(-9), lh = 10, top = 40;
|
|
ctx.fillStyle = 'rgba(8,10,16,0.88)'; ctx.fillRect(6, top, W - 12, list.length * lh + 16);
|
|
ctx.font = 'bold 7px monospace'; ctx.textAlign = 'left';
|
|
ctx.fillStyle = '#8fa0b8'; ctx.fillText('SWING LOG — tap scoreboard to hide', 10, top + 5);
|
|
const maxCh = Math.floor((W - 20) / 4.3);
|
|
list.forEach((e, i) => {
|
|
ctx.fillStyle = e.result === 'HIT' ? '#7fd07f' : '#ff9060';
|
|
ctx.fillText((e.at + 's ' + e.result + ' ' + e.reason).slice(0, maxCh), 10, top + 16 + i * lh);
|
|
});
|
|
}
|
|
ctx.textAlign = 'left';
|
|
}
|