Ball boys sprint across the field; honest hit registration
- ball boy redesign: spawns at the sideline on point end, sprints across at the ball's depth with a proper side-view run animation, scoops the ball mid-run, despawns off the other side (no more net-post idling/sliding) - hit feel: ball snaps to the racket at the contact frame so hits visually connect; contact radius tightened 1.7 -> 1.3 (assist does the positioning, the tap must be honest); missed swings now play an audible swish Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9e6e686cff
commit
769dea8755
6 files changed
+44
-28
No files matched your search
@@ -10,7 +10,7 @@ const TUNE = {
|
||||
gravity: 9.2, // slightly floaty vs 9.8 for readability
|
||||
bounceY: 0.58, bounceXZ: 0.78,
|
||||
playerSpeed: 5.4, aiSpeed: 5.0,
|
||||
reach: 1.7, aiReach: 1.6,
|
||||
reach: 1.3, aiReach: 1.6, // tight contact radius: the assist positions you, the tap must be honest
|
||||
swingWindup: 0.06, // s before contact window opens
|
||||
swingWindow: 0.16, // s contact window length
|
||||
baseShotSpeed: 15, qualityShotSpeed: 9, // horizontal m/s = base + q*quality
|
||||
@@ -134,6 +134,18 @@ function noiseBuf(ac, len) {
|
||||
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
|
||||
return b;
|
||||
}
|
||||
function swish(vol = 0.3) { // racket cutting air — the sound of a whiff
|
||||
try {
|
||||
const ac = audio(), t = ac.currentTime;
|
||||
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, 0.16);
|
||||
const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.setValueAtTime(1600, t);
|
||||
bp.frequency.exponentialRampToValueAtTime(3200, t + 0.12); bp.Q.value = 1.5;
|
||||
const g = ac.createGain();
|
||||
g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + 0.05);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, t + 0.16);
|
||||
src.connect(bp).connect(g).connect(ac.destination); src.start();
|
||||
} catch (e) {}
|
||||
}
|
||||
function pock(vol = 0.8, freq = 900) { // racket contact / bounce thock
|
||||
try {
|
||||
const ac = audio(), t = ac.currentTime;
|
||||
@@ -180,7 +192,7 @@ function say(text, delay = 0) { // fallback when a recorded umpire line is missi
|
||||
const SPR = {};
|
||||
for (const n of ['p_idle1','p_idle2','p_bounce','p_toss','p_smash','p_run1','p_run2','p_swing1','p_swing2',
|
||||
'o_idle1','o_idle2','o_bounce','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2',
|
||||
'x_ump','x_boy_kneel','x_boy_stand']) {
|
||||
'x_ump','x_boyrun1','x_boyrun2']) {
|
||||
const img = new Image();
|
||||
img.onload = () => SPR[n] = img;
|
||||
img.src = 'assets/' + n + '.png';
|
||||
@@ -194,16 +206,11 @@ const score = { pts: [0, 0], games: [0, 0], server: 'P' }; // pts index 0=P 1=O
|
||||
const PTS = ['Love', 'Fifteen', 'Thirty', 'Forty'];
|
||||
let phase = 'pretoss'; // pretoss | toss | rally | over
|
||||
let serveNum = 1, overT = 0, hitstop = 0, flash = null, idleT = 0, outMarker = null; // flash = {text,x,y,t}
|
||||
const BOYS = [ // ball boys crouch beside the net posts, fetch dead balls, run back
|
||||
{ hx: -6.2, hz: COURT.netZ - 0.8, x: -6.2, z: COURT.netZ - 0.8, state: 'idle', tx: 0, tz: 0, runT: 0 },
|
||||
{ hx: 6.2, hz: COURT.netZ + 0.8, x: 6.2, z: COURT.netZ + 0.8, state: 'idle', tx: 0, tz: 0, runT: 0 },
|
||||
];
|
||||
let boy = null; // a ball boy sprints across the field, scoops the dead ball, exits the far side
|
||||
function sendBoy() {
|
||||
const bx = ball.x, bz = ball.z;
|
||||
if (Math.abs(bx) > 12 || bz < -4 || bz > 27) return; // ball left the field, let it go
|
||||
const dist = b => Math.hypot(b.x - bx, b.z - bz);
|
||||
const boy = dist(BOYS[0]) <= dist(BOYS[1]) ? BOYS[0] : BOYS[1];
|
||||
boy.state = 'fetch'; boy.tx = bx; boy.tz = bz;
|
||||
if (ball.z < -3 || ball.z > 27) return; // ball left the field lengthwise, let it go
|
||||
const dir = ball.x > 0 ? 1 : -1; // enter from the opposite side so the pickup is mid-run
|
||||
boy = { x: dir > 0 ? -13 : 13, z: ball.z, dir, runT: 0 };
|
||||
}
|
||||
let aiServeT = 0, aiSwingCool = 0;
|
||||
const crowd = { cheer: 0, dots: [] };
|
||||
@@ -239,6 +246,9 @@ function solveShot(x0, y0, z0, tx, tz, s) {
|
||||
function doHit(ch, quality, aim) {
|
||||
// aim: {ax: -1|0|1, az: -1|0|1} from held keys at contact (az -1 = deep, 1 = short/drop)
|
||||
const toFar = ch.side === 'P';
|
||||
// snap the ball to the racket at the contact frame so the hit visually connects
|
||||
ball.x = ch.x + ch.facing * 0.5;
|
||||
ball.z = ch.z + (toFar ? -0.3 : 0.3);
|
||||
const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality);
|
||||
let tx = aim.ax * 2.7 + err();
|
||||
let tzBase = aim.az < 0 ? 3.0 : aim.az > 0 ? 8.6 : 5.4; // depth from target baseline
|
||||
@@ -456,7 +466,10 @@ function update(dt) {
|
||||
else if (Math.abs(assist) > 0.25) P.facing = assist > 0 ? 1 : -1; // hysteresis: small corrections don't flip facing
|
||||
}
|
||||
}
|
||||
if (P.swingT >= 0) { P.swingT += dt; trySwing(P, false); if (P.swingT > 0.4) P.swingT = -1; }
|
||||
if (P.swingT >= 0) {
|
||||
P.swingT += dt; trySwing(P, false); // a contact resets swingT to -1 inside trySwing
|
||||
if (P.swingT > TUNE.swingWindup + TUNE.swingWindow + 0.02) { P.swingT = -1; if (phase === 'rally') swish(); }
|
||||
}
|
||||
if (P.pose === 'swing' || P.pose === 'serve') { P.poseT += dt; if (P.poseT > 0.38) P.pose = 'idle'; }
|
||||
|
||||
// --- AI
|
||||
@@ -518,18 +531,15 @@ function update(dt) {
|
||||
}
|
||||
}
|
||||
|
||||
// ball boys: fetch the dead ball, then trot home
|
||||
for (const b of BOYS) {
|
||||
if (b.state === 'idle') continue;
|
||||
b.runT += dt;
|
||||
const tx = b.state === 'fetch' ? b.tx : b.hx, tz = b.state === 'fetch' ? b.tz : b.hz;
|
||||
const dx = tx - b.x, dz = tz - b.z, d = Math.hypot(dx, dz);
|
||||
if (d < 0.25) {
|
||||
if (b.state === 'fetch' && !ball.active) ball.collected = true; // scoops it up
|
||||
b.state = b.state === 'fetch' ? 'return' : 'idle';
|
||||
continue;
|
||||
// ball boy: sprint across, scoop the ball in passing, despawn off the far side
|
||||
if (boy) {
|
||||
boy.x += boy.dir * 7.5 * dt; boy.runT += dt;
|
||||
if (!ball.collected && !ball.active) {
|
||||
const dz = ball.z - boy.z;
|
||||
boy.z += Math.sign(dz) * Math.min(Math.abs(dz), 3 * dt); // track the rolling ball a little
|
||||
if (Math.abs(boy.x - ball.x) < 0.4 && Math.abs(dz) < 1.2) ball.collected = true;
|
||||
}
|
||||
b.x += dx / d * 6.5 * dt; b.z += dz / d * 6.5 * dt;
|
||||
if (Math.abs(boy.x) > 13.5) boy = null;
|
||||
}
|
||||
if (outMarker && (outMarker.t -= dt) <= 0) outMarker = null;
|
||||
|
||||
@@ -568,9 +578,15 @@ function drawStatic(name, x, z, hMeters, bob = 0) {
|
||||
ctx.drawImage(img, p.x - w / 2, p.y - h - bob, w, h);
|
||||
}
|
||||
function drawBoy(b) {
|
||||
const moving = b.state !== 'idle';
|
||||
drawStatic(moving ? 'x_boy_stand' : 'x_boy_kneel', b.x, b.z, moving ? 1.45 : 1.05,
|
||||
moving ? Math.floor(b.runT * 16) % 2 : 0);
|
||||
const img = SPR[Math.floor(b.runT * 10) % 2 ? 'x_boyrun1' : 'x_boyrun2']; if (!img) return;
|
||||
const p = proj(b.x, 0, b.z);
|
||||
const h = 1.35 * p.s, w = h * img.width / img.height;
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.25)';
|
||||
ctx.beginPath(); ctx.ellipse(p.x, p.y, w * 0.3, Math.max(1, h * 0.04), 0, 0, 7); ctx.fill();
|
||||
ctx.save(); ctx.translate(p.x, p.y - (Math.floor(b.runT * 20) % 2));
|
||||
if (b.dir < 0) ctx.scale(-1, 1);
|
||||
ctx.drawImage(img, -w / 2, -h, w, h);
|
||||
ctx.restore();
|
||||
}
|
||||
function drawRing(x, z, color) {
|
||||
const p = proj(x, 0, z);
|
||||
@@ -669,7 +685,7 @@ function render() {
|
||||
|
||||
// court staff, then entities far→near, net between
|
||||
drawStatic('x_ump', 7.1, COURT.netZ, 2.7);
|
||||
for (const b of BOYS) if (b.z <= COURT.netZ) drawBoy(b);
|
||||
if (boy && boy.z <= COURT.netZ) drawBoy(boy);
|
||||
drawChar(O);
|
||||
// net
|
||||
const nl = proj(-COURT.netW / 2, 0, COURT.netZ), nr = proj(COURT.netW / 2, 0, COURT.netZ);
|
||||
@@ -679,7 +695,7 @@ function render() {
|
||||
ctx.strokeStyle = '#eeeeee'; ctx.lineWidth = 1.5;
|
||||
ctx.beginPath(); ctx.moveTo(nlt.x, nlt.y); ctx.lineTo(nrt.x, nrt.y); ctx.stroke();
|
||||
ctx.fillStyle = '#333'; ctx.fillRect(nl.x - 1, nlt.y, 2, nl.y - nlt.y); ctx.fillRect(nr.x - 1, nrt.y, 2, nr.y - nrt.y);
|
||||
for (const b of BOYS) if (b.z > COURT.netZ) drawBoy(b);
|
||||
if (boy && boy.z > COURT.netZ) drawBoy(boy);
|
||||
|
||||
// server idly bouncing the ball while waiting
|
||||
if (phase === 'pretoss') {
|
||||
|
||||
Reference in new issue
Block a user