diff --git a/HANDOFF-MATCH.md b/HANDOFF-MATCH.md index 8f95619..b9b4c01 100644 --- a/HANDOFF-MATCH.md +++ b/HANDOFF-MATCH.md @@ -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 diff --git a/index.html b/index.html index 440bd8a..f59df57 100644 --- a/index.html +++ b/index.html @@ -659,6 +659,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/config.js b/src/config.js index b76246d..5d9d73e 100644 --- a/src/config.js +++ b/src/config.js @@ -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 diff --git a/src/gameplay.js b/src/gameplay.js index f6cf398..7c4cd90 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -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; } diff --git a/src/render.js b/src/render.js index ae6383f..09568d9 100644 --- a/src/render.js +++ b/src/render.js @@ -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. diff --git a/src/state.js b/src/state.js index 7376d75..05a9d33 100644 --- a/src/state.js +++ b/src/state.js @@ -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 diff --git a/test.js b/test.js index bbd4b6e..5dcab7a 100644 --- a/test.js +++ b/test.js @@ -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