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>
No files matched your search
|
Before Width: | Height: | Size: 1013 KiB |
|
After Width: | Height: | Size: 582 KiB |
|
Before Width: | Height: | Size: 538 KiB |
|
After Width: | Height: | Size: 990 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 9.2 KiB |
@@ -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();
|
||||
|
||||