Splatter: the pull IS the shot — direction aims, length is power
The ball economy made it harder in the wrong way: waiting for a ballboy is friction, not difficulty, and walking with the finger was one gesture too many for anyone to discover. Both are out — a spent ball hands you a fresh one again, and the thumb only ever does the slingshot. The brake moves into the gesture instead. The pull's LENGTH is now the shot's power and nothing else: under TUNE.zSplatQ it merely staggers, at full draw it's quality 1.0 with the PERFECT flash and hitstop. A lazy rhythm of short pulls can't kill anything, so tempo stops being a strategy. The pull's DIRECTION is a ray, not a landing point, which is the ergonomic fix for having to drag off the bottom of the phone: distance to the target no longer costs drag length. The first shambler standing in the lane eats the ball, whether it's 5 or 21 metres away, and the cone widens with range so distant lanes stay pickable. Full power now sits at 95px of travel. Also gone: the free predictive auto-lock on a directionless aim (zombieTarget returns null with no direction, and a bare hold no longer fires at all). Melee goes back to cleaving the cluster — it's the panic button — with the 1s cooldown left in as the brake. The reticle reads power throughout: cool blue only staggers, hot red kills, the drawn band grows behind you, and the pull clicks the moment it crosses the kill threshold. Verified by simulated touch gestures: 10px cancels, 35px = 0.31 (stagger), 60px = 0.60, 95px = 1.00; the same 60px pull reaches shamblers at 5m, 21m and diagonally; 22 serves in 30s, so the pace is back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2787778ad2
commit
ff973cf6bb
6 files changed
+80
-95
No files matched your search
@@ -31,11 +31,15 @@ export const TUNE = {
|
||||
zSplatQ: 0.5, // min shot quality to splatter; below = stagger pushback
|
||||
zPushback: 1.4, // m a staggered zombie is knocked back
|
||||
splatterOverT: 0.1, // s the spent-ball 'over' beat lasts before the next serve is armed
|
||||
// ball economy: a dead ball LIES on the court. No serve until it's back in your hand.
|
||||
pickupR: 1.4, // m — walk over your loose ball to pick it up instantly (the fast way)
|
||||
eatBallR: 0.8, // m — a shambler this close to a loose ball swallows it
|
||||
looseMax: 2.0, // s before the boys hand you one anyway — the ceiling on standing ball-less. THE fire-rate knob.
|
||||
fetchEaten: 3.5, // s until fresh balls arrive after one got eaten — the only real punishment in the economy
|
||||
// the slingshot: the pull's DIRECTION picks a lane, its LENGTH is power. Deliberately DECOUPLED —
|
||||
// reaching a far shambler must not cost a longer drag than the screen has room for. The old
|
||||
// mapping (pull length = reach) is why you had to drag off the bottom of the phone.
|
||||
pullMin: 18, // px of pull below which the release cancels — no shot, nothing spent
|
||||
pullFull: 95, // px of pull for a full-power shot. Keep this well inside one thumb's travel.
|
||||
pullQMin: 0.12, // shot quality at pullMin, scaling to 1.0 (= PERFECT) at pullFull. Below
|
||||
// zSplatQ it only staggers, so a lazy flick can't kill: THE anti-spam knob.
|
||||
aimCone: 0.45, // lane half-width per meter of range — the aim cone widens with distance
|
||||
aimReach: 22, // m the aim ray runs when no shambler stands in the lane
|
||||
backstopZ: -1.8, // z of the far backstop — must sit IN FRONT of the -2 retire line, or balls die before they reach it
|
||||
backstopKeep: 0.55, // pace a ball keeps after the rebound — still lethal, slower
|
||||
backstopPop: 1.8, // m/s of lift off the fence, so the rebound travels back instead of dying at the net posts
|
||||
|
||||
+29
-51
@@ -22,20 +22,30 @@ export function predictLanding() { // first-bounce point, closed form — exact,
|
||||
return { x: ball.x + ball.vx * t, z: ball.z + ball.vz * t };
|
||||
}
|
||||
|
||||
// splatter FREE-AIM: the flick vector maps to a landing point from where you stand — a short
|
||||
// flick drops it close, a long one reaches the far baseline. A zombie within zSnap of that
|
||||
// point magnets the shot (with walk-lead); otherwise the ball lands exactly where you flicked.
|
||||
// splatter FREE-AIM (slingshot). The pull is a RAY, not a landing point: its DIRECTION picks the
|
||||
// lane you fire down, its LENGTH is power — and nothing else. Pulling further does NOT reach
|
||||
// further, which is the whole fix: a shambler at the far baseline costs the same short pull as
|
||||
// one on top of you, so the gesture always fits on the screen. The first body standing in the
|
||||
// lane is the target; the cone widens with range so distant lanes stay pickable.
|
||||
export const pullFrac = mag => Math.max(0, Math.min(1, (mag - TUNE.pullMin) / (TUNE.pullFull - TUNE.pullMin)));
|
||||
export const pullQuality = mag => TUNE.pullQMin + pullFrac(mag) * (1 - TUNE.pullQMin);
|
||||
|
||||
export function flickTarget(ch, aim) { // exported: render draws the live reticle with it
|
||||
if (!aim.mag) return { zt: zombieTarget(null) }; // pure hold, no direction yet: auto-target the most advanced
|
||||
const R = Math.min(1, Math.max(0.12, aim.mag / 160)) * 22; // px flick length -> meters of reach
|
||||
const tx = Math.max(-6.5, Math.min(6.5, ch.x + aim.fx * R));
|
||||
const tz = Math.max(1, Math.min(24.5, ch.z + aim.fy * R)); // screen up = up-court (-z)
|
||||
let zt = null, bd = TUNE.zSnap;
|
||||
const q = pullQuality(aim.mag), f = pullFrac(aim.mag);
|
||||
if (aim.mag < TUNE.pullMin) return { q, f }; // barely drawn: no lane yet, so no target
|
||||
const ax = aim.fx, az = aim.fy; // unit vector; input already inverted the pull. screen up = -z
|
||||
let zt = null, near = 1e9;
|
||||
for (const zb of zombies) {
|
||||
const d = Math.hypot(zb.x - tx, zb.z - tz);
|
||||
if (d < bd) { bd = d; zt = zb; }
|
||||
const dx = zb.x - ch.x, dz = zb.z - ch.z;
|
||||
const along = dx * ax + dz * az; // how far down the lane it stands
|
||||
if (along <= 0) continue; // behind the shot
|
||||
if (Math.abs(dx * az - dz * ax) > TUNE.zSnap + TUNE.aimCone * along) continue; // outside the cone
|
||||
if (along < near) { near = along; zt = zb; } // the FIRST one in the lane eats it
|
||||
}
|
||||
return { tx, tz, zt };
|
||||
const R = zt ? near : TUNE.aimReach; // empty lane: the ball just flies the full length of it
|
||||
const tx = Math.max(-6.5, Math.min(6.5, ch.x + ax * R));
|
||||
const tz = Math.max(1, Math.min(24.5, ch.z + az * R));
|
||||
return { tx, tz, zt, q, f };
|
||||
}
|
||||
|
||||
// splatter tap/lane aiming (no flick): the shot locks onto the best-matching zombie
|
||||
@@ -43,15 +53,13 @@ export function flickTarget(ch, aim) { // exported: render draws the live reticl
|
||||
function zombieTarget(aim) {
|
||||
if (!zombies.length) return null;
|
||||
const none = !aim || (!aim.ax && !aim.az);
|
||||
if (none) return null; // no direction, no lock. Free predictive auto-aim was the reason a
|
||||
// bare rhythm of taps could clear the court without any aiming at all.
|
||||
let best = null, bs = 1e9;
|
||||
for (const zb of zombies) {
|
||||
let s;
|
||||
if (none) s = COURT.L - zb.z; // no direction: the most advanced one is the most urgent
|
||||
else {
|
||||
s = Math.abs(zb.x - (P.x + aim.ax * 4)); // lane relative to where YOU stand
|
||||
let s = Math.abs(zb.x - (P.x + aim.ax * 4)); // lane relative to where YOU stand
|
||||
if (aim.az < 0) s += zb.z * 0.35; // flick up: prefer the far ones
|
||||
if (aim.az > 0) s += (COURT.L - zb.z) * 0.35; // flick down: the close ones on your side
|
||||
}
|
||||
if (s < bs) { bs = s; best = zb; }
|
||||
}
|
||||
return best;
|
||||
@@ -282,7 +290,7 @@ export function proceedAfterClear() {
|
||||
function startChangeover() {
|
||||
G.changeover = true;
|
||||
G.phase = 'over'; G.overT = TUNE.changeoverT;
|
||||
sendBoy(); giveBall(); // the breather always leaves you holding a ball
|
||||
sendBoy();
|
||||
if (G.tinDue) { // the boys arm you — unless they took a game since the last delivery
|
||||
G.tinDue = false;
|
||||
if (G.shameGame) G.flash = { text: 'NO NEW BALLS', t: 1.6 }; // the umpire withholds
|
||||
@@ -340,20 +348,8 @@ export function spentBall() {
|
||||
ball.active = false;
|
||||
G.phase = 'over';
|
||||
if (!G.changeover) G.overT = TUNE.splatterOverT;
|
||||
// The ball economy: a spent ball doesn't evaporate into a fresh serve — it LIES where it died.
|
||||
// Reclaim it by walking it down (fast, but it drags you off the baseline), let a boy fetch it
|
||||
// (slow and safe), or watch a shambler swallow it (very slow). This is what stops serve spam:
|
||||
// the fire rate is now a positioning decision, not a thumb-tempo one.
|
||||
if (G.mode === 'splatter' && !G.interlude && !G.horde && !(G.mgT > 0)) {
|
||||
G.loose = { t: 0 }; ball.y = 0; ball.collected = false;
|
||||
sendBoy(ball.x, ball.z);
|
||||
}
|
||||
}
|
||||
|
||||
// a ball is in your hand again — serving is armed. Called wherever the rules hand you one.
|
||||
export function giveBall() { G.loose = false; G.fetchT = 0; }
|
||||
export const armed = () => !G.loose && G.fetchT <= 0;
|
||||
|
||||
// the swarm keeps coming: no serving, the racket auto-fires, the thumb drag steers you
|
||||
function startHorde() {
|
||||
if (G.horde) return;
|
||||
@@ -588,7 +584,6 @@ function tryGunBurst() {
|
||||
// slingshot serve, stage one: the pull-back starts the toss animation. The ball rises while
|
||||
// you aim (dotted line, render.js); the release (dragServe) fires it. Only from a fresh pretoss.
|
||||
export function serveToss() {
|
||||
if (G.mode === 'splatter' && !armed()) return; // no ball in hand — nothing to toss
|
||||
if (score.server === 'P' && G.phase === 'pretoss' && !ball.active) tossBall(P);
|
||||
}
|
||||
|
||||
@@ -599,10 +594,10 @@ export function dragServe(aim) {
|
||||
if (G.interlude || G.horde || G.mgT > 0 || G.changeover) return; // the duel serves like tennis, not the reticle
|
||||
if (G.phase !== 'pretoss' && G.phase !== 'toss') return; // one ball: no re-serving over a live rally
|
||||
if (tryGunBurst()) return;
|
||||
if (!armed()) return;
|
||||
if (aim.mag < TUNE.pullMin) return; // released without really drawing: cancel, nothing spent
|
||||
ball.active = true; ball.collected = false; ball.bounces = 0; ball.firstLand = null;
|
||||
ball.x = P.x + 0.35; ball.z = P.z; ball.y = 2.2; ball.g = TUNE.gravity;
|
||||
hitServe(P, 0.62, aim);
|
||||
hitServe(P, pullQuality(aim.mag), aim); // a full pull is quality 1.0 — PERFECT, hitstop and all
|
||||
}
|
||||
|
||||
export function keyPressed(code) {
|
||||
@@ -625,7 +620,6 @@ export function keyPressed(code) {
|
||||
}
|
||||
if (G.phase === 'pretoss' && score.server === 'P') {
|
||||
if (tryGunBurst()) return; // GUN selected: the serve gesture pops a burst, no ball
|
||||
if (G.mode === 'splatter' && !armed()) return; // empty-handed: the tap was just the swish above
|
||||
tossBall(P); return; // plain tap: the two-stage toss ritual (tennis, and the duel)
|
||||
}
|
||||
if (G.phase === 'toss' && score.server === 'P') {
|
||||
@@ -663,13 +657,11 @@ export function update(dt) {
|
||||
crowd.cheer = Math.max(0, crowd.cheer - dt);
|
||||
G.idleT += dt;
|
||||
const w = BOUNCE_IDLE.tempo;
|
||||
if (G.phase === 'pretoss' && (G.mode !== 'splatter' || armed())
|
||||
&& Math.sin(G.idleT * w) * Math.sin((G.idleT - dt) * w) < 0) pock(0.12, 420); // ball bounce (only if you hold one)
|
||||
if (G.phase === 'pretoss' && Math.sin(G.idleT * w) * Math.sin((G.idleT - dt) * w) < 0) pock(0.12, 420); // ball bounce
|
||||
if (G.phase === 'toss') { const sv = score.server === 'P' ? P : O; sv.pose = 'serve'; sv.poseT = 0; }
|
||||
|
||||
// --- player movement (near half only, generous margins)
|
||||
// splatter also moves during pretoss: with a ball lying on the court, walking is the whole point
|
||||
if (G.phase === 'rally' || (G.mode === 'splatter' && G.phase === 'pretoss')) {
|
||||
if (G.phase === 'rally') {
|
||||
if (G.mode === 'tennis' && ball.active && ball.lastHit === 'O') { // closest approach, for never-swung passes
|
||||
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8);
|
||||
if (!G.rallyNear || d < G.rallyNear.d) G.rallyNear = { d, y: ball.y, bz: ball.z, px: P.x, pz: P.z };
|
||||
@@ -734,19 +726,6 @@ export function update(dt) {
|
||||
// --- zombies + transition timeline
|
||||
if (G.mode !== 'tennis') updateZombies(dt);
|
||||
if (G.meleeT > 0) G.meleeT -= dt;
|
||||
|
||||
// --- the loose ball: three ways it comes back, each with its own price in seconds
|
||||
if (G.loose) {
|
||||
G.loose.t += dt;
|
||||
if (Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8) < TUNE.pickupR) { giveBall(); pock(0.5, 900); } // you ran it down
|
||||
else if (ball.collected) giveBall(); // a boy scooped it and tossed it back
|
||||
else if (zombies.some(zb => zb.variant !== 'r' && Math.hypot(zb.x - ball.x, zb.z - ball.z) < TUNE.eatBallR)) {
|
||||
G.loose = false; G.fetchT = TUNE.fetchEaten; // one of them swallowed it. New balls, please.
|
||||
G.flash = { text: 'BALL EATEN', t: 1.3 };
|
||||
pock(0.9, 130); voice('new_balls', 0.4);
|
||||
}
|
||||
else if (G.loose.t > TUNE.looseMax) giveBall(); // out of reach (far court, off court): the boys just bring you one
|
||||
} else if (G.fetchT > 0 && (G.fetchT -= dt) <= 0) G.fetchT = 0;
|
||||
if (G.clearing && !zombies.some(z => z.variant !== 'r')) proceedAfterClear(); // the won-game wave is mopped up — advance
|
||||
if (G.mode === 'transition') transition(dt);
|
||||
|
||||
@@ -756,7 +735,6 @@ export function update(dt) {
|
||||
&& G.phase === 'pretoss' && zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled) {
|
||||
zombies[0].dueled = true; // one duel per opponent — after that it just rallies
|
||||
G.weapon = 0; // tennis rules for the duel: force plain balls, no tins
|
||||
giveBall(); // ...and a fresh one in hand, whatever happened to the last
|
||||
G.interlude = { introT: 2.4 }; // the intro beat: serving blocked, centered card, colors return
|
||||
zombieDuck(true); // fade the zombie music out for the 1:1 duel
|
||||
voice('play_resume', 0.2); voice('sudden_death', 2.4);
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
// functions at runtime, never during module evaluation.
|
||||
|
||||
import { cv } from './dom.js';
|
||||
import { audio } from './audio.js';
|
||||
import { audio, pock } from './audio.js';
|
||||
import { TUNE } from './config.js';
|
||||
import { G, score } from './state.js';
|
||||
import { keyPressed, dragServe, selectWeapon, serveToss } from './gameplay.js';
|
||||
import { keyPressed, dragServe, selectWeapon, serveToss, pullQuality } from './gameplay.js';
|
||||
import { rackHit } from './render.js';
|
||||
import { togglePanel } from './panel.js';
|
||||
|
||||
@@ -41,10 +42,8 @@ export function takeFlickAim(maxAgeMs = 450) {
|
||||
|
||||
// the two-stage serve: a drag that STARTS at a serveable pretoss aims a visible reticle
|
||||
// (G.aim, drawn by render); release fires the serve there, dragging back home cancels.
|
||||
// With no ball in hand this is false, so the same drag steers your legs instead — which is
|
||||
// exactly what you need then: walking your loose ball down.
|
||||
const canAimServe = () =>
|
||||
G.mode === 'splatter' && G.phase === 'pretoss' && !G.loose && !(G.fetchT > 0)
|
||||
G.mode === 'splatter' && G.phase === 'pretoss'
|
||||
&& score.server === 'P' && G.weapon !== 4 // GUN pops on tap, no reticle
|
||||
&& !G.interlude && !G.horde && !(G.mgT > 0) && !G.changeover; // the duel serves like tennis: no reticle
|
||||
|
||||
@@ -62,7 +61,7 @@ cv.addEventListener('touchstart', e => {
|
||||
if (!stick) {
|
||||
stick = { id: t.identifier, x0: t.clientX, y0: t.clientY, t0: performance.now(),
|
||||
moved: false, aimable: canAimServe() }; // context locked at touch start — a movement drag never morphs into a serve
|
||||
// the reticle appears the moment the thumb lands: mag 0 = auto-target the most advanced
|
||||
// the reticle appears the moment the thumb lands, but mag 0 aims at nothing — you have to pull
|
||||
if (stick.aimable) G.aim = { fx: 0, fy: 0, mag: 0 };
|
||||
else steerPX = (t.clientX - r.left) / r.width * 2 - 1;
|
||||
}
|
||||
@@ -77,10 +76,14 @@ cv.addEventListener('touchmove', e => {
|
||||
if (t.identifier !== stick.id) continue;
|
||||
const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy);
|
||||
if (dist > DEAD) stick.moved = true;
|
||||
if (stick.aimable) { // slingshot: PULL BACK, launch forward — aim is the inverse of the drag (home = auto-target)
|
||||
if (stick.aimable) { // slingshot: PULL BACK, launch forward — the shot is the inverse of the drag
|
||||
if (dist > DEAD) {
|
||||
if (!stick.tossed) { serveToss(); stick.tossed = true; } // the pull kicks off the toss animation
|
||||
G.aim = { fx: -dx / dist, fy: -dy / dist, mag: dist };
|
||||
// the moment the pull is strong enough to actually splatter, it clicks — you can feel
|
||||
// where the threshold is without watching the reticle
|
||||
const hot = pullQuality(dist) >= TUNE.zSplatQ;
|
||||
if (hot !== !!stick.hot) { stick.hot = hot; if (hot) pock(0.45, 1250); }
|
||||
} else G.aim = { fx: 0, fy: 0, mag: 0 };
|
||||
continue;
|
||||
}
|
||||
@@ -98,10 +101,10 @@ const endTouch = e => {
|
||||
const dt = performance.now() - stick.t0;
|
||||
const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy);
|
||||
const aim = G.aim; G.aim = null; steerPX = null;
|
||||
if (stick.aimable && (stick.moved || dt >= 250)) { // two-stage release
|
||||
// a pure HOLD (never moved) fires at the auto-target; a drag fires at the reticle;
|
||||
// a drag walked back home cancels — the tin stays in stock
|
||||
if (aim && (!stick.moved || dist > DEAD * 1.5)) dragServe(aim);
|
||||
if (stick.aimable && stick.moved) { // slingshot release
|
||||
// only a real pull fires — walked back under pullMin, it cancels and nothing is spent.
|
||||
// (a bare hold used to fire at a free auto-target; that was the whole exploit)
|
||||
if (aim && dist >= TUNE.pullMin) dragServe(aim);
|
||||
}
|
||||
else if (!stick.moved && dt < 250) keyPressed('Space'); // tap
|
||||
else if (dt < 300 && dist > 28) { // flick = aimed swing
|
||||
|
||||
@@ -396,18 +396,24 @@ export function render() {
|
||||
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');
|
||||
if (G.loose) drawRing(ball.x, ball.z, '#f8e048'); // your ball is down there — go and get it
|
||||
// slingshot serve aim: an angry-birds dotted trajectory from the racket to the aim point;
|
||||
// red = the magnet has a zombie locked, yellow = a free spot on the court
|
||||
// slingshot aim: the pull's direction is the lane, its length is POWER. Everything on screen
|
||||
// reads power, because that's the decision — a dim thin line only staggers, a hot fat one kills.
|
||||
if (G.aim && G.mode === 'splatter' && !G.interlude && (G.phase === 'pretoss' || G.phase === 'toss' || G.phase === 'rally')) {
|
||||
const ft = flickTarget(P, G.aim);
|
||||
const tx = ft.zt ? ft.zt.x : ft.tx, tz = ft.zt ? ft.zt.z : ft.tz;
|
||||
if (tx !== undefined) {
|
||||
const col = ft.zt ? '#ff5050' : '#f8e048';
|
||||
const kills = ft.q >= TUNE.zSplatQ;
|
||||
// cool blue-white = this pull only staggers. It has to stay READABLE on the dark lawn —
|
||||
// the player still needs to see where they're aiming, they just need to know it's weak.
|
||||
const col = !kills ? '#9db4d0' : ft.zt ? '#ff5050' : '#f8e048';
|
||||
const a = proj(P.x, 1.4, P.z), b = proj(tx, 0, tz);
|
||||
ctx.strokeStyle = col; ctx.lineWidth = 2; ctx.setLineDash([3, 5]);
|
||||
// the drawn band: it grows BEHIND you as you pull, so the gesture has a visible cost
|
||||
const bl = proj(P.x - G.aim.fx * ft.f * 3, 1.2, P.z - G.aim.fy * ft.f * 3);
|
||||
ctx.strokeStyle = col; ctx.lineWidth = 1 + ft.f * 2;
|
||||
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(bl.x, bl.y); ctx.stroke();
|
||||
ctx.setLineDash([3, Math.max(2, 7 - ft.f * 5)]); // dashes tighten up as the shot gets hot
|
||||
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.setLineDash([]); ctx.lineWidth = 1;
|
||||
drawRing(tx, tz, col);
|
||||
if (ft.zt) { const hp = proj(ft.zt.x, 2.2, ft.zt.z); ctx.fillStyle = col; ctx.fillRect(hp.x - 1, hp.y - 4, 3, 3); }
|
||||
}
|
||||
@@ -435,14 +441,14 @@ export function render() {
|
||||
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' && !(G.mode === 'splatter' && (G.loose || G.fetchT > 0))) { // empty-handed = no ball to bounce
|
||||
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 (and any triple-serve side balls in flight)
|
||||
if ((ball.active || G.phase === 'over' || G.loose) && !ball.collected) drawBall(ball.x, ball.y, ball.z, true, ball.bomb, ball.flame);
|
||||
if ((ball.active || G.phase === 'over') && !ball.collected) drawBall(ball.x, ball.y, ball.z, true, ball.bomb, ball.flame);
|
||||
for (const e of extras) drawBall(e.x, e.y, e.z);
|
||||
// splatter particles (fire embers ride the same array)
|
||||
for (const pt of parts) {
|
||||
@@ -530,10 +536,8 @@ export function render() {
|
||||
const touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-5 TO SWITCH';
|
||||
const sel = ['PLAIN', 'BOMB', 'TRIPLE', 'FIRE', 'GUN'][G.weapon];
|
||||
const txt = G.mode === 'splatter'
|
||||
? (G.loose ? 'RUN YOUR BALL DOWN'
|
||||
: G.fetchT > 0 ? 'NEW BALLS PLEASE...'
|
||||
: G.weapon === 4 ? (touch ? 'TAP TO UNLOAD THE GUN' : 'SPACE UNLOADS THE GUN')
|
||||
: G.aim ? 'PULL BACK TO AIM · RELEASE TO FIRE'
|
||||
? (G.weapon === 4 ? (touch ? 'TAP TO UNLOAD THE GUN' : 'SPACE UNLOADS THE GUN')
|
||||
: G.aim ? 'PULL BACK: FURTHER = HARDER'
|
||||
: G.weapon > 0 ? `SERVE FIRES: ${sel} · ${switchHint}`
|
||||
: touch ? 'TAP TO SERVE · PULL BACK TO AIM' : 'SPACE SERVES · ARROWS AIM')
|
||||
: G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE')
|
||||
|
||||
@@ -45,8 +45,6 @@ export const G = {
|
||||
tinDue: false, // the rules called for new balls — deliver at the next changeover
|
||||
pendingGame: false, // a game just ended — flash 'GAME N' when the next one arms
|
||||
aim: null, // live two-stage serve aim {fx, fy, mag} while the thumb is down (splatter pretoss)
|
||||
loose: false, // splatter: {t} while your ball lies on the court — no serving until it's in your hand again
|
||||
fetchT: 0, // s until a ball is handed back to you (boy delivery, or fresh balls after one got eaten)
|
||||
meleeT: 0, // s of racket-swish cooldown left
|
||||
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
|
||||
|
||||
+12
-14
@@ -6,7 +6,7 @@
|
||||
import { TUNE, COURT } from './config.js';
|
||||
import { G, P, ball, sendBoy, setupPoint } from './state.js';
|
||||
import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx } from './audio.js';
|
||||
import { splatPoint, cheer, spentBall, resolveInterlude, giveBall } from './gameplay.js';
|
||||
import { splatPoint, cheer, spentBall, resolveInterlude } from './gameplay.js';
|
||||
|
||||
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
|
||||
export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer
|
||||
@@ -80,20 +80,18 @@ function woundBurst(zb) {
|
||||
t: 0.35 + Math.random() * 0.3, dark: Math.random() < 0.6 });
|
||||
}
|
||||
|
||||
// swishing the racket takes out the NEAREST shambler in reach — one swing, one body. It used to
|
||||
// cleave the whole cluster, which made standing still and tapping better than playing tennis.
|
||||
// (the racket-zombie boss shrugs melee off entirely)
|
||||
// swishing the racket cleaves every shambler in reach — it's the panic button for when they're
|
||||
// already on top of you, so it stays a full sweep. TUNE.meleeCool is the brake that stops it
|
||||
// from being the default attack. (the racket-zombie boss shrugs melee off entirely)
|
||||
export function meleeSwipe(x, z, range) {
|
||||
let best = -1, bd = range;
|
||||
for (let i = 0; i < zombies.length; i++) {
|
||||
let hit = 0;
|
||||
for (let i = zombies.length - 1; i >= 0; i--) {
|
||||
const zb = zombies[i];
|
||||
if (zb.variant === 'r') continue;
|
||||
const d = Math.hypot(zb.x - x, zb.z - z);
|
||||
if (d < bd) { bd = d; best = i; }
|
||||
if (!zb || zb.variant === 'r') continue; // !zb: killZombie can sweep the court mid-loop
|
||||
if (Math.hypot(zb.x - x, zb.z - z) < range) { killZombie(i, !!G.horde); hit++; }
|
||||
}
|
||||
if (best < 0) return 0;
|
||||
killZombie(best, !!G.horde); swish(0.35);
|
||||
return 1;
|
||||
if (hit) swish(0.35);
|
||||
return hit;
|
||||
}
|
||||
|
||||
function splatter(i, fanfare = true) { // the MAIN ball hit a zombie
|
||||
@@ -123,7 +121,7 @@ export function spawnRacketZombie() { // one takes up residence on the far court
|
||||
function endHorde() {
|
||||
G.horde = null; pellets.length = 0;
|
||||
if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // survive the wave → a racket zombie stays
|
||||
giveBall(); setupPoint(); // the wave is over: a ball in hand, and any zombies still standing are yours to mop up
|
||||
setupPoint(); // serve loop resumes; any zombies still standing are yours to mop up
|
||||
voice('quiet_dark', 0.5, 0.85);
|
||||
}
|
||||
function tickHorde(dt) {
|
||||
@@ -184,7 +182,7 @@ export function updateZombies(dt) {
|
||||
else if (G.mgT > 0 && G.phase === 'rally') { // machine-gun tin: horde firepower, regular rules
|
||||
G.mgT -= dt;
|
||||
if ((G.mgCool -= dt) <= 0) { firePellet(); G.mgCool = 1 / TUNE.mgRate; }
|
||||
if (G.mgT <= 0) { giveBall(); setupPoint(); } // ammo dry — the serve loop resumes, ball in hand
|
||||
if (G.mgT <= 0) setupPoint(); // ammo dry — the serve loop resumes
|
||||
}
|
||||
if (!G.horde && !G.changeover && !G.interlude && !G.clearing) { // breathers, duels AND the mop-up after a won game get a quiet court (no new spawns)
|
||||
spawnT -= dt;
|
||||
|
||||
Reference in new issue
Block a user