From e571e46d929689c577ae1824f525e0515adbff37 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Thu, 13 Aug 2026 16:18:53 +0200 Subject: [PATCH] Far bodies draw off a flattened scale curve, and the camera leans into the deep court MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Oliver: the far-side opponent is barely visible in portrait, and the lens was already at its measured ceiling. TUNE.farK (0.25) grows far bodies ~20% while the court keeps the true projection — the 16-bit tennis trick, anchored so the near player is untouched. TUNE.farCamY/farCamZ add a broadcast lean while the ball is on the far half, pivoted about the player's feet (fOff) so the pull room the apron math promised never moves. Both live in the T panel; both awaiting the phone verdict — see the PROJECT.md session log. Co-Authored-By: Claude Fable 5 --- PROJECT.md | 22 ++++++++++++++++++++ index.html | 2 +- src/config.js | 13 ++++++++++++ src/render.js | 56 ++++++++++++++++++++++++++++++++++++++++++--------- 4 files changed, 82 insertions(+), 11 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 39f3901..d7c2b8a 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -405,6 +405,28 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu `RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone. +## Session log: the far court, without spending the lens (2026-08-13) + +Oliver: the far-side opponent is barely visible in portrait, and the lens (camZ/camF, already at its +measured ceiling — see fitViewport) and world height were the only knobs he knew. Two new ones, both +AWAITING HIS PHONE VERDICT — nothing here is settled yet: + +- **`TUNE.farK` — bodies draw off a flattened scale curve, the court projects true.** The 16-bit + tennis trick: `s' = s·(sNear/s)^farK`, anchored at the player's deepest stance (he is untouched), + clamped so nothing nearer ever shrinks. At 0.25 the far-baseline opponent draws ~20% bigger + (31 → 38 logical px) and the court gives up nothing. Applied in render.js `farB(z)` to every body + AND to every anchor that hangs off a body height (flames, boss HP, muzzle glow, drill chevron, + umpire) — boosting only the sprite left the HP bar sitting on the boss's hat. Known residual: the + ball keeps the true projection, so at the far baseline it meets a boosted racket ~5px off. 0 + restores pure projection. +- **`TUNE.farCamY`/`farCamZ` — a broadcast lean while the ball is on the far half** (tennis + match + only; the gore modes would pump). Eased like aimCam, and pivoted about the player's deepest stance + via `fOff` in render.js — without that pivot 0.35m of camY slides his feet 14px toward the rack, + and the apron math promised that room. Measured in-pane: far court rises ~14px and grows ~1.3%, + player feet drift ≤2.3px and return exactly. The backdrop cache repaints while the camera eases + (~0.35ms/frame desktop, settled key at both ends) — if the phone stutters in rallies, zero these + two before touching anything else. + ## Session log: nothing arrives unannounced (2026-08-11, same day) Oliver's retest: *"the horde is not announced anymore"* and *"bosses are just there now"*. Both were diff --git a/index.html b/index.html index 102820f..747d021 100644 --- a/index.html +++ b/index.html @@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/config.js b/src/config.js index cecae40..4fbc3b1 100644 --- a/src/config.js +++ b/src/config.js @@ -274,6 +274,19 @@ export const TUNE = { aimCamY: 0, // m of camera height change (negative drops you into the shot) aimCamZ: 0, // m of camera dolly (negative = closer). Both 0 = the camera holds still, // which is where Oliver landed: the aim graphics carry the read on their own. + // FAR-BODY READABILITY. Bodies (never the court) draw off a flattened scale curve: + // s' = s * (sNear/s)^farK, anchored at the player's deepest stance so HE is untouched. The court + // lines carry the perspective; an oversized far body reads as "legible", not "wrong" — the + // 16-bit tennis trick. It buys what the lens could not: the camZ-55 note above fitViewport + // measured the court being spent (umpire clipped, top-down look) for the same far pixels. + farK: 0.25, // 0 = pure projection. 0.25 grows the far-baseline opponent ~21%. + // BALL-FOLLOW LEAN (tennis + match only): while the ball is on the FAR half the camera eases + // toward camY+farCamY / camZ+farCamZ — a broadcast-style lean into the deep court. It pivots + // about the near player's feet (see fOff in render.js), so his stance and the pull room the + // apron math promised never move. Both 0 = the camera holds still. Defaults are deliberately + // shy of visible: the phone (not the desktop pane) is where a moving camera gets its verdict. + farCamY: 0.35, // m of camera rise while the ball is far (steepens the look-down) + farCamZ: -0.6, // m of camera dolly while the ball is far (negative = closer) backstopZ: -1.8, // z of the far backstop — must sit IN FRONT of the -2 retire line, or balls die before they reach it backstopKeep: 0.55, // pace a ball keeps after the rebound — still lethal, slower backstopPop: 1.8, // m/s of lift off the fence, so the rebound travels back instead of dying at the net posts diff --git a/src/render.js b/src/render.js index 009a750..19cd007 100644 --- a/src/render.js +++ b/src/render.js @@ -195,9 +195,24 @@ for (const ms of [0, 120, 400, 1200]) setTimeout(fitViewport, ms); // ...and the // behind the net a shot you can actually judge. Eased once per frame in render(), never mid-frame // — every proj() call within one frame must share the same camera or the scene shears. let aimCam = 0; +// The ball-follow lean (see config farCamY/farCamZ): eased once per frame like aimCam. fOff is the +// compensation that pivots the move about the near player's deepest stance — without it any camY +// or camZ change slides his feet toward the rack (14 logical px for 0.4 m of camY), and the apron +// math in fitViewport promised that pull room. Recomputed in render() whenever farCam moves. +let farCam = 0, fOff = 0; export function proj(x, y, z) { - const d = TUNE.camZ + aimCam * TUNE.aimCamZ - z, s = TUNE.camF / d; - return { x: W / 2 + x * s, y: TUNE.horizon + (TUNE.camY + aimCam * TUNE.aimCamY - y) * s, s }; + const d = TUNE.camZ + aimCam * TUNE.aimCamZ + farCam * TUNE.farCamZ - z, s = TUNE.camF / d; + return { x: W / 2 + x * s, y: TUNE.horizon + fOff + (TUNE.camY + aimCam * TUNE.aimCamY + farCam * TUNE.farCamY - y) * s, s }; +} +// FAR-BODY READABILITY (config farK): the flattened scale curve BODIES draw with, while the court +// keeps the true projection. Anchored at the player's deepest stance, clamped so nothing nearer +// ever shrinks. It is a pure function of z so every anchor that hangs off a body (flames, boss HP, +// muzzle glow) agrees with the sprite by passing a boosted METRES height through the same proj() +// it always used — boosting only the drawn frame left the HP bar sitting on the boss's hat. +function farB(z) { + if (!TUNE.farK) return 1; + const s = proj(0, 0, z).s, r = proj(0, 0, P_BOUNDS.zMax).s; + return s > 0 && s < r ? Math.pow(r / s, TUNE.farK) : 1; } @@ -392,7 +407,10 @@ function drawBackdrop(pal, pt) { // aimCam knobs are actually in use, so an ordinary pull never invalidates the cache. const key = [W, H, court.id, pt.toFixed(2), !!crowdImg, !!(SPR[court.ads] || SPR['x_ads']), TUNE.horizon, TUNE.camZ, TUNE.camY, TUNE.camF, - (aimCam * TUNE.aimCamY).toFixed(2), (aimCam * TUNE.aimCamZ).toFixed(2)].join('|'); + (aimCam * TUNE.aimCamY).toFixed(2), (aimCam * TUNE.aimCamZ).toFixed(2), + // the lean moves the cached court too — repaints happen only while farCam eases, + // then the key settles (farCam snaps to exactly 0/1) + (farCam * TUNE.farCamY).toFixed(2), (farCam * TUNE.farCamZ).toFixed(2)].join('|'); if (key !== bgKey) { bgKey = key; if (!bg) bg = document.createElement('canvas'); @@ -497,7 +515,7 @@ function drawBoy(b) { const p = proj(b.x, 0, b.z); // scale off the frame's OWN pixel height, not a fixed BOY_H: the sheets are common-scaled per // sheet, so a deep mopping lean is genuinely shorter and must not be stretched back upright - const h = BOY_H * p.s * (img.height / SPRITE_H), w = h * img.width / img.height; + const h = BOY_H * p.s * (img.height / SPRITE_H) * farB(b.z), w = h * img.width / img.height; drawShadow(p.x, p.y, w * 0.3, h * 0.04, 0.25); ctx.save(); ctx.translate(p.x, p.y - (mop ? 0 : Math.floor(b.runT * 20) % 2)); if (b.dir < 0) ctx.scale(-1, 1); @@ -523,7 +541,8 @@ const ZSTRIDE = { g: TUNE.bruteStride, k: TUNE.rollerStride }; // it separates itself from the static ads and the crowd behind it at 20 px tall. function drawBarrel(zb) { const img = SPR.z_barrel1; - const h = TUNE.barrelH * (img ? img.height / SPRITE_H : 1); + const B = farB(zb.z); // readability boost — drum, glow anchor and rolling frames share it + const h = TUNE.barrelH * (img ? img.height / SPRITE_H : 1) * B; const p = proj(zb.x, h * 0.5, zb.z); const k = 0.55 + 0.45 * Math.sin((zb.roll || 0) * 3.1); // the pulse rides the roll, not a clock ctx.save(); @@ -546,7 +565,7 @@ function drawBarrel(zb) { if (img) { // two frames alternating off GROUND COVERED is what makes it read as rolling rather than sliding const n = SPR.z_barrel2 && stepFrame((zb.roll || 0) / 0.5, 2) ? 'z_barrel2' : 'z_barrel1'; - drawStatic(n, zb.x, zb.z, TUNE.barrelH * (SPR[n].height / SPRITE_H)); + drawStatic(n, zb.x, zb.z, TUNE.barrelH * (SPR[n].height / SPRITE_H) * B); } else { // until the PNG lands, a lit drum ctx.fillStyle = UI.pink; const w = Math.max(2, p.s * 0.34), hh = Math.max(3, p.s * 0.44); @@ -568,7 +587,7 @@ function drawMachine(zb) { const name = dmg && SPR[dmg] ? dmg : firing && SPR.z_mach2 ? 'z_mach2' : 'z_mach1'; const img = SPR[name]; if (!img) return; // sheet not loaded yet: draw nothing, not a box - const h = MACH_H * (img.height / SPRITE_H); + const h = MACH_H * (img.height / SPRITE_H) * farB(zb.z); // readability boost — muzzle glow rides h too drawStatic(name, zb.x, zb.z, h); if (firing) { // the TELEGRAPH still needs to read at 20px tall, so the muzzle glow stays procedural const k = Math.max(0, 1 - zb.windT / (zb.windMax || TUNE.machWind)), p = proj(zb.x, h * 0.62, zb.z); // fade over the ACTUAL wind @@ -737,6 +756,9 @@ function drawZombie(zb) { // already existed for the wound stages; it belongs to every frame, and it is the same rule // _slice.py's common scaling is built on (see PROJECT.md: heights follow the PNG). if (SPR[name]) h *= SPR[name].height / SPRITE_H; + // the readability boost, AFTER every art-driven height rule: h is in metres here, so the flames, + // the boss glow and the HP bar below all inherit it through the same proj() as the sprite + h *= farB(zb.z); // feeding: a fast bob over the body instead of a stride. Same sprites, different rhythm. // ...and the SHAMBLE BOB is the same rule again, which it was breaking twice over. It was // `Math.floor(zb.walkT * 5.2) % 2` — a 1 px lift on a WALL CLOCK, applied to every body every @@ -958,7 +980,7 @@ function drawDrillHud() { const legal = drillLegal(); for (let i = 0; i < legal.length; i++) { const zb = legal[i]; - const p = proj(zb.x, zombieH(zb) + 0.5, zb.z); + const p = proj(zb.x, zombieH(zb) * farB(zb.z) + 0.5, zb.z); // hover clear of the BOOSTED head if (p.s <= 0) continue; const bob = Math.sin(G.idleT * 6) * 2; const px = Math.round(p.x), py = Math.round(p.y + bob); @@ -1402,7 +1424,10 @@ function drawChar(ch) { // of a swap to the run frames; both are gone. Sliding a sprite sideways is not footwork — it reads // as the man skating — and the idle pair's own weight shift is the whole of what he should be doing. const p = proj(ch.x, 0, ch.z); - const hpx = PLAYER_H * p.s; + // the readability boost is what makes the MATCH OPPONENT legible: at the far baseline he was + // ~34 logical px of faithful anatomy, which is mush. The near player anchors the curve (farB + // clamps at his deepest stance), so P draws within a hair of his old size everywhere he stands. + const hpx = PLAYER_H * p.s * farB(ch.z); // pick sprite const pre = ch.side === 'P' ? 'p_' : 'o_'; let name = pre + (Math.floor(G.idleT * 1.6) % 2 ? 'idle1' : 'idle2'); // idle weight shift @@ -1922,6 +1947,15 @@ export function render() { ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density // ease the aim camera FIRST, before anything projects — one camera per frame (see proj) aimCam += ((G.aim ? 1 : 0) - aimCam) * 0.15; + // ...and the ball-follow lean with it, tennis rallies only: the gore modes fire a ball down-court + // every second and the camera would pump. Slower ease than the aim (a broadcast lean, not a snap). + farCam += (((G.match || G.mode === 'tennis') && ball.active && ball.z < COURT.netZ ? 1 : 0) - farCam) * 0.06; + if (farCam < 0.001) farCam = 0; else if (farCam > 0.999) farCam = 1; // settle exactly, so the + // backdrop cache key stops moving at either end + // pivot the lean about the player's deepest stance — see the fOff note at proj() + const fs0 = TUNE.camF / (TUNE.camZ - P_BOUNDS.zMax); + const fs1 = TUNE.camF / (TUNE.camZ + farCam * TUNE.farCamZ - P_BOUNDS.zMax); + fOff = TUNE.camY * fs0 - (TUNE.camY + farCam * TUNE.farCamY) * fs1; if (G.shake) { // bomb kick: jitter the whole world (transform is reset before the HUD) const m = G.shake.mag * G.shake.t / 0.35; ctx.translate((Math.random() * 2 - 1) * m, (Math.random() * 2 - 1) * m); @@ -2009,7 +2043,9 @@ export function render() { // far-side drawables in z order (far → near), then the net, then the near side const farSide = [ - { z: COURT.netZ - 0.01, f: () => drawStatic('x_ump', UMP_X, COURT.netZ, UMP_H) }, + // the umpire wears the same boost as a body standing at his depth, or he'd read as the one + // shrunken figure on a court of boosted ones + { z: COURT.netZ - 0.01, f: () => drawStatic('x_ump', UMP_X, COURT.netZ, UMP_H * farB(COURT.netZ)) }, ]; if (!G.hideO) farSide.push({ z: O.z, f: () => drawChar(O) }); if (G.boy && G.boy.z <= COURT.netZ) farSide.push({ z: G.boy.z, f: () => drawBoy(G.boy) });