diff --git a/config.js b/config.js index 52249cd..16e8e96 100644 --- a/config.js +++ b/config.js @@ -333,6 +333,17 @@ export const BOSS_ART = [ export const MACH_H = 1.55; // was 1.15 — waist high read as a prop you could ignore. Still furniture // (a body is PLAYER_H 1.88), but big enough to be a thing on the court. + +// Drawn height of a body on the far side, by variant. render draws to it AND the serve aims at a +// fraction of it, so the two cannot drift — a shot at a brute has to arrive at the brute's chest, +// not at the height a shambler's chest happens to be. +export const zombieH = zb => zb.variant === 'g' ? 2.7 : zb.variant === 'd' ? 2.15 + : zb.variant === 'r' ? 1.85 : zb.variant === 'm' ? MACH_H + : zb.variant === 'B' ? 1.8 * (zb.scale || 1.5) : 1.8; +// Where on that body the ball is aimed, and where the aim mark is drawn. Solving the shot to the +// GROUND under a shambler meant the ball came down past it and struck at the ankles — the wound is +// on the torso, so the ball should arrive there. +export const Z_CHEST = 0.58; export const BOY_H = 1.5; // was 1.2 — the mopping boy read as a distant speck at that scale // how far off-court a boy starts. The mop boy comes in from just off-screen (he is slow, and // waiting 8s for him to walk into shot is dead time); a fetching boy sprints in from further out. diff --git a/gameplay.js b/gameplay.js index d0aad08..2a854bc 100644 --- a/gameplay.js +++ b/gameplay.js @@ -1,6 +1,6 @@ // Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update. -import { TUNE, UI, BOSS_ART, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE } from './config.js'; +import { TUNE, UI, BOSS_ART, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST } from './config.js'; import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js'; import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck } from './audio.js'; import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js'; @@ -8,10 +8,12 @@ import { W } from './render.js'; // runtime-only cycle (render imports gameplay) import { updateZombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies } from './zombies.js'; // ------------------------------------------------------------- ballistics ---- -// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g. -export function solveShot(x0, y0, z0, tx, tz, s, g = TUNE.gravity) { +// Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g. +// `ty` is the height it arrives AT — ball-on-the-grass by default, but a splatter serve aims at a +// shambler's chest, and solving to the ground under one is what made the ball strike the ankles. +export function solveShot(x0, y0, z0, tx, tz, s, g = TUNE.gravity, ty = 0.08) { const dx = tx - x0, dz = tz - z0, dist = Math.hypot(dx, dz), T = dist / s; - return { vx: dx / T, vz: dz / T, vy: (0.08 - y0 + 0.5 * g * T * T) / T }; + return { vx: dx / T, vz: dz / T, vy: (ty - y0 + 0.5 * g * T * T) / T }; } export function predictLanding() { // first-bounce point, closed form — exact, so the ring never jitters @@ -107,9 +109,12 @@ function doHit(ch, quality, aim) { const g = TUNE.gravity * (1 + quality * TUNE.spinFactor); let v = solveShot(ball.x, ball.y, ball.z, tx, tz, s, g); // low contact → trade pace for loft until the net is cleared; good timing gets a - // comfortable margin, sloppy timing keeps some tape risk + // comfortable margin, sloppy timing keeps some tape risk. Same crossesNet guard as serveArc's, + // for the same reason: a splatter volley at a body on your own side has no tape to clear, and + // without this it was lobbed at a fraction of its pace. (In tennis the ball always crosses.) const margin = 0.06 + 0.22 * quality; - for (let i = 0; i < NET_LOFT_TRIES; i++) { + const crossesNet = (ball.z - COURT.netZ) * (tz - COURT.netZ) < 0; + for (let i = 0; crossesNet && i < NET_LOFT_TRIES; i++) { const tN = (COURT.netZ - ball.z) / v.vz; const yN = ball.y + v.vy * tN - 0.5 * g * tN * tN; if (tN <= 0 || yN >= COURT.netH + margin) break; @@ -172,19 +177,29 @@ function splatServeTarget(sv, aim, s, x0, z0, ex = 0) { const ft = aim && aim.fx !== undefined ? flickTarget(sv, aim) : { zt: zombieTarget(aim) }; if (ft.zt) { const T0 = Math.hypot(ft.zt.x - x0, ft.zt.z - z0) / s; - return { tx: ft.zt.x + ex, tz: Math.min(23, ft.zt.z + ft.zt.v * T0), ft }; // lead the walk + // ty: aim at the CHEST of whatever this is, so the ball arrives on the body it is going to + // wound. Scaled off the same height render draws it at, so a brute is struck on the brute. + return { tx: ft.zt.x + ex, tz: Math.min(23, ft.zt.z + ft.zt.v * T0), // lead the walk + ty: zombieH(ft.zt) * Z_CHEST, ft }; } - if (ft.tx !== undefined) return { tx: ft.tx, tz: ft.tz, ft }; + if (ft.tx !== undefined) return { tx: ft.tx, tz: ft.tz, ty: 0.08, ft }; // empty lane: onto the grass return null; } // The launch itself: topspin gravity, then trade pace for arc until the net is genuinely cleared. // A shambler just the other side of the tape is only reachable on a loft. -function serveArc(x0, y0, z0, tx, tz, quality) { +function serveArc(x0, y0, z0, tx, tz, quality, ty = 0.08) { let s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed; const g = TUNE.gravity * (1 + quality * TUNE.serveSpin); // the harder you hit it, the harder it dips - let v = solveShot(x0, y0, z0, tx, tz, s, g); - for (let i = 0; i < NET_LOFT_TRIES; i++) { + let v = solveShot(x0, y0, z0, tx, tz, s, g, ty); + // THERE IS ONLY A NET TO CLEAR IF THE SHOT CROSSES IT. A shambler on your own side sits short of + // the tape, so the ball never reaches that plane — but `tN` is still positive (the net is ahead + // in the direction of travel, the ball simply lands first), the height test there is far below + // the tape, and the loop cut pace all twelve times. Measured: every same-side shot left the + // racket at 28% of nominal, as a slow loop, which is exactly the shot you need to be fast — + // those are the ones about to eat you. + const crossesNet = (z0 - COURT.netZ) * (tz - COURT.netZ) < 0; + for (let i = 0; crossesNet && i < NET_LOFT_TRIES; i++) { const tN = (COURT.netZ - z0) / v.vz; const yN = y0 + v.vy * tN - 0.5 * g * tN * tN; // The sim only samples the net plane once a frame, so a steeply DIVING ball can test below @@ -193,7 +208,7 @@ function serveArc(x0, y0, z0, tx, tz, quality) { // cushion is paid for in pace, and it was taxing precisely the shots meant to hurt. const margin = 0.06 + Math.abs(v.vy - g * tN) * 0.04; // 0.04 s = main.js's dt clamp if (tN <= 0 || yN >= COURT.netH + margin) break; - s *= 0.9; v = solveShot(x0, y0, z0, tx, tz, s, g); + s *= 0.9; v = solveShot(x0, y0, z0, tx, tz, s, g, ty); } return { v, g }; } @@ -207,15 +222,16 @@ export function previewServe(aim) { const s = TUNE.serveSpeed + q * TUNE.serveQualitySpeed; const st = splatServeTarget(P, aim, s, c.x, c.z); if (!st) return null; - const { v, g } = serveArc(c.x, c.y, c.z, st.tx, st.tz, q); - return shotBand(c.x, c.y, c.z, v, g, st.tx, st.tz, !!st.ft.zt, q); + const { v, g } = serveArc(c.x, c.y, c.z, st.tx, st.tz, q, st.ty); + return shotBand(c.x, c.y, c.z, v, g, st.tx, st.tz, st.ty, !!st.ft.zt, q); } // The drawable form of a shot: everything render needs and nothing that would pin a zombie object -// alive (`zt` is a flag, not the body — the trail below outlives whatever it was aimed at). -function shotBand(x0, y0, z0, v, g, tx, tz, zt, q) { +// alive (`zt` is a flag, not the body — the trail below outlives whatever it was aimed at). `ty` is +// the arrival height, so the mark render paints sits exactly where the ball is going. +function shotBand(x0, y0, z0, v, g, tx, tz, ty, zt, q) { const T = Math.hypot(tx - x0, tz - z0) / (Math.hypot(v.vx, v.vz) || 1); // flight time to target - return { x0, y0, z0, v, g, tx, tz, zt, q, f: qualityFrac(q), T, t: 0 }; + return { x0, y0, z0, v, g, tx, tz, ty, zt, q, f: qualityFrac(q), T, t: 0 }; } function hitServe(sv, quality, aim = null) { @@ -232,12 +248,12 @@ function hitServe(sv, quality, aim = null) { const boxZ = toFar ? COURT.svcFar + 1.6 : COURT.svcNear - 1.6; tzs = boxZ + (Math.random() * 2 - 1) * (1 - quality) * 1.5; } - const { v, g } = serveArc(ball.x, ball.y, ball.z, boxX, tzs, quality); + const { v, g } = serveArc(ball.x, ball.y, ball.z, boxX, tzs, quality, st ? st.ty : 0.08); // THE BALL ERASES ITS OWN AIM. Cutting the band at the instant of release made a fast rally // strobe — band, gone, band, gone. Instead the arc stays and the ball eats it from behind as it // flies, so the aim resolves into the shot rather than blinking out of existence. This is the // ACTUAL launch, error included, so the ball tracks the band it is consuming exactly. - if (st) G.trail = shotBand(ball.x, ball.y, ball.z, v, g, boxX, tzs, !!st.ft.zt, quality); + if (st) G.trail = shotBand(ball.x, ball.y, ball.z, v, g, boxX, tzs, st.ty, !!st.ft.zt, quality); ball.g = g; ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; ball.lastHit = sv.side; ball.bounces = 0; diff --git a/render.js b/render.js index 790a8cb..450732d 100644 --- a/render.js +++ b/render.js @@ -1,7 +1,7 @@ // Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state). import { cv, ctx } from './dom.js'; -import { TUNE, UI, EATEN_GRAB, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, BOUNCE_IDLE, SPRITE_H } from './config.js'; +import { TUNE, UI, EATEN_GRAB, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, BOUNCE_IDLE, SPRITE_H, zombieH } from './config.js'; import { P, O, ball, score, crowd, G, court } from './state.js'; import { predictLanding, previewServe } from './gameplay.js'; import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js'; @@ -354,8 +354,10 @@ function drawZombie(zb) { const chairDown = zb.variant === 's' && zb.shieldT > 0; // chair dropped — hit it NOW. Its own sprite // now (z_sdown); it used to be walk frame 3, // which a 4-frame cycle would walk right through - let h = zb.variant === 'g' ? 2.7 : zb.variant === 'd' ? 2.15 : rk ? 1.85 : 1.8; // the brute towers half again over the crowd; the steward stands between him and it - if (zb.variant === 'B') h = 1.8 * (zb.scale || 1.5); // ...and a chapter boss towers over the brute + // the brute towers half again over the crowd, the steward stands between him and it, and a + // chapter boss towers over the brute. Shared with gameplay, which aims the serve at a fraction + // of this — if the two tables drifted the ball would arrive at the wrong part of the body. + let h = zombieH(zb); // NOT const: the headless boss skin shortens it, and the sprite sheet scales it // wounded skins: multi-hit enemies show their damage as hp drops (arm off → head gone, still // shambling). The blood is NOT painted in — open wounds gush live particles (see bleed() in // zombies.js). Fall back to the healthy sprite until the wounded PNG has loaded. @@ -502,7 +504,7 @@ function drawAimMark(a, u0) { if (left <= 0.01) return; const col = a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0'; const onBody = !!a.zt; - const m = proj(a.tx, onBody ? 0.95 : 0, a.tz); + const m = proj(a.tx, a.ty, a.tz); // a.ty is where the ball ARRIVES — mark and impact are one point ctx.globalAlpha = (onBody ? 0.42 : 0.24) * left; ctx.fillStyle = col; ctx.beginPath();