Splatter: Angry-Birds slingshot serve — pull back to toss, dotted-line aim, release fires

The touch serve drag now inverts: pull toward yourself to launch up-court.
The first pull past the deadzone starts the real toss animation (serveToss),
dragServe fires straight from the toss phase without banishing its own fresh
toss, and a held aim keeps the toss airborne instead of dropping. Render shows
an Angry-Birds dotted trajectory racket->target (red = zombie locked). Two-stage
tap serve, mid-rally re-toss/banish, and keyboard serve+arrow-aim unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 4.8 committed 2026-07-17 22:56:22 +02:00
1 parent 6df0ddb00f
commit 013fe8152d
3 files changed
+31 -16

No files matched your search

+10 -4
View File
@@ -570,14 +570,20 @@ function tryGunBurst() {
return true; return true;
} }
// release of the two-stage aim: an instant serve to the reticle (quality fixed — placement // 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 (score.server === 'P' && G.phase === 'pretoss' && !ball.active) tossBall(P);
}
// release of the slingshot aim: an instant serve to the reticle (quality fixed — placement
// is this serve's power; the toss ritual keeps the PERFECT-timing power ceiling) // is this serve's power; the toss ritual keeps the PERFECT-timing power ceiling)
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') return; // rally too: fire again while a ball's still up if (G.phase !== 'pretoss' && G.phase !== 'rally' && G.phase !== 'toss') return; // toss = our own slingshot wind-up
if (tryGunBurst()) return; 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 if (ball.active && G.phase !== 'toss') // rapid fire: banish the live rally ball as a ballistic clone — but NOT our fresh toss
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 }); 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;
@@ -819,7 +825,7 @@ export function update(dt) {
((ball.z < -2 && ball.vz < 0) || (ball.z > 25.5 && ball.vz > 0) || Math.abs(ball.x) > 7.5)))) { ((ball.z < -2 && ball.vz < 0) || (ball.z > 25.5 && ball.vz > 0) || Math.abs(ball.x) > 7.5)))) {
spentBall(); spentBall();
} }
if (G.phase === 'toss' && ball.y <= 1.4 && ball.vy < 0) { // uncaught toss → retoss if (G.phase === 'toss' && ball.y <= 1.4 && ball.vy < 0 && !G.aim) { // uncaught toss → retoss (a held slingshot aim keeps the toss up)
ball.active = false; G.phase = 'pretoss'; ball.active = false; G.phase = 'pretoss';
if (score.server === 'O') G.aiServeT = 0.8; if (score.server === 'O') G.aiServeT = 0.8;
} }
+6 -3
View File
@@ -5,7 +5,7 @@
import { cv } from './dom.js'; import { cv } from './dom.js';
import { audio } from './audio.js'; import { audio } from './audio.js';
import { G, score } from './state.js'; import { G, score } from './state.js';
import { keyPressed, dragServe, selectWeapon } from './gameplay.js'; import { keyPressed, dragServe, selectWeapon, serveToss } from './gameplay.js';
import { rackHit } from './render.js'; import { rackHit } from './render.js';
import { togglePanel } from './panel.js'; import { togglePanel } from './panel.js';
@@ -75,8 +75,11 @@ cv.addEventListener('touchmove', e => {
if (t.identifier !== stick.id) continue; if (t.identifier !== stick.id) continue;
const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy); const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy);
if (dist > DEAD) stick.moved = true; if (dist > DEAD) stick.moved = true;
if (stick.aimable) { // aiming, not steering: the reticle tracks the thumb (home = auto-target) if (stick.aimable) { // slingshot: PULL BACK, launch forward — aim is the inverse of the drag (home = auto-target)
G.aim = dist > DEAD ? { fx: dx / dist, fy: dy / dist, mag: dist } : { fx: 0, fy: 0, mag: 0 }; 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 };
} else G.aim = { fx: 0, fy: 0, mag: 0 };
continue; continue;
} }
const r = cv.getBoundingClientRect(); const r = cv.getBoundingClientRect();
+15 -9
View File
@@ -396,14 +396,20 @@ 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');
// two-stage serve aim: the reticle tracks the thumb; red = the magnet has a zombie locked // slingshot serve aim: an angry-birds dotted trajectory from the racket to the aim point;
if (G.aim && G.mode === 'splatter' && !G.interlude && (G.phase === 'pretoss' || G.phase === 'rally')) { // 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')) {
const ft = flickTarget(P, G.aim); const ft = flickTarget(P, G.aim);
if (ft.zt) { // locked: the ring rides the zombie itself, plus a marker over its head const tx = ft.zt ? ft.zt.x : ft.tx, tz = ft.zt ? ft.zt.z : ft.tz;
drawRing(ft.zt.x, ft.zt.z, '#ff5050'); if (tx !== undefined) {
const hp = proj(ft.zt.x, 2.2, ft.zt.z); const col = ft.zt ? '#ff5050' : '#f8e048';
ctx.fillStyle = '#ff5050'; ctx.fillRect(hp.x - 1, hp.y - 4, 3, 3); const a = proj(P.x, 1.4, P.z), b = proj(tx, 0, tz);
} else if (ft.tx !== undefined) drawRing(ft.tx, ft.tz, '#f8e048'); ctx.strokeStyle = col; ctx.lineWidth = 2; ctx.setLineDash([3, 5]);
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
ctx.setLineDash([]);
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); }
}
} }
// far-side drawables in z order (far → near), then the net, then the near side // far-side drawables in z order (far → near), then the net, then the near side
@@ -524,9 +530,9 @@ export function render() {
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.weapon === 4 ? (touch ? 'TAP TO UNLOAD THE GUN' : 'SPACE UNLOADS THE GUN')
: G.aim ? 'RELEASE TO SERVE · DRAG BACK TO CANCEL' : 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 · HOLD + DRAG TO AIM' : 'SPACE SERVES · ARROWS AIM') : touch ? 'TAP TO SERVE · PULL BACK TO AIM' : 'SPACE SERVES · ARROWS AIM')
: G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE') : G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE')
: (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE'); : (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE');
ctx.fillText(txt, W / 2, H - TUNE.rackH - RACK_BOT - 17); // sit clear above the rack ctx.fillText(txt, W / 2, H - TUNE.rackH - RACK_BOT - 17); // sit clear above the rack