Zombie tennis: one game per opponent, blood only
Triage failed the only test that matters — "i think the normal player does not grasp this" — and the lesson across four drill designs is that every invented rule needs teaching, while the rules the scene already implies need none. So the new mode is the game's own oldest rule: tennis. MATCH TEST in the back room (#match). One game per opponent, real 15/30/40 scoring. Built on top of the duel machinery: G.match runs a permanent interlude, which is already what makes serving tennis-like, hides the rack, quiets the spawn director and wakes racketAI. The one change is in resolveInterlude — a settled point feeds matchPoint instead of ending anything. About 90 lines for a whole mode, because the deepest system in the repo was already built. The score is the body: a quarter of its pool per point won, floored at 1, so a love game walks the racket zombie through every wound stage and the game point fells it. Its points draw your blood — hp--, eaten at zero, matchHeal 2 per game won is how you last. Regicide is inherited: kill the body mid-game and the game banks by hand. The timing is drawn, per Oliver's ask: an approach ring around the incoming ball that kisses a fixed inner ring exactly at the sweet contact distance (gold inside the band), and an apex band beside the toss spanning the same 0.12m keyPressed scores as perfect, with a tick riding the live ball. Both cues use the mechanics' own numbers, so the picture cannot lie. Verified by autopilot: 7 games in 90 simulated seconds, opponents 1..8, pools 4/6/8/10, wounds tracking points exactly. 36 tests, 15 seeds clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
be191dba82
commit
0620e97d6c
9 files changed
+298
-16
No files matched your search
+113
-7
@@ -10,8 +10,8 @@
|
||||
import { TUNE, UI, bossArt, COURT, HOME } from './config.js';
|
||||
import { G, P, ball, score, setupPoint, sendBoy } from './state.js';
|
||||
import { voice, zombieDuck, aww } from './audio.js';
|
||||
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie } from './zombies.js';
|
||||
import { cheer, aside, endBurst, deliverTin, showModal, logSwing } from './gameplay.js';
|
||||
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js';
|
||||
import { cheer, aside, endBurst, deliverTin, showModal, logSwing, advancePoint, scoreKey } from './gameplay.js';
|
||||
import { startDrillStream } from './drill.js';
|
||||
|
||||
// the court is mopped up (or was already clear): play advances to whatever the won game earned —
|
||||
@@ -63,7 +63,7 @@ function startChangeover() {
|
||||
export function startDuel() {
|
||||
// Hard mutual exclusion, HERE, where every caller routes through: a duel must never begin while
|
||||
// another control mode owns the court. Each of these has ended a run when they overlapped.
|
||||
if (G.interlude || G.horde || G.bossFight || G.drill) return;
|
||||
if (G.interlude || G.horde || G.bossFight || G.drill || G.match) return;
|
||||
if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent
|
||||
for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel
|
||||
G.weapon = 0; // tennis rules for the duel: plain balls, no tins
|
||||
@@ -86,7 +86,7 @@ export function startDuel() {
|
||||
// It threatens BOTH channels: its volleys chip your hp, and letting it reach you is the ordinary
|
||||
// instant death. G.bossFight holds the chapter number while it runs.
|
||||
export function startBossFight(chapter) {
|
||||
if (G.bossFight || G.drill) return;
|
||||
if (G.bossFight || G.drill || G.match) return;
|
||||
G.bossFight = chapter;
|
||||
zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage
|
||||
G.horde = null; G.mgT = 0; endBurst(); G.clearing = false;
|
||||
@@ -135,7 +135,7 @@ export function endBossFight() {
|
||||
// was walking around a moment ago is still walking around inside a drill round. The drill's own
|
||||
// clock and rules live in drill.js; only the transition is here, where the rule can be enforced.
|
||||
export function startDrill() {
|
||||
if (G.drill || G.interlude || G.horde || G.bossFight) return false;
|
||||
if (G.drill || G.interlude || G.horde || G.bossFight || G.match) return false;
|
||||
G.drill = { t: 0, spawnT: 0, score: 0, streak: 0, kills: 0, faults: 0, lost: 0, best: 0, msg: null };
|
||||
G.changeover = false; G.clearing = false; G.afterClear = null;
|
||||
// ...and DISARM the ordinary schedule. Taking the court is not enough on its own: a pending
|
||||
@@ -203,8 +203,114 @@ export function watchdog(dt) {
|
||||
stuckT = 0; stuckSig = '';
|
||||
}
|
||||
|
||||
// ---- ZOMBIE TENNIS: the exhibition match. One GAME per opponent, real 15/30/40 scoring, and the
|
||||
// score IS the body — every point you win tears a wound stage off the racket zombie (the same
|
||||
// fraction-of-hpMax skins the duel uses), every point it wins draws your blood (the ordinary hp
|
||||
// channel; at zero you are eaten as usual). Win the game and the corpse comes apart; a fresh
|
||||
// opponent shambles on with more hp. Games won stays the ladder metric.
|
||||
//
|
||||
// Deliberately built ON TOP of the duel machinery rather than beside it: G.interlude is what makes
|
||||
// serving tennis-like, keeps the reticle and the rack away, quiets the spawn director and wakes
|
||||
// racketAI — all already true. The ONE difference is where a finished point goes: resolveInterlude
|
||||
// hands it to matchPoint instead of ending anything, and the interlude simply never resolves until
|
||||
// the match mode ends. Regicide is inherited for free: smashing the ball into the body still wounds
|
||||
// it, and killing it outright wins the game early (killZombie fires resolveInterlude('P'), which
|
||||
// lands in matchGameWon via the empty-court check).
|
||||
export function startMatch() {
|
||||
if (G.interlude || G.horde || G.bossFight || G.drill || G.match) return false;
|
||||
G.match = { opp: 1, ending: false };
|
||||
G.weapon = 0; G.mgT = 0; endBurst(); pellets.length = 0; extras.length = 0; shots.length = 0;
|
||||
zombies.length = 0;
|
||||
G.boy = null;
|
||||
score.pts = [0, 0];
|
||||
matchOpponent();
|
||||
ball.active = false; setupPoint();
|
||||
zombieDuck(true);
|
||||
voice('play_resume', 0.2);
|
||||
if (!G.seen.match) {
|
||||
G.seen.match = true;
|
||||
showModal({ title: 'EXHIBITION MATCH', color: UI.hot,
|
||||
body: ['WIN THE GAME — EVERY POINT WOUNDS IT', 'ITS POINTS DRAW YOUR BLOOD'] });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function matchOpponent() {
|
||||
spawnRacketZombie();
|
||||
const zb = zombies.find(z => z.variant === 'r');
|
||||
zb.dueled = true; // wakes racketAI, same as a fresh duel
|
||||
zb.hp = zb.hpMax = TUNE.matchOppHp + (G.match.opp - 1) * TUNE.matchOppHpPer;
|
||||
G.interlude = { introT: 1.6 }; // the duel machinery IS the match machinery
|
||||
}
|
||||
|
||||
export function endMatch() {
|
||||
if (!G.match) return;
|
||||
G.match = null; G.interlude = false;
|
||||
zombies.length = 0; shots.length = 0;
|
||||
zombieDuck(false);
|
||||
ball.active = false; setupPoint();
|
||||
}
|
||||
|
||||
// One tennis point is settled — the ONLY entry is resolveInterlude's hook below, so every way a
|
||||
// point can end (bounce out, double bounce, a body hit, a whiff) funnels through the duel's own
|
||||
// plumbing untouched.
|
||||
function matchPoint(side) {
|
||||
const d = G.match;
|
||||
if (d.ending) return;
|
||||
const zb = zombies.find(z => z.variant === 'r');
|
||||
if (side === 'P' && zb) {
|
||||
// a QUARTER of its pool per point, floored at 1 — four points walk it through every wound stage
|
||||
// and only the game-winning point actually fells it, so the body and the scoreboard agree all
|
||||
// the way to the end. (Ball hits wound it on top of this; that is the regicide path, kept.)
|
||||
zb.hp = Math.max(1, zb.hp - Math.ceil(zb.hpMax / 4));
|
||||
}
|
||||
if (side === 'O') {
|
||||
G.hp--; G.hurtT = TUNE.hurtGrace; G.hurtFlash = 0.6;
|
||||
aww(0.8);
|
||||
if (G.hp <= 0) return killPlayer(); // eaten — the run summary, as ever
|
||||
}
|
||||
const over = advancePoint(side); // banks the game and resets the points itself
|
||||
if (over) return side === 'P' ? matchGameWon(zb, true) : matchGameLost();
|
||||
voice(scoreKey(), 0.4);
|
||||
if (side === 'P') cheer(0.8, 0.3);
|
||||
ball.active = false; setupPoint(); // next point, same opponent
|
||||
}
|
||||
|
||||
function matchGameWon(zb, banked = false) {
|
||||
const d = G.match;
|
||||
d.ending = true; // killZombie below re-enters resolveInterlude
|
||||
if (zb && zombies.includes(zb)) explode(zb.x, zb.z); // the game-winning point IS the kill
|
||||
// REGICIDE arrives here without advancePoint having banked anything (the body died mid-game, not
|
||||
// on a game point) — bank the game by hand or the scoreboard drifts from the ladder.
|
||||
if (!banked) { score.games[0]++; score.pts = [0, 0]; }
|
||||
G.runGames++; // the ladder metric, same as every mode
|
||||
G.hp = Math.min(G.hpMax, G.hp + TUNE.matchHeal); // a clean game is how you last
|
||||
voice('game_player', 0.3); cheer(2.4, 0.6);
|
||||
G.card = { title: 'GAME.', sub1: 'NEXT OPPONENT', color: UI.pick, t: 2.2 };
|
||||
d.opp++;
|
||||
matchOpponent(); // fresh body, more hp, its own intro beat
|
||||
d.ending = false;
|
||||
ball.active = false; setupPoint();
|
||||
G.phase = 'over'; G.overT = 2.2;
|
||||
}
|
||||
|
||||
function matchGameLost() {
|
||||
// Reachable only through deuce (four straight points is exactly your hp) — the body keeps its
|
||||
// wounds, the scoreboard banks their game, and the match simply plays on.
|
||||
voice('game_opponent', 0.3); aww(1);
|
||||
ball.active = false; setupPoint();
|
||||
G.phase = 'over'; G.overT = 1.8;
|
||||
}
|
||||
|
||||
export function resolveInterlude(side) {
|
||||
if (!G.interlude) return;
|
||||
// THE MATCH HOOK: a settled point feeds the game score instead of ending the interlude — the
|
||||
// interlude is permanent while a match runs (it is what keeps the serving tennis-like and the
|
||||
// spawn director quiet). Regicide arrives here too, as a 'P' with the body already gone.
|
||||
if (G.match) {
|
||||
if (!zombies.some(z => z.variant === 'r') && !G.match.ending) return matchGameWon(null);
|
||||
return matchPoint(side);
|
||||
}
|
||||
G.interlude = false;
|
||||
zombieDuck(false); // duel over — bring the music back up
|
||||
ball.active = false; G.phase = 'over'; G.overT = 2.2;
|
||||
@@ -240,7 +346,7 @@ export function resolveInterlude(side) {
|
||||
// anymore". It is an ordinary 'over' beat (no serving, the world keeps walking, the RESUMING clock
|
||||
// counts it down under the flash), and startHorde still owns the transition itself.
|
||||
export function warnHorde() {
|
||||
if (G.horde || G.interlude || G.bossFight || G.drill) return false; // same court-ownership contract as the rest
|
||||
if (G.horde || G.interlude || G.bossFight || G.drill || G.match) return false; // same court-ownership contract as the rest
|
||||
G.hordeWarn = TUNE.hordeWarnT;
|
||||
G.flash = { text: 'THEY KEEP COMING', t: TUNE.hordeWarnT };
|
||||
ball.active = false; G.phase = 'over'; G.overT = TUNE.hordeWarnT;
|
||||
@@ -251,7 +357,7 @@ export function warnHorde() {
|
||||
// Returns false when another mode owns the court — the caller re-arms the latch rather than
|
||||
// silently eating the wave the set paid for.
|
||||
export function startHorde() {
|
||||
if (G.horde || G.interlude || G.bossFight || G.drill) return false; // same contract as startDuel: one mode owns the court
|
||||
if (G.horde || G.interlude || G.bossFight || G.drill || G.match) return false; // same contract as startDuel: one mode owns the court
|
||||
G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked
|
||||
const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse
|
||||
// the gun grows WITH the wave: spawn rate and length both scale by grow, so a flat gun fell
|
||||
|
||||
@@ -379,6 +379,19 @@ export const TUNE = {
|
||||
// AND a 1 hp body skips every ZWOUND stage, so the gore art never renders.
|
||||
// Also flattens triage — pre-softening a heavy IS the planning layer
|
||||
drillCaughtBack: 45, // s of ramp handed back when one reaches you. A real setback, not a reset
|
||||
// ---- ZOMBIE TENNIS (beats.startMatch) — the exhibition match. One game per opponent, blood only.
|
||||
matchOppHp: 4, // the first opponent's pool. FOUR, deliberately: a quarter comes off per point
|
||||
// won, so a love game walks it through every wound stage and the game point
|
||||
// fells it — the scoreboard and the body agree all the way down
|
||||
matchOppHpPer: 2, // extra pool per opponent felled — later corpses soak more regicide punishment
|
||||
matchHeal: 2, // hp back for a game won (the duel's healPerDuel rate) — a clean game is how
|
||||
// you last; their every point costs 1 of your playerHp 4
|
||||
// The timing windows, DRAWN (the match's whole ask: "what is the possible tap window, what is the
|
||||
// sweet spot"). Numbers shared with the mechanics they visualise, so the picture cannot lie:
|
||||
matchRingFar: 9, // m — the approach ring fades in once the incoming ball is this close
|
||||
matchRingSweet: 2.7, // m — contact distance where the rings kiss (the 30-tapped-points benchmark
|
||||
// lands hits tapping at 2.2..3.2 m, so the sweet spot is its centre)
|
||||
matchRingBand: 0.5, // m either side of sweet that draws gold — the plausible window, made visible
|
||||
fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped)
|
||||
lowPower: 1, // >= 0.5 caps RES at 3 (−44% of pixels painted per frame) — the battery saver,
|
||||
// flippable live from the T panel. DEFAULT ON since 2026-08-08: Oliver judged
|
||||
|
||||
+3
-3
@@ -194,7 +194,7 @@ function tossBall(sv) {
|
||||
ball.vx = 0; ball.vz = 0; ball.vy = TOSS.vy; ball.g = TUNE.gravity;
|
||||
G.phase = 'toss';
|
||||
}
|
||||
function serveApexY() { return TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gravity); } // ~2.95
|
||||
export function serveApexY() { return TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gravity); } // ~2.95
|
||||
|
||||
// how many 10%-pace cuts a shot may take to get over the net. 8 wasn't enough for a target
|
||||
// sitting right behind the tape — that needs a near-vertical drop, so ~10 cuts (pace × 0.35).
|
||||
@@ -379,7 +379,7 @@ function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- scoring ----
|
||||
function scoreKey() {
|
||||
export function scoreKey() {
|
||||
const N = ['love', 'fifteen', 'thirty', 'forty'];
|
||||
const s = score.server === 'P' ? [score.pts[0], score.pts[1]] : [score.pts[1], score.pts[0]];
|
||||
if (s[0] >= 3 && s[1] >= 3) {
|
||||
@@ -432,7 +432,7 @@ export function cheer(seconds, vol) {
|
||||
crowd.cheer = Math.max(crowd.cheer, seconds, APPLAUSE_T);
|
||||
}
|
||||
|
||||
function advancePoint(side) { // shared scoring math; returns whether a game was won
|
||||
export function advancePoint(side) { // shared scoring math; returns whether a game was won
|
||||
const i = side === 'P' ? 0 : 1, j = 1 - i;
|
||||
score.pts[i]++;
|
||||
if (score.pts[i] >= 4 && score.pts[i] - score.pts[j] >= 2) {
|
||||
|
||||
+7
-2
@@ -6,7 +6,7 @@
|
||||
import { TUNE, COURTS, TOURS, EATEN_RESTART, WEAPONS, bossArt } from './config.js';
|
||||
import { P, O, ball, score, G, setupPoint, selectCourt, selectTour } from './state.js';
|
||||
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js';
|
||||
import { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill } from './beats.js';
|
||||
import { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch } from './beats.js';
|
||||
import { fitViewport, render, proj, RES, SPRV, LOAD } from './render.js';
|
||||
import { voice, ambient, menuMusic, menuDuck } from './audio.js';
|
||||
import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js';
|
||||
@@ -227,6 +227,10 @@ document.getElementById('scDrill').addEventListener('click', () => {
|
||||
start(); intoSplatter();
|
||||
startDrill();
|
||||
});
|
||||
document.getElementById('scMatch').addEventListener('click', () => {
|
||||
start(); intoSplatter();
|
||||
startMatch();
|
||||
});
|
||||
|
||||
// The list is built by ASKING bossArt which design each chapter gets, so it can never drift from
|
||||
// what the run actually spawns — including the champion sitting on chapter 10 rather than 7.
|
||||
@@ -386,10 +390,11 @@ function laserFire() {
|
||||
if (location.hash === '#laser') setTimeout(laserScene, 600);
|
||||
// #drill — the same door from a URL, so a phone can be sent straight into the mode
|
||||
if (location.hash === '#drill') setTimeout(() => { start(); intoSplatter(); startDrill(); }, 700);
|
||||
if (location.hash === '#match') setTimeout(() => { start(); intoSplatter(); startMatch(); }, 700);
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
// exposed for in-browser sanity checks and simulated playtests
|
||||
window.T = { TUNE, ball, P, O, score, G, zombies, laserScene, laserFire, pause: v => { paused = v !== false; }, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, spawnBarrel, grantWeapon, toggleTestMode, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, dragServe, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight, startDrill, endDrill,
|
||||
window.T = { TUNE, ball, P, O, score, G, zombies, laserScene, laserFire, pause: v => { paused = v !== false; }, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, spawnBarrel, grantWeapon, toggleTestMode, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, dragServe, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch,
|
||||
stats, step: dt => update(dt), render, press: keyPressed,
|
||||
get phase() { return G.phase; }, set phase(v) { G.phase = v; } };
|
||||
+50
-2
@@ -3,7 +3,7 @@
|
||||
import { cv, ctx } from './dom.js';
|
||||
import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS } from './config.js';
|
||||
import { P, O, ball, score, crowd, G, court, tour } from './state.js';
|
||||
import { predictLanding, previewServe, slotId, slotStock } from './gameplay.js';
|
||||
import { predictLanding, previewServe, slotId, slotStock, serveApexY } from './gameplay.js';
|
||||
import { drillQueue, drillLegal, drillMult } from './drill.js';
|
||||
import { zombies, gore, piles, parts, pellets, extras, shots, pops } from './zombies.js';
|
||||
|
||||
@@ -680,6 +680,53 @@ function drawZombie(zb) {
|
||||
if (v === 'B' && zb.hpMax) drawBossHp(zb, h);
|
||||
}
|
||||
|
||||
// THE MATCH'S TIMING, DRAWN. Oliver: "the tennis timing mechanic would need to be visualized
|
||||
// somehow (what is the possible tap window what is the sweet spot)". Two cues, both riding numbers
|
||||
// the mechanics already use, so the picture cannot drift from the physics:
|
||||
//
|
||||
// THE APPROACH RING (the rally tap): a ring around the incoming ball whose radius tracks its
|
||||
// distance to the sweet contact range — the rings kiss exactly when the ball is where the tap
|
||||
// lands a clean hit (matchRingSweet, the centre of the tapped-points benchmark's 2.2..3.2 m), and
|
||||
// the ring turns gold inside the band. The landing ring set this precedent: a marker that
|
||||
// contracts toward its moment IS a timing cue, no legend needed.
|
||||
function drawApproachRing() {
|
||||
if (!G.match || G.phase !== 'rally' || !ball.active || ball.lastHit !== 'O') return;
|
||||
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); // the CONTACT test's own metric
|
||||
if (d > TUNE.matchRingFar || ball.vz < 0) return; // far away, or already going back
|
||||
const p = proj(ball.x, ball.y, ball.z);
|
||||
if (p.s <= 0) return;
|
||||
const inBand = Math.abs(d - TUNE.matchRingSweet) < TUNE.matchRingBand;
|
||||
const rSweet = Math.max(6, p.s * 0.055); // the fixed target ring
|
||||
const r = rSweet * Math.max(1, d / TUNE.matchRingSweet); // the closing ring — they kiss at sweet
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeStyle = inBand ? UI.warm : 'rgba(255,255,255,0.55)';
|
||||
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, 7); ctx.stroke();
|
||||
ctx.strokeStyle = inBand ? UI.warm : 'rgba(255,255,255,0.3)';
|
||||
ctx.beginPath(); ctx.arc(p.x, p.y, rSweet, 0, 7); ctx.stroke();
|
||||
}
|
||||
|
||||
// THE APEX BAND (the serve tap): a thin guide beside the tossed ball with a gold band spanning the
|
||||
// perfect window (serveApexY +- the same 0.12 m keyPressed scores as perfect) and a wider white
|
||||
// zone for the playable one. A tick rides the live ball height. The ghost hand MIMES this moment;
|
||||
// the band makes it a target you can see coming.
|
||||
function drawApexBand() {
|
||||
if (!G.match || G.phase !== 'toss' || score.server !== 'P') return;
|
||||
const gx = ball.x + 0.55; // beside the toss, out of the ball's way
|
||||
const apex = serveApexY();
|
||||
const yTop = proj(gx, apex + 0.45, ball.z), yBot = proj(gx, 1.2, ball.z);
|
||||
if (yTop.s <= 0) return;
|
||||
const x = Math.round(yTop.x);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.35)'; // the guide
|
||||
ctx.beginPath(); ctx.moveTo(x, yTop.y); ctx.lineTo(x, yBot.y); ctx.stroke();
|
||||
const bTop = proj(gx, apex + 0.12, ball.z).y, bBot = proj(gx, apex - 0.12, ball.z).y;
|
||||
ctx.fillStyle = hexA(UI.warm, 0.8); // the perfect window, gold
|
||||
ctx.fillRect(x - 3, Math.round(bTop), 6, Math.max(2, Math.round(bBot - bTop)));
|
||||
const tick = proj(gx, ball.y, ball.z).y; // where the ball is NOW
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillRect(x - 5, Math.round(tick), 10, 2);
|
||||
}
|
||||
|
||||
// THE TRIAGE READOUT: score, multiplier, best — and the calibration marker. There is no strip and
|
||||
// no called list any more; the court is the puzzle, so the HUD only carries what the court cannot
|
||||
// say: how much the run is worth and how hot the streak is.
|
||||
@@ -1975,7 +2022,8 @@ export function render() {
|
||||
ctx.restore();
|
||||
ctx.textBaseline = 'top'; ctx.textAlign = 'center';
|
||||
if (G.mode === 'splatter' && !G.interlude && !G.drill) drawRack(); // courtside weapon rack (hidden during the tennis-rules duel AND the drill, which is played with plain balls)
|
||||
drawDrillHud(); // the called list — no-op unless a drill is running
|
||||
drawDrillHud(); // the triage readout — no-op unless a drill is running
|
||||
drawApproachRing(); drawApexBand(); // the match's timing, drawn — no-ops unless a match is running
|
||||
// phase C: the summary fades UP over the death animation instead of cutting on top of it
|
||||
if (G.mode === 'eaten' && G.eatenT > EATEN_SUMMARY) {
|
||||
const fade = Math.min(1, (G.eatenT - EATEN_SUMMARY) / 0.6);
|
||||
|
||||
@@ -104,6 +104,9 @@ export const G = {
|
||||
interlude: false, // sudden-death duel vs the racket zombie: falsy, or {introT} while announcing
|
||||
duelLerp: 0, // 0..1 — how far the court has returned to tennis colors for the duel
|
||||
horde: null, // active horde wave {t, spawnT, mgT} — machine-gun racket, no serving
|
||||
match: null, // ZOMBIE TENNIS (beats.startMatch): {opp, ending}. One GAME per opponent,
|
||||
// real scoring — every point you win wounds the racket zombie, every point it
|
||||
// wins draws your blood. Runs ON TOP of a permanent interlude.
|
||||
drill: null, // the TRIAGE drill (src/drill.js): {t, spawnT, score, streak, kills, faults,
|
||||
// lost, best, msg}. Owns the court like every other mode — see beats.startDrill.
|
||||
// One rule: always kill the body that would reach you first. `t` is time
|
||||
|
||||
Reference in new issue
Block a user