Splatter: ball economy — one ball, fetch it back, no more toss-spam
The slingshot serve didn't fix the gameplay because four things were free at once: pure-hold auto-target with predictive lead, a hardcoded 0.62 serve quality, infinite ammo with an instant mid-rally re-toss, and a melee swish on every single tap that cleaved the whole cluster. Perfecting the pretoss/toss rhythm beat the game; no skill required. The ball is scarce now. A spent ball LIES where it died (G.loose, ringed on court) and there is no serve until it's back in your hand: walk it down (instant, and the only reason to leave the baseline), let a boy scoop it, wait out TUNE.looseMax, or watch a shambler swallow it — the one real punishment. Fire rate is a positioning decision instead of a thumb-tempo one. Also: the far backstop rebounds a ball with pace left, still lethal on the way home and volleyable (trySwing lets P re-hit their own ball in splatter); melee drops to nearest-one-only on a 1s cooldown; and the mid-rally re-toss is gone. With no ball in hand the thumb-drag steers your legs instead of the reticle — without that, walking your ball down is impossible on touch. TUNE.looseMax is the fire-rate knob. Measured: ~4s per serve standing still, ~0.4s if you go and get it. 600 taps in 10s now yield exactly one serve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
013fe8152d
commit
2787778ad2
6 files changed
+95
-35
No files matched your search
@@ -31,6 +31,15 @@ export const TUNE = {
|
|||||||
zSplatQ: 0.5, // min shot quality to splatter; below = stagger pushback
|
zSplatQ: 0.5, // min shot quality to splatter; below = stagger pushback
|
||||||
zPushback: 1.4, // m a staggered zombie is knocked back
|
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
|
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
|
||||||
|
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
|
||||||
|
meleeCool: 1.0, // s between racket swishes — melee is the panic button, not the default
|
||||||
zPierce: 0.75, // pace a ball keeps after a splatter — hot balls plow through lined-up pairs
|
zPierce: 0.75, // pace a ball keeps after a splatter — hot balls plow through lined-up pairs
|
||||||
ballChangeFirst: 3, // game of the first new-balls delivery (real rule: 7, then every 9 — too sparse here)
|
ballChangeFirst: 3, // game of the first new-balls delivery (real rule: 7, then every 9 — too sparse here)
|
||||||
ballChangeEvery: 4, // games between deliveries after the first
|
ballChangeEvery: 4, // games between deliveries after the first
|
||||||
|
|||||||
+60
-21
@@ -282,7 +282,7 @@ export function proceedAfterClear() {
|
|||||||
function startChangeover() {
|
function startChangeover() {
|
||||||
G.changeover = true;
|
G.changeover = true;
|
||||||
G.phase = 'over'; G.overT = TUNE.changeoverT;
|
G.phase = 'over'; G.overT = TUNE.changeoverT;
|
||||||
sendBoy();
|
sendBoy(); giveBall(); // the breather always leaves you holding a ball
|
||||||
if (G.tinDue) { // the boys arm you — unless they took a game since the last delivery
|
if (G.tinDue) { // the boys arm you — unless they took a game since the last delivery
|
||||||
G.tinDue = false;
|
G.tinDue = false;
|
||||||
if (G.shameGame) G.flash = { text: 'NO NEW BALLS', t: 1.6 }; // the umpire withholds
|
if (G.shameGame) G.flash = { text: 'NO NEW BALLS', t: 1.6 }; // the umpire withholds
|
||||||
@@ -340,7 +340,19 @@ export function spentBall() {
|
|||||||
ball.active = false;
|
ball.active = false;
|
||||||
G.phase = 'over';
|
G.phase = 'over';
|
||||||
if (!G.changeover) G.overT = TUNE.splatterOverT;
|
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
|
// the swarm keeps coming: no serving, the racket auto-fires, the thumb drag steers you
|
||||||
function startHorde() {
|
function startHorde() {
|
||||||
@@ -510,7 +522,10 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
|
|||||||
const t = ch.swingT;
|
const t = ch.swingT;
|
||||||
if (t < TUNE.swingWindup || t > TUNE.swingWindup + win) return;
|
if (t < TUNE.swingWindup || t > TUNE.swingWindup + win) return;
|
||||||
if (!ball.active || G.phase !== 'rally') return;
|
if (!ball.active || G.phase !== 'rally') return;
|
||||||
if (ball.lastHit === ch.side) return;
|
// splatter: your OWN ball is fair game. It rebounds off the backstop and comes back at you —
|
||||||
|
// volleying it is how you keep firing without walking it down. (The duel is tennis: one touch.)
|
||||||
|
const own = G.mode === 'splatter' && !G.interlude && ch.side === 'P';
|
||||||
|
if (ball.lastHit === ch.side && !own) return;
|
||||||
const dx = ball.x - ch.x, dz = ball.z - ch.z;
|
const dx = ball.x - ch.x, dz = ball.z - ch.z;
|
||||||
if (Math.hypot(dx, dz * 0.8) > reach || ball.y > CONTACT.maxY) return;
|
if (Math.hypot(dx, dz * 0.8) > reach || ball.y > CONTACT.maxY) return;
|
||||||
if (ball.y < CONTACT.minY && ball.vy <= 0) return; // a DROPPING ball below the ankles is past you — but a rising one is a half-volley, always scoopable
|
if (ball.y < CONTACT.minY && ball.vy <= 0) return; // a DROPPING ball below the ankles is past you — but a rising one is a half-volley, always scoopable
|
||||||
@@ -573,6 +588,7 @@ function tryGunBurst() {
|
|||||||
// slingshot serve, stage one: the pull-back starts the toss animation. The ball rises while
|
// 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.
|
// you aim (dotted line, render.js); the release (dragServe) fires it. Only from a fresh pretoss.
|
||||||
export function serveToss() {
|
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);
|
if (score.server === 'P' && G.phase === 'pretoss' && !ball.active) tossBall(P);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,10 +597,9 @@ export function serveToss() {
|
|||||||
export function dragServe(aim) {
|
export function dragServe(aim) {
|
||||||
if (G.mode !== 'splatter' || score.server !== 'P') return;
|
if (G.mode !== 'splatter' || score.server !== 'P') return;
|
||||||
if (G.interlude || G.horde || G.mgT > 0 || G.changeover) return; // the duel serves like tennis, not the reticle
|
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 !== 'rally' && G.phase !== 'toss') return; // toss = our own slingshot wind-up
|
if (G.phase !== 'pretoss' && G.phase !== 'toss') return; // one ball: no re-serving over a live rally
|
||||||
if (tryGunBurst()) return;
|
if (tryGunBurst()) return;
|
||||||
if (ball.active && G.phase !== 'toss') // rapid fire: banish the live rally ball as a ballistic clone — but NOT our fresh toss
|
if (!armed()) return;
|
||||||
extras.push({ x: ball.x, y: ball.y, z: ball.z, vx: ball.vx, vy: ball.vy, vz: ball.vz, pow: ball.pow || 0.5, t: 2.5 });
|
|
||||||
ball.active = true; ball.collected = false; ball.bounces = 0; ball.firstLand = null;
|
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;
|
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, 0.62, aim);
|
||||||
@@ -593,28 +608,24 @@ export function dragServe(aim) {
|
|||||||
export function keyPressed(code) {
|
export function keyPressed(code) {
|
||||||
if (code !== 'Space') return;
|
if (code !== 'Space') return;
|
||||||
audio(); // unlock on first gesture
|
audio(); // unlock on first gesture
|
||||||
if (G.mode === 'splatter') meleeSwipe(P.x, P.z, TUNE.meleeRange); // a swish cleaves adjacent shamblers
|
// a swish cleaves adjacent shamblers — but it's on a cooldown now: the panic button, not the
|
||||||
|
// default attack. A whiff costs nothing (nothing was in range), a connect locks it for meleeCool.
|
||||||
|
if (G.mode === 'splatter' && G.meleeT <= 0 && meleeSwipe(P.x, P.z, TUNE.meleeRange)) G.meleeT = TUNE.meleeCool;
|
||||||
if (G.horde || G.mgT > 0) return; // auto-fire modes: a tap only swishes, never serves
|
if (G.horde || G.mgT > 0) return; // auto-fire modes: a tap only swishes, never serves
|
||||||
if (G.changeover) return; // the breather is mandatory — the melee swish above still works
|
if (G.changeover) return; // the breather is mandatory — the melee swish above still works
|
||||||
if (G.interlude && G.interlude.introT > 0) return; // let the umpire finish announcing the duel
|
if (G.interlude && G.interlude.introT > 0) return; // let the umpire finish announcing the duel
|
||||||
// splatter: a tap/flick during the brief 'over' beat re-arms the next serve immediately
|
// splatter: a tap/flick during the brief 'over' beat re-arms the next serve immediately
|
||||||
if (G.mode === 'splatter' && G.phase === 'over') { ball.active = false; setupPoint(); }
|
if (G.mode === 'splatter' && G.phase === 'over') { ball.active = false; setupPoint(); }
|
||||||
// splatter serve stays a TWO-STAGE ritual — toss up, THEN fire — but the toss can start any
|
// splatter serve stays a TWO-STAGE ritual — toss up, THEN fire. It can only start from
|
||||||
// time, even with a ball still in flight: the old ball flies on as a ballistic clone so the
|
// pretoss WITH a ball in hand: one ball, and it has to come back before you fire it again.
|
||||||
// toss+fire pair chains without waiting for it to leave. A flick is the aimed one-motion
|
// A flick is the aimed one-motion variant (the reticle). The duel is exempt: it serves like tennis.
|
||||||
// variant (the reticle). The duel is exempt: it serves like tennis.
|
if (G.mode === 'splatter' && !G.interlude && score.server === 'P' && G.phase === 'pretoss') {
|
||||||
if (G.mode === 'splatter' && !G.interlude && score.server === 'P'
|
|
||||||
&& (G.phase === 'pretoss' || G.phase === 'rally')) {
|
|
||||||
const flick = takeFlickAim();
|
const flick = takeFlickAim();
|
||||||
if (flick) { dragServe(flick); return; } // dragServe handles GUN (burst) and the live-ball banish
|
if (flick) { dragServe(flick); return; } // dragServe handles GUN (burst) and the ball check
|
||||||
if (G.phase === 'rally') { // a tap mid-rally re-arms the TOSS (stage one); the next tap fires
|
|
||||||
if (tryGunBurst()) return;
|
|
||||||
if (ball.active) extras.push({ x: ball.x, y: ball.y, z: ball.z, vx: ball.vx, vy: ball.vy, vz: ball.vz, pow: ball.pow || 0.5, t: 2.5 });
|
|
||||||
tossBall(P); return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (G.phase === 'pretoss' && score.server === 'P') {
|
if (G.phase === 'pretoss' && score.server === 'P') {
|
||||||
if (tryGunBurst()) return; // GUN selected: the serve gesture pops a burst, no ball
|
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)
|
tossBall(P); return; // plain tap: the two-stage toss ritual (tennis, and the duel)
|
||||||
}
|
}
|
||||||
if (G.phase === 'toss' && score.server === 'P') {
|
if (G.phase === 'toss' && score.server === 'P') {
|
||||||
@@ -652,11 +663,13 @@ export function update(dt) {
|
|||||||
crowd.cheer = Math.max(0, crowd.cheer - dt);
|
crowd.cheer = Math.max(0, crowd.cheer - dt);
|
||||||
G.idleT += dt;
|
G.idleT += dt;
|
||||||
const w = BOUNCE_IDLE.tempo;
|
const w = BOUNCE_IDLE.tempo;
|
||||||
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 === '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 === 'toss') { const sv = score.server === 'P' ? P : O; sv.pose = 'serve'; sv.poseT = 0; }
|
if (G.phase === 'toss') { const sv = score.server === 'P' ? P : O; sv.pose = 'serve'; sv.poseT = 0; }
|
||||||
|
|
||||||
// --- player movement (near half only, generous margins)
|
// --- player movement (near half only, generous margins)
|
||||||
if (G.phase === 'rally') {
|
// 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.mode === 'tennis' && ball.active && ball.lastHit === 'O') { // closest approach, for never-swung passes
|
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);
|
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 };
|
if (!G.rallyNear || d < G.rallyNear.d) G.rallyNear = { d, y: ball.y, bz: ball.z, px: P.x, pz: P.z };
|
||||||
@@ -679,7 +692,9 @@ export function update(dt) {
|
|||||||
P.z += mz * TUNE.playerSpeed * slow * dt;
|
P.z += mz * TUNE.playerSpeed * slow * dt;
|
||||||
let assist = 0;
|
let assist = 0;
|
||||||
if (!mx && !mz && TUNE.autoAssist > 0 && ball.active && !G.horde && !(G.mgT > 0)) { // auto-fire: you aim by hand, no assist
|
if (!mx && !mz && TUNE.autoAssist > 0 && ball.active && !G.horde && !(G.mgT > 0)) { // auto-fire: you aim by hand, no assist
|
||||||
const chasing = ball.lastHit === 'O';
|
// splatter: anything travelling back toward you is yours to run down — your own rebound
|
||||||
|
// included. The assist puts you there; the volley timing stays honest.
|
||||||
|
const chasing = ball.lastHit === 'O' || (G.mode === 'splatter' && !G.interlude && ball.vz > 0);
|
||||||
// no `slow` here: tapping must not freeze your legs, or anticipating a shot loses it
|
// no `slow` here: tapping must not freeze your legs, or anticipating a shot loses it
|
||||||
const rate = TUNE.playerSpeed * TUNE.autoAssist * dt * (chasing ? 1 : 0.55);
|
const rate = TUNE.playerSpeed * TUNE.autoAssist * dt * (chasing ? 1 : 0.55);
|
||||||
const tgt = chasing ? intercept('P') : HOME.P; // run to the ball, then recover home
|
const tgt = chasing ? intercept('P') : HOME.P; // run to the ball, then recover home
|
||||||
@@ -718,6 +733,20 @@ export function update(dt) {
|
|||||||
|
|
||||||
// --- zombies + transition timeline
|
// --- zombies + transition timeline
|
||||||
if (G.mode !== 'tennis') updateZombies(dt);
|
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.clearing && !zombies.some(z => z.variant !== 'r')) proceedAfterClear(); // the won-game wave is mopped up — advance
|
||||||
if (G.mode === 'transition') transition(dt);
|
if (G.mode === 'transition') transition(dt);
|
||||||
|
|
||||||
@@ -727,6 +756,7 @@ export function update(dt) {
|
|||||||
&& G.phase === 'pretoss' && zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled) {
|
&& 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
|
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
|
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
|
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
|
zombieDuck(true); // fade the zombie music out for the 1:1 duel
|
||||||
voice('play_resume', 0.2); voice('sudden_death', 2.4);
|
voice('play_resume', 0.2); voice('sudden_death', 2.4);
|
||||||
@@ -814,6 +844,15 @@ export function update(dt) {
|
|||||||
if (G.interlude) resolveInterlude('O'); // sudden death: its passing shot ends the duel
|
if (G.interlude) resolveInterlude('O'); // sudden death: its passing shot ends the duel
|
||||||
else { if (!G.horde && !G.clearing) splatPoint('O'); spentBall(); } // clearing = mop-up, no scoring either way
|
else { if (!G.horde && !G.clearing) splatPoint('O'); spentBall(); } // clearing = mop-up, no scoring either way
|
||||||
}
|
}
|
||||||
|
// the far backstop: a LIVE ball doesn't leave the field, it rebounds off the fence and comes
|
||||||
|
// back down-court — still lethal on the way home, and yours to volley. Spent balls just die.
|
||||||
|
if (G.mode === 'splatter' && !G.interlude && ball.active && ball.lastHit === 'P'
|
||||||
|
&& ball.z < TUNE.backstopZ && ball.vz < -3) { // only a ball with real pace left comes back; a roller just stops
|
||||||
|
ball.z = TUNE.backstopZ;
|
||||||
|
ball.vz *= -TUNE.backstopKeep; ball.vx *= 0.7;
|
||||||
|
ball.vy = Math.max(ball.vy, TUNE.backstopPop); // the fence kicks it up, so it carries back through the crowd
|
||||||
|
pock(0.5, 200); // chain-link thud
|
||||||
|
}
|
||||||
// splatter: retire spent or hopeless balls fast so the next serve comes right up.
|
// splatter: retire spent or hopeless balls fast so the next serve comes right up.
|
||||||
// z-bounds gate on travel direction — a serve born behind the baseline (P.z up to 26)
|
// z-bounds gate on travel direction — a serve born behind the baseline (P.z up to 26)
|
||||||
// heads to the far court (vz<0), so it must not read as "out the back" on frame one.
|
// heads to the far court (vz<0), so it must not read as "out the back" on frame one.
|
||||||
|
|||||||
@@ -41,8 +41,10 @@ export function takeFlickAim(maxAgeMs = 450) {
|
|||||||
|
|
||||||
// the two-stage serve: a drag that STARTS at a serveable pretoss aims a visible reticle
|
// 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.
|
// (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 = () =>
|
const canAimServe = () =>
|
||||||
G.mode === 'splatter' && (G.phase === 'pretoss' || G.phase === 'rally') // rally too: rapid serves mid-flight
|
G.mode === 'splatter' && G.phase === 'pretoss' && !G.loose && !(G.fetchT > 0)
|
||||||
&& score.server === 'P' && G.weapon !== 4 // GUN pops on tap, no reticle
|
&& 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
|
&& !G.interlude && !G.horde && !(G.mgT > 0) && !G.changeover; // the duel serves like tennis: no reticle
|
||||||
|
|
||||||
|
|||||||
@@ -396,6 +396,7 @@ export function render() {
|
|||||||
if (l) drawRing(l.x, l.z, 'rgba(255,255,255,0.85)');
|
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.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;
|
// 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
|
// red = the magnet has a zombie locked, yellow = a free spot on the court
|
||||||
if (G.aim && G.mode === 'splatter' && !G.interlude && (G.phase === 'pretoss' || G.phase === 'toss' || G.phase === 'rally')) {
|
if (G.aim && G.mode === 'splatter' && !G.interlude && (G.phase === 'pretoss' || G.phase === 'toss' || G.phase === 'rally')) {
|
||||||
@@ -434,14 +435,14 @@ export function render() {
|
|||||||
zombies.filter(zb => zb.z > COURT.netZ).sort((a, b) => a.z - b.z).forEach(drawZombie);
|
zombies.filter(zb => zb.z > COURT.netZ).sort((a, b) => a.z - b.z).forEach(drawZombie);
|
||||||
|
|
||||||
// server idly bouncing the ball while waiting
|
// server idly bouncing the ball while waiting
|
||||||
if (G.phase === 'pretoss') {
|
if (G.phase === 'pretoss' && !(G.mode === 'splatter' && (G.loose || G.fetchT > 0))) { // empty-handed = no ball to bounce
|
||||||
const sv = score.server === 'P' ? P : O;
|
const sv = score.server === 'P' ? P : O;
|
||||||
const bx = sv.x - BOUNCE_IDLE.dx * (sv.side === 'P' ? 1 : -1);
|
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;
|
const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * BOUNCE_IDLE.apex;
|
||||||
drawBall(bx, by, sv.z);
|
drawBall(bx, by, sv.z);
|
||||||
}
|
}
|
||||||
// the match ball (and any triple-serve side balls in flight)
|
// the match ball (and any triple-serve side balls in flight)
|
||||||
if ((ball.active || G.phase === 'over') && !ball.collected) drawBall(ball.x, ball.y, ball.z, true, ball.bomb, ball.flame);
|
if ((ball.active || G.phase === 'over' || G.loose) && !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);
|
for (const e of extras) drawBall(e.x, e.y, e.z);
|
||||||
// splatter particles (fire embers ride the same array)
|
// splatter particles (fire embers ride the same array)
|
||||||
for (const pt of parts) {
|
for (const pt of parts) {
|
||||||
@@ -529,7 +530,9 @@ export function render() {
|
|||||||
const touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-5 TO SWITCH';
|
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 sel = ['PLAIN', 'BOMB', 'TRIPLE', 'FIRE', 'GUN'][G.weapon];
|
||||||
const txt = G.mode === 'splatter'
|
const txt = G.mode === 'splatter'
|
||||||
? (G.weapon === 4 ? (touch ? 'TAP TO UNLOAD THE GUN' : 'SPACE UNLOADS THE GUN')
|
? (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.aim ? 'PULL BACK TO AIM · RELEASE TO FIRE'
|
||||||
: G.weapon > 0 ? `SERVE FIRES: ${sel} · ${switchHint}`
|
: G.weapon > 0 ? `SERVE FIRES: ${sel} · ${switchHint}`
|
||||||
: touch ? 'TAP TO SERVE · PULL BACK TO AIM' : 'SPACE SERVES · ARROWS AIM')
|
: touch ? 'TAP TO SERVE · PULL BACK TO AIM' : 'SPACE SERVES · ARROWS AIM')
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ export const G = {
|
|||||||
tinDue: false, // the rules called for new balls — deliver at the next changeover
|
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
|
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)
|
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
|
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
|
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
|
horde: null, // active horde wave {t, spawnT, mgT} — machine-gun racket, no serving
|
||||||
|
|||||||
+14
-10
@@ -6,7 +6,7 @@
|
|||||||
import { TUNE, COURT } from './config.js';
|
import { TUNE, COURT } from './config.js';
|
||||||
import { G, P, ball, sendBoy, setupPoint } from './state.js';
|
import { G, P, ball, sendBoy, setupPoint } from './state.js';
|
||||||
import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx } from './audio.js';
|
import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx } from './audio.js';
|
||||||
import { splatPoint, cheer, spentBall, resolveInterlude } from './gameplay.js';
|
import { splatPoint, cheer, spentBall, resolveInterlude, giveBall } from './gameplay.js';
|
||||||
|
|
||||||
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
|
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
|
||||||
export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer
|
export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer
|
||||||
@@ -80,16 +80,20 @@ function woundBurst(zb) {
|
|||||||
t: 0.35 + Math.random() * 0.3, dark: Math.random() < 0.6 });
|
t: 0.35 + Math.random() * 0.3, dark: Math.random() < 0.6 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// swishing the racket cleaves any shambler in reach (the racket-zombie boss shrugs melee off)
|
// 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)
|
||||||
export function meleeSwipe(x, z, range) {
|
export function meleeSwipe(x, z, range) {
|
||||||
let hit = 0;
|
let best = -1, bd = range;
|
||||||
for (let i = zombies.length - 1; i >= 0; i--) {
|
for (let i = 0; i < zombies.length; i++) {
|
||||||
const zb = zombies[i];
|
const zb = zombies[i];
|
||||||
if (!zb || zb.variant === 'r') continue; // !zb: killZombie can sweep the court mid-loop
|
if (zb.variant === 'r') continue;
|
||||||
if (Math.hypot(zb.x - x, zb.z - z) < range) { killZombie(i, !!G.horde); hit++; }
|
const d = Math.hypot(zb.x - x, zb.z - z);
|
||||||
|
if (d < bd) { bd = d; best = i; }
|
||||||
}
|
}
|
||||||
if (hit) swish(0.35);
|
if (best < 0) return 0;
|
||||||
return hit;
|
killZombie(best, !!G.horde); swish(0.35);
|
||||||
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function splatter(i, fanfare = true) { // the MAIN ball hit a zombie
|
function splatter(i, fanfare = true) { // the MAIN ball hit a zombie
|
||||||
@@ -119,7 +123,7 @@ export function spawnRacketZombie() { // one takes up residence on the far court
|
|||||||
function endHorde() {
|
function endHorde() {
|
||||||
G.horde = null; pellets.length = 0;
|
G.horde = null; pellets.length = 0;
|
||||||
if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // survive the wave → a racket zombie stays
|
if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // survive the wave → a racket zombie stays
|
||||||
setupPoint(); // serve loop resumes; any zombies still standing are yours to mop up
|
giveBall(); setupPoint(); // the wave is over: a ball in hand, and any zombies still standing are yours to mop up
|
||||||
voice('quiet_dark', 0.5, 0.85);
|
voice('quiet_dark', 0.5, 0.85);
|
||||||
}
|
}
|
||||||
function tickHorde(dt) {
|
function tickHorde(dt) {
|
||||||
@@ -180,7 +184,7 @@ export function updateZombies(dt) {
|
|||||||
else if (G.mgT > 0 && G.phase === 'rally') { // machine-gun tin: horde firepower, regular rules
|
else if (G.mgT > 0 && G.phase === 'rally') { // machine-gun tin: horde firepower, regular rules
|
||||||
G.mgT -= dt;
|
G.mgT -= dt;
|
||||||
if ((G.mgCool -= dt) <= 0) { firePellet(); G.mgCool = 1 / TUNE.mgRate; }
|
if ((G.mgCool -= dt) <= 0) { firePellet(); G.mgCool = 1 / TUNE.mgRate; }
|
||||||
if (G.mgT <= 0) setupPoint(); // ammo dry — the serve loop resumes
|
if (G.mgT <= 0) { giveBall(); setupPoint(); } // ammo dry — the serve loop resumes, ball in hand
|
||||||
}
|
}
|
||||||
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)
|
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;
|
spawnT -= dt;
|
||||||
|
|||||||
Reference in new issue
Block a user