The length of the flick is the strength of the aim, and you can see it leave
The rally flick was read as three lanes: sideways past 0.38 of its own length took the whole corner, a hair under it took dead centre, and nothing lived in between — "sometimes applied very strong, sometimes it barely does anything", with no way to tell which one you had asked for. swayOf reads the swipe's LENGTH as a continuous strength, exactly as the serve already did, and doHit spends that instead of the lane vote. Keys keep the full lane; the opponent's aim has no length and is untouched. That is also what hands the OUT back: going out was never a choice, because every aimed shot took the full width and then the scatter on top. Below about 0.7 of a full swipe — the line minus aimError, over the width timing can earn — the worst mistiming in the game cannot sail it now, and above it the old risk curve is exactly as tested. And the aim is drawn while the thumb is still moving: the placement ring is a receipt that arrives with the ball already gone, so the same ring is shown live with a ray from the player's feet, cream while the ask is safe and hot once the sway crosses that derived threshold. The receipt goes red when the shot really is out, so the two rings together are the lesson. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
99b2dec3bf
commit
f3d6f71144
8 files changed
+144
-14
No files matched your search
@@ -79,6 +79,16 @@ export const TUNE = {
|
||||
// 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.
|
||||
// THE LENGTH OF THE SWIPE IS THE STRENGTH OF THE AIM (px of finger travel; see gameplay.swayOf).
|
||||
// The rally flick used to be read as three LANES: 0.38 of its own length sideways bought the whole
|
||||
// corner, a hair under it bought dead centre, and nothing lived in between — so the same gesture
|
||||
// came out "very strong" or "barely anything" and which one you got was invisible (Oliver: "a lack
|
||||
// of agency, the player can't really tell if there was skill or luck"). The serve already read its
|
||||
// flick as a continuous strength; the rally does now too, on the same shape of numbers.
|
||||
flickMin: 28, // px before a drag counts as a flick at all — input.js's own threshold
|
||||
flickFull: 130, // ...and px of travel that buys the FULL width. About a third of a phone
|
||||
// screen: a short swipe is a safe half-court ball, and only a committed
|
||||
// one goes hunting the line, which is what makes going OUT a chosen risk.
|
||||
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"
|
||||
|
||||
+18
-4
@@ -123,10 +123,23 @@ function zombieTarget(aim) {
|
||||
return best;
|
||||
}
|
||||
|
||||
// A FLICK'S LENGTH IS ITS STRENGTH. The rally aim read the gesture as three LANES — sideways past
|
||||
// 0.38 of its own length took the whole corner, a hair under it took dead centre — so one flick came
|
||||
// out "applied very strong" and the next "barely does anything", with nothing in between and no way
|
||||
// to tell which you had asked for. Length scales the sway continuously now, exactly as it already
|
||||
// did on the serve: a short swipe is a safe half-court ball, a committed one hunts the line. That is
|
||||
// also what hands the OUT back to the player — the scatter can only sail a shot that was already
|
||||
// aimed near the line, so going out becomes a risk you chose rather than one that was rolled for you.
|
||||
// Keys have no length; they keep the full lane.
|
||||
export const swayOf = aim => aim.mag === undefined ? aim.ax
|
||||
: aim.fx * Math.min(1, Math.max(0, (aim.mag - TUNE.flickMin) / (TUNE.flickFull - TUNE.flickMin)));
|
||||
|
||||
// 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);
|
||||
// It reads the same sway the shot does, so a swipe too short to bend the ball is honestly counted
|
||||
// as flat rather than resetting the lesson it just failed to use.
|
||||
export const flatCount = (streak, aim) => (aim && Math.abs(swayOf(aim)) > 0.15 ? 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)
|
||||
@@ -142,7 +155,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, aimedLane = false;
|
||||
let tx, tz, aimedLane = false, sway = 0;
|
||||
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;
|
||||
@@ -164,7 +177,8 @@ function doHit(ch, quality, aim) {
|
||||
// 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 ? TUNE.flickWidth : 2.7) * reach + err();
|
||||
sway = swayOf(aim); // -1..1, continuous on touch — see swayOf
|
||||
tx = sway * (toFar ? TUNE.flickWidth : 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;
|
||||
// A FORWARD FLICK IS A SMASH. Up-screen is the far court, so flicking INTO the court asked for
|
||||
@@ -201,7 +215,7 @@ function doHit(ch, quality, aim) {
|
||||
// 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)) {
|
||||
if (aimedLane && ch.side === 'P' && aim && (sway || 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
|
||||
|
||||
+14
-4
@@ -7,7 +7,7 @@ import { audio, pock } from './audio.js';
|
||||
import { TUNE, WEAPONS } from './config.js';
|
||||
import { benchButtons, W, H } from './render.js'; // W/H are live bindings: fitViewport rewrites them
|
||||
import { G, score } from './state.js';
|
||||
import { keyPressed, dragServe, selectWeapon, serveToss, pullQuality, pullPerfect, dismissModal, slotId, dodge } from './gameplay.js';
|
||||
import { keyPressed, dragServe, selectWeapon, serveToss, pullQuality, pullPerfect, dismissModal, slotId, dodge, rallyFeel } from './gameplay.js';
|
||||
import { rackHit } from './render.js';
|
||||
import { togglePanel } from './panel.js';
|
||||
|
||||
@@ -31,6 +31,7 @@ export const axisZ = () => touchAxis.z || (keys.ArrowUp || keys.KeyW ? -1 : 0) +
|
||||
let stick = null; // {id, x0, y0, t0, moved}
|
||||
let flickAim = null; // {ax, az, t} aim override for the swing just triggered
|
||||
const DEAD = 14;
|
||||
const FLICK_MS = 300; // a release later than this is a rescue drag, never a swing (see endTouch)
|
||||
|
||||
// gameplay consumes the flick at contact; returns null if none or expired
|
||||
export function takeFlickAim(maxAgeMs = 450) {
|
||||
@@ -66,6 +67,7 @@ export function resetInput() {
|
||||
for (const k in keys) delete keys[k];
|
||||
touchAxis.x = touchAxis.z = 0;
|
||||
stick = flickAim = steerPX = spare = null;
|
||||
G.swipe = null;
|
||||
}
|
||||
|
||||
// A LOST TOUCHEND LOCKS THE GAME, part two. visibilitychange/blur (below) only catches the case
|
||||
@@ -77,7 +79,7 @@ export function resetInput() {
|
||||
// Any new touch event proves the old finger is gone if it isn't in e.touches. Drop it then.
|
||||
const dropStale = e => {
|
||||
if (stick && ![...e.touches].some(t => t.identifier === stick.id)) {
|
||||
stick = null; touchAxis.x = touchAxis.z = 0; steerPX = null; G.aim = null;
|
||||
stick = null; touchAxis.x = touchAxis.z = 0; steerPX = null; G.aim = null; G.swipe = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -181,6 +183,14 @@ cv.addEventListener('touchmove', e => {
|
||||
steerPX = (t.clientX - r.left) / r.width * 2 - 1;
|
||||
touchAxis.x = Math.abs(dx) > DEAD ? Math.sign(dx) : 0;
|
||||
touchAxis.z = Math.abs(dy) > DEAD ? Math.sign(dy) : 0;
|
||||
// THE FLICK, SHOWN WHILE IT IS STILL A FLICK. The placement ring is a receipt — it arrives with
|
||||
// the ball already gone. This is the same aim BEFORE the release, which is the half the player
|
||||
// could not see: how much width the length of the swipe is buying, and whether that is already
|
||||
// past the line. Only inside the window where a release would still BE a swing (the same
|
||||
// FLICK_MS/flickMin gate endTouch uses) — past that the gesture is a run and a target hanging
|
||||
// off it would be a lie. Rally modes only: gore's flick free-aims at a body, not at a lane.
|
||||
G.swipe = rallyFeel() && dist > TUNE.flickMin && performance.now() - stick.t0 < FLICK_MS
|
||||
? { fx: dx / dist, fy: dy / dist, mag: dist } : null;
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
@@ -197,14 +207,14 @@ const endTouch = e => {
|
||||
if (aim && dist >= TUNE.pullMin) dragServe(aim);
|
||||
}
|
||||
else if (!stick.moved && dt < 250) { if (!stick.nearRack) keyPressed('Space'); } // tap — dead if it was a near-miss at the rack
|
||||
else if (!stick.adopted && dt < 300 && dist > 28) { // flick = aimed swing — a rescue drag is never a swing
|
||||
else if (!stick.adopted && dt < FLICK_MS && dist > TUNE.flickMin) { // flick = aimed swing — a rescue drag is never a swing
|
||||
flickAim = { ax: Math.abs(dx) > dist * 0.38 ? Math.sign(dx) : 0,
|
||||
az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 0,
|
||||
fx: dx / dist, fy: dy / dist, mag: dist, // raw vector: splatter free-aim
|
||||
t: performance.now() };
|
||||
keyPressed('Space');
|
||||
}
|
||||
stick = null; touchAxis.x = 0; touchAxis.z = 0;
|
||||
stick = null; touchAxis.x = 0; touchAxis.z = 0; G.swipe = null;
|
||||
}
|
||||
};
|
||||
cv.addEventListener('touchend', endTouch, { passive: false });
|
||||
|
||||
+34
-4
@@ -1,9 +1,9 @@
|
||||
// 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, 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, HEAD_CROP, CUP_ROUNDS, TINT } 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, HEAD_CROP, CUP_ROUNDS, TINT, SHOT_DEPTH } from './config.js';
|
||||
import { P, O, ball, score, crowd, G, court, tour, kit, skin } from './state.js';
|
||||
import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel } from './gameplay.js';
|
||||
import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel, swayOf } from './gameplay.js';
|
||||
import { drillQueue, drillLegal, drillMult } from './drill.js';
|
||||
import { zombies, gore, piles, parts, pellets, extras, shots, pops } from './zombies.js';
|
||||
|
||||
@@ -2271,9 +2271,39 @@ export function render() {
|
||||
// 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;
|
||||
// ...and it goes RED when the shot is actually sailing. "The number one reason I die is that I
|
||||
// myself keep firing balls into the out" — and the ring said so only by sitting a few pixels
|
||||
// past a painted line while the ball was still in the air. The colour is the line call, early.
|
||||
const q = G.placeMark.q ?? 1, out = Math.abs(G.placeMark.x) > COURT.W / 2;
|
||||
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);
|
||||
drawRing(G.placeMark.x, G.placeMark.z, out ? UI.bad : q > 0.62 ? UI.hot : UI.warm, 1.35 - 0.6 * q);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
// THE FLICK BEFORE IT IS SPENT: where the swipe under your thumb is currently asking the ball to
|
||||
// go, drawn from the moment it is long enough to count until the moment it stops being a flick.
|
||||
// The placement ring above is a receipt — it arrives with the ball already gone, which teaches
|
||||
// nothing about a gesture you cannot feel the length of. This is the same aim, live: the ring
|
||||
// walks out toward the corner as you swipe further, and turns red once you are asking for a ball
|
||||
// the line cannot hold. Timing still decides how much of it you actually get — that is what the
|
||||
// receipt says, and the difference between the two rings is exactly the lesson.
|
||||
if (G.swipe && G.phase === 'rally') {
|
||||
const sway = swayOf(G.swipe);
|
||||
const x = sway * TUNE.flickWidth;
|
||||
const z = G.swipe.fy < -0.38 ? SHOT_DEPTH.deep : G.swipe.fy > 0.38 ? SHOT_DEPTH.short : SHOT_DEPTH.mid;
|
||||
// ...and it goes HOT exactly where the shot stops being safe. Below this much sway the worst
|
||||
// mistiming in the game cannot push the scatter past the sideline; above it, it can — so the
|
||||
// colour is the honest answer to "was I too hard?", and it is DERIVED from the same three
|
||||
// numbers doHit spends, never a hand-picked threshold that would rot the moment one moved.
|
||||
const safe = (COURT.W / 2 - TUNE.aimError) / (TUNE.flickWidth * TUNE.flickReach);
|
||||
const col = Math.abs(sway) > safe ? UI.hot : UI.cream;
|
||||
ctx.globalAlpha = 0.35 + 0.45 * Math.abs(sway); // the size of the ask is the brightness
|
||||
// the ray is what makes the ring findable: the target sits deep in perspective and is small,
|
||||
// and 300 ms is not long enough to go looking for it. It leaves from your own feet, so the
|
||||
// gesture, the man and the corner are one line — and its length IS the strength you swiped.
|
||||
const a = proj(P.x, 0, P.z), b = proj(x, 0, z);
|
||||
ctx.strokeStyle = col; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(Math.round(a.x), Math.round(a.y)); ctx.lineTo(Math.round(b.x), Math.round(b.y)); ctx.stroke();
|
||||
drawRing(x, z, col, 0.9);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
// landing ring: white = incoming ball (a PREDICTION, gone the moment the ball touches down —
|
||||
|
||||
@@ -119,6 +119,8 @@ export const G = {
|
||||
hurtT: 0, // s of post-hit grace left; also drives the red hit flash
|
||||
hurtFlash: 0, // s of the impact PULSE left (the persistent damage vignette reads hp directly)
|
||||
placeMark: null, // {x, z, t} — where your aimed rally shot is going (match): the flick made visible
|
||||
swipe: null, // {fx, fy, mag} — the rally flick WHILE the thumb is still moving, so the width
|
||||
// its length is buying is visible before the ball leaves (input.js sets it)
|
||||
asideT: 14, // s until the umpire's next unsolicited remark (TUNE.asideEvery)
|
||||
praiseT: 0, // s until the next praise word is allowed (TUNE.praiseEvery)
|
||||
bossDue: 0, // chapter number the schedule owes you — fired at the next hand-cleared court
|
||||
|
||||
Reference in new issue
Block a user