The flick shows itself, the width is a dial, the blood stays on
Three asks. The placement ring: an aimed rally hit stamps a gold ring that fades where the shot is going — flick, watch it bite the corner, learn the cause. Not stamped for unaimed hits (marking scatter would teach noise), gone in 0.8s (a permanent ring reads as a target rather than feedback). The opponent's width is the ladder's third dial. Its returns scatter off aimError x (1 - quality), which painted lines from game one. matchWideBase 2.1 keeps opponent one inside what an auto-run player can cover — measured, 25 returns all inside +-2.2m — and matchWidePer earns 0.4m per rung, so line-painting is the late ladder. Speed, reliability, width: three dials. The blood vignette replaces the temporary border. The old wash flashed 0.3s and vanished, so between hits the screen said you were fine. A radial vignette reads G.hp directly now — its depth is your damage fraction, permanent until healed, hit pulse riding on top. Global: the hp channel is global. Same no-HP-bar philosophy one layer out; at 1 hp the frame is properly blood-rimmed and the court centre stays legible. 44 tests, 12 seeds clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d6df582cd4
commit
8ccf0a8fad
7 files changed
+81
-9
No files matched your search
+17
@@ -931,6 +931,23 @@ strength because the balls kept landing where he wasn't.
|
|||||||
(stepping in to take the ball pre-bounce carries reach x1.4 / window x1.8), and serve pace off the
|
(stepping in to take the ball pre-bounce carries reach x1.4 / window x1.8), and serve pace off the
|
||||||
apex. The placement flick is taught nowhere on touch — worth a ghost-hand lesson if the mode ships.
|
apex. The placement flick is taught nowhere on touch — worth a ghost-hand lesson if the mode ships.
|
||||||
|
|
||||||
|
**EIGHTEENTH PASS — the flick shows itself, the width is a dial, the blood stays on.**
|
||||||
|
- **THE PLACEMENT RING**: an aimed rally hit (flick or held direction at contact) stamps
|
||||||
|
`G.placeMark` and a gold ring fades where the shot is going — flick, watch it bite the corner,
|
||||||
|
learn the cause. Deliberately NOT stamped for unaimed hits: marking the scatter would teach noise.
|
||||||
|
Fades in 0.8 s; a permanent ring would read as a target to hit rather than feedback.
|
||||||
|
- **`matchWideBase` 2.1 / `matchWidePer` 0.4 — the opponent's WIDTH is the ladder's third dial.**
|
||||||
|
Its returns scatter off `aimError x (1 - quality)`, which painted lines from game one ("still a
|
||||||
|
LOT of unreachable balls"). Opponent one now keeps its returns inside what an auto-run player can
|
||||||
|
actually cover (measured: 25 returns, all inside ±2.2 m), and earns 0.4 m more width per rung —
|
||||||
|
line-painting is the LATE ladder. Speed, reliability, width: three dials, all per-rung.
|
||||||
|
- **THE BLOOD VIGNETTE replaces the temporary border.** The old edge wash flashed 0.3 s and
|
||||||
|
vanished — between hits the screen said you were fine. Now a radial vignette reads `G.hp`
|
||||||
|
directly: its depth IS your damage fraction, permanent until healed, and the hit pulse rides on
|
||||||
|
top. Global (the hp channel is global), not match-only. Same no-HP-bar philosophy, one layer out:
|
||||||
|
the state is worn, not reported. At 1 hp the frame is properly blood-rimmed and the court centre
|
||||||
|
stays legible.
|
||||||
|
|
||||||
**Open, and every one of them is Oliver's call after he plays it:**
|
**Open, and every one of them is Oliver's call after he plays it:**
|
||||||
1. ~~**A ball that carries through the lane can complete two steps at once.**~~ Largely CLOSED by
|
1. ~~**A ball that carries through the lane can complete two steps at once.**~~ Largely CLOSED by
|
||||||
the slice removal (seventh pass) — a cut ball picking its own next victim was the main way this
|
the slice removal (seventh pass) — a cut ball picking its own next victim was the main way this
|
||||||
|
|||||||
+1
-1
@@ -400,6 +400,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="src/main.js?v=169"></script>
|
<script type="module" src="src/main.js?v=170"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -394,6 +394,10 @@ export const TUNE = {
|
|||||||
matchFlub: 0.08, // the match's own whiff base — the duel's 0.2 gives away a fifth of all
|
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
|
// returns, which across a game is an opponent that beats itself
|
||||||
matchFlubKeep: 0.82, // ...and it keeps only this much per rung — the ladder now that hp is flat
|
matchFlubKeep: 0.82, // ...and it keeps only this much per rung — the ladder now that hp is flat
|
||||||
|
matchWideBase: 2.1, // m either side of centre the FIRST opponent's returns may land — the width an
|
||||||
|
// auto-run player can actually cover. Its scatter painted lines from game one
|
||||||
|
// ("still a LOT of unreachable balls"); now line-painting is the late ladder
|
||||||
|
matchWidePer: 0.4, // ...earning this much more width per opponent felled
|
||||||
matchHeal: 2, // hp back for a game won (the duel's healPerDuel rate)
|
matchHeal: 2, // hp back for a game won (the duel's healPerDuel rate)
|
||||||
// The timing windows, DRAWN (the match's whole ask: "what is the possible tap window, what is the
|
// The timing windows, DRAWN (the match's whole ask: "what is the possible tap window, what is the
|
||||||
// sweet spot"). Numbers shared with the mechanics they visualise, so the picture cannot lie:
|
// sweet spot"). Numbers shared with the mechanics they visualise, so the picture cannot lie:
|
||||||
|
|||||||
@@ -146,6 +146,20 @@ function doHit(ch, quality, aim) {
|
|||||||
tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8;
|
tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8;
|
||||||
}
|
}
|
||||||
tx = Math.max(-5, Math.min(5, tx));
|
tx = Math.max(-5, Math.min(5, tx));
|
||||||
|
if (G.match) {
|
||||||
|
// THE OPPONENT'S WIDTH IS THE LADDER'S THIRD DIAL. Its returns scatter off aimError x (1 - 0.55
|
||||||
|
// quality), which paints lines an auto-run player cannot reach — "still a LOT of unreachable
|
||||||
|
// balls". Opponent one keeps its returns inside matchWideBase of the centre line; each rung
|
||||||
|
// earns matchWidePer more width, so the line-painting is the LATE ladder, not the opening one.
|
||||||
|
if (ch.side !== 'P') {
|
||||||
|
const wide = TUNE.matchWideBase + (G.match.opp - 1) * TUNE.matchWidePer;
|
||||||
|
tx = Math.max(-wide, Math.min(wide, tx));
|
||||||
|
}
|
||||||
|
// THE PLACEMENT, MADE VISIBLE: your aimed rally hit stamps its landing point, and render draws a
|
||||||
|
// fading ring there — flick, see the ring bite the corner, learn the cause. Only when a flick or
|
||||||
|
// held direction actually aimed it: an unaimed hit marking its scatter would teach noise.
|
||||||
|
else if (aim && (aim.ax || aim.az || aim.fx !== undefined)) G.placeMark = { x: tx, z: tz, t: 0.8 };
|
||||||
|
}
|
||||||
// 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
|
||||||
// pace arc over the net and still land in — the core tennis trade-off
|
// pace arc over the net and still land in — the core tennis trade-off
|
||||||
const g = TUNE.gravity * (1 + quality * TUNE.spinFactor);
|
const g = TUNE.gravity * (1 + quality * TUNE.spinFactor);
|
||||||
@@ -1235,6 +1249,7 @@ export function update(dt) {
|
|||||||
// died, so the biggest gore burst in the game happened underneath it and nobody ever saw it.
|
// died, so the biggest gore burst in the game happened underneath it and nobody ever saw it.
|
||||||
// BELOW the modal return, like the flash: a card raised in the same beat as a popup used to
|
// BELOW the modal return, like the flash: a card raised in the same beat as a popup used to
|
||||||
// burn its whole life dimmed-out underneath it — now it holds and plays AFTER the dismissal.
|
// burn its whole life dimmed-out underneath it — now it holds and plays AFTER the dismissal.
|
||||||
|
if (G.placeMark && (G.placeMark.t -= dt) <= 0) G.placeMark = null; // the placement ring fades
|
||||||
if (G.card) {
|
if (G.card) {
|
||||||
if (G.card.wait > 0) G.card.wait -= dt;
|
if (G.card.wait > 0) G.card.wait -= dt;
|
||||||
else { G.card.t -= dt; if (G.card.t <= 0) G.card = null; }
|
else { G.card.t -= dt; if (G.card.t <= 0) G.card = null; }
|
||||||
|
|||||||
+24
-7
@@ -1727,6 +1727,13 @@ export function render() {
|
|||||||
}
|
}
|
||||||
drawPiles(false); // far-side piles (occluded by the net); near-side piles drawn after the net
|
drawPiles(false); // far-side piles (occluded by the net); near-side piles drawn after the net
|
||||||
|
|
||||||
|
// THE PLACEMENT RING (match): where your aimed rally shot is going, drawn the moment you flick.
|
||||||
|
// Gold, fading over 0.8 s — flick, watch the ring bite the corner, learn what the gesture does.
|
||||||
|
if (G.placeMark) {
|
||||||
|
ctx.globalAlpha = Math.min(1, G.placeMark.t / 0.5);
|
||||||
|
drawRing(G.placeMark.x, G.placeMark.z, UI.warm, 0.9);
|
||||||
|
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') {
|
||||||
@@ -1954,13 +1961,23 @@ export function render() {
|
|||||||
// bomb bloom: a warm blood-orange wash, not a white-out. At 0.85 white it blinded you on a phone
|
// bomb bloom: a warm blood-orange wash, not a white-out. At 0.85 white it blinded you on a phone
|
||||||
// and hid the very gore eruption it was announcing.
|
// and hid the very gore eruption it was announcing.
|
||||||
if (G.boomFlash > 0) { ctx.fillStyle = `rgba(255,150,70,${Math.min(1, G.boomFlash / 0.06) * 0.3})`; ctx.fillRect(0, 0, W, H); }
|
if (G.boomFlash > 0) { ctx.fillStyle = `rgba(255,150,70,${Math.min(1, G.boomFlash / 0.06) * 0.3})`; ctx.fillRect(0, 0, W, H); }
|
||||||
// took a ranged hit: a red wash from the screen EDGES inward. Deliberately not a full-screen fill —
|
// THE BLOOD VIGNETTE — the hp channel, worn permanently. The old edge wash flashed for a third
|
||||||
// this channel must read as "you are bleeding", not as "something exploded" (that is boomFlash).
|
// of a second and vanished, so between hits the screen said you were fine; Oliver asked for the
|
||||||
if (G.hurtFlash > 0) {
|
// opposite: "a blood red vignette which gets worse with every hit". It reads G.hp directly — a
|
||||||
const a = Math.min(1, G.hurtFlash / 0.3) * 0.55, b = 26;
|
// radial darkening of the frame's edges whose depth IS your damage fraction — and the hit itself
|
||||||
ctx.fillStyle = `rgba(150,10,14,${a.toFixed(3)})`;
|
// rides on top as a brief brighter pulse (hurtFlash, kept). No HP bar anywhere, still: the
|
||||||
ctx.fillRect(0, 0, W, b); ctx.fillRect(0, H - b, W, b);
|
// vignette is the same drawn-on-the-man philosophy, one layer out.
|
||||||
ctx.fillRect(0, 0, b, H); ctx.fillRect(W - b, 0, b, H);
|
{
|
||||||
|
const dmg = 1 - Math.max(0, G.hp) / (G.hpMax || 1);
|
||||||
|
const pulse = G.hurtFlash > 0 ? Math.min(1, G.hurtFlash / 0.3) * 0.3 : 0;
|
||||||
|
if (dmg > 0 || pulse > 0) {
|
||||||
|
const a = Math.min(0.72, dmg * 0.5 + pulse);
|
||||||
|
const g = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * (0.52 - dmg * 0.14),
|
||||||
|
W / 2, H / 2, Math.max(W, H) * 0.74);
|
||||||
|
g.addColorStop(0, 'rgba(120,8,12,0)');
|
||||||
|
g.addColorStop(1, `rgba(120,8,12,${a.toFixed(3)})`);
|
||||||
|
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HUD — Wimbledon manual scoreboard as a 16-bit panel: bevelled bottle-green box (light
|
// HUD — Wimbledon manual scoreboard as a 16-bit panel: bevelled bottle-green box (light
|
||||||
|
|||||||
+2
-1
@@ -94,7 +94,8 @@ export const G = {
|
|||||||
hp: TUNE.playerHp,
|
hp: TUNE.playerHp,
|
||||||
hpMax: TUNE.playerHp,
|
hpMax: TUNE.playerHp,
|
||||||
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 wash left
|
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
|
||||||
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
|
||||||
|
|||||||
@@ -589,6 +589,24 @@ test('the match recovers to CENTRE between shots — a wide return must not stra
|
|||||||
} finally { TUNE.autoAssist = assist; }
|
} finally { TUNE.autoAssist = assist; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the first opponent cannot paint lines: its returns are width-clamped', () => {
|
||||||
|
// aimError x (1 - 0.55 quality) scattered returns to +-3.5 m, which an auto-run player cannot
|
||||||
|
// cover — "still a LOT of unreachable balls". Width is the ladder's third dial now.
|
||||||
|
assert.ok(TUNE.matchWideBase < 2.5, 'opponent one is kept inside reachable width');
|
||||||
|
assert.ok(TUNE.matchWidePer > 0, 'and the width is earned back per rung');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the placement mark fades on its own clock', () => {
|
||||||
|
// The stamp itself lives inside doHit (aimed match hits only) and is exercised by play; what the
|
||||||
|
// state machine owes is that a mark never lingers — a permanent ring would read as a target the
|
||||||
|
// player must hit rather than feedback on where the last shot went.
|
||||||
|
startMatch();
|
||||||
|
for (let i = 0; i < 2.0 * 60; i++) update(DT);
|
||||||
|
G.placeMark = { x: 2, z: 4, t: 0.8 };
|
||||||
|
for (let i = 0; i < 60; i++) update(DT);
|
||||||
|
assert.equal(G.placeMark, null, 'gone within a second');
|
||||||
|
});
|
||||||
|
|
||||||
test('THE REAL DEATH LOOP: a toss after their rally touch is not their passing shot', () => {
|
test('THE REAL DEATH LOOP: a toss after their rally touch is not their passing shot', () => {
|
||||||
// tossBall left ball.lastHit stale. After any rally the zombie touched last, the fresh toss still
|
// tossBall left ball.lastHit stale. After any rally the zombie touched last, the fresh toss still
|
||||||
// said lastHit 'O' — and the match serves from BEHIND the baseline (z 24.3), past the passing-shot
|
// said lastHit 'O' — and the match serves from BEHIND the baseline (z 24.3), past the passing-shot
|
||||||
|
|||||||
Reference in new issue
Block a user