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
+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);
|
||||
|
||||
Reference in new issue
Block a user