Far bodies draw off a flattened scale curve, and the camera leans into the deep court

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 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-13 16:18:53 +02:00
1 parent 031a203bb4
commit e571e46d92
4 files changed
+82 -11

No files matched your search

+22
View File
@@ -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
+1 -1
View File
@@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
</div>
</div>
</div>
<script type="module" src="src/main.js?v=209"></script>
<script type="module" src="src/main.js?v=210"></script>
</body>
</html>
+13
View File
@@ -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
+46 -10
View File
@@ -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) });