Let the ball follow the mirror, and stop the serve shrinking her

Four things Oliver caught on the phone.

The screen title is the ANNOUNCEMENT face now. A menu heading is a thing that
shouts, the same register the canvas gives SET and CHAPTER — it was wearing the
HUD font, which is the voice for scores and key hints. Bangers carries its own
weight, so it takes size and air instead of Silkscreen's letter-spacing, plus
the padding under it he asked for.

Her toss and smash were shrinking the whole woman. The slice anchors frame 0 to
224 px, and frame 0 was the toss — whose arm is straight up, so the BODY got
squeezed to make room for it. This is the trap already written down for the
raised racket; it just moved to a different sheet. Re-sliced the retained sheet
at the men's toss height (250) instead, so toss/smash/pull come out 250/293/191
against his 250/320/213 and her body matches her own idle again. No re-roll.

The pre-serve ball did not follow the MIRROR. drawChar flips the whole sprite
when a server faces left, so dodging during the bounce swapped his hands over
and left the ball hanging out by the racket — one `* sv.facing` on the offset.
It was wrong in the men's world the whole time, it just took a second body
standing next to it before anyone saw it.

And she reaches further out and higher than he does, so BOUNCE_IDLE's pair is
overridable per tour now (0.46/0.78 against his 0.36/0.68). Measured off the
drawn hand by eye, which is the only way those two numbers have ever been set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-04 12:37:59 +02:00
1 parent 14a1e5d261
commit 0ca43bf68f
7 files changed
+24 -10

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 14 KiB

+4 -1
View File
@@ -412,9 +412,12 @@ export const COURTS = [
// (the racket zombie — a zombified opponent) have a second drawing; the horde, the officials and
// the ball boys are shared, because they are staff, not the draw. `sfx` is the sprite-name suffix
// render.js appends: null means "the frames as drawn", which is the men's set.
// dx/apex are optional and override BOUNCE_IDLE's pair: she reaches further out and higher than he
// does, so the pre-serve ball has to meet a different palm. Same rule as BOUNCE_IDLE — measured off
// the drawn hand by eye, not calculated.
export const TOURS = [
{ id: 'men', name: "MEN'S SINGLES", tile: 'tour_m', sfx: null },
{ id: 'women', name: "WOMEN'S SINGLES", tile: 'tour_w', sfx: '_f' },
{ id: 'women', name: "WOMEN'S SINGLES", tile: 'tour_w', sfx: '_f', dx: 0.46, apex: 0.78 },
];
// netW was the singles-stick width (10.06) while the court is drawn to the DOUBLES lines, so the
+6 -4
View File
@@ -86,9 +86,11 @@
/* the overlay is HIDDEN during play, not removed: a restart puts it straight back, and the
display:grid above would otherwise win over the hidden attribute */
#intro[hidden] { display: none; }
/* headline: taller than the body text, so a screen reads as a screen and not as a list */
#intro h2 { margin: 0 0 20px; font: 400 15px Silkscreen, monospace; color: #e0cc72;
letter-spacing: 3px; line-height: 1.5; }
/* headline: the ANNOUNCEMENT face, the same register the canvas uses for SET and CHAPTER — a
screen title is a thing that shouts, not a line of HUD. Bangers carries its own weight, so it
wants size and air rather than Silkscreen's letter-spacing. */
#intro h2 { margin: 0 0 26px; font: 400 30px Bangers, cursive; color: #e0cc72;
letter-spacing: 2px; line-height: 1.2; padding-bottom: 6px; }
/* one button look for every menu screen */
.btn { display: block; width: min(340px, 84vw); margin: 0 auto 10px; padding: 12px 10px;
background: rgba(255,255,255,0.04); border: 2px solid #3b4354; color: #dfe6f2;
@@ -198,6 +200,6 @@
</div>
</div>
</div>
<script type="module" src="main.js?v=120"></script>
<script type="module" src="main.js?v=121"></script>
</body>
</html>
+2 -2
View File
@@ -62,7 +62,7 @@ const tourList = document.getElementById('tours');
for (const t of TOURS) {
const b = document.createElement('button');
b.className = t.id === pickedTour ? 'on' : '';
b.innerHTML = `<img src="assets/${t.tile}.png?v=83" alt=""><b>${t.name}</b>`;
b.innerHTML = `<img src="assets/${t.tile}.png?v=84" alt=""><b>${t.name}</b>`;
b.addEventListener('click', () => {
pickedTour = t.id;
for (const el of tourList.children) el.classList.toggle('on', el === b);
@@ -75,7 +75,7 @@ const courtList = document.getElementById('courtList');
for (const c of COURTS) {
const b = document.createElement('button');
b.className = 'court' + (c.id === picked ? ' on' : ''); // your last court comes up pre-picked
b.innerHTML = `<img src="assets/${c.tile}.png?v=83" alt=""><span><b>${c.name}</b><i>${c.sub}</i></span>`;
b.innerHTML = `<img src="assets/${c.tile}.png?v=84" alt=""><span><b>${c.name}</b><i>${c.sub}</i></span>`;
// Tapping a tile SELECTS it and nothing more. It used to start the match on the spot, which gave
// you no way to look at the three of them and no way back out of the screen.
b.addEventListener('click', () => {
+12 -3
View File
@@ -198,7 +198,7 @@ function drawRunOff() {
// ---------------------------------------------------------------- sprites ----
// assets/<name>.png; game falls back to code-drawn figures until sprites load.
const SPRV = 83; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
const SPRV = 84; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
const SPR = {};
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_run3','p_run4','p_swing1','p_swing2','p_die1','p_die2','p_die3','p_die4','p_die5',
'p_pull', // the serve wind-up: the racket dropping behind him while the pull is drawn
@@ -877,6 +877,11 @@ function drawPiles(near) { // split at the net like zombies so height occludes c
// body out of existence (the same rule drawZombie's missing-frame guard is built on).
const SK = n => (tour.sfx && SPR[n + tour.sfx]) ? n + tour.sfx : n;
// BOUNCE_IDLE.dx/apex are measured off the DRAWN PALM (see config), so a tour whose player reaches
// differently needs its own pair. Only the women's row carries them; ?? falls back to the men's.
const bounceDx = () => tour.dx ?? BOUNCE_IDLE.dx;
const bounceApex = () => tour.apex ?? BOUNCE_IDLE.apex;
function drawChar(ch) {
// No split-step. The receiver used to be slid 0.12 m side to side while waiting on a serve, on top
// of a swap to the run frames; both are gone. Sliding a sprite sideways is not footwork — it reads
@@ -1366,9 +1371,13 @@ export function render() {
// drawn from BEHIND (his left hand is on the viewer's left) and the opponent from the FRONT
// (his left hand is on the viewer's right). One sign for both put the opponent's ball out by
// his racket instead of under his palm.
// ...and the offset FOLLOWS THE FACING, because drawChar mirrors the whole sprite when a server
// faces left — dodging during the bounce swapped his hands over and left the ball hanging off
// the racket side. One `* sv.facing` fixes it for both players and both tours; it was wrong in
// the men's world too, it just took a second body to notice.
const mine = sv.side === 'P';
const bx = sv.x + (mine ? -BOUNCE_IDLE.dx : BOUNCE_IDLE.oDx);
const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * (mine ? BOUNCE_IDLE.apex : BOUNCE_IDLE.oApex);
const bx = sv.x + sv.facing * (mine ? -bounceDx() : BOUNCE_IDLE.oDx);
const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * (mine ? bounceApex() : BOUNCE_IDLE.oApex);
drawBall(bx, by, sv.z);
}
// the match ball (and any triple-serve side balls in flight)