Exact touchdown, the boy finishes his shift, healing announces itself, 4K heads
Five of Oliver's six (the sixth is a play-mode discussion): - THE DOUBLE FAULTS WERE FRAME QUANTIZATION: the bounce registered a full integration step after true contact — ~0.75 m of travel at serve pace — so serves aimed half a metre safe were judged long. Classic enforces the service box, the match only the court, hence 'zombie tennis does not'. The bounce now rewinds to y=0 before anything reads the position. Measured in-pane: 30/40 faults on good taps before, 0/40 after (mistimed and smudged taps also 0/40; aimed hard serves keep their scatter risk). Also: the strike flick dead zone is 44 px so a smudged tap stays a tap, and the aim base clamps 0.18 m inside the lines — scatter, not the aim, is what faults. - The mop beat holds the walk-on until the boy actually LEAVES the court (ordered off at beat end, 6 s cap). - A match game win that restores blood shows the duel's WOUNDS TREATED popup; full-blood wins stay silent. - The opponent walk-on card holds 4.0 s (was 2.4). - Card heads are cut from the 4K sheets at 144 px (assets/_heads.py, z_<fam>head.png, HEAD_CROP stays as fallback) — the old 65 px crops were the 'very low res' heads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c847bcbfa1
commit
c6218a6284
17 files changed
+178
-23
No files matched your search
+28
-8
@@ -417,11 +417,22 @@ export function tickMatchFall(dt) {
|
||||
// on with its own intro card, exactly as it used to on the kill frame.
|
||||
const d = G.match;
|
||||
if (d && d.mopT > 0 && (d.mopT -= dt) <= 0) {
|
||||
d.mopT = 0;
|
||||
matchOpponent();
|
||||
d.ending = false; // play re-opens only now that a body is on court
|
||||
matchReady();
|
||||
G.phase = 'over'; G.overT = 2.2; // matchReady's setupPoint re-arms pretoss; keep the old beat
|
||||
// Time is up — but THE COURT IS HIS UNTIL HE LEAVES IT (Oliver: "no playing as long as the
|
||||
// mopboy is on court"). He is ordered off (jobs zeroed, the half-pushed stain dropped) and
|
||||
// the walk-on holds until he is clear of the playing area; the 6 s cap is a belt on top of
|
||||
// the braces — nothing should ever need it, but a stalled beat must not stall the match.
|
||||
const b = G.boy;
|
||||
if (b && b.mop && Math.abs(b.x) < 6.5 && (d.mopHold = (d.mopHold || 0) + dt) < 6) {
|
||||
b.jobs = 0; b.tx = undefined; b.tz = undefined; b.mopT = 0;
|
||||
if (G.interlude && G.interlude.introT < 1) G.interlude.introT = 1; // serving stays barred
|
||||
d.mopT = 0.001; // the beat re-checks next tick
|
||||
} else {
|
||||
d.mopT = 0; d.mopHold = 0;
|
||||
matchOpponent();
|
||||
d.ending = false; // play re-opens only now that a body is on court
|
||||
matchReady();
|
||||
G.phase = 'over'; G.overT = 2.2; // matchReady's setupPoint re-arms pretoss; keep the old beat
|
||||
}
|
||||
}
|
||||
if (!G.matchFall) return tickMatchServe(dt);
|
||||
const f = G.matchFall;
|
||||
@@ -506,8 +517,10 @@ function matchOpponent() {
|
||||
// so killZombie can file the kill under it without asking G which opponent we were on.
|
||||
zb.foe = matchFoe(G.match.opp);
|
||||
zb.fam = zb.foe.fam; // ...and the body it wears (config.ZFAMS)
|
||||
// the walk-on card wears the opponent's own head (config.HEAD_CROP — a crop, not new art)
|
||||
G.card = { title: zb.foe.name, sub1: zb.foe.line, color: UI.hot, t: 2.4, head: zb.fam };
|
||||
// the walk-on card wears the opponent's own head (config.HEAD_CROP — a crop, not new art).
|
||||
// 4.0 s: at 2.4 the head + name + punchline were gone before they were read ("the intro card
|
||||
// of the player should last longer")
|
||||
G.card = { title: zb.foe.name, sub1: zb.foe.line, color: UI.hot, t: 4.0, head: zb.fam };
|
||||
zb.hp = zb.hpMax = TUNE.matchOppHp; // a FLAT pool: with regicide gone the body is pure
|
||||
// scoreboard (a quarter per point), so a deeper pool would
|
||||
// change nothing — the ladder escalates in racketAI instead
|
||||
@@ -574,7 +587,14 @@ function matchGameWon(zb, banked = false) {
|
||||
// on a game point) — bank the game by hand or the scoreboard drifts from the ladder.
|
||||
if (!banked) { score.games[0]++; score.pts = [0, 0]; }
|
||||
G.runGames++; // the ladder metric, same as every mode
|
||||
G.hp = Math.min(G.hpMax, G.hp + TUNE.matchHeal); // a clean game is how you last
|
||||
// a clean game is how you last — and when it actually restores blood, the trainer SAYS so
|
||||
// (Oliver: "show wounds healed popup in zombie tennis when appropriate"). Same popup as the
|
||||
// duel's, so healing has one face everywhere; a full-blood win stays silent.
|
||||
const healed = Math.min(G.hpMax, G.hp + TUNE.matchHeal) - G.hp;
|
||||
G.hp += healed;
|
||||
if (healed > 0)
|
||||
showModal({ title: 'WOUNDS TREATED', icon: 'x_cross', iconH: 52, color: UI.pick,
|
||||
body: ['THE TRAINER PATCHED YOU UP', G.hp + ' OF ' + G.hpMax + ' BACK'] });
|
||||
voice('game_player', 0.3); cheer(2.4, 0.6);
|
||||
// GAME is the FLASH and the next opponent's name plate is the CARD. It used to be the other way
|
||||
// round and matchOpponent's plate landed on the same slot a frame later, so 'GAME.' never drew
|
||||
|
||||
+25
-2
@@ -379,7 +379,16 @@ function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
|
||||
else {
|
||||
// target: diagonal service box. Serving from deuce side → target box is across.
|
||||
const scatter = (0.8 + hard * TUNE.serveHardErr) * (1 - quality);
|
||||
boxX = (deuceSide() ? -1 : 1) * (toFar ? 1 : -1) * 2.0 + sx * TUNE.serveAimX
|
||||
// THE AIM BASE NEVER LEAVES THE BOX. A full sway used to carry the target ACROSS the centre
|
||||
// line — an aimed serve was a fault by construction, not by risk. The sway now runs from the
|
||||
// box centre to 0.18 m INSIDE either line, and only the timing scatter (added after) can
|
||||
// carry the ball out — which is the tennis being asked for: the line is where you aim, the
|
||||
// miss is what you risked. (Classic enforces the box; see fault()'s caller.)
|
||||
const side = (deuceSide() ? -1 : 1) * (toFar ? 1 : -1);
|
||||
const half = COURT.W / 2;
|
||||
const aim = side * 2.0 + sx * TUNE.serveAimX;
|
||||
const lo = side < 0 ? -half + 0.18 : 0.18, hi = side < 0 ? -0.18 : half - 0.18;
|
||||
boxX = Math.max(lo, Math.min(hi, aim))
|
||||
+ (Math.random() * 2 - 1) * TUNE.aimError * scatter;
|
||||
// DEPTH RIDES QUALITY (player serves only): a flush strike aims near the service line, the
|
||||
// ONLY depth where flat pace survives the net-clearance loop — MEASURED with the replica
|
||||
@@ -1296,7 +1305,10 @@ export function keyPressed(code) {
|
||||
// unit components; mag carries the length). Keyboard: left/right sway, up = the hard one.
|
||||
// A plain tap stays the safe centre-box serve — that is the second-serve decision.
|
||||
const kb = { ax: axisX(), az: axisZ() };
|
||||
const k = flick ? Math.max(0, Math.min(1, (flick.mag - 24) / 130)) : 0;
|
||||
// the dead zone is 44 px, well above the 28 px flick threshold: a slightly smudged strike TAP
|
||||
// must stay a safe serve — accidental sway was most of classic's "double fault very often",
|
||||
// because classic's line call enforces the service box (the match's only checks the court)
|
||||
const k = flick ? Math.max(0, Math.min(1, (flick.mag - 44) / 130)) : 0;
|
||||
const sway = flick ? flick.fx * k : kb.ax * 0.75;
|
||||
const hard = flick ? Math.max(0, -flick.fy) * k : (kb.az < 0 ? 0.8 : 0);
|
||||
hitServe(P, q, sway || hard ? { sx: sway, hard } : null);
|
||||
@@ -1688,6 +1700,17 @@ export function update(dt) {
|
||||
}
|
||||
}
|
||||
if (ball.y <= 0 && ball.vy < 0) {
|
||||
// EXACT TOUCHDOWN, not the frame after it. The integrated position lands up to one full
|
||||
// step PAST the true contact — at serve pace that is ~0.75 m of travel in 1/60 s, so a
|
||||
// serve aimed half a metre inside the service line was routinely JUDGED long. Oliver saw it
|
||||
// as "classic tennis gives double fault very often" (the match's line call only checks the
|
||||
// whole court, which is why zombie tennis seemed immune). Rewind the step to y = 0 before
|
||||
// the bounce position is read by anything — the line call, the bounce dust, the mark.
|
||||
const yOld = ball.y - ball.vy * dt; // where this step began (vy already g-updated)
|
||||
if (yOld > 0) {
|
||||
const back = dt * (1 - yOld / (yOld - ball.y)); // time spent below the ground this step
|
||||
ball.x -= ball.vx * back; ball.z -= ball.vz * back;
|
||||
}
|
||||
ball.y = 0; ball.vy = -ball.vy * TUNE.bounceY;
|
||||
ball.vx *= TUNE.bounceXZ; ball.vz *= TUNE.bounceXZ;
|
||||
if (Math.abs(ball.vy) > 1.0) dustPuff(ball.x, ball.z, 3, 0.7); // a real bounce kicks up the court
|
||||
|
||||
+17
-7
@@ -221,7 +221,7 @@ function farB(z) {
|
||||
// Asset cache-buster — bump with main.js?v=N (see the img.src note below). EXPORTED because the
|
||||
// menu screens hang <img> tags on the same files, and two copies of this number is one copy too
|
||||
// many: the one that does not get bumped is the one that serves a cached 404.
|
||||
export const SPRV = 111;
|
||||
export const SPRV = 112;
|
||||
// The boot bar in main.js watches this. Nothing here CHANGES when things load — every sprite was
|
||||
// already requested at import — it just makes the wait visible and holds the menu back until the
|
||||
// last sheet has landed. See the onerror note below for the other half of "sometimes missing".
|
||||
@@ -273,6 +273,9 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','
|
||||
// filenames typed out here (each of which is a chance to typo one and have a body
|
||||
// vanish for a frame — the oldest bug in this file).
|
||||
...ZFAMS.flatMap(famFrames),
|
||||
// the card's 4K-cut head icons (assets/_heads.py). 'r' has no fam sheet — his
|
||||
// card keeps the HEAD_CROP fallback, so no file and no 404 for him.
|
||||
...ZFAMS.filter(k => k !== 'r').map(k => 'z_' + k + 'head'),
|
||||
'w_ball','w_bomb','w_triple','w_fire','w_gun', // weapon-rack icons
|
||||
'w_beacon','w_laser','w_rocket','w_flamer', // ...and the four that come out of a barrel
|
||||
'z_barrel1','z_barrel2', // the weapon barrel, two frames so it ROLLS
|
||||
@@ -1711,10 +1714,16 @@ function cardBody(c, dx) {
|
||||
// and boss names of any length, and Bangers 22 ran a long one off both edges of a phone. Measure
|
||||
// once and shrink to the band; 22 stays 22 for everything that already fit.
|
||||
// THE OPPONENT CARD CARRIES ITS HEAD (Oliver: the punchline plus a picture of his head). The
|
||||
// icon is a straight crop out of the family's own healthy frame — config.HEAD_CROP, measured,
|
||||
// never separate art — drawn left of the text block, which shifts right to keep the whole
|
||||
// group centred as one. A family with no crop row degrades to the plain text card.
|
||||
const hd = c.head && HEAD_CROP[c.head] && SPR['z_' + c.head + '1'] ? HEAD_CROP[c.head] : null;
|
||||
// icon prefers the 4K-cut z_<fam>head.png (assets/_heads.py — the installed 224 px frames gave
|
||||
// ~65 px heads drawn at 144 device px, which is the "very low res" he reported) and degrades
|
||||
// to the HEAD_CROP crop of the healthy frame, then to the plain text card. Never separate art:
|
||||
// both sources are the body already on court.
|
||||
const headImg = c.head && SPR['z_' + c.head + 'head'];
|
||||
const hd = headImg ? { img: headImg, w: headImg.width, h: headImg.height }
|
||||
: c.head && HEAD_CROP[c.head] && SPR['z_' + c.head + '1']
|
||||
? { img: SPR['z_' + c.head + '1'], box: HEAD_CROP[c.head],
|
||||
w: HEAD_CROP[c.head].w, h: HEAD_CROP[c.head].h }
|
||||
: null;
|
||||
const ih = hd ? CARD_H - 14 : 0, iw = hd ? Math.round(hd.w * ih / hd.h) : 0;
|
||||
const room = W - 24 - (hd ? iw + 8 : 0);
|
||||
ctx.font = B(22);
|
||||
@@ -1731,8 +1740,9 @@ function cardBody(c, dx) {
|
||||
let blockW = 0;
|
||||
for (const [t, f] of lines) { ctx.font = f; blockW = Math.max(blockW, ctx.measureText(t).width); }
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(SPR['z_' + c.head + '1'], hd.x, hd.y, hd.w, hd.h,
|
||||
Math.round(cx - blockW / 2 - 8 - iw), Math.round(CARD_MID - ih / 2), iw, ih);
|
||||
const hx = Math.round(cx - blockW / 2 - 8 - iw), hy = Math.round(CARD_MID - ih / 2);
|
||||
if (hd.box) ctx.drawImage(hd.img, hd.box.x, hd.box.y, hd.box.w, hd.box.h, hx, hy, iw, ih);
|
||||
else ctx.drawImage(hd.img, hx, hy, iw, ih);
|
||||
}
|
||||
for (const [t, f, rel, col] of lines) { ctx.font = f; shadowText(t, cx, y + rel, col); }
|
||||
ctx.textAlign = 'center'; ctx.restore(); // the band's neighbours (flash, countdown) assume centred
|
||||
|
||||
Reference in new issue
Block a user