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

+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; }