The flick stops losing the point, and the corpse falls and serves
THE FLICK. "when returning a ball with a spin it seems too sensitive, 8/10 times the ball lands out." Arithmetic, not feel. The singles line is 4.115 m; an aimed shot targeted a flat ax*3.3 and THEN added err() on top — up to +-2.4 m at quality 0 — so a flicked return landed as wide as 5.7 m, and the only clamp in the path sat at +-5, outside the court. An UNAIMED return targets 0 + the same scatter and is therefore always in: aiming was the thing losing the point, which is backwards for the mode's one act of skill. TUNE.flickReach scales the aim width by the same quality the scatter shrinks with, so the two can never sum past the line — worst case 1.49+2.4 = 3.89, and a perfectly timed flick still gets the full 3.3 with nothing left to scatter. The test asserts that sum across the whole quality range rather than sampling rallies. Not named `aimReach`: that key already exists further down TUNE (the slingshot's 22 m aim ray) and the duplicate silently won until a test caught it. The placement ring carries `q` now and is the flick's receipt — tight and hot when your timing earned the full width, wide and dim when you barely got a racket on it. Without it the rule is invisible: the shot just goes less wide. THE FALL (handoff item 4). A point that takes a limb knocks the body down and it drags itself up short of it. The three collapse frames wear the stage it STILL WAS and the rise wears the stage it has BECOME — that order is the whole beat, and it is what turns a popping wound stage into something the point did. Own clock beside the horde's warning, for the reason this file keeps relearning (a beat that moves the phase under a latch strands it); the 'over' beat it sets outlives the fall so it cannot expire into setupPoint() mid-collapse. Only fires when the stage actually moved — a quarter off a full pool is 0.75, so point one has no limb to explain and a second of collapse for nothing is a beat the player waits out. THE OPPONENT SERVES (handoff item 7), and it was mostly switched off rather than missing. setupPoint already placed a non-player server behind its own baseline and armed aiServeT; update() already tossed and struck for O. Both were gated on `G.mode === 'tennis'` and the match runs in splatter under a permanent interlude — one condition. Genuinely new: service alternates at each game boundary (the match calls advancePoint directly and the flip lives in tennis's pointTo, which it bypasses — the same shape as the G.serveNum bug), the zombie takes O's mark so picture and ballistics agree on where it stands, and the serve poses are named onto it because O is invisible here. racketStage() moves to config: drawZombie and the fall must not answer the same question twice, which is how the boss's skins and its blood drifted apart. Docs: PROJECT.md gains THE ZOMBIE FAMILY PIPELINE — the measured API contract (4K is a parameter and the default is 1K; n is 1 so "return both" is not a thing; aspect is an enum so pad to it), why magenta stays despite green being the documented key, and the two things _zsheet.py must do globally. Verified in the browser: brute in the match, z_brfall2 -> z_brw1rise on a wounding point, and bounce -> toss -> serve off the opponent's own baseline. 58 tests green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cba2fb8a8b
commit
c1f7268a04
9 files changed
+412
-37
No files matched your search
+92
-4
@@ -7,8 +7,8 @@
|
||||
// (beats <-> gameplay and beats <-> zombies are runtime-only import cycles, same pattern as
|
||||
// input <-> gameplay: the imported bindings are only touched inside functions, never at load.)
|
||||
|
||||
import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME } from './config.js';
|
||||
import { G, P, O, ball, score, setupPoint, sendBoy } from './state.js';
|
||||
import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage } from './config.js';
|
||||
import { G, P, O, ball, score, setupPoint, sendBoy, other } from './state.js';
|
||||
import { voice, zombieDuck, aww } from './audio.js';
|
||||
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js';
|
||||
import { cheer, aside, endBurst, deliverTin, showModal, logSwing, advancePoint, scoreKey } from './gameplay.js';
|
||||
@@ -305,6 +305,7 @@ export function watchdog(dt) {
|
||||
export function startMatch() {
|
||||
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return false;
|
||||
G.match = { opp: 1, ending: false };
|
||||
G.matchFall = null;
|
||||
G.hp = G.hpMax = TUNE.matchPlayerHp; // a real match is longer than four mistakes — the gore
|
||||
// mode's 4 was "too weak" here, felt not calculated
|
||||
score.server = 'P'; // you serve every point (the corpse never learned to toss)
|
||||
@@ -337,7 +338,83 @@ function matchReady() {
|
||||
G.serveNum = 1;
|
||||
setupPoint();
|
||||
const rz = zombies.find(z => z.variant === 'r');
|
||||
if (rz) { rz.x = O.x; rz.z = 1.0; rz.pose = 'idle'; rz.swingT = -1; rz.swingCool = 0.3; }
|
||||
// THE OPPONENT STANDS WHERE THE POINT PUTS IT: on its receiving mark, or behind its own baseline
|
||||
// when it is the one serving. setupPoint has just placed O — the invisible tennis body the serve
|
||||
// machinery is driven off — on exactly that spot, so the zombie simply takes it. One source for
|
||||
// the mark, which is what stops the picture and the ballistics disagreeing about where it stands.
|
||||
if (rz) {
|
||||
rz.x = O.x; rz.z = score.server === 'O' ? O.z : 1.0;
|
||||
rz.pose = 'idle'; rz.swingT = -1; rz.swingCool = 0.3;
|
||||
rz.showFrame = null; rz.serveHold = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- THE FALL. A point won knocks the body DOWN and it drags itself back up MINUS THE LIMB.
|
||||
//
|
||||
// It exists to explain a thing the mode was doing silently: the wound stage POPPED between points,
|
||||
// so an arm simply stopped being there and nothing said why. Now the three collapse frames are the
|
||||
// body it STILL WAS and the rise is the body it has BECOME — the order is the whole beat, and it is
|
||||
// what makes the missing arm read as something the point did rather than a sprite swap.
|
||||
//
|
||||
// On its OWN clock, deliberately, and not on the 'over' beat it also sets. Any beat that moves the
|
||||
// phase underneath a latch strands it forever — that is the mgT lock this file already carries a
|
||||
// section about, and the horde's warning is on its own clock for the same reason. The 'over' beat is
|
||||
// only there to keep the court quiet, and it is set to outlive the fall so it cannot expire into
|
||||
// setupPoint() mid-collapse and hand the player a serve over a body that is still on the ground.
|
||||
const FALL = [['fall1', 0.16], ['fall2', 0.18], ['fall3', 0.30], ['rise', 0.34]];
|
||||
export const FALL_LEN = FALL.reduce((s, f) => s + f[1], 0);
|
||||
|
||||
function startFall(from) {
|
||||
G.matchFall = { t: 0, from };
|
||||
ball.active = false;
|
||||
G.phase = 'over'; G.overT = FALL_LEN + 0.6; // outlives the fall on purpose — see above
|
||||
}
|
||||
|
||||
// Ticked from update(), beside the horde's warning, for the same reason it is. It also drives the
|
||||
// OPPONENT'S SERVE POSES, because both are the same job — naming the frame while the body is doing
|
||||
// something the walk cycle has no picture for.
|
||||
export function tickMatchFall(dt) {
|
||||
if (!G.matchFall) return tickMatchServe(dt);
|
||||
const f = G.matchFall;
|
||||
const zb = zombies.find(z => z.variant === 'r');
|
||||
if (!zb) { G.matchFall = null; return matchReady(); } // felled by something else mid-fall
|
||||
f.t += dt;
|
||||
let acc = 0, cur = null;
|
||||
for (const [suf, len] of FALL) { acc += len; if (f.t < acc) { cur = suf; break; } }
|
||||
if (!cur) { // back on its feet: hand the point back
|
||||
G.matchFall = null; zb.showFrame = null;
|
||||
return matchReady();
|
||||
}
|
||||
// ...and this is the line the beat is for: the collapse wears the OLD stage, the rise wears the
|
||||
// NEW one. drawZombie's showFrame override degrades a missing stage to the healthy frame, so a
|
||||
// family whose fall rows have not been drawn yet still plays the beat instead of blinking out.
|
||||
const st = cur === 'rise' ? racketStage(zb) : f.from;
|
||||
zb.showFrame = 'z_' + (zb.fam || 'r') + (st ? 'w' + st : '') + cur;
|
||||
zb.pose = 'idle'; zb.swingT = -1;
|
||||
}
|
||||
|
||||
// ---- THE OPPONENT SERVES. Alternating service is real tennis, and it doubles the mode's skill
|
||||
// surface: until now the corpse only ever RETURNED, so return timing — half of the sport — was never
|
||||
// asked for. Almost all of it already existed and was switched off: setupPoint's tennis branch
|
||||
// already places a non-player server behind its own baseline and arms G.aiServeT, and update()
|
||||
// already tosses and strikes for O. Both were gated on `G.mode === 'tennis'`, and the match runs in
|
||||
// splatter with a permanent interlude, so the whole machine was one condition away from running.
|
||||
//
|
||||
// What is genuinely new is the PICTURE. O is invisible here — the body you see is the zombie — so
|
||||
// the serve poses have to be named onto it, exactly as the fall names its collapse: bounce while it
|
||||
// waits, toss as the ball goes up, and the strike held briefly after contact so the swing reads as
|
||||
// something the body did rather than a ball that simply departed.
|
||||
function tickMatchServe(dt) {
|
||||
if (!G.match || score.server !== 'O') return;
|
||||
const zb = zombies.find(z => z.variant === 'r');
|
||||
if (!zb) return;
|
||||
if (zb.serveHold > 0) zb.serveHold -= dt;
|
||||
const st = racketStage(zb);
|
||||
const pre = 'z_' + (zb.fam || 'r') + (st ? 'w' + st : '');
|
||||
zb.showFrame = zb.serveHold > 0 ? pre + 'serve'
|
||||
: G.phase === 'pretoss' ? pre + 'bounce'
|
||||
: G.phase === 'toss' ? pre + 'toss'
|
||||
: null;
|
||||
}
|
||||
|
||||
function matchOpponent() {
|
||||
@@ -359,7 +436,7 @@ function matchOpponent() {
|
||||
|
||||
export function endMatch() {
|
||||
if (!G.match) return;
|
||||
G.match = null; G.interlude = false;
|
||||
G.match = null; G.interlude = false; G.matchFall = null; // a latch left armed outlives its mode
|
||||
zombies.length = 0; shots.length = 0;
|
||||
zombieDuck(false);
|
||||
ball.active = false; setupPoint();
|
||||
@@ -372,11 +449,16 @@ function matchPoint(side) {
|
||||
const d = G.match;
|
||||
if (d.ending) return;
|
||||
const zb = zombies.find(z => z.variant === 'r');
|
||||
let fellFrom = -1;
|
||||
if (side === 'P' && zb) {
|
||||
// a QUARTER of its pool per point, floored at 1 — four points walk it through every wound stage
|
||||
// and only the game-winning point actually fells it, so the body and the scoreboard agree all
|
||||
// the way to the end. (Ball hits wound it on top of this; that is the regicide path, kept.)
|
||||
const was = racketStage(zb);
|
||||
zb.hp = Math.max(1, zb.hp - Math.ceil(zb.hpMax / 4));
|
||||
// ONLY when the stage actually moved. A point that chips the pool without crossing a threshold
|
||||
// has no limb to explain, and a second of collapse for nothing is a beat the player waits out.
|
||||
if (racketStage(zb) !== was) fellFrom = was;
|
||||
}
|
||||
if (side === 'O') {
|
||||
G.hp--; G.hurtT = TUNE.hurtGrace; G.hurtFlash = 0.6;
|
||||
@@ -387,6 +469,7 @@ function matchPoint(side) {
|
||||
if (over) return side === 'P' ? matchGameWon(zb, true) : matchGameLost();
|
||||
voice(scoreKey(), 0.4);
|
||||
if (side === 'P') cheer(0.8, 0.3);
|
||||
if (fellFrom >= 0) return startFall(fellFrom); // it goes down, and gets up short of a limb
|
||||
matchReady(); // next point, same opponent, both on their marks
|
||||
}
|
||||
|
||||
@@ -405,6 +488,10 @@ function matchGameWon(zb, banked = false) {
|
||||
// once — and the plate is the better card anyway: it announces the win by replacing the loser.
|
||||
G.flash = { text: 'GAME.', t: 1.4 };
|
||||
d.opp++;
|
||||
// ALTERNATING SERVICE, as the sport has it. The match calls advancePoint directly, which banks
|
||||
// the game but never touches the server — that flip lives in tennis's own pointTo, which the
|
||||
// match bypasses (the same shape as the G.serveNum bug this file already carries a note about).
|
||||
score.server = other(score.server);
|
||||
matchOpponent(); // fresh body, its own intro beat
|
||||
d.ending = false;
|
||||
matchReady();
|
||||
@@ -415,6 +502,7 @@ function matchGameLost() {
|
||||
// Reachable only through deuce (four straight points is exactly your hp) — the body keeps its
|
||||
// wounds, the scoreboard banks their game, and the match simply plays on.
|
||||
voice('game_opponent', 0.3); aww(1);
|
||||
score.server = other(score.server); // their game, and now their serve
|
||||
matchReady();
|
||||
G.phase = 'over'; G.overT = 1.8;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,13 @@ export const TUNE = {
|
||||
// tape AND come down on a target, so it got throttled back to walking pace.
|
||||
// Heavy topspin lets it leave flat and DIVE, which is what a smash actually is.
|
||||
aimError: 2.4, // m of scatter at quality 0
|
||||
// NOT `aimReach` — that name is taken further down by the slingshot's aim-ray LENGTH (22 m), and
|
||||
// the duplicate key silently won: every aimed shot reached 22x its width until a test caught it.
|
||||
flickReach: 0.45, // how much of an aimed shot's width a MISTIMED flick gets (1 = all of it).
|
||||
// Reach x quality must stay under the sideline once aimError is added on
|
||||
// top, or the flick goes out — which is exactly the bug this fixed:
|
||||
// 3.3 * 0.45 + 2.4 = 3.89 against a 4.11 m line. Raise it and the flick
|
||||
// gets punchy again AND starts sailing wide on sloppy timing.
|
||||
perfectMs: 45, // |offset from window center| for PERFECT
|
||||
// The tennis CPU only exists in the OPENING match (after the transition, tennis never comes
|
||||
// back — duels use racket* dials), so these three are the "how long until the first game is won"
|
||||
@@ -735,6 +742,15 @@ export const BENCH_STAGES = ['HEALTHY', 'ARM OFF', 'HEADLESS'];
|
||||
// the gate working, not the gate being wrong. Re-registering it is one entry here; see the note at
|
||||
// the top of _master.py for the fix its next attempt needs.
|
||||
export const ZFAMS = ['r', 'br'];
|
||||
|
||||
// THE RACKET FAMILY'S THREE STAGES, as a FRACTION of the pool — never against literal hp, the rule
|
||||
// this whole file already holds (see woundStage). It lived inline in drawZombie; the FALL BEAT needs
|
||||
// the same answer to know which body collapses and which one gets back up, and two copies of a
|
||||
// threshold is exactly how the boss's skins and its blood drifted apart before.
|
||||
export const racketStage = zb => {
|
||||
const f = zb.hp / (zb.hpMax || TUNE.racketHp);
|
||||
return f <= 0.34 ? 2 : f <= 0.67 ? 1 : 0; // 0 unhurt, 1 arm off, 2 headless
|
||||
};
|
||||
const POSES = ['bounce', 'toss', 'serve', 'fall1', 'fall2', 'fall3', 'rise'];
|
||||
export const famFrames = f => [
|
||||
`z_${f}1`, `z_${f}2`, `z_${f}3`, `z_${f}4`, // stance, shuffle A, shuffle B, forehand
|
||||
|
||||
+43
-11
@@ -8,7 +8,7 @@ import { W } from './render.js'; // runtime-only cycle (render imports gameplay)
|
||||
import { updateDrill } from './drill.js';
|
||||
import { endRun, beginRun, MODE_CLASSIC } from './stats.js';
|
||||
import { updateZombies, updateParts, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js';
|
||||
import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog, updateBench, benchKey } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing
|
||||
import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog, updateBench, benchKey, tickMatchFall } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing
|
||||
|
||||
// ------------------------------------------------------------- ballistics ----
|
||||
// Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g.
|
||||
@@ -130,7 +130,7 @@ function doHit(ch, quality, aim) {
|
||||
ball.x = ch.x + ch.facing * 0.5;
|
||||
ball.z = ch.z + (toFar ? -0.3 : 0.3);
|
||||
const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality);
|
||||
let tx, tz;
|
||||
let tx, tz, aimedLane = false;
|
||||
let s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed;
|
||||
const ft = G.mode !== 'tennis' && !G.interlude && ch.side === 'P' // the duel aims like tennis: lanes, not free-aim
|
||||
? (aim && aim.fx !== undefined ? flickTarget(ch, aim) : { zt: zombieTarget(aim) }) : null;
|
||||
@@ -142,9 +142,20 @@ function doHit(ch, quality, aim) {
|
||||
} else if (ft && ft.tx !== undefined) {
|
||||
tx = ft.tx + err() * 0.4; tz = ft.tz + err() * 0.3; // no magnet: the ball goes where you flicked
|
||||
} else {
|
||||
tx = aim.ax * (toFar ? 3.3 : 2.7) + err(); // your aimed shots go properly wide
|
||||
// AN AIMED SHOT REACHES ONLY AS FAR AS YOUR TIMING EARNED IT. The singles line is 4.11 m out.
|
||||
// This used to aim a flick at a flat 3.3 m and then add err() on top — up to +-2.4 m at quality
|
||||
// 0 — so a flicked return with ordinary timing landed as wide as 5.7 m and the only clamp was
|
||||
// at 5. An UNAIMED return, targeting 0 + the same scatter, was always in. The flick, the one
|
||||
// thing in the mode that is supposed to be skill, was what lost the point: "8/10 times the ball
|
||||
// lands out". Reach now scales with the same quality the scatter shrinks with, so the two can
|
||||
// never add up past the line — worst case is 1.49 + 2.4 = 3.89 at quality 0, and a perfectly
|
||||
// timed flick is the full 3.3 with no scatter left on it at all. Placement is still earned;
|
||||
// it just stops being a coin toss you are punished for calling.
|
||||
const reach = ch.side === 'P' ? TUNE.flickReach + (1 - TUNE.flickReach) * quality : 1;
|
||||
tx = aim.ax * (toFar ? 3.3 : 2.7) * reach + err();
|
||||
const tzBase = aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid;
|
||||
tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8;
|
||||
aimedLane = true;
|
||||
}
|
||||
tx = Math.max(-5, Math.min(5, tx));
|
||||
if (G.match && ch.side !== 'P') {
|
||||
@@ -165,10 +176,15 @@ function doHit(ch, quality, aim) {
|
||||
const wide = (TUNE.matchWideBase + (G.match.opp - 1) * TUNE.matchWidePer) * (ch.foe?.wide || 1);
|
||||
tx = Math.max(-wide, Math.min(wide, tx));
|
||||
}
|
||||
// THE PLACEMENT, MADE VISIBLE: your aimed rally hit stamps its landing point, and render draws a
|
||||
// fading ring there — flick, see the ring bite the corner, learn the cause. Only when a flick or
|
||||
// held direction actually aimed it: an unaimed hit marking its scatter would teach noise.
|
||||
else if (aim && (aim.ax || aim.az || aim.fx !== undefined)) G.placeMark = { x: tx, z: tz, t: 0.8 };
|
||||
}
|
||||
// THE PLACEMENT, MADE VISIBLE: your aimed rally hit stamps its landing point, and render draws a
|
||||
// fading ring there — flick, see the ring bite the corner, learn the cause. Only when a flick or
|
||||
// held direction actually aimed it: an unaimed hit marking its scatter would teach noise.
|
||||
// It carries `q` now, so the ring also says HOW MUCH OF THE FLICK LANDED — tight and hot when your
|
||||
// timing earned the full reach, wide and dim when it barely applied. Without that the new
|
||||
// quality-scaled reach above is invisible: the shot simply goes less wide and nothing says why.
|
||||
if (aimedLane && ch.side === 'P' && aim && (aim.ax || aim.az)) {
|
||||
G.placeMark = { x: tx, z: tz, t: 0.8, q: quality };
|
||||
}
|
||||
// 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
|
||||
@@ -1506,11 +1522,22 @@ export function update(dt) {
|
||||
G.duelLerp = Math.max(0, Math.min(1, G.duelLerp + (G.interlude ? dt : -dt) / 0.8));
|
||||
|
||||
// --- AI (the opponent only exists in tennis mode)
|
||||
if (G.mode === 'tennis' && score.server === 'O' && G.phase === 'pretoss') {
|
||||
// THE MATCH SERVES TOO. This was gated on `G.mode === 'tennis'` and the match runs in splatter
|
||||
// under a permanent interlude, so the corpse could only ever RETURN — return timing, half the
|
||||
// sport, was never asked of the player. setupPoint already places a non-player server behind its
|
||||
// own baseline and arms aiServeT; this is the one condition that was keeping it switched off.
|
||||
// The intro beat holds it: a body still walking on must not open with a serve.
|
||||
const oServes = (G.mode === 'tennis' || G.match) && score.server === 'O'
|
||||
&& !(G.interlude && G.interlude.introT > 0);
|
||||
if (oServes && G.phase === 'pretoss') {
|
||||
G.aiServeT -= dt;
|
||||
if (G.aiServeT <= 0) tossBall(O);
|
||||
} else if (G.mode === 'tennis' && score.server === 'O' && G.phase === 'toss') {
|
||||
if (ball.vy < 0 && ball.y < serveApexY() - 0.1) { G.wasServe = true; hitServe(O, 0.6 + Math.random() * 0.35); }
|
||||
} else if (oServes && G.phase === 'toss') {
|
||||
if (ball.vy < 0 && ball.y < serveApexY() - 0.1) {
|
||||
G.wasServe = true; hitServe(O, 0.6 + Math.random() * 0.35);
|
||||
// ...and the body that just served holds the strike, so the swing reads as its own (beats)
|
||||
if (G.match) { const rz = zombies.find(z => z.variant === 'r'); if (rz) rz.serveHold = 0.4; }
|
||||
}
|
||||
}
|
||||
if (G.mode === 'tennis' && G.phase === 'rally') {
|
||||
G.aiReactT += dt;
|
||||
@@ -1549,7 +1576,9 @@ export function update(dt) {
|
||||
|
||||
// --- racket zombies rally from the far court (splatter only, between hordes)
|
||||
if (G.mode === 'splatter' && !G.horde)
|
||||
if (!G.bench) for (const rz of zombies) if (rz.variant === 'r') racketAI(rz, dt); // the bench drives its own body
|
||||
// ...and not while the body is on the ground: the fall owns the sprite AND the pose for its
|
||||
// whole run, and racketAI would put a collapsing corpse back on its feet to chase a stray ball.
|
||||
if (!G.bench && !G.matchFall) for (const rz of zombies) if (rz.variant === "r") racketAI(rz, dt); // the bench drives its own body
|
||||
|
||||
// --- ball physics
|
||||
if (ball.active) {
|
||||
@@ -1644,6 +1673,9 @@ export function update(dt) {
|
||||
G.hordeWarn = 0;
|
||||
if (!startHorde()) G.pendingHorde = true;
|
||||
}
|
||||
// ...and the match's FALL runs on its own clock for exactly the same reason, above the 'over'
|
||||
// block so it can hand the point back before that block puts the player in a serve stance.
|
||||
tickMatchFall(dt);
|
||||
if (G.phase === 'over') {
|
||||
G.overT -= dt;
|
||||
if (G.overT <= 0) {
|
||||
|
||||
+9
-5
@@ -1,7 +1,7 @@
|
||||
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
|
||||
|
||||
import { cv, ctx } from './dom.js';
|
||||
import { BENCH, BENCH_STAGES, ZFAMS, famFrames, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS } from './config.js';
|
||||
import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS } from './config.js';
|
||||
import { P, O, ball, score, crowd, G, court, tour } from './state.js';
|
||||
import { predictLanding, previewServe, slotId, slotStock, serveApexY } from './gameplay.js';
|
||||
import { drillQueue, drillLegal, drillMult } from './drill.js';
|
||||
@@ -633,8 +633,7 @@ function drawZombie(zb) {
|
||||
// (TUNE.racketHpPerGame), so the old `hp === 2` / `hp <= 1` thresholds would have left a late-run
|
||||
// boss looking untouched until the killing blow. Keep these in step with bleed()'s anchors.
|
||||
else if (rk) {
|
||||
const f = zb.hp / (zb.hpMax || TUNE.racketHp);
|
||||
const st = f <= 0.34 ? 2 : f <= 0.67 ? 1 : 0; // 0 unhurt, 1 arm off, 2 headless
|
||||
const st = racketStage(zb); // config's rule — the fall beat reads the same one
|
||||
if (st === 2) h = 1.52; // headless: a head shorter, so the shoulders land where a whole zombie's do
|
||||
if (st && rNew) {
|
||||
// every wound stage carries the SAME three poses as the healthy body, so a one-armed
|
||||
@@ -1842,8 +1841,13 @@ export function render() {
|
||||
// THE PLACEMENT RING (match): where your aimed rally shot is going, drawn the moment you flick.
|
||||
// Gold, fading over 0.8 s — flick, watch the ring bite the corner, learn what the gesture does.
|
||||
if (G.placeMark) {
|
||||
ctx.globalAlpha = Math.min(1, G.placeMark.t / 0.5);
|
||||
drawRing(G.placeMark.x, G.placeMark.z, UI.warm, 0.9);
|
||||
// THE RING IS THE FLICK'S RECEIPT. Its POSITION says where the ball is going; its SIZE and
|
||||
// COLOUR say how much of your flick was actually applied, because reach scales with timing
|
||||
// (see doHit) and that would otherwise be an invisible rule. Tight and hot = you earned the
|
||||
// full width; wide and dim = you got a racket on it and little else.
|
||||
const q = G.placeMark.q ?? 1;
|
||||
ctx.globalAlpha = Math.min(1, G.placeMark.t / 0.5) * (0.55 + 0.45 * q);
|
||||
drawRing(G.placeMark.x, G.placeMark.z, q > 0.62 ? UI.hot : UI.warm, 1.35 - 0.6 * q);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
// landing ring: white = incoming ball (a PREDICTION, gone the moment the ball touches down —
|
||||
|
||||
@@ -131,6 +131,7 @@ export const G = {
|
||||
clearWinner: null, // 'P'|'O' — who took the game/set being cleared (for the milestone card)
|
||||
pendingHorde: false,// a set was won; the horde is WARNED after the set card's pause
|
||||
hordeWarn: 0, // s left of the THEY KEEP COMING breather; the wave lands when it expires (beats.warnHorde)
|
||||
matchFall: null, // {t, from} while a wounded match opponent collapses and drags itself up (beats.tickMatchFall)
|
||||
card: null, // {title, sub1, sub2, color, t} — centered milestone card (set won, duel); the reusable 'block'
|
||||
// the modal: a real interruption. Unlike card/flash it FREEZES the world (update() early-returns)
|
||||
// and only a tap or key clears it, so a popup can be read without being eaten while you read it.
|
||||
|
||||
Reference in new issue
Block a user