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:
wuwiandClaude Opus 5 committed 2026-08-17 08:21:18 +02:00
1 parent 0c90337f2d
commit c613b6ceba
7 files changed
+147 -21

No files matched your search

+18
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+2
View File
@@ -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