Splatter feel pass: baseline turret, tennis duel, rapid serves, bleeding wounds

- Stand at the baseline: in splatter the player is pinned to the baseline
  (a turret, side-to-side only) — the duel is exempt so it keeps free depth.
- Zombie 1:1 duel now plays like real tennis: no free-aim flick, no drag
  reticle; lane aim + the toss-then-hit serve (gate on !G.interlude in
  doHit/hitServe/keyPressed/canAimServe).
- Rapid serves: the two-stage reticle serve now fires mid-rally too — a ball
  still in flight is banished to a ballistic clone so the next serve launches
  at once. Serve as fast as you can drag-release.
- Wounded skins for multi-hit foes: brute (arm off) and racket boss (arm off,
  then headless) swap by remaining hp, with a graceful fallback to the healthy
  sprite. The headless boss is drawn a head shorter so the shoulders line up.
- Dynamic gore: the blood is NOT painted in — open wounds gush live particles
  every frame (arm stumps + a neck geyser on the headless boss), absurdly, and
  bounded so it self-regulates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 4.8 committed 2026-07-17 20:39:07 +02:00
1 parent 7936d83f0f
commit df4eaf6e39
10 files changed
+53 -21

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 885 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+19 -15
View File
@@ -66,7 +66,7 @@ function doHit(ch, quality, aim) {
const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality);
let tx, tz;
let s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed;
const ft = G.mode !== 'tennis' && ch.side === 'P'
const ft = G.mode !== 'tennis' && !G.interlude && ch.side === 'P' // the duel aims like tennis: lanes, not free-aim
? (aim && aim.fx !== undefined ? flickTarget(ch, aim) : { zt: zombieTarget(aim) }) : null;
if (ft && ft.zt) {
const zt = ft.zt;
@@ -124,7 +124,7 @@ function hitServe(sv, quality, aim = null) {
const toFar = sv.side === 'P';
const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed;
let boxX, tzs;
const ft = G.mode !== 'tennis'
const ft = G.mode !== 'tennis' && !G.interlude // the duel serves like tennis: to the box, not free-aim
? (aim && aim.fx !== undefined ? flickTarget(sv, aim) : { zt: zombieTarget(aim) }) : null;
if (ft && ft.zt) { // magnet hit, or (tap) the most advanced zombie
const zt = ft.zt;
@@ -547,9 +547,12 @@ function tryGunBurst() {
// release of the two-stage aim: an instant serve to the reticle (quality fixed — placement
// is this serve's power; the toss ritual keeps the PERFECT-timing power ceiling)
export function dragServe(aim) {
if (G.mode !== 'splatter' || G.phase !== 'pretoss' || score.server !== 'P') return;
if (G.horde || G.mgT > 0 || G.changeover || (G.interlude && G.interlude.introT > 0)) 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.phase !== 'pretoss' && G.phase !== 'rally') return; // rally too: fire again while a ball's still up
if (tryGunBurst()) return;
if (ball.active) // rapid fire: the ball in flight keeps going as a ballistic clone so the next serve can launch now
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.x = P.x + 0.35; ball.z = P.z; ball.y = 2.2; ball.g = TUNE.gravity;
hitServe(P, 0.62, aim);
@@ -564,18 +567,16 @@ export function keyPressed(code) {
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
if (G.mode === 'splatter' && G.phase === 'over') { ball.active = false; setupPoint(); }
// splatter rapid serve: a flick fires an aimed quick serve — in pretoss OR mid-rally (dragServe
// banishes any live ball so the next launches now). The duel is exempt: it serves like tennis.
if (G.mode === 'splatter' && !G.interlude && score.server === 'P'
&& (G.phase === 'pretoss' || G.phase === 'rally')) {
const flick = takeFlickAim();
if (flick) { dragServe(flick); return; } // dragServe handles GUN (burst) and the live-ball banish
}
if (G.phase === 'pretoss' && score.server === 'P') {
if (tryGunBurst()) return; // GUN selected: the serve gesture pops a burst, no ball
if (G.mode === 'splatter') {
const flick = takeFlickAim();
if (flick) { // flick = a quick aimed serve; a plain tap still does the full toss ritual
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, flick);
return;
}
}
tossBall(P); return;
tossBall(P); return; // plain tap: the two-stage toss ritual (tennis, and the duel)
}
if (G.phase === 'toss' && score.server === 'P') {
// serve timing: distance from apex = quality — a real serve, not a free tap
@@ -585,7 +586,7 @@ export function keyPressed(code) {
if (dy < 0.12) q = 1;
G.wasServe = true;
// a flick at the apex (or held arrows) makes this a DELIBERATE serve: aimed + consumes a special
const flick = G.mode === 'splatter' ? takeFlickAim() : null;
const flick = G.mode === 'splatter' && !G.interlude ? takeFlickAim() : null;
const kb = { ax: axisX(), az: axisZ() };
hitServe(P, q, flick || (kb.ax || kb.az ? kb : null));
return;
@@ -658,6 +659,9 @@ export function update(dt) {
if (mx) P.facing = mx > 0 ? 1 : -1;
else if (Math.abs(assist) > 0.25) P.facing = assist > 0 ? 1 : -1; // hysteresis: small corrections don't flip facing
}
// splatter: you hold the baseline (a turret) — only side-to-side, no drifting up-court.
// The duel is exempt: it's tennis again, so depth is yours to chase the rally.
if (G.mode === 'splatter' && !G.interlude) P.z = HOME.P.z;
if (P.swingT >= 0) {
P.swingT += dt;
if (G.mode === 'tennis' && ball.active && ball.lastHit === 'O') { // sample the approach for the miss log (t = what trySwing tests this frame)
+3 -2
View File
@@ -42,8 +42,9 @@ 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.
const canAimServe = () =>
G.mode === 'splatter' && G.phase === 'pretoss' && score.server === 'P' && G.weapon !== 4 // GUN pops on tap, no reticle
&& !G.horde && !(G.mgT > 0) && !G.changeover && !(G.interlude && G.interlude.introT > 0);
G.mode === 'splatter' && (G.phase === 'pretoss' || G.phase === 'rally') // rally too: rapid serves mid-flight
&& 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
let steerPX = null; // live absolute finger x (-1..1 across the canvas) — machine-gun steering reads it
export const touchPX = () => steerPX;
+12 -4
View File
@@ -67,7 +67,8 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','
'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2',
'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4','x_crowd','x_ads',
'z_a1','z_a2','z_a3','z_a4','z_b1','z_b2','z_b3','z_b4','z_c1','z_c2',
'z_f1','z_f2','z_f3','z_f4','z_g1','z_g2','z_s1','z_s2','z_s3',
'z_f1','z_f2','z_f3','z_f4','z_g1','z_g2','z_gw','z_s1','z_s2','z_s3',
'z_cw1','z_cw2', // wounded skins for multi-hit enemies (brute, racket boss)
'w_ball','w_bomb','w_triple','w_fire','w_gun', // weapon-rack icons
'scoreboard']) {
const img = new Image();
@@ -146,8 +147,15 @@ function drawZombie(zb) {
// same CYCLE frequency as before — more frames subdivide the stride, they don't speed it up
let frame = rk && zb.pose === 'swing' ? 2 : Math.floor(zb.walkT * (zb.variant === 'f' ? 6 : 2.6) * N / 2) % N + 1;
if (zb.variant === 's' && zb.shieldT > 0) frame = 3; // chair dropped — hit it NOW
const h = zb.variant === 'g' ? 2.7 : rk ? 1.85 : 1.8; // the brute towers half again over the crowd
drawStatic('z_' + v + frame, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2);
let h = zb.variant === 'g' ? 2.7 : rk ? 1.85 : 1.8; // the brute towers half again over the crowd
// 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.
let name = 'z_' + v + frame;
if (zb.variant === 'g' && zb.hp < TUNE.bruteHp && SPR.z_gw) name = 'z_gw';
else if (rk && zb.hp <= 1 && SPR.z_cw2) { name = 'z_cw2'; h = 1.52; } // headless: a head shorter, so the shoulders land where a whole zombie's do
else if (rk && zb.hp === 2 && SPR.z_cw1) name = 'z_cw1';
drawStatic(name, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2);
if (rk) { // HP bar floating above the mini-boss
const p = proj(zb.x, 2.05, zb.z), w = Math.max(10, p.s * 0.5), x = p.x - w / 2;
ctx.fillStyle = '#300'; ctx.fillRect(x, p.y, w, 3);
@@ -377,7 +385,7 @@ export function render() {
}
if (G.outMarker) drawRing(G.outMarker.x, G.outMarker.z, G.outMarker.out ? '#ff5050' : '#f8e048');
// two-stage serve aim: the reticle tracks the thumb; red = the magnet has a zombie locked
if (G.aim && G.mode === 'splatter' && G.phase === 'pretoss') {
if (G.aim && G.mode === 'splatter' && !G.interlude && (G.phase === 'pretoss' || G.phase === 'rally')) {
const ft = flickTarget(P, G.aim);
if (ft.zt) { // locked: the ring rides the zombie itself, plus a marker over its head
drawRing(ft.zt.x, ft.zt.z, '#ff5050');
+19
View File
@@ -155,6 +155,23 @@ export function explode(x, z) {
if (G.idleT - G.lastOrderT > 3) { voice('order', 0.35); G.lastOrderT = G.idleT; } // the umpire loses composure
}
// wounded zombies gush blood — continuously and absurdly — from every open wound. Anchors are
// approximate body-height offsets (drawStatic never mirrors, so a fixed side stays put); the
// parts loop's gravity arcs each spurt into a spray. Bounded so the geyser self-regulates.
function bleed(zb) {
if (parts.length > 520) return; // absurd, but not unbounded
let wounds;
if (zb.variant === 'g' && zb.hp < TUNE.bruteHp) wounds = [[-0.3, 1.95, 3.2]]; // brute: torn-off arm
else if (zb.variant === 'r' && zb.hp <= 1) wounds = [[0.05, 1.5, 6.5], [0.4, 1.15, 2.6]]; // headless: neck geyser + torn arm
else if (zb.variant === 'r' && zb.hp === 2) wounds = [[0.4, 1.2, 3]]; // racket: torn-off arm
else return;
for (const w of wounds)
for (let k = 0; k < 4; k++) parts.push({
x: zb.x + w[0] + (Math.random() * 2 - 1) * 0.12, y: w[1] + (Math.random() * 2 - 1) * 0.1, z: zb.z + (Math.random() * 2 - 1) * 0.12,
vx: (Math.random() * 2 - 1) * 1.9, vy: w[2] * (0.5 + Math.random() * 0.7), vz: (Math.random() * 2 - 1) * 1.4,
t: 0.5 + Math.random() * 0.6, dark: Math.random() < 0.45 });
}
export function updateZombies(dt) {
moanT -= dt;
if (moanT <= 0) { moan(); moanT = 2.5 + Math.random() * 4; }
@@ -183,6 +200,8 @@ export function updateZombies(dt) {
if (!zb) continue; // a kill can cascade into sweepCourt (game won mid-loop) and gut the array under us
zb.walkT += dt;
zb.z += zb.v * dt;
bleed(zb); // open wounds bleed every frame they're standing
if (zb.shieldT > 0) zb.shieldT -= dt; // staggered chair comes back up when this runs out
if (zb.variant === 'r') { /* the racket boss holds station — its AI owns its x */ }
else if (G.mode === 'splatter' && zb.z > COURT.netZ) { // on YOUR half they close in — each on its own lane, not a column