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:
1 parent
031a203bb4
commit
e571e46d92
4 files changed
+82
-11
No files matched your search
+46
-10
@@ -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) });
|
||||
|
||||
Reference in new issue
Block a user