Landing rings, ball-boy fetching, 2x sprite density, assist flicker fix

- white ring shows where the incoming ball will land; yellow/red ring shows
  where your shot will land (red = going out) — Mario-Tennis-style feedback
- ball boys wait at the net posts, fetch every dead ball, and trot home
- canvas renders at 2x density, all sprites re-sliced at 112px; run/swing
  sheets regenerated in the finer style
- assist: 0.35m deadzone + facing hysteresis kills the left/right flicker;
  player reach 1.5 -> 1.7

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-07-11 13:56:18 +02:00
1 parent dc1e12b9b6
commit 9e6e686cff
26 files changed
+83 -19

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1013 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 582 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

+83 -19
View File
@@ -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.5, aiReach: 1.6,
reach: 1.7, aiReach: 1.6,
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
@@ -28,13 +28,15 @@ COURT.svcNear = COURT.netZ + 6.4; // 18.285
// ---------------------------------------------------------------- canvas ----
const cv = document.getElementById('c'), ctx = cv.getContext('2d');
let W = cv.width, H = cv.height;
const RES = 2; // physical pixels per logical pixel — sprites keep 2x detail
let W = 480, H = 270;
function fitViewport() { // landscape 480x270, portrait 270x480 with a longer lens
const portrait = innerHeight > innerWidth;
const [w, h] = portrait ? [270, 480] : [480, 270];
if (cv.width !== w) {
cv.width = W = w; cv.height = H = h;
if (cv.width !== w * RES) {
W = w; H = h;
cv.width = w * RES; cv.height = h * RES;
TUNE.camF = portrait ? 400 : 280;
TUNE.horizon = portrait ? 90 : 40;
buildCrowd();
@@ -191,7 +193,18 @@ const ball = { x: 0, y: 1, z: 20, vx: 0, vy: 0, vz: 0, active: false, lastHit: '
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; // flash = {text,x,y,t}
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 },
];
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;
}
let aiServeT = 0, aiSwingCool = 0;
const crowd = { cheer: 0, dots: [] };
function buildCrowd() {
@@ -244,6 +257,10 @@ function doHit(ch, quality, aim) {
}
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
ball.lastHit = ch.side; ball.bounces = 0; ball.firstLand = null;
if (ch.side === 'P') { // shot marker: where this will land — red means it's going out
const ok = Math.abs(tx) <= COURT.W / 2 + 0.02 && tz >= -0.02 && tz <= COURT.netZ;
outMarker = { x: tx, z: tz, out: !ok, t: 1.3 };
}
pock(0.9, 950);
if (quality > 0.999) { hitstop = 0.05; flash = { text: 'PERFECT', t: 0.7 }; pock(0.5, 1600); }
ch.pose = 'swing'; ch.poseT = 0;
@@ -251,7 +268,7 @@ function doHit(ch, quality, aim) {
// ----------------------------------------------------------------- serve ----
function tossBall(sv) {
ball.active = true; ball.bounces = 0; ball.firstLand = null;
ball.active = true; ball.bounces = 0; ball.firstLand = null; ball.collected = false;
ball.x = sv.x + 0.35 * (sv.side === 'P' ? 1 : -1); ball.z = sv.z; ball.y = 1.8;
ball.vx = 0; ball.vz = 0; ball.vy = 4.6;
phase = 'toss';
@@ -263,9 +280,11 @@ function hitServe(sv, quality) {
const boxX = (deuceSide() ? -1 : 1) * (toFar ? 1 : -1) * 2.0 + (Math.random() * 2 - 1) * TUNE.aimError * 0.8 * (1 - quality);
const boxZ = toFar ? COURT.svcFar + 1.6 : COURT.svcNear - 1.6;
const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed;
const v = solveShot(ball.x, ball.y, ball.z, boxX, boxZ + (Math.random() * 2 - 1) * (1 - quality) * 1.5, s);
const tzs = boxZ + (Math.random() * 2 - 1) * (1 - quality) * 1.5;
const v = solveShot(ball.x, ball.y, ball.z, boxX, tzs, s);
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
ball.lastHit = sv.side; ball.bounces = 0;
if (sv.side === 'P') outMarker = { x: boxX, z: tzs, out: !inServeBox(boxX, tzs), t: 1.3 };
phase = 'rally'; pock(1, 1100);
sv.pose = 'serve'; sv.poseT = 0;
if (quality > 0.999) { flash = { text: 'PERFECT', t: 0.7 }; hitstop = 0.05; }
@@ -290,6 +309,7 @@ function pointTo(side, reason) {
score.games[i]++; score.pts = [0, 0]; gameOver = true;
}
phase = 'over'; overT = gameOver ? 3.2 : 2.2;
sendBoy();
if (reason) voice(reason);
if (gameOver) {
voice(side === 'P' ? 'game_player' : 'game_opponent', 0.7);
@@ -302,7 +322,7 @@ function pointTo(side, reason) {
serveNum = 1;
}
function fault() {
if (serveNum === 1) { serveNum = 2; voice('fault'); phase = 'over'; overT = 1.4; }
if (serveNum === 1) { serveNum = 2; voice('fault'); phase = 'over'; overT = 1.4; sendBoy(); }
else pointTo(other(score.server), 'double_fault');
}
const other = s => s === 'P' ? 'O' : 'P';
@@ -421,18 +441,19 @@ function update(dt) {
const rate = TUNE.playerSpeed * TUNE.autoAssist * slow * dt * (chasing ? 1 : 0.55);
const tgt = chasing ? intercept('P') : { x: 0, z: 22.8 }; // run to the ball, then recover home
const dx = tgt.x - P.x, dz = tgt.z - P.z, d = Math.hypot(dx, dz);
if (d > 0.05) {
if (d > 0.35) { // deadzone: stand ready near the spot instead of dithering over it
P.x += dx / d * Math.min(rate, d);
P.z += dz / d * Math.min(rate, d);
assist = dx || 0.01;
assist = dx;
}
}
P.x = Math.max(-6.5, Math.min(6.5, P.x));
P.z = Math.max(COURT.netZ + 1.2, Math.min(26, P.z));
const moving = mx || mz || Math.abs(assist) > 0.005;
const moving = mx || mz || assist !== 0;
P.runT += moving ? dt : 0;
if (P.pose !== 'swing' && P.pose !== 'serve') P.pose = moving ? 'run' : 'idle';
if (mx || assist) P.facing = (mx || assist) > 0 ? 1 : -1;
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
}
}
if (P.swingT >= 0) { P.swingT += dt; trySwing(P, false); if (P.swingT > 0.4) P.swingT = -1; }
@@ -453,7 +474,7 @@ function update(dt) {
O.x += dx / d * Math.min(TUNE.aiSpeed * dt, d);
O.z += dz / d * Math.min(TUNE.aiSpeed * dt, d);
O.pose = O.pose === 'swing' ? 'swing' : 'run'; O.runT += dt;
if (dx) O.facing = dx > 0 ? 1 : -1;
if (Math.abs(dx) > 0.25) O.facing = dx > 0 ? 1 : -1;
} else if (O.pose === 'run') O.pose = 'idle';
aiSwingCool -= dt;
if (incoming && aiSwingCool <= 0 && O.swingT < 0) {
@@ -497,9 +518,34 @@ 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;
}
b.x += dx / d * 6.5 * dt; b.z += dz / d * 6.5 * dt;
}
if (outMarker && (outMarker.t -= dt) <= 0) outMarker = null;
if (phase === 'over') { overT -= dt; if (overT <= 0) setupPoint(); }
}
function predictLanding() { // first-bounce point of the ball's current trajectory
let { x, y, z, vx, vy, vz } = ball;
const g = TUNE.gravity, dt = 0.01;
for (let i = 0; i < 300; i++) {
vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt;
if (y <= 0) return { x, z };
}
return null;
}
// ---------------------------------------------------------------- render ----
function courtXZ(x, z) { return proj(x, 0, z); }
function line(x1, z1, x2, z2, w = 1) {
@@ -513,13 +559,23 @@ function quadXZ(x1, z1, x2, z2, color) { // filled court-plane rect (screen trap
ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(c.x, c.y); ctx.lineTo(d.x, d.y); ctx.fill();
}
function drawStatic(name, x, z, hMeters) {
function drawStatic(name, x, z, hMeters, bob = 0) {
const img = SPR[name]; if (!img) return;
const p = proj(x, 0, z);
const h = hMeters * 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.4, Math.max(1, h * 0.04), 0, 0, 7); ctx.fill();
ctx.drawImage(img, p.x - w / 2, p.y - h, w, h);
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);
}
function drawRing(x, z, color) {
const p = proj(x, 0, z);
ctx.strokeStyle = color; ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * 0.3, p.s * 0.12, 0, 0, 7); ctx.stroke();
}
function drawChar(ch) {
@@ -542,7 +598,7 @@ function drawChar(ch) {
ctx.fillStyle = 'rgba(0,0,0,0.28)';
ctx.beginPath(); ctx.ellipse(p.x, p.y, hpx * 0.22, hpx * 0.06, 0, 0, 7); ctx.fill();
if (img) {
const k = hpx / 56; // sprites share one scale; idle pose is 56px tall
const k = hpx / 112; // sprites share one scale; idle pose is 112px tall
const w = img.width * k, h = img.height * k;
ctx.save();
ctx.translate(p.x, p.y);
@@ -570,6 +626,7 @@ function drawFallbackChar(ch, p, h) { // ponytail: replaced by generated sprites
}
function render() {
ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density
// stands: tiered crowd from top of screen down to just above the far court
const standsBottom = proj(0, 0, 0).y - 12;
ctx.fillStyle = '#232e42'; ctx.fillRect(0, 0, W, standsBottom);
@@ -603,10 +660,16 @@ function render() {
line(0, COURT.svcFar, 0, COURT.svcNear, 1);
line(0, 0, 0, 0.35, 1); line(0, COURT.L - 0.35, 0, COURT.L, 1); // center marks
// landing rings: white = incoming ball, yellow/red = your shot (red lands out)
if (phase === 'rally' && ball.active && ball.bounces === 0 && ball.lastHit === 'O') {
const l = predictLanding();
if (l) drawRing(l.x, l.z, 'rgba(255,255,255,0.85)');
}
if (outMarker) drawRing(outMarker.x, outMarker.z, outMarker.out ? '#ff5050' : '#f8e048');
// court staff, then entities far→near, net between
drawStatic('x_boy_kneel', -4.2, -1.3, 1.1);
drawStatic('x_boy_stand', 4.2, -1.3, 1.45);
drawStatic('x_ump', 7.1, COURT.netZ, 2.7);
for (const b of BOYS) if (b.z <= COURT.netZ) drawBoy(b);
drawChar(O);
// net
const nl = proj(-COURT.netW / 2, 0, COURT.netZ), nr = proj(COURT.netW / 2, 0, COURT.netZ);
@@ -616,6 +679,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);
// server idly bouncing the ball while waiting
if (phase === 'pretoss') {
@@ -628,7 +692,7 @@ function render() {
ctx.fillStyle = '#e8f03a'; ctx.beginPath(); ctx.arc(bp.x, bp.y, Math.max(1.5, bp.s * 0.09), 0, 7); ctx.fill();
}
// ball shadow + ball
if (ball.active || phase === 'over') {
if ((ball.active || phase === 'over') && !ball.collected) {
const sh = proj(ball.x, 0, ball.z);
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath(); ctx.ellipse(sh.x, sh.y, Math.max(1.5, sh.s * 0.1), Math.max(1, sh.s * 0.04), 0, 0, 7); ctx.fill();