Merge the flick's strength, the freed legs and the quieter out into main
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
440db9dcc1
8 files changed
+221
-20
No files matched your search
+63
@@ -493,6 +493,69 @@ it in one of the pauses") — the first cut rode the incoming ball, at the momen
|
|||||||
least attention to spare. Touch only, rally modes only, and the tap lesson wins if both qualify:
|
least attention to spare. Touch only, rally modes only, and the tap lesson wins if both qualify:
|
||||||
teaching placement to someone who cannot reach the ball is the wrong lesson.
|
teaching placement to someone who cannot reach the ball is the wrong lesson.
|
||||||
|
|
||||||
|
**...and its LENGTH is its strength** (2026-08-18). Oliver: *"the flick to aim sometimes is applied
|
||||||
|
very strong, sometimes it barely does anything. this leads to a lack of agency, the player can't
|
||||||
|
really tell if there was skill or luck."* He was right and it was the gesture reader, not the feel:
|
||||||
|
the rally flick was quantised to THREE LANES (`ax`, a swipe past 0.38 of its own length sideways),
|
||||||
|
so two neighbouring gestures differed by the whole court and nothing lived in between. `gameplay.
|
||||||
|
swayOf` reads the swipe's length as a continuous 0..1 (`TUNE.flickMin`..`flickFull`, px) exactly as
|
||||||
|
the SERVE already did, and `doHit` spends that instead of `ax`. Keys have no length, so they keep
|
||||||
|
the full lane, and the AI's aim (no `mag`) is untouched.
|
||||||
|
|
||||||
|
**...and the out was tightened once more** (2026-08-18, *"make it a little harder to hit the out"*):
|
||||||
|
`flickReach` 0.62 -> 0.52, halving the worst-case rate from ~15% to ~7% and pinned by the test. The
|
||||||
|
shape is unchanged — a perfect flick still never sails, a badly timed full swipe still can — but a
|
||||||
|
mistimed one now lands NEARER THE MIDDLE rather than nearer the line, which is the only half of that
|
||||||
|
trade a player who cannot see his own timing can do anything about. **`flickReach` is the out dial**;
|
||||||
|
`aimError` is shared with the opponent's returns and with every aimed shot in the gore modes.
|
||||||
|
|
||||||
|
**This is also what hands the OUT back to the player.** Oliver: *"the number one reason i die is
|
||||||
|
that i myself keep firing balls into the out."* Going out was never his choice — every aimed shot
|
||||||
|
took the FULL width and then `err()` on top, so the dice decided. Width follows the swipe now, so
|
||||||
|
the risk band has a floor the player can stop short of: **below `(line - aimError) / (flickWidth *
|
||||||
|
flickReach)` ≈ 0.7 of a full swipe, the worst mistiming in the game cannot put the ball out**, and
|
||||||
|
above it the old risk curve is untouched. The tested contract from the pass before this one still
|
||||||
|
holds — a perfectly timed flick never sails, a badly timed full one can.
|
||||||
|
|
||||||
|
**THE AUTO-RUN OWNS THE LEGS, so the drag does not** (2026-08-18, Oliver: *"could it be that when
|
||||||
|
playing touch on mobile zombie tennis and tennis i am still able to manually move by dragging? in
|
||||||
|
autorun this must be disabled"* — it could, and it was). It was not a harmless second option: a
|
||||||
|
manual axis SUPPRESSES the assist (the `!mx && !mz` gate in the movement block), so holding a drag
|
||||||
|
traded a full-speed run to the intercept point for a `0.35x` shuffle aimed at wherever the thumb
|
||||||
|
went rather than at where the ball will be. Walking was strictly worse than standing still, which is
|
||||||
|
most of *"i tend to not be able to reach the balls"*. `input.js` stops writing `touchAxis` under
|
||||||
|
`rallyFeel()`; the drag keeps its one rally meaning (release it quickly and it is the aimed swing),
|
||||||
|
keys stay a deliberate desktop override, and GORE keeps its drag-walk — there the assist recovers
|
||||||
|
depth only and holding your own artillery lane is the point.
|
||||||
|
|
||||||
|
**THE AIM IS DRAWN WHILE THE THUMB IS STILL MOVING.** Oliver asked to *"visualize the strength of my
|
||||||
|
flick at flick-time"*, and he was asking for the half the placement ring cannot give: the ring is a
|
||||||
|
RECEIPT, it arrives with the ball already gone. `G.swipe` (set by input.js during the drag, cleared
|
||||||
|
on release) marks where the ball is being ASKED to land.
|
||||||
|
|
||||||
|
**It is a cloud, not a diagram.** The first cut was a ring on a ray from the player's feet and it
|
||||||
|
was rejected on sight (*"pretty bare bones... the line from the player to the indicator must go
|
||||||
|
away, it is distracting"*): a line drawn across the court is a HUD element laid over the game.
|
||||||
|
What is there now is a soft stain on the ground in the same procedural register as the dust and the
|
||||||
|
blood — it GROWS with the length of the swipe and it WARMS as that ask approaches the width no
|
||||||
|
timing can keep in. Three things were learned making it read, all of them the hard way:
|
||||||
|
* **metres, not pixels** — sized off `p.s` so perspective shrinks it with distance. A pixel radius
|
||||||
|
looked fine at the baseline and was an 8-px smudge at the far corner, which is where it lives.
|
||||||
|
* **a stain, not a glow** — `globalCompositeOperation = 'lighter'` turned the red end YELLOW over
|
||||||
|
green grass, and the hue is the entire message.
|
||||||
|
* **the ramp is pale lime -> gold -> red, not green -> red** — green on green is invisible, so the
|
||||||
|
obvious ramp showed nothing across the whole safe half of its range. The cool end carries its
|
||||||
|
contrast in LIGHTNESS, which is what makes it read on grass, clay and slab alike.
|
||||||
|
The threshold is not a flip: the colour is already gold before you reach it, so "too hard" is
|
||||||
|
something you watch coming. **It is OFF as of its first playtest** (*"hide the indicator for now"*)
|
||||||
|
— `TUNE.aimCloud` gates the whole block and the tuning panel can flip it back live. Kept rather than
|
||||||
|
deleted: it is the only thing that shows the gesture's strength before the ball leaves, and the
|
||||||
|
lesson it teaches has no other teacher. Only inside the window where a release would still BE a flick (`FLICK_MS`/`TUNE.flickMin`, the same
|
||||||
|
gate `endTouch` uses) — past that the gesture is a run and a target hanging off it would be a lie.
|
||||||
|
Rally modes only (`rallyFeel`); gore's flick free-aims at a body, not at a lane. The placement ring
|
||||||
|
turns `UI.bad` when the shot really is out, so the two rings together ARE the lesson: what you asked
|
||||||
|
for, what your timing gave you, and the difference between them.
|
||||||
|
|
||||||
**The umpire and the boy.** `aside()` refused during any interlude and the match runs on a permanent
|
**The umpire and the boy.** `aside()` refused during any interlude and the match runs on a permanent
|
||||||
one, so he was silently muted for the entire mode — the one mode that is literally a tennis match he
|
one, so he was silently muted for the entire mode — the one mode that is literally a tennis match he
|
||||||
is sitting over (`G.interlude && !G.match` now, the same shape as `sendBoy`). Six dry lines: two on
|
is sitting over (`G.interlude && !G.match` now, the same shape as `sendBoy`). Six dry lines: two on
|
||||||
|
|||||||
+1
-1
@@ -659,6 +659,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="src/main.js?v=280"></script>
|
<script type="module" src="src/main.js?v=288"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+24
-4
@@ -69,16 +69,36 @@ export const TUNE = {
|
|||||||
flickWidth: 3.95, // m off centre a PERFECTLY timed flick reaches. The singles line is 4.115,
|
flickWidth: 3.95, // m off centre a PERFECTLY timed flick reaches. The singles line is 4.115,
|
||||||
// so this is the corner with 17 cm to spare. Raising it past the line does
|
// so this is the corner with 17 cm to spare. Raising it past the line does
|
||||||
// not make the flick punchier, it makes a perfect one go out.
|
// not make the flick punchier, it makes a perfect one go out.
|
||||||
flickReach: 0.62, // how much of an aimed shot's width a MISTIMED flick gets (1 = all of it).
|
flickReach: 0.52, // how much of an aimed shot's width a MISTIMED flick gets (1 = all of it).
|
||||||
// 0.42 made going out IMPOSSIBLE, which fixed the 8-in-10 bug and then
|
// 0.42 made going out IMPOSSIBLE, which fixed the 8-in-10 bug and then
|
||||||
// overshot it: "flicking: too easy. i must be able to play out." At 0.62
|
// overshot it: "flicking: too easy. i must be able to play out." At 0.62
|
||||||
// a mistimed flick CAN sail (~15% of the time at quality 0, on the worst
|
// a mistimed flick sailed ~15% of the time at quality 0 and a perfectly
|
||||||
// half of the scatter) and a perfectly timed one never can — the risk is
|
// timed one never could — the risk was back, and timing rather than a
|
||||||
// back and it is timing, not a coin toss, that decides it.
|
// coin toss decided it. 0.52 keeps that shape and halves the worst-case
|
||||||
|
// rate to ~7% ("make it a little harder to hit the out"): a badly timed
|
||||||
|
// flick now lands NEARER THE MIDDLE rather than nearer the line, which
|
||||||
|
// is the only half of the trade a player who cannot see his own timing
|
||||||
|
// can do anything about. THIS is the out dial — aimError is shared with
|
||||||
|
// the opponent's returns and with every aimed shot in the gore modes.
|
||||||
// Reach x quality must stay under the sideline once aimError is added on
|
// Reach x quality must stay under the sideline once aimError is added on
|
||||||
// top, or the flick goes out — which is exactly the bug this fixed:
|
// 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
|
// 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.
|
// 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
|
||||||
|
aimCloud: 0, // 1 draws the live aim cloud under the moving thumb (render's G.swipe
|
||||||
|
// block); 0 hides it. OFF after its first playtest — "hide the indicator
|
||||||
|
// for now" — kept as a switch rather than deleted because the reading it
|
||||||
|
// gives (how much width the swipe is buying, before the ball leaves) is
|
||||||
|
// the only teacher the gesture has, and the panel can flip it back live.
|
||||||
|
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
|
perfectMs: 45, // |offset from window center| for PERFECT
|
||||||
// The tennis CPU only exists in the OPENING match (after the transition, tennis never comes
|
// 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"
|
// 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;
|
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
|
// 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
|
// 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.
|
// 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) {
|
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)
|
// 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.x = ch.x + ch.facing * 0.5;
|
||||||
ball.z = ch.z + (toFar ? -0.3 : 0.3);
|
ball.z = ch.z + (toFar ? -0.3 : 0.3);
|
||||||
const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality);
|
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;
|
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
|
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;
|
? (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;
|
// 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.
|
// it just stops being a coin toss you are punished for calling.
|
||||||
const reach = ch.side === 'P' ? TUNE.flickReach + (1 - TUNE.flickReach) * quality : 1;
|
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;
|
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;
|
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
|
// 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
|
// 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
|
// 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.
|
// 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 };
|
G.placeMark = { x: tx, z: tz, t: 0.8, q: quality };
|
||||||
}
|
}
|
||||||
// topspin: quality shots dive under extra effective gravity, which is what lets real
|
// topspin: quality shots dive under extra effective gravity, which is what lets real
|
||||||
|
|||||||
+24
-6
@@ -7,7 +7,7 @@ import { audio, pock } from './audio.js';
|
|||||||
import { TUNE, WEAPONS } from './config.js';
|
import { TUNE, WEAPONS } from './config.js';
|
||||||
import { benchButtons, W, H } from './render.js'; // W/H are live bindings: fitViewport rewrites them
|
import { benchButtons, W, H } from './render.js'; // W/H are live bindings: fitViewport rewrites them
|
||||||
import { G, score } from './state.js';
|
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 { rackHit } from './render.js';
|
||||||
import { togglePanel } from './panel.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 stick = null; // {id, x0, y0, t0, moved}
|
||||||
let flickAim = null; // {ax, az, t} aim override for the swing just triggered
|
let flickAim = null; // {ax, az, t} aim override for the swing just triggered
|
||||||
const DEAD = 14;
|
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
|
// gameplay consumes the flick at contact; returns null if none or expired
|
||||||
export function takeFlickAim(maxAgeMs = 450) {
|
export function takeFlickAim(maxAgeMs = 450) {
|
||||||
@@ -66,6 +67,7 @@ export function resetInput() {
|
|||||||
for (const k in keys) delete keys[k];
|
for (const k in keys) delete keys[k];
|
||||||
touchAxis.x = touchAxis.z = 0;
|
touchAxis.x = touchAxis.z = 0;
|
||||||
stick = flickAim = steerPX = spare = null;
|
stick = flickAim = steerPX = spare = null;
|
||||||
|
G.swipe = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A LOST TOUCHEND LOCKS THE GAME, part two. visibilitychange/blur (below) only catches the case
|
// 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.
|
// Any new touch event proves the old finger is gone if it isn't in e.touches. Drop it then.
|
||||||
const dropStale = e => {
|
const dropStale = e => {
|
||||||
if (stick && ![...e.touches].some(t => t.identifier === stick.id)) {
|
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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -179,8 +181,24 @@ cv.addEventListener('touchmove', e => {
|
|||||||
}
|
}
|
||||||
const r = stick.r;
|
const r = stick.r;
|
||||||
steerPX = (t.clientX - r.left) / r.width * 2 - 1;
|
steerPX = (t.clientX - r.left) / r.width * 2 - 1;
|
||||||
touchAxis.x = Math.abs(dx) > DEAD ? Math.sign(dx) : 0;
|
// THE AUTO-RUN OWNS THE LEGS IN A RALLY, so the drag does not. Walking here was strictly worse
|
||||||
touchAxis.z = Math.abs(dy) > DEAD ? Math.sign(dy) : 0;
|
// than standing still: a manual axis SUPPRESSES the assist (the `!mx && !mz` gate in update),
|
||||||
|
// so holding a drag traded a full-speed run to the intercept point for a 0.35x shuffle that
|
||||||
|
// goes wherever your thumb went rather than where the ball will be — which is most of "i tend
|
||||||
|
// to not be able to reach the balls". The gesture keeps its one rally meaning: let go quickly
|
||||||
|
// and it is the aimed swing. Keys are untouched; a held arrow on a desktop is still an override.
|
||||||
|
// Gore keeps its drag-walk — there the assist only recovers depth and you hold your own lane.
|
||||||
|
const legs = !rallyFeel();
|
||||||
|
touchAxis.x = legs && Math.abs(dx) > DEAD ? Math.sign(dx) : 0;
|
||||||
|
touchAxis.z = legs && 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 });
|
}, { passive: false });
|
||||||
|
|
||||||
@@ -197,14 +215,14 @@ const endTouch = e => {
|
|||||||
if (aim && dist >= TUNE.pullMin) dragServe(aim);
|
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.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,
|
flickAim = { ax: Math.abs(dx) > dist * 0.38 ? Math.sign(dx) : 0,
|
||||||
az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 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
|
fx: dx / dist, fy: dy / dist, mag: dist, // raw vector: splatter free-aim
|
||||||
t: performance.now() };
|
t: performance.now() };
|
||||||
keyPressed('Space');
|
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 });
|
cv.addEventListener('touchend', endTouch, { passive: false });
|
||||||
|
|||||||
+50
-4
@@ -1,9 +1,9 @@
|
|||||||
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
|
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
|
||||||
|
|
||||||
import { cv, ctx } from './dom.js';
|
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 { 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 { drillQueue, drillLegal, drillMult } from './drill.js';
|
||||||
import { zombies, gore, piles, parts, pellets, extras, shots, pops } from './zombies.js';
|
import { zombies, gore, piles, parts, pellets, extras, shots, pops } from './zombies.js';
|
||||||
|
|
||||||
@@ -16,6 +16,14 @@ const hexLerp = (a, b, t) => {
|
|||||||
const [ar, ag, ab] = c(a), [br, bg, bb] = c(b);
|
const [ar, ag, ab] = c(a), [br, bg, bb] = c(b);
|
||||||
return `rgb(${Math.round(ar + (br - ar) * t)},${Math.round(ag + (bg - ag) * t)},${Math.round(ab + (bb - ab) * t)})`;
|
return `rgb(${Math.round(ar + (br - ar) * t)},${Math.round(ag + (bg - ag) * t)},${Math.round(ab + (bb - ab) * t)})`;
|
||||||
};
|
};
|
||||||
|
// The live aim cloud's ramp, walked in TWO steps: pale lime -> gold -> red. A straight green-to-red
|
||||||
|
// lerp was the obvious version and it failed on the only court that matters — green on green is
|
||||||
|
// invisible, so the whole safe half of the range showed nothing. The cool end carries its contrast
|
||||||
|
// in LIGHTNESS instead of hue, which is what makes it read on grass, clay and slab alike, and gold
|
||||||
|
// in the middle keeps every step of the ramp distinct from the two beside it.
|
||||||
|
const AIM_COOL = '#d8eaae', AIM_MID = '#e0cc72', AIM_HOT = '#d64a3a';
|
||||||
|
const aimHeat = t => t < 0.5 ? hexLerp(AIM_COOL, AIM_MID, t * 2) : hexLerp(AIM_MID, AIM_HOT, (t - 0.5) * 2);
|
||||||
|
|
||||||
// how far into the rot we are, 0 (tennis) .. 1 (splatter) — duels lerp it back home
|
// how far into the rot we are, 0 (tennis) .. 1 (splatter) — duels lerp it back home
|
||||||
function paletteT() {
|
function paletteT() {
|
||||||
return G.mode === 'tennis' ? 0 : G.mode === 'splatter' ? 1 - G.duelLerp : Math.min(1, G.transT / 6);
|
return G.mode === 'tennis' ? 0 : G.mode === 'splatter' ? 1 - G.duelLerp : Math.min(1, G.transT / 6);
|
||||||
@@ -2271,11 +2279,49 @@ export function render() {
|
|||||||
// COLOUR say how much of your flick was actually applied, because reach scales with timing
|
// 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
|
// (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.
|
// 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);
|
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;
|
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' && TUNE.aimCloud) {
|
||||||
|
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;
|
||||||
|
// A CLOUD, NOT A DIAGRAM. The first cut was a ring on a ray from the player's feet, and a line
|
||||||
|
// drawn across the court is a HUD element laid over the game — Oliver: "pretty bare bones...
|
||||||
|
// the line must go away, it is distracting". This is a soft mark on the grass instead, in the
|
||||||
|
// same procedural register as the dust and the blood: it sits where the ball is being asked to
|
||||||
|
// land, it GROWS with the length of the swipe, and it warms from green to red as that ask
|
||||||
|
// approaches the width no timing can keep in. The threshold is not a flip — the colour is
|
||||||
|
// already amber before you reach it, so "too hard" is something you watch coming.
|
||||||
|
const a = Math.abs(sway), p = proj(x, 0, z);
|
||||||
|
if (p.s > 0) {
|
||||||
|
const safe = (COURT.W / 2 - TUNE.aimError) / (TUNE.flickWidth * TUNE.flickReach);
|
||||||
|
const col = aimHeat(Math.min(1, a / safe));
|
||||||
|
const rx = p.s * (0.55 + 1.35 * a), ry = rx * 0.4; // metres of ground, so perspective shrinks it with distance
|
||||||
|
ctx.fillStyle = col;
|
||||||
|
// NOT additive: 'lighter' turned the red end yellow over green grass, and the hue IS the
|
||||||
|
// message here. A soft stain keeps green green and red red on every court in the table.
|
||||||
|
// stacked rather than a gradient: three soft passes build a chunky core the way the dust and
|
||||||
|
// the piles do, and a smooth radial ramp would be the one un-pixelated thing on the court
|
||||||
|
for (let i = 3; i >= 1; i--) {
|
||||||
|
ctx.globalAlpha = 0.13 + 0.10 * a;
|
||||||
|
ctx.beginPath(); ctx.ellipse(Math.round(p.x), Math.round(p.y), rx * i / 3, ry * i / 3, 0, 0, 7); ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
// landing ring: white = incoming ball (a PREDICTION, gone the moment the ball touches down —
|
// landing ring: white = incoming ball (a PREDICTION, gone the moment the ball touches down —
|
||||||
// the ring that lingered where the ball had already landed confused testers and is removed)
|
// the ring that lingered where the ball had already landed confused testers and is removed)
|
||||||
if (G.phase === 'rally' && ball.active && ball.bounces === 0 && ball.lastHit === 'O') {
|
if (G.phase === 'rally' && ball.active && ball.bounces === 0 && ball.lastHit === 'O') {
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ export const G = {
|
|||||||
hurtT: 0, // s of post-hit grace left; also drives the red hit flash
|
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)
|
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
|
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)
|
asideT: 14, // s until the umpire's next unsolicited remark (TUNE.asideEvery)
|
||||||
praiseT: 0, // s until the next praise word is allowed (TUNE.praiseEvery)
|
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
|
bossDue: 0, // chapter number the schedule owes you — fired at the next hand-cleared court
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
|
|||||||
// ---- game modules (real code) ----------------------------------------------
|
// ---- game modules (real code) ----------------------------------------------
|
||||||
const state = await import('./src/state.js');
|
const state = await import('./src/state.js');
|
||||||
const { G, P, O, score, ball, setupPoint } = state;
|
const { G, P, O, score, ball, setupPoint } = state;
|
||||||
const { update, forceZombies, startGore, splatPoint, restartRun, flickTarget, flatCount, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe } = await import('./src/gameplay.js');
|
const { update, forceZombies, startGore, splatPoint, restartRun, flickTarget, flatCount, swayOf, 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 { 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');
|
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
|
// the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a
|
||||||
@@ -807,6 +807,9 @@ test('an aimed return can be played OUT, but only by mistiming it', () => {
|
|||||||
};
|
};
|
||||||
assert.ok(pOut(0) > 0.02, 'a badly timed flick CAN go out: ' + (pOut(0) * 100).toFixed(1) + '%');
|
assert.ok(pOut(0) > 0.02, 'a badly timed flick CAN go out: ' + (pOut(0) * 100).toFixed(1) + '%');
|
||||||
assert.ok(pOut(0) < 0.25, '...but not the coin toss it was (every flick under q 0.66 went out)');
|
assert.ok(pOut(0) < 0.25, '...but not the coin toss it was (every flick under q 0.66 went out)');
|
||||||
|
// ...and "make it a little harder to hit the out" (2026-08-18) is a NUMBER, not a vibe: the
|
||||||
|
// worst-case rate was halved off 15%, and this is what stops flickReach drifting back up.
|
||||||
|
assert.ok(pOut(0) < 0.10, 'a mistimed flick lands nearer the middle than the line: ' + (pOut(0) * 100).toFixed(1) + '%');
|
||||||
assert.equal(pOut(1), 0, 'a perfectly timed flick never goes out');
|
assert.equal(pOut(1), 0, 'a perfectly timed flick never goes out');
|
||||||
for (let q = 0.05; q <= 1.0001; q += 0.05)
|
for (let q = 0.05; q <= 1.0001; q += 0.05)
|
||||||
assert.ok(pOut(q) <= pOut(q - 0.05) + 1e-9, `risk falls as timing improves (q ${q.toFixed(2)})`);
|
assert.ok(pOut(q) <= pOut(q - 0.05) + 1e-9, `risk falls as timing improves (q ${q.toFixed(2)})`);
|
||||||
@@ -814,6 +817,41 @@ test('an aimed return can be played OUT, but only by mistiming it', () => {
|
|||||||
assert.ok(aimW > line * 0.9, 'a perfectly timed flick still reaches the corner');
|
assert.ok(aimW > line * 0.9, 'a perfectly timed flick still reaches the corner');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a flick's LENGTH is its width: the three-lane cliff is gone", () => {
|
||||||
|
// The gesture used to be read as three LANES off a 0.38 ratio, so two neighbouring swipes differed
|
||||||
|
// by the whole court — "sometimes applied very strong, sometimes barely does anything", with no
|
||||||
|
// way to tell which one you had asked for. Length is the strength now, as it always was on serve.
|
||||||
|
const swipe = (m, fx = 1) => ({ ax: 1, fx, fy: 0, mag: m });
|
||||||
|
assert.equal(swayOf({ ax: -1 }), -1, 'keys have no length — a held direction is the whole lane');
|
||||||
|
assert.equal(swayOf(swipe(TUNE.flickMin)), 0, 'a swipe at the flick threshold aims nothing yet');
|
||||||
|
assert.equal(swayOf(swipe(TUNE.flickFull)), 1, '...and a committed one takes the whole width');
|
||||||
|
assert.equal(swayOf(swipe(999)), 1, 'over-swiping cannot aim past the corner');
|
||||||
|
let prev = 0;
|
||||||
|
for (let m = TUNE.flickMin; m <= TUNE.flickFull; m += 2) {
|
||||||
|
const v = swayOf(swipe(m));
|
||||||
|
assert.ok(v >= prev - 1e-9 && v - prev < 0.05, `the sway climbs smoothly at ${m}px, no cliff`);
|
||||||
|
prev = v;
|
||||||
|
}
|
||||||
|
// ...and a diagonal is a diagonal, not a lane vote: under the old ratio this aimed dead centre
|
||||||
|
assert.ok(Math.abs(swayOf(swipe(TUNE.flickFull, 0.35)) - 0.35) < 1e-9, 'fx carries the aim');
|
||||||
|
assert.equal(flatCount(3, swipe(TUNE.flickMin + 4)), 4, 'a swipe too short to bend it counts as flat');
|
||||||
|
assert.equal(flatCount(3, swipe(TUNE.flickFull)), 0, '...and a real one ends the lesson');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a gentle flick cannot be played out — only a committed one can', () => {
|
||||||
|
// Oliver: "the number one reason i die is that i myself keep firing balls into the out." Every
|
||||||
|
// aimed shot took the FULL lane and then +-aimError on top, so whether it sailed was the dice's
|
||||||
|
// call at any width the player picked. Width follows the swipe now, so the risk band has an edge
|
||||||
|
// the player can stop short of — and going out becomes a thing you chose, not a thing rolled.
|
||||||
|
const line = COURT.W / 2;
|
||||||
|
const worst = s => s * TUNE.flickWidth * TUNE.flickReach + TUNE.aimError; // widest it can land
|
||||||
|
assert.ok(worst(0.5) < line, 'half a swipe is in at ANY timing');
|
||||||
|
assert.ok(worst(1) > line, '...and a full one can still sail: the risk is kept, and it is chosen');
|
||||||
|
// the threshold render colours the live aim ring with is that same arithmetic, not a magic number
|
||||||
|
const safe = (line - TUNE.aimError) / (TUNE.flickWidth * TUNE.flickReach);
|
||||||
|
assert.ok(safe > 0.4 && safe < 1, 'the ring turns hot inside the swipe range, not at either end');
|
||||||
|
});
|
||||||
|
|
||||||
test('a wounding point knocks the body down and it gets up MINUS the limb', () => {
|
test('a wounding point knocks the body down and it gets up MINUS the limb', () => {
|
||||||
// The beat exists because the wound stage used to POP between points — an arm stopped being there
|
// The beat exists because the wound stage used to POP between points — an arm stopped being there
|
||||||
// and nothing said why. The contract: the collapse wears the stage it STILL WAS, the rise wears
|
// and nothing said why. The contract: the collapse wears the stage it STILL WAS, the rise wears
|
||||||
|
|||||||
Reference in new issue
Block a user