The body plays the wounds it wears, and the flick finally has a teacher
Score-is-the-body was a picture: the opponent lost an arm and swung exactly as far, lost its head and missed exactly as often. Two dials now read the same racketStage the SKINS are picked with, so what you watch it lose is what it loses — deliberately small (6% reach, 17.5% whiff per stage), texture under the ladder rather than a second difficulty curve, with a test on that ceiling. An unhurt body still returns exactly 1 and 1, and the whole thing is gated on G.match: the gore duel's racket zombie is untouched. And the placement flick, the mode's one act of skill, was taught nowhere on touch — the placement ring is a receipt, not a lesson, and you only get one if you already knew to swipe. The ghost hand teaches it now, in the same register as PULL DOWN and TAP AS IT ARRIVES, riding the failure it answers: a run of returns that left with no aim on them. The tap lesson still wins when both qualify — placement is the wrong thing to teach someone who cannot reach the ball. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0c90337f2d
commit
c613b6ceba
7 files changed
+147
-21
No files matched your search
+24
-11
@@ -61,14 +61,19 @@ communicating its own rules. Model A only works if the player can see they are b
|
||||
board names it (`short`), and the kill files under it so the OPPONENTS FELLED screen can list it.
|
||||
*(The word "ladder" was retired — it had come to mean the opponent list, the wound stages AND a
|
||||
stats screen. One word per thing now: OPPONENTS, WOUND STAGES, and a sprite FAMILY.)*
|
||||
**Still open, and still the cheapest depth in the mode:** `shots[]` and the PARRY are fully built
|
||||
and completely unused here. A body-shot thrower is a personality no dial can express.
|
||||
~~**Still open:** `shots[]` and the PARRY are fully built and completely unused here.~~
|
||||
**SCRATCHED** (2026-08-17, Oliver): a body-shot thrower in the match is not wanted. Both
|
||||
mechanics stay where they are; do not revive this without him asking.
|
||||
|
||||
3. **Wound-driven play** *(small, big payoff)* — the injuries should MEAN something: a one-armed
|
||||
opponent loses reach, a headless one flubs more. Score-is-the-body becomes score-changes-the-game.
|
||||
Read the fraction the way `drawZombie` and `bleed()` do (never literal hp).
|
||||
*The art is now there for it:* every wound stage has its own ready stance, shuffle pair and
|
||||
forehand (`z_rw1*`, `z_rw2*`), so a wounded opponent still plays rather than reverting to a walk.
|
||||
3. ~~**Wound-driven play**~~ **DONE** (2026-08-17). Two dials, both read through `racketStage` —
|
||||
the very call the SKINS are picked with, so what you watch it lose is what it loses:
|
||||
`config.matchWound` returns `{reach, flub}` and `racketAI` spends it on its contact radius and
|
||||
its whiff rate. **Deliberately small** ("not too pronounced"): ~6% reach and ~17.5% more whiff
|
||||
PER STAGE, so at headless it is 12% and 35% — texture under the ladder, never a second
|
||||
difficulty curve, and a test pins that ceiling so a later tuner cannot quietly cross it.
|
||||
Unhurt returns exactly `{1, 1}` and the whole thing is gated on `G.match`, which is what keeps
|
||||
the gore duel's racket zombie untouched. Note the arithmetic: a quarter off a full pool is 0.75,
|
||||
so **point one changes nothing** — the dials move on points two and three, with the art.
|
||||
|
||||
4. ~~**The fall animation**~~ **DONE** (2026-08-12). Point won → the body COLLAPSES → drags itself up
|
||||
minus the limb. `beats.tickMatchFall`, on its OWN clock beside the horde's warning (a beat that
|
||||
@@ -85,10 +90,18 @@ communicating its own rules. Model A only works if the player can see they are b
|
||||
keeps its door (MATCH TEST still works) — the menu did not replace it, it just stopped being the
|
||||
only way in.
|
||||
|
||||
6. **Teaching** *(small)* — a ghost-hand lesson for the PLACEMENT FLICK. It is the counter-attack
|
||||
(flick during the swing aims the return; wrong-footing is how points are won) and it is taught
|
||||
nowhere on touch. The placement ring (`G.placeMark`) already shows the result; nothing teaches
|
||||
the cause.
|
||||
6. ~~**Teaching**~~ **DONE** (2026-08-17). The ghost-hand lesson for the PLACEMENT FLICK, in the
|
||||
same register as PULL DOWN and TAP AS IT ARRIVES: a dotted track out of the player, the hand
|
||||
riding it, SWIPE AS YOU HIT, mimed both ways in turn so it never teaches one corner.
|
||||
It rides the failure it answers — `G.flatStreak`, returns that left with no aim on them
|
||||
(`flatCount`, reset by the first aimed one) — and only after `TUNE.flickHintAfter` of them,
|
||||
because hitting flat is a legal shot and not a mistake. **One lesson at a time:** the tap hint
|
||||
wins if both qualify, since teaching placement to someone who cannot reach the ball is the wrong
|
||||
lesson. Touch only (on a keyboard the aim is held keys, and SWIPE would name a gesture that does
|
||||
not exist there), and it runs in the MATCH as well as classic — the match is `splatter` under a
|
||||
permanent interlude, so a `G.mode === 'tennis'` gate would have missed the mode it was built for.
|
||||
The words sit ABOVE the stroke: below them is the baseline, and 10px type over that white line
|
||||
is unreadable.
|
||||
|
||||
7. ~~**Opponent serve games**~~ **DONE** (2026-08-12), and it was almost entirely switched off rather
|
||||
than missing. `setupPoint`'s tennis branch already placed a non-player server behind its own
|
||||
|
||||
+1
-1
@@ -659,6 +659,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="src/main.js?v=277"></script>
|
||||
<script type="module" src="src/main.js?v=278"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -313,6 +313,9 @@ export const TUNE = {
|
||||
// court — so every downward pull picked the same body and the angle felt inert.
|
||||
// 0.10 gives ±2.9 m out there: you have to actually point at a lane.
|
||||
pullHintAfter: 1.5, // s of doing nothing in the serve stance before the pull gesture demos itself
|
||||
flickHintAfter: 4, // returns hit with no aim at all before the flick demos itself. Not 2 like the
|
||||
// tap lesson: hitting flat is a legal shot, not a failure, so it takes a RUN
|
||||
// of them to read as "he does not know the gesture exists"
|
||||
aimTie: 0.12, // how much worse-aimed a NEARER body may be and still win the lane (0..1 of the
|
||||
// cone half-width). 0 = pure best-aligned wins; 1 = the old "nearest in the
|
||||
// lane always wins", which is what made the magnet grab the wrong shambler.
|
||||
@@ -506,6 +509,13 @@ export const TUNE = {
|
||||
matchFlub: 0.08, // the match's own whiff base — the duel's 0.2 gives away a fifth of all
|
||||
// returns, which across a game is an opponent that beats itself
|
||||
matchFlubKeep: 0.76, // ...and it keeps only this much per rung — the ladder now that hp is flat
|
||||
// THE WOUNDS PLAY, not just show. The score is the body, so the body it has left should be worth
|
||||
// something at the racket: a one-armed opponent cannot cover as much, a headless one whiffs more.
|
||||
// Deliberately SMALL per stage (Oliver: "not too pronounced") — the ladder is still the difficulty
|
||||
// curve and this is the point-to-point texture under it, worth ~12% reach and ~35% more whiff at
|
||||
// the LAST stage, which is one point from falling anyway. Read through racketStage, never hp.
|
||||
matchWoundReach: 0.06, // reach lost per wound stage (1 arm off, 2 headless) — match only
|
||||
matchWoundFlub: 0.175, // ...and extra whiff earned per stage, on top of the rung's own rate
|
||||
matchReach: 1.35, // contact reach multiplier in the MATCH. The recorder showed swings missing by
|
||||
// centimetres point after point — the assist parks you near the ball, not inside
|
||||
// a 1.3 m reach. A rally mode lives on contact feeling fair. Match-only
|
||||
@@ -1112,6 +1122,14 @@ 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
|
||||
};
|
||||
// ...and what that stage COSTS it at the racket. One place, because racketAI reads it twice and two
|
||||
// copies of a threshold is how the boss's skins and its blood drifted apart before. An unhurt body
|
||||
// gets exactly 1 and 1, so the gore duel's racket zombie is untouched by this (see the G.match gate
|
||||
// at the call sites — the duel is one sudden-death point and has no wound arc to express).
|
||||
export const matchWound = zb => {
|
||||
const st = racketStage(zb);
|
||||
return { reach: 1 - TUNE.matchWoundReach * st, flub: 1 + TUNE.matchWoundFlub * st };
|
||||
};
|
||||
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
|
||||
|
||||
+19
-4
@@ -1,6 +1,6 @@
|
||||
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
|
||||
|
||||
import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, CUP_ROUNDS } from './config.js';
|
||||
import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, matchWound, CUP_ROUNDS } from './config.js';
|
||||
import { P, O, ball, score, crowd, G, court, tour, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js';
|
||||
import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js';
|
||||
import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js';
|
||||
@@ -123,10 +123,21 @@ function zombieTarget(aim) {
|
||||
return best;
|
||||
}
|
||||
|
||||
// A return that left with a direction on it resets the flick lesson; a flat one is one more piece of
|
||||
// evidence that the player does not know the gesture is there. Exported for the suite — the touch
|
||||
// flick cannot be pressed headlessly, and this rule is the whole of the lesson's trigger.
|
||||
export const flatCount = (streak, aim) => (aim && aim.ax ? 0 : streak + 1);
|
||||
|
||||
function doHit(ch, quality, aim) {
|
||||
// aim: {ax: -1|0|1, az: -1|0|1} from held keys / flick at contact (az -1 = deep, 1 = short)
|
||||
const toFar = ch.side === 'P';
|
||||
if (ch.side === 'P') G.passStreak = 0; // a landed swing ends the tap-to-hit lesson
|
||||
if (ch.side === 'P') {
|
||||
G.passStreak = 0; // a landed swing ends the tap-to-hit lesson
|
||||
// ...and a landed swing WITH A DIRECTION ON IT ends the flick lesson. Counted here rather than
|
||||
// at the gesture because a flick that arrives too late to be read is a flick that taught the
|
||||
// player nothing: what matters is whether the ball left with an aim on it. Held keys count too.
|
||||
G.flatStreak = flatCount(G.flatStreak, aim);
|
||||
}
|
||||
// snap the ball to the racket at the contact frame so the hit visually connects
|
||||
ball.x = ch.x + ch.facing * 0.5;
|
||||
ball.z = ch.z + (toFar ? -0.3 : 0.3);
|
||||
@@ -1322,12 +1333,16 @@ function racketAI(rz, dt) {
|
||||
earnFlip(rz, 'face', Math.abs(dx) > 0.2 ? (dx > 0 ? 1 : -1) : 0, dt);
|
||||
rz.vx = dx / d * step / dt;
|
||||
} else { rz.vx = 0; if (rz.pose === 'run') rz.pose = 'idle'; }
|
||||
// THE WOUNDS PLAY. Score-is-the-body was a picture until now: it lost an arm and swung exactly
|
||||
// as far, lost its head and missed exactly as often. Both dials read the same racketStage the
|
||||
// SKINS do, so what you see it lose is what it loses. Match only — see config.matchWound.
|
||||
const wnd = G.match ? matchWound(rz) : { reach: 1, flub: 1 };
|
||||
if (rz.swingCool <= 0 && rz.swingT < 0
|
||||
&& Math.hypot(ball.x - rz.x, (ball.z - rz.z) * 0.8) < TUNE.aiReach
|
||||
&& Math.hypot(ball.x - rz.x, (ball.z - rz.z) * 0.8) < TUNE.aiReach * wnd.reach
|
||||
&& ball.y < CONTACT.maxY && ball.y > CONTACT.minY) {
|
||||
// ...and the match has its OWN flub base: the duel's 0.2 hands a fifth of all returns away,
|
||||
// which across a game is an opponent that beats itself.
|
||||
const flub = G.match ? TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, G.match.opp - 1) * (rz.foe?.flub || 1)
|
||||
const flub = G.match ? TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, G.match.opp - 1) * (rz.foe?.flub || 1) * wnd.flub
|
||||
: TUNE.racketFlub;
|
||||
if (Math.random() < flub) rz.swingCool = 0.5; // whiff — it hesitates (rarer per rung of the ladder)
|
||||
else { rz.swingT = TUNE.swingWindup + TUNE.swingWindow / 2; trySwing(rz, true, TUNE.racketQMax); rz.swingT = -1; rz.swingCool = 0.3; }
|
||||
|
||||
+35
-3
@@ -2136,6 +2136,32 @@ function drawHitHint() {
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ---- ...and the lesson AFTER that one. The tap gets the ball back; the FLICK decides where it
|
||||
// goes, and wrong-footing the body across the net is how points are actually won. It was taught
|
||||
// nowhere on touch: the placement ring shows the result of a flick, which is a receipt, not a
|
||||
// lesson — if you never happen to swipe you never get one, so you never learn there was anything
|
||||
// to learn. Same ghost hand as every other gesture in this game, over the same shoulder, riding
|
||||
// the same failure it answers (a run of returns hit flat — doHit resets flatStreak the moment one
|
||||
// leaves with an aim on it). It mimes BOTH ways in turn or it teaches one corner.
|
||||
function drawFlickHint() {
|
||||
const cyc = 1.4, t = G.idleT % cyc;
|
||||
const dir = (((G.idleT / cyc) | 0) % 2) ? -1 : 1;
|
||||
const slide = Math.min(1, t / 0.8), gone = t > 0.95; // stroke, held a beat, lifted, repeat
|
||||
if (gone) return;
|
||||
const p0 = proj(P.x, 0, P.z);
|
||||
const hx = Math.round(p0.x), hy = Math.round(p0.y) - 52;
|
||||
ctx.save(); ctx.globalAlpha = 0.9;
|
||||
ctx.fillStyle = UI.dim; // the track the thumb travels
|
||||
for (let d = 10; d <= 38; d += 7) ctx.fillRect(hx + dir * d - 1, hy, 2, 2);
|
||||
drawGhostHand(hx + dir * (10 + 28 * slide), hy, 1); // starts clear of his own sprite
|
||||
ctx.font = F(10); ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
||||
// ...and the words sit ABOVE the stroke and OPPOSITE it: below is the player's own feet and the
|
||||
// BASELINE, and 10px type across a bright white line is the one place on this court it cannot be
|
||||
// read. Clamped off the screen edges too — this rides the player and he stands in the corners.
|
||||
shadowText('SWIPE AS YOU HIT', Math.max(52, Math.min(W - 52, hx - dir * 22)), hy - 26, UI.warm);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// The modal: drawn LAST, over everything, and the world behind it is frozen (gameplay.update
|
||||
// early-returns). Unlike drawCard this is a wall — nothing moves until it is tapped away.
|
||||
// easeOutBack: overshoots past the target and settles. The arcade snap — nothing in a cabinet
|
||||
@@ -2703,9 +2729,15 @@ export function render() {
|
||||
if (txt) shadowText(txt, W / 2, METER_Y(), UI.warm); // same slot the draw meter uses
|
||||
if (demo) drawPullHint(); // show the gesture instead of only naming it
|
||||
}
|
||||
// the one surviving tennis struggle hint: rides the exact failure it answers
|
||||
if (G.mode === 'tennis' && G.phase === 'rally' && G.passStreak >= 2 && ball.active && ball.lastHit === 'O')
|
||||
drawHitHint();
|
||||
// the tennis struggle hints: both ride the exact failure they answer, and only one at a time —
|
||||
// teaching the flick to someone who cannot get a racket on the ball is the wrong lesson, so the
|
||||
// tap always wins. The flick lesson also runs in the MATCH (G.match plays under splatter's
|
||||
// permanent interlude, not mode 'tennis') and on touch only: on a keyboard the aim is held keys,
|
||||
// and 'SWIPE' would be an instruction for a gesture that does not exist there.
|
||||
const inRally = G.phase === 'rally' && ball.active && ball.lastHit === 'O';
|
||||
if (G.mode === 'tennis' && inRally && G.passStreak >= 2) drawHitHint();
|
||||
else if (IS_TOUCH && inRally && (G.mode === 'tennis' || G.match) && G.flatStreak >= TUNE.flickHintAfter)
|
||||
drawFlickHint();
|
||||
// The free-floating register: no panel, one fixed slot, shouted. PERFECT, GAME N, DOUBLE KILL,
|
||||
// RETURNED, CHANGEOVER, weapon labels — everything that used to be plain white 12px text sitting
|
||||
// directly on the crowd. It keeps its 1px jitter; the shadow is what makes it legible anywhere.
|
||||
|
||||
@@ -103,6 +103,8 @@ export const G = {
|
||||
rackGlow: null, // {slot, t} — a freshly gathered barrel weapon pulses its rack tile briefly
|
||||
// instead of equipping itself (popBarrel keeps your current selection)
|
||||
passStreak: 0, // consecutive rally balls that passed untouched (tennis) — ≥2 shows TAP TO HIT
|
||||
flatStreak: 0, // consecutive returns hit with NO aim on them — the flick is the whole skill of
|
||||
// the mode and nothing on touch ever said so (render.drawFlickHint)
|
||||
// ---- the sidestep. The movement block is 'rally'-only on purpose, so this integrates OUTSIDE it
|
||||
// and is the only way to move in the serve stance (which is where a boss fight is spent).
|
||||
dodgeDir: 0, // -1 | 0 | 1 — which way the current step is going, 0 = standing
|
||||
|
||||
@@ -84,7 +84,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
|
||||
// ---- game modules (real code) ----------------------------------------------
|
||||
const state = await import('./src/state.js');
|
||||
const { G, P, O, score, ball, setupPoint } = state;
|
||||
const { update, forceZombies, startGore, splatPoint, restartRun, flickTarget, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe } = await import('./src/gameplay.js');
|
||||
const { update, forceZombies, startGore, splatPoint, restartRun, flickTarget, flatCount, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe } = await import('./src/gameplay.js');
|
||||
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN, startNetVs, endNetVs } = await import('./src/beats.js');
|
||||
const { mirror, netMsg, netTest, netTick } = await import('./src/net.js');
|
||||
// the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a
|
||||
@@ -94,7 +94,7 @@ const { zombies, spawnZombie, pellets, parts } = await import('./src/zombies.js'
|
||||
const { killPlayer } = await import('./src/zombies.js');
|
||||
const { drillQueue, drillLegal, arrival, drillMult, DRILL_TYPES } = await import('./src/drill.js');
|
||||
const cfg = await import('./src/config.js');
|
||||
const { TUNE, bossArt, COURT, racketStage } = cfg;
|
||||
const { TUNE, bossArt, COURT, racketStage, matchWound } = cfg;
|
||||
const statsMod = await import('./src/stats.js');
|
||||
const audio = await import('./src/audio.js');
|
||||
const { read: readStats, check: statsCheck } = statsMod;
|
||||
@@ -603,6 +603,52 @@ test('a match ball never wounds the body: the score is the only way through', ()
|
||||
assert.equal(zb.hp, zb.hpMax, 'and unwounded — "killing by smashing" is out of the match');
|
||||
});
|
||||
|
||||
test('THE WOUNDS PLAY: a wounded opponent covers less and whiffs more', () => {
|
||||
startMatch();
|
||||
const zb = oppOf();
|
||||
const w = f => matchWound({ hp: zb.hpMax * f, hpMax: zb.hpMax });
|
||||
assert.deepEqual(w(1), { reach: 1, flub: 1 }, 'an unhurt body plays exactly as it always did');
|
||||
const arm = w(0.5), head = w(0.25); // one point in is still stage 0 — a quarter is 0.75
|
||||
assert.ok(head.reach < arm.reach && arm.reach < 1, 'reach falls with every stage it loses');
|
||||
assert.ok(head.flub > arm.flub && arm.flub > 1, '...and the whiff rate climbs with it');
|
||||
assert.ok(head.reach > 0.85 && head.flub < 1.5,
|
||||
'and it stays TEXTURE under the ladder, not a second difficulty curve: ' + JSON.stringify(head));
|
||||
// ...and it is wired to the racket, not just to the table: a gap the whole body covers and the
|
||||
// headless one cannot. ONE frame, so its own chase (~0.06 m) can never close the 0.19 m gap.
|
||||
const swingsAt = (hp, d) => {
|
||||
zb.hp = hp; zb.swingCool = 0; zb.swingT = -1;
|
||||
ball.active = true; ball.lastHit = 'P'; ball.bounces = 0; ball.pow = 0;
|
||||
ball.x = zb.x + d; ball.z = zb.z; ball.y = 1.0;
|
||||
ball.vx = 0; ball.vy = 0; ball.vz = -1; // incoming, or racketAI ignores it entirely
|
||||
update(DT);
|
||||
return zb.swingCool > 0; // a whiff arms the cooldown too — this is REACH
|
||||
};
|
||||
const gap = TUNE.aiReach * (1 + head.reach) / 2; // between the two reaches
|
||||
assert.ok(swingsAt(zb.hpMax, gap), 'the whole body reaches ' + gap.toFixed(2) + ' m');
|
||||
assert.ok(!swingsAt(1, gap), '...and the headless one does not');
|
||||
});
|
||||
|
||||
test('the FLICK LESSON arms on a run of aimless returns and dies on an aimed one', () => {
|
||||
assert.equal(flatCount(0, { ax: 0, az: 0 }), 1, 'a flat return is evidence');
|
||||
assert.equal(flatCount(3, { ax: 0, az: -1 }), 4, '...and so is a depth-only one: this teaches WIDTH');
|
||||
assert.equal(flatCount(9, { ax: -1, az: 0 }), 0, 'one aimed return and the lesson is over');
|
||||
assert.equal(flatCount(9, null), 10, 'no aim at all is the flattest shot there is');
|
||||
assert.ok(TUNE.flickHintAfter > 2, 'hitting flat is legal — it takes a RUN of them to nag');
|
||||
// the wiring: a real match return with nothing on it counts itself
|
||||
startMatch();
|
||||
const zb = oppOf();
|
||||
G.interlude.introT = 0; // the umpire has finished announcing — the tap is a swing now
|
||||
G.flatStreak = 0; G.phase = 'rally';
|
||||
P.x = 0; P.z = 23; P.swingT = -1;
|
||||
ball.active = true; ball.lastHit = 'O'; ball.bounces = 1;
|
||||
ball.x = P.x; ball.z = P.z - 0.4; ball.y = 1.0; ball.vx = 0; ball.vy = 0; ball.vz = 1;
|
||||
keyPressed('Space');
|
||||
for (let i = 0; i < 20 && ball.lastHit !== 'P'; i++) update(DT);
|
||||
assert.equal(ball.lastHit, 'P', 'he got a racket on it');
|
||||
assert.equal(G.flatStreak, 1, 'and the flat return was counted');
|
||||
assert.ok(zombies.includes(zb), '(the body is untouched by any of this)');
|
||||
});
|
||||
|
||||
test('THE DEATH LOOP: a fault before a lost point must not arm second-serve forever', () => {
|
||||
// The match bypasses tennis's pointTo — the only thing that ever reset G.serveNum — so the first
|
||||
// fault of the match made EVERY later missed serve a double fault: a point, blood, and repeat
|
||||
|
||||
Reference in new issue
Block a user