Zombie town: the match continues
Win your first game and the applause cuts off mid-clap. The palette drains,
the umpire lowers his voice ('Quiet... please'), your opponent turns — torn
red polo, dragging his racket — and shamblers come over the billboard wall.
The scoreboard rewrites CPU to THEM. 'Play.'
- zombies.js: shamblers walk your way; quality shots splatter them (gore
decal + particle burst + wet synth splat), weak sitters only stagger them
back; one crossing your baseline is a point for THEM
- tennis scoring machinery keeps running untouched: fifteen, thirty, deuce,
and 'Game, set... massacre' on a game win, all in recorded umpire English
- ball boys mop the gore they pass (their destiny fulfilled)
- transition: palette lerp, scripted spawns incl. the turned opponent,
new recorded lines (quiet_dark, remain_seated, play, new_balls)
- moan ambience, drained splatter palette, all zombie params in TUNE
Verified: transition triggers on first game win, splat/stagger/baseline
scoring all correct, dead-ball flow feeds fresh serves, tennis mode
regression clean (6/6 returns, 6/6 points resolved).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f221ff84df
commit
69e3a0c310
20 files changed
+247
-25
No files matched your search
@@ -4,6 +4,29 @@ 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
|
||||
@@ -35,7 +58,8 @@ export function proj(x, y, z) {
|
||||
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']) {
|
||||
'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';
|
||||
@@ -93,6 +117,10 @@ function drawBoy(b) {
|
||||
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
|
||||
@@ -147,11 +175,12 @@ function drawFallbackChar(ch, p, h) { // shown only until sprites load
|
||||
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 = '#232e42'; ctx.fillRect(0, 0, W, standsBottom);
|
||||
ctx.fillStyle = pal.stands; ctx.fillRect(0, 0, W, standsBottom);
|
||||
for (let r = 0; r < 16; r++) {
|
||||
ctx.fillStyle = r % 2 ? '#2a3750' : '#26324a';
|
||||
ctx.fillStyle = r % 2 ? pal.tierA : pal.tierB;
|
||||
ctx.fillRect(0, 6 + r * 5.5, W, 5.5);
|
||||
}
|
||||
for (const d of crowd.dots) {
|
||||
@@ -160,17 +189,17 @@ export function render() {
|
||||
ctx.fillStyle = d.c; ctx.fillRect(d.x + jx | 0, d.y + jy | 0, 2, 2);
|
||||
}
|
||||
// courtside billboard wall
|
||||
ctx.fillStyle = '#0e5c2e'; ctx.fillRect(0, standsBottom - 10, W, 10);
|
||||
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, '#295c2d'); // out area
|
||||
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, '#4cae52'); // lawn (doubles court)
|
||||
for (let z = 0; z < COURT.L; z += 2.97) quadXZ(-dw, z, dw, Math.min(z + 1.49, COURT.L), '#46a54c'); // mow stripes
|
||||
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);
|
||||
@@ -180,6 +209,13 @@ export function render() {
|
||||
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();
|
||||
@@ -189,10 +225,11 @@ export function render() {
|
||||
|
||||
// far-side drawables in z order (far → near), then the net, then the near side
|
||||
const farSide = [
|
||||
{ z: O.z, f: () => drawChar(O) },
|
||||
{ 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
|
||||
@@ -204,6 +241,7 @@ export function render() {
|
||||
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') {
|
||||
@@ -214,6 +252,12 @@ export function render() {
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
drawChar(P);
|
||||
|
||||
// HUD — scoreboard
|
||||
@@ -226,7 +270,7 @@ export function render() {
|
||||
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('CPU ' + score.games[1] + ' ' + ptTxt(1), 12, 22);
|
||||
ctx.fillText((G.mode === 'tennis' ? 'CPU ' : 'THEM ') + score.games[1] + ' ' + ptTxt(1), 12, 22);
|
||||
ctx.textAlign = 'center';
|
||||
if (G.phase === 'pretoss' && score.server === 'P') {
|
||||
ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace';
|
||||
|
||||
Reference in new issue
Block a user