Topspin pace model, beatable CPU, boy run cycle, umpire reverb, z-order fix
- fake topspin: quality shots dive under extra effective gravity, so real pace (up to 28 m/s) can clear the net AND land in — without it the net-clearance loop silently slowed every shot to a coverable lob, which is why the CPU returned everything regardless of aim - no grass-scraping: balls below ankle height (0.24m) are past you, both sides - CPU: 0.45s shot-read delay, 4.3 m/s legs, half-speed recovery between shots. Verified gradient: perfect/good corner shots are winners, center balls and weak shots still come back - player aimed shots go wider (3.3m vs 2.7m) - ball boy: nano-banana mirrored stride frames (was stride-vs-hop), height 1.2m, z-ordered against umpire and CPU (no more running over the chair) - umpire voice through a synthesized convolver reverb — center-court PA echo Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f3c2af4ef5
commit
456e168c27
4 files changed
+52
-26
No files matched your search
Binary file not shown.
|
Before Width: | Height: | Size: 452 KiB After Width: | Height: | Size: 763 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 9.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.2 KiB |
@@ -8,16 +8,18 @@
|
|||||||
const TUNE = {
|
const TUNE = {
|
||||||
camZ: 36, camY: 9, camF: 280, horizon: 40,
|
camZ: 36, camY: 9, camF: 280, horizon: 40,
|
||||||
gravity: 9.2, // slightly floaty vs 9.8 for readability
|
gravity: 9.2, // slightly floaty vs 9.8 for readability
|
||||||
|
spinFactor: 0.9, // extra dive on quality shots (fake topspin)
|
||||||
bounceY: 0.58, bounceXZ: 0.78,
|
bounceY: 0.58, bounceXZ: 0.78,
|
||||||
playerSpeed: 5.4, aiSpeed: 5.0,
|
playerSpeed: 5.4, aiSpeed: 4.3,
|
||||||
reach: 1.3, aiReach: 1.6, // tight contact radius: the assist positions you, the tap must be honest
|
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
|
swingWindup: 0.06, // s before contact window opens
|
||||||
swingWindow: 0.16, // s contact window length
|
swingWindow: 0.16, // s contact window length
|
||||||
baseShotSpeed: 15, qualityShotSpeed: 9, // horizontal m/s = base + q*quality
|
baseShotSpeed: 16, qualityShotSpeed: 12, // horizontal m/s = base + q*quality; perfect timing = real pace
|
||||||
serveSpeed: 21, serveQualitySpeed: 7,
|
serveSpeed: 21, serveQualitySpeed: 7,
|
||||||
aimError: 2.4, // m of scatter at quality 0
|
aimError: 2.4, // m of scatter at quality 0
|
||||||
perfectMs: 45, // |offset from window center| for PERFECT
|
perfectMs: 45, // |offset from window center| for PERFECT
|
||||||
aiQualityMin: 0.55, aiQualityMax: 0.95, aiFlub: 0.05,
|
aiQualityMin: 0.45, aiQualityMax: 0.85, aiFlub: 0.09,
|
||||||
|
aiReaction: 0.45, // s before the CPU reads your shot — well-aimed corners are winners
|
||||||
autoAssist: matchMedia('(pointer: coarse)').matches ? 1.15 : 0, // auto-run speed factor (mobile); slightly superhuman legs
|
autoAssist: matchMedia('(pointer: coarse)').matches ? 1.15 : 0, // auto-run speed factor (mobile); slightly superhuman legs
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,11 +118,25 @@ const audio = () => {
|
|||||||
// Umpire lines are pre-recorded files (assets/audio/*.m4a) played via WebAudio —
|
// Umpire lines are pre-recorded files (assets/audio/*.m4a) played via WebAudio —
|
||||||
// works on mobile where speechSynthesis is unreliable. say() remains the fallback.
|
// works on mobile where speechSynthesis is unreliable. say() remains the fallback.
|
||||||
const VOICE = {};
|
const VOICE = {};
|
||||||
|
let reverbNode = null;
|
||||||
|
function reverb(ac) { // stadium PA tail: exponentially decaying noise as impulse response
|
||||||
|
if (reverbNode) return reverbNode;
|
||||||
|
const len = ac.sampleRate * 1.3, ir = ac.createBuffer(2, len, ac.sampleRate);
|
||||||
|
for (let c = 0; c < 2; c++) {
|
||||||
|
const d = ir.getChannelData(c);
|
||||||
|
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 2.8);
|
||||||
|
}
|
||||||
|
reverbNode = ac.createConvolver(); reverbNode.buffer = ir;
|
||||||
|
const wet = ac.createGain(); wet.gain.value = 0.35;
|
||||||
|
reverbNode.connect(wet).connect(ac.destination);
|
||||||
|
return reverbNode;
|
||||||
|
}
|
||||||
function voice(name, delay = 0) {
|
function voice(name, delay = 0) {
|
||||||
const ac = audio();
|
const ac = audio();
|
||||||
const play = buf => {
|
const play = buf => {
|
||||||
const src = ac.createBufferSource(); src.buffer = buf;
|
const src = ac.createBufferSource(); src.buffer = buf;
|
||||||
src.connect(ac.destination); src.start(ac.currentTime + delay);
|
src.connect(ac.destination); src.connect(reverb(ac)); // dry + center-court echo
|
||||||
|
src.start(ac.currentTime + delay);
|
||||||
};
|
};
|
||||||
if (VOICE[name]) return play(VOICE[name]);
|
if (VOICE[name]) return play(VOICE[name]);
|
||||||
fetch('assets/audio/' + name + '.m4a')
|
fetch('assets/audio/' + name + '.m4a')
|
||||||
@@ -212,7 +228,7 @@ function sendBoy() {
|
|||||||
const dir = ball.x > 0 ? 1 : -1; // enter from the opposite side so the pickup is mid-run
|
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 };
|
boy = { x: dir > 0 ? -13 : 13, z: ball.z, dir, runT: 0 };
|
||||||
}
|
}
|
||||||
let aiServeT = 0, aiSwingCool = 0;
|
let aiServeT = 0, aiSwingCool = 0, aiReactT = 9;
|
||||||
const crowd = { cheer: 0, dots: [] };
|
const crowd = { cheer: 0, dots: [] };
|
||||||
function buildCrowd() {
|
function buildCrowd() {
|
||||||
crowd.dots.length = 0;
|
crowd.dots.length = 0;
|
||||||
@@ -237,10 +253,10 @@ function setupPoint() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------- ballistics ----
|
// ------------------------------------------------------------- ballistics ----
|
||||||
// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s. Returns velocity.
|
// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g.
|
||||||
function solveShot(x0, y0, z0, tx, tz, s) {
|
function solveShot(x0, y0, z0, tx, tz, s, g = TUNE.gravity) {
|
||||||
const dx = tx - x0, dz = tz - z0, dist = Math.hypot(dx, dz), T = dist / s;
|
const dx = tx - x0, dz = tz - z0, dist = Math.hypot(dx, dz), T = dist / s;
|
||||||
return { vx: dx / T, vz: dz / T, vy: (0.08 - y0 + 0.5 * TUNE.gravity * T * T) / T };
|
return { vx: dx / T, vz: dz / T, vy: (0.08 - y0 + 0.5 * g * T * T) / T };
|
||||||
}
|
}
|
||||||
|
|
||||||
function doHit(ch, quality, aim) {
|
function doHit(ch, quality, aim) {
|
||||||
@@ -250,26 +266,31 @@ function doHit(ch, quality, aim) {
|
|||||||
ball.x = ch.x + ch.facing * 0.5;
|
ball.x = ch.x + ch.facing * 0.5;
|
||||||
ball.z = ch.z + (toFar ? -0.3 : 0.3);
|
ball.z = ch.z + (toFar ? -0.3 : 0.3);
|
||||||
const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality);
|
const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality);
|
||||||
let tx = aim.ax * 2.7 + err();
|
let tx = aim.ax * (ch.side === 'P' ? 3.3 : 2.7) + err(); // your aimed shots go properly wide
|
||||||
let tzBase = aim.az < 0 ? 3.0 : aim.az > 0 ? 8.6 : 5.4; // depth from target baseline
|
let tzBase = aim.az < 0 ? 3.0 : aim.az > 0 ? 8.6 : 5.4; // depth from target baseline
|
||||||
let tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8;
|
let tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8;
|
||||||
tx = Math.max(-5, Math.min(5, tx));
|
tx = Math.max(-5, Math.min(5, tx));
|
||||||
let s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed;
|
let s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed;
|
||||||
let v = solveShot(ball.x, ball.y, ball.z, tx, tz, s);
|
// topspin: quality shots dive under extra effective gravity, which is what lets real
|
||||||
|
// pace arc over the net and still land in — the core tennis trade-off
|
||||||
|
const g = TUNE.gravity * (1 + quality * TUNE.spinFactor);
|
||||||
|
let v = solveShot(ball.x, ball.y, ball.z, tx, tz, s, g);
|
||||||
// low contact → trade pace for loft until the net is cleared; good timing gets a
|
// low contact → trade pace for loft until the net is cleared; good timing gets a
|
||||||
// comfortable margin, sloppy timing keeps some tape risk
|
// comfortable margin, sloppy timing keeps some tape risk
|
||||||
const margin = 0.06 + 0.22 * quality;
|
const margin = 0.06 + 0.22 * quality;
|
||||||
for (let i = 0; i < 8; i++) {
|
for (let i = 0; i < 8; i++) {
|
||||||
const tN = (COURT.netZ - ball.z) / v.vz;
|
const tN = (COURT.netZ - ball.z) / v.vz;
|
||||||
const yN = ball.y + v.vy * tN - 0.5 * TUNE.gravity * tN * tN;
|
const yN = ball.y + v.vy * tN - 0.5 * g * tN * tN;
|
||||||
if (tN <= 0 || yN >= COURT.netH + margin) break;
|
if (tN <= 0 || yN >= COURT.netH + margin) break;
|
||||||
s *= 0.9; v = solveShot(ball.x, ball.y, ball.z, tx, tz, s);
|
s *= 0.9; v = solveShot(ball.x, ball.y, ball.z, tx, tz, s, g);
|
||||||
}
|
}
|
||||||
|
ball.g = g;
|
||||||
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
|
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
|
||||||
ball.lastHit = ch.side; ball.bounces = 0; ball.firstLand = null;
|
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
|
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;
|
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 };
|
outMarker = { x: tx, z: tz, out: !ok, t: 1.3 };
|
||||||
|
aiReactT = 0; // CPU hasn't read the shot yet
|
||||||
}
|
}
|
||||||
pock(0.9, 950);
|
pock(0.9, 950);
|
||||||
if (quality > 0.999) { hitstop = 0.05; flash = { text: 'PERFECT', t: 0.7 }; pock(0.5, 1600); }
|
if (quality > 0.999) { hitstop = 0.05; flash = { text: 'PERFECT', t: 0.7 }; pock(0.5, 1600); }
|
||||||
@@ -280,7 +301,7 @@ function doHit(ch, quality, aim) {
|
|||||||
function tossBall(sv) {
|
function tossBall(sv) {
|
||||||
ball.active = true; ball.bounces = 0; ball.firstLand = null; ball.collected = false;
|
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.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;
|
ball.vx = 0; ball.vz = 0; ball.vy = 4.6; ball.g = TUNE.gravity;
|
||||||
phase = 'toss';
|
phase = 'toss';
|
||||||
}
|
}
|
||||||
function serveApexY() { return 1.8 + 4.6 * 4.6 / (2 * TUNE.gravity); } // ~2.95
|
function serveApexY() { return 1.8 + 4.6 * 4.6 / (2 * TUNE.gravity); } // ~2.95
|
||||||
@@ -291,7 +312,9 @@ function hitServe(sv, quality) {
|
|||||||
const boxZ = toFar ? COURT.svcFar + 1.6 : COURT.svcNear - 1.6;
|
const boxZ = toFar ? COURT.svcFar + 1.6 : COURT.svcNear - 1.6;
|
||||||
const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed;
|
const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed;
|
||||||
const tzs = boxZ + (Math.random() * 2 - 1) * (1 - quality) * 1.5;
|
const tzs = boxZ + (Math.random() * 2 - 1) * (1 - quality) * 1.5;
|
||||||
const v = solveShot(ball.x, ball.y, ball.z, boxX, tzs, s);
|
const g = TUNE.gravity * (1 + quality * 0.35); // kick serves dip a little too
|
||||||
|
const v = solveShot(ball.x, ball.y, ball.z, boxX, tzs, s, g);
|
||||||
|
ball.g = g;
|
||||||
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
|
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
|
||||||
ball.lastHit = sv.side; ball.bounces = 0;
|
ball.lastHit = sv.side; ball.bounces = 0;
|
||||||
if (sv.side === 'P') outMarker = { x: boxX, z: tzs, out: !inServeBox(boxX, tzs), t: 1.3 };
|
if (sv.side === 'P') outMarker = { x: boxX, z: tzs, out: !inServeBox(boxX, tzs), t: 1.3 };
|
||||||
@@ -374,7 +397,7 @@ function intercept(side) {
|
|||||||
const ch = side === 'O' ? O : P;
|
const ch = side === 'O' ? O : P;
|
||||||
const spd = side === 'O' ? TUNE.aiSpeed : TUNE.playerSpeed * TUNE.autoAssist;
|
const spd = side === 'O' ? TUNE.aiSpeed : TUNE.playerSpeed * TUNE.autoAssist;
|
||||||
let { x, y, z, vx, vy, vz } = ball;
|
let { x, y, z, vx, vy, vz } = ball;
|
||||||
const g = TUNE.gravity, dt = 0.01;
|
const g = ball.g || TUNE.gravity, dt = 0.01;
|
||||||
const inZone = side === 'O' ? zz => zz <= 9.5 : zz => zz >= COURT.L - 9.5;
|
const inZone = side === 'O' ? zz => zz <= 9.5 : zz => zz >= COURT.L - 9.5;
|
||||||
const home = side === 'O' ? { x: 0, z: 2.5 } : { x: 0, z: 22.8 };
|
const home = side === 'O' ? { x: 0, z: 2.5 } : { x: 0, z: 22.8 };
|
||||||
let fallback = null, deepest = null;
|
let fallback = null, deepest = null;
|
||||||
@@ -401,6 +424,7 @@ function trySwing(ch, isAI) {
|
|||||||
if (ball.lastHit === ch.side) return;
|
if (ball.lastHit === ch.side) return;
|
||||||
const dx = ball.x - ch.x, dz = ball.z - ch.z;
|
const dx = ball.x - ch.x, dz = ball.z - ch.z;
|
||||||
if (Math.hypot(dx, dz * 0.8) > reach || ball.y > 2.3) return;
|
if (Math.hypot(dx, dz * 0.8) > reach || ball.y > 2.3) return;
|
||||||
|
if (ball.y < 0.24) return; // below the ankles the ball is past you — no grass-scraping saves
|
||||||
// quality: how centered in the window the contact lands
|
// quality: how centered in the window the contact lands
|
||||||
const center = TUNE.swingWindup + TUNE.swingWindow / 2;
|
const center = TUNE.swingWindup + TUNE.swingWindow / 2;
|
||||||
const off = Math.abs(t - center) / (TUNE.swingWindow / 2);
|
const off = Math.abs(t - center) / (TUNE.swingWindow / 2);
|
||||||
@@ -486,19 +510,21 @@ function update(dt) {
|
|||||||
if (ball.vy < 0 && ball.y < serveApexY() - 0.1) { phaseWasServe = true; hitServe(O, 0.6 + Math.random() * 0.35); }
|
if (ball.vy < 0 && ball.y < serveApexY() - 0.1) { phaseWasServe = true; hitServe(O, 0.6 + Math.random() * 0.35); }
|
||||||
}
|
}
|
||||||
if (phase === 'rally') {
|
if (phase === 'rally') {
|
||||||
const incoming = ball.active && ball.lastHit === 'P';
|
aiReactT += dt;
|
||||||
|
const incoming = ball.active && ball.lastHit === 'P' && aiReactT >= TUNE.aiReaction;
|
||||||
const tgt = incoming ? intercept('O') : { x: 0, z: 2.5 };
|
const tgt = incoming ? intercept('O') : { x: 0, z: 2.5 };
|
||||||
|
const spd = TUNE.aiSpeed * (incoming ? 1 : 0.5); // slow recovery: wrong-footing wins points
|
||||||
const dx = tgt.x - O.x, dz = tgt.z - O.z, d = Math.hypot(dx, dz);
|
const dx = tgt.x - O.x, dz = tgt.z - O.z, d = Math.hypot(dx, dz);
|
||||||
if (d > 0.1) {
|
if (d > 0.1) {
|
||||||
O.x += dx / d * Math.min(TUNE.aiSpeed * dt, d);
|
O.x += dx / d * Math.min(spd * dt, d);
|
||||||
O.z += dz / d * Math.min(TUNE.aiSpeed * dt, d);
|
O.z += dz / d * Math.min(spd * dt, d);
|
||||||
O.pose = O.pose === 'swing' ? 'swing' : 'run'; O.runT += dt;
|
O.pose = O.pose === 'swing' ? 'swing' : 'run'; O.runT += dt;
|
||||||
if (Math.abs(dx) > 0.25) 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';
|
} else if (O.pose === 'run') O.pose = 'idle';
|
||||||
aiSwingCool -= dt;
|
aiSwingCool -= dt;
|
||||||
if (incoming && aiSwingCool <= 0 && O.swingT < 0) {
|
if (incoming && aiSwingCool <= 0 && O.swingT < 0) {
|
||||||
// same radius trySwing uses for contact, so an armed swing actually connects
|
// same radius trySwing uses for contact, so an armed swing actually connects
|
||||||
const near = Math.hypot(ball.x - O.x, (ball.z - O.z) * 0.8) < TUNE.aiReach && ball.y < 2.3 && ball.vz < 0;
|
const near = Math.hypot(ball.x - O.x, (ball.z - O.z) * 0.8) < TUNE.aiReach && ball.y < 2.3 && ball.y > 0.24 && ball.vz < 0;
|
||||||
if (near) {
|
if (near) {
|
||||||
if (Math.random() < TUNE.aiFlub) aiSwingCool = 0.5; // whiff: hesitates and misses
|
if (Math.random() < TUNE.aiFlub) aiSwingCool = 0.5; // whiff: hesitates and misses
|
||||||
else { O.swingT = TUNE.swingWindup + TUNE.swingWindow / 2; trySwing(O, true); O.swingT = -1; aiSwingCool = ball.lastHit === 'O' ? 0.4 : 0.12; }
|
else { O.swingT = TUNE.swingWindup + TUNE.swingWindow / 2; trySwing(O, true); O.swingT = -1; aiSwingCool = ball.lastHit === 'O' ? 0.4 : 0.12; }
|
||||||
@@ -509,7 +535,7 @@ function update(dt) {
|
|||||||
|
|
||||||
// --- ball physics
|
// --- ball physics
|
||||||
if (ball.active) {
|
if (ball.active) {
|
||||||
ball.vy -= TUNE.gravity * dt;
|
ball.vy -= (ball.g || TUNE.gravity) * dt;
|
||||||
const oldZ = ball.z;
|
const oldZ = ball.z;
|
||||||
ball.x += ball.vx * dt; ball.y += ball.vy * dt; ball.z += ball.vz * dt;
|
ball.x += ball.vx * dt; ball.y += ball.vy * dt; ball.z += ball.vz * dt;
|
||||||
// net: plane at netZ, only during rally flight
|
// net: plane at netZ, only during rally flight
|
||||||
@@ -554,7 +580,7 @@ function update(dt) {
|
|||||||
|
|
||||||
function predictLanding() { // first-bounce point of the ball's current trajectory
|
function predictLanding() { // first-bounce point of the ball's current trajectory
|
||||||
let { x, y, z, vx, vy, vz } = ball;
|
let { x, y, z, vx, vy, vz } = ball;
|
||||||
const g = TUNE.gravity, dt = 0.01;
|
const g = ball.g || TUNE.gravity, dt = 0.01;
|
||||||
for (let i = 0; i < 300; i++) {
|
for (let i = 0; i < 300; i++) {
|
||||||
vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt;
|
vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt;
|
||||||
if (y <= 0) return { x, z };
|
if (y <= 0) return { x, z };
|
||||||
@@ -586,7 +612,7 @@ function drawStatic(name, x, z, hMeters, bob = 0) {
|
|||||||
function drawBoy(b) {
|
function drawBoy(b) {
|
||||||
const img = SPR[Math.floor(b.runT * 10) % 2 ? 'x_boyrun1' : 'x_boyrun2']; if (!img) return;
|
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 p = proj(b.x, 0, b.z);
|
||||||
const h = 1.35 * p.s, w = h * img.width / img.height;
|
const h = 1.2 * p.s, w = h * img.width / img.height;
|
||||||
ctx.fillStyle = 'rgba(0,0,0,0.25)';
|
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.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));
|
ctx.save(); ctx.translate(p.x, p.y - (Math.floor(b.runT * 20) % 2));
|
||||||
@@ -689,10 +715,10 @@ function render() {
|
|||||||
}
|
}
|
||||||
if (outMarker) drawRing(outMarker.x, outMarker.z, outMarker.out ? '#ff5050' : '#f8e048');
|
if (outMarker) drawRing(outMarker.x, outMarker.z, outMarker.out ? '#ff5050' : '#f8e048');
|
||||||
|
|
||||||
// court staff, then entities far→near, net between
|
// far-side drawables in z order (far → near), then the net, then the near side
|
||||||
drawStatic('x_ump', 7.1, COURT.netZ, 2.7);
|
const farSide = [{ z: O.z, f: () => drawChar(O) }, { z: COURT.netZ - 0.01, f: () => drawStatic('x_ump', 7.1, COURT.netZ, 2.7) }];
|
||||||
if (boy && boy.z <= COURT.netZ) drawBoy(boy);
|
if (boy && boy.z <= COURT.netZ) farSide.push({ z: boy.z, f: () => drawBoy(boy) });
|
||||||
drawChar(O);
|
farSide.sort((a, b) => a.z - b.z).forEach(d => d.f());
|
||||||
// net
|
// net
|
||||||
const nl = proj(-COURT.netW / 2, 0, COURT.netZ), nr = proj(COURT.netW / 2, 0, COURT.netZ);
|
const nl = proj(-COURT.netW / 2, 0, COURT.netZ), nr = proj(COURT.netW / 2, 0, COURT.netZ);
|
||||||
const nlt = proj(-COURT.netW / 2, COURT.netH, COURT.netZ), nrt = proj(COURT.netW / 2, COURT.netH, COURT.netZ);
|
const nlt = proj(-COURT.netW / 2, COURT.netH, COURT.netZ), nrt = proj(COURT.netW / 2, COURT.netH, COURT.netZ);
|
||||||
|
|||||||
Reference in new issue
Block a user