Feedback round: fire actually spreads, brutes lurch, crowd stops flickering
- Fire never spread in play. The flame ball's full pierce wiped its own lane, so
the bodies that would have caught were already gone. It now ignites everything
it FLIES PAST (TUNE.burnBallR), with a wider ignition and spread radius. One
flame serve into a scattered 10-wave kills 9, nine burning at once.
- Wounded brutes slid along like decals: every wound stage is a single frame and
the brute now spends most of its life wounded. They get a procedural lumber
instead of eight more sprites — translate + squash, no rotation, so the pixels
stay on the grid, and the amplitudes scale with the projection or the whole
thing goes sub-pixel at range.
- The "scoreboard flicker" was the crowd: the cheer bounce was per-column
Math.random() every frame, i.e. TV static, showing through the scoreboard art's
transparent ornate edges. Now a smooth per-column sine on idleT.
- Ball boys: mopping is the default in splatter mode now, and the boy is half
again as tall — at 1.2m he read as a distant speck. The running fetch-boy
survives for tennis, where there is nothing on the grass but a ball.
- Changeover 7s -> 5s, with the mop crossing timed to fill it.
- Save-to-homescreen: a bloody tennis ball, wired up as apple-touch-icon +
favicon + webmanifest. The Dockerfile now ships the manifest and gives it its
own MIME type via an exact-match location (a bare `types {}` block would have
replaced nginx's whole map and broken .js and .png with it).
Benchmarks re-run: CPU returns 67/72 serves (93.1%, above the 11/12 bar — a
20/24 sample in between was flub noise), 30 human-tapped points = 50 hits /
0 whiffs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a09a5d7559
commit
ff77922e85
14 files changed
+92
-19
No files matched your search
+5
-2
@@ -1,6 +1,9 @@
|
|||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
# no-cache => browsers revalidate every request (ETag 304s are cheap), so a new
|
# no-cache => browsers revalidate every request (ETag 304s are cheap), so a new
|
||||||
# deploy is picked up on plain reload. ponytail: switch to hashed filenames if traffic ever matters.
|
# deploy is picked up on plain reload. ponytail: switch to hashed filenames if traffic ever matters.
|
||||||
RUN printf 'server {\n listen 80;\n root /usr/share/nginx/html;\n etag on;\n location / { add_header Cache-Control "no-cache, must-revalidate"; }\n}\n' > /etc/nginx/conf.d/default.conf
|
# The exact-match location gives the manifest its own MIME type — nginx's default map has no
|
||||||
COPY index.html *.js /usr/share/nginx/html/
|
# .webmanifest entry and Chrome refuses to install a homescreen app served as octet-stream.
|
||||||
|
# (a bare `types {}` block would have REPLACED the whole map and broken .js/.png too.)
|
||||||
|
RUN printf 'server {\n listen 80;\n root /usr/share/nginx/html;\n etag on;\n location = /manifest.webmanifest { default_type application/manifest+json; add_header Cache-Control "no-cache, must-revalidate"; }\n location / { add_header Cache-Control "no-cache, must-revalidate"; }\n}\n' > /etc/nginx/conf.d/default.conf
|
||||||
|
COPY index.html manifest.webmanifest *.js /usr/share/nginx/html/
|
||||||
COPY assets /usr/share/nginx/html/assets
|
COPY assets /usr/share/nginx/html/assets
|
||||||
+20
-2
@@ -134,8 +134,26 @@ New foundations you must know before touching anything:
|
|||||||
0 whiffs** — but you MUST set `T.TUNE.autoAssist = 1.15` first, the Browser pane reports
|
0 whiffs** — but you MUST set `T.TUNE.autoAssist = 1.15` first, the Browser pane reports
|
||||||
`pointer: fine` so the assist is off and every ball reads as "never reachable".
|
`pointer: fine` so the assist is off and every ball reads as "never reachable".
|
||||||
- Brute is 5 hp: worst-case (max ramp) it still needs 22.5s to cross, vs ~7s of serves to kill.
|
- Brute is 5 hp: worst-case (max ramp) it still needs 22.5s to cross, vs ~7s of serves to kill.
|
||||||
- `TUNE.burnSpread` 1.3 m chains reliably through shoulder-to-shoulder shamblers (~1.0 m apart);
|
|
||||||
at 1.2 m spacing the tail of a line can survive. It is a T-panel knob if Oliver wants it hotter.
|
## Feedback round on the polish (2026-07-27, same day)
|
||||||
|
- **Fire never spread in real play.** The flame ball has full pierce and instant-kill, so it wiped
|
||||||
|
its own lane and left nobody to catch. Fixed by `TUNE.burnBallR` — the ball ignites everything it
|
||||||
|
FLIES PAST, every frame, whether or not it hits — plus wider `burnIgniteR` 4.5 / `burnSpread` 2.2.
|
||||||
|
One flame serve into a realistic scattered 10-wave now kills 9, with 9 burning at once.
|
||||||
|
**When testing fire, fire a real ARC**: a flat ball from z=16 hits the net at y≈0.83 (netH 0.914)
|
||||||
|
and bounces back at you — the first "fire is broken" reading was my test hitting the tape.
|
||||||
|
- **Wounded brutes need their own animation.** Every wound stage is a single frame and the brute
|
||||||
|
now spends most of its life wounded, so it slid along like a decal. Rather than 8 more sprites
|
||||||
|
they get a procedural lumber in `drawZombie` (translate + squash, no rotation, so pixels stay on
|
||||||
|
the grid). Amplitudes MUST scale with `p.s` — at a fixed fraction the whole lurch was sub-pixel
|
||||||
|
at range and read as nothing moving.
|
||||||
|
- **The scoreboard "flicker" was the crowd.** The cheer bounce was `-Math.random()*2` per column
|
||||||
|
per frame, i.e. TV static, showing through the scoreboard art's transparent ornate edges. Now a
|
||||||
|
smooth per-column sine on `idleT`. Test for it: render twice WITHOUT stepping — identical pixels
|
||||||
|
means nothing in the draw path is rolling dice per frame.
|
||||||
|
- Ball boys: `sendBoy`'s `mop` now DEFAULTS to `G.mode === 'splatter'`. The running fetch-boy still
|
||||||
|
exists, but only in tennis mode where there is nothing on the grass but a ball. `BOY_H` 1.2 → 1.5.
|
||||||
|
- `TUNE.burnSpread` is a T-panel knob if Oliver wants fire hotter still.
|
||||||
|
|
||||||
## Gotchas learned the hard way
|
## Gotchas learned the hard way
|
||||||
- Browser-pane verification: python http.server sends no cache headers, so edited ES
|
- Browser-pane verification: python http.server sends no cache headers, so edited ES
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 400 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
@@ -30,8 +30,9 @@ export const TUNE = {
|
|||||||
zSpawnStep: 0.22, // spawn-gap reduction per finished game
|
zSpawnStep: 0.22, // spawn-gap reduction per finished game
|
||||||
zMax: 3, // shamblers on court in game 1 (cap grows +1 per game to zMaxCap; 0 = spawns off, for tests)
|
zMax: 3, // shamblers on court in game 1 (cap grows +1 per game to zMaxCap; 0 = spawns off, for tests)
|
||||||
zMaxCap: 14, // absolute shambler cap
|
zMaxCap: 14, // absolute shambler cap
|
||||||
changeoverT: 7.0, // s the changeover breather lasts (after odd games, as the rules demand)
|
changeoverT: 5.0, // s the changeover breather lasts (after odd games, as the rules demand)
|
||||||
boyMopSpeed: 4.0, // m/s a MOPPING ball boy crosses at (a fetching one still sprints at 7.5)
|
boyMopSpeed: 5.0, // m/s a MOPPING ball boy crosses at (a fetching one still sprints at 7.5).
|
||||||
|
// Sized so one crossing ~fills the changeover instead of stranding him mid-court.
|
||||||
gameBeatT: 1.6, // s the game-end beat lasts on even (non-changeover) games
|
gameBeatT: 1.6, // s the game-end beat lasts on even (non-changeover) games
|
||||||
eatenR: 0.9, // m a shambler must close to eat you — matches the visual sprite overlap
|
eatenR: 0.9, // m a shambler must close to eat you — matches the visual sprite overlap
|
||||||
zSnap: 0.9, // m of lateral slack at zero range — the aim cone's base width (fat-thumb forgiveness)
|
zSnap: 0.9, // m of lateral slack at zero range — the aim cone's base width (fat-thumb forgiveness)
|
||||||
@@ -85,8 +86,10 @@ export const TUNE = {
|
|||||||
// fire: the flame ball no longer just kills what it touches, it starts an epidemic.
|
// fire: the flame ball no longer just kills what it touches, it starts an epidemic.
|
||||||
burnTime: 3.0, // s a body burns before the fire goes out
|
burnTime: 3.0, // s a body burns before the fire goes out
|
||||||
burnDps: 2.0, // hp lost per second while burning — burnTime*burnDps must exceed bruteHp or tanks walk out of the fire
|
burnDps: 2.0, // hp lost per second while burning — burnTime*burnDps must exceed bruteHp or tanks walk out of the fire
|
||||||
burnSpread: 1.3, // m — a burning body ignites anything this close, every frame it burns
|
burnSpread: 2.2, // m — a burning body ignites anything this close, every frame it burns
|
||||||
burnIgniteR: 2.6, // m — the flame ball's ignition radius around the body it kills
|
burnIgniteR: 4.5, // m — the flame ball's ignition radius around the body it kills
|
||||||
|
burnBallR: 2.4, // m — the flame ball also lights anything it FLIES PAST. Without this the ball's
|
||||||
|
// full pierce simply killed the whole lane outright and nothing was left to catch fire.
|
||||||
burnPanic: 1.0, // walk-speed multiplier while burning (1 = off; crank it for panicked sprinting)
|
burnPanic: 1.0, // walk-speed multiplier while burning (1 = off; crank it for panicked sprinting)
|
||||||
setCardT: 2.0, // s the centered SET card holds (the 'kurze unterbrechung' before the horde)
|
setCardT: 2.0, // s the centered SET card holds (the 'kurze unterbrechung' before the horde)
|
||||||
bruteHp: 5, // splat-quality hits (or pellets) a brute ('g') soaks before it dies — one wound skin per stage
|
bruteHp: 5, // splat-quality hits (or pellets) a brute ('g') soaks before it dies — one wound skin per stage
|
||||||
@@ -117,7 +120,7 @@ COURT.svcNear = COURT.netZ + 6.4; // near service line
|
|||||||
|
|
||||||
// world sizes (meters)
|
// world sizes (meters)
|
||||||
export const PLAYER_H = 1.88;
|
export const PLAYER_H = 1.88;
|
||||||
export const BOY_H = 1.2;
|
export const BOY_H = 1.5; // was 1.2 — the mopping boy read as a distant speck at that scale
|
||||||
export const UMP_H = 3.0;
|
export const UMP_H = 3.0;
|
||||||
export const UMP_X = 5.6; // chair right beside the doubles line — x 7.1 projected off-screen in portrait
|
export const UMP_X = 5.6; // chair right beside the doubles line — x 7.1 projected off-screen in portrait
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -328,7 +328,7 @@ function startChangeover() {
|
|||||||
// out with the mop, across the near court where the gore is — NOT to the ball: a dead ball
|
// out with the mop, across the near court where the gore is — NOT to the ball: a dead ball
|
||||||
// parked off the field (sendBoy's z guard) meant no boy came out at all, and the breather is
|
// parked off the field (sendBoy's z guard) meant no boy came out at all, and the breather is
|
||||||
// now long enough that an empty court reads as the game having stalled.
|
// now long enough that an empty court reads as the game having stalled.
|
||||||
sendBoy(0, COURT.L * 0.62, true);
|
sendBoy(0, COURT.L * 0.75, true); // near enough the camera to actually watch him work
|
||||||
G.flash = { text: 'CHANGEOVER', t: 1.4 };
|
G.flash = { text: 'CHANGEOVER', t: 1.4 };
|
||||||
// no tins here anymore — weapons are won in the duel, never handed out on a schedule
|
// no tins here anymore — weapons are won in the duel, never handed out on a schedule
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-2
@@ -3,7 +3,17 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Splatter Tennis</title>
|
<title>Splatter Tennis</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<!-- save-to-homescreen: a bloody tennis ball. iOS needs the apple-* tags (it ignores the
|
||||||
|
manifest's display/name); Android reads the manifest. -->
|
||||||
|
<link rel="icon" type="image/png" href="assets/favicon.png">
|
||||||
|
<link rel="apple-touch-icon" href="assets/icon-180.png">
|
||||||
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Splatter">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="theme-color" content="#2b2735">
|
||||||
<style>
|
<style>
|
||||||
/* pixel HUD font — bundled so it renders offline; used by the canvas scoreboard + rack */
|
/* pixel HUD font — bundled so it renders offline; used by the canvas scoreboard + rack */
|
||||||
@font-face { font-family: 'Silkscreen'; font-weight: 400; font-display: block;
|
@font-face { font-family: 'Silkscreen'; font-weight: 400; font-display: block;
|
||||||
@@ -50,6 +60,6 @@
|
|||||||
<p class="start">TAP TO START</p>
|
<p class="start">TAP TO START</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="main.js?v=26"></script>
|
<script type="module" src="main.js?v=29"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "Splatter Tennis",
|
||||||
|
"short_name": "Splatter",
|
||||||
|
"description": "Fifteen. Thirty. Forty. Dead.",
|
||||||
|
"start_url": "./",
|
||||||
|
"scope": "./",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#10141c",
|
||||||
|
"theme_color": "#2b2735",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "assets/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "assets/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||||
|
{ "src": "assets/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -177,7 +177,22 @@ function drawZombie(zb) {
|
|||||||
else if ((zb.variant === 'a' || zb.variant === 'b') && zb.hp < TUNE.shamblerHp && SPR['z_' + zb.variant + 'w']) name = 'z_' + zb.variant + 'w'; // gashed shambler
|
else if ((zb.variant === 'a' || zb.variant === 'b') && zb.hp < TUNE.shamblerHp && SPR['z_' + zb.variant + 'w']) name = 'z_' + zb.variant + 'w'; // gashed shambler
|
||||||
else if (rk && zb.hp <= 1 && SPR.z_cw2) { name = 'z_cw2'; h = 1.52; } // headless: a head shorter, so the shoulders land where a whole zombie's do
|
else if (rk && zb.hp <= 1 && SPR.z_cw2) { name = 'z_cw2'; h = 1.52; } // headless: a head shorter, so the shoulders land where a whole zombie's do
|
||||||
else if (rk && zb.hp === 2 && SPR.z_cw1) name = 'z_cw1';
|
else if (rk && zb.hp === 2 && SPR.z_cw1) name = 'z_cw1';
|
||||||
drawStatic(name, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2);
|
// Every wound stage is a SINGLE frame, and the brute now spends most of its life wounded — so
|
||||||
|
// without this it slid toward you like a decal. Rather than eight more sprites, the wrecked
|
||||||
|
// stages get a procedural lumber: it heaves sideways, sags onto each step and drags the trailing
|
||||||
|
// foot. Pure translate + squash (no rotation), so the pixels stay on the grid.
|
||||||
|
const wrecked = zb.variant === 'g' && zb.hp < TUNE.bruteHp && name !== 'z_g' + frame;
|
||||||
|
if (wrecked) {
|
||||||
|
const p = proj(zb.x, 0, zb.z), w = zb.walkT * 2.4; // slow and heavy — it is barely holding together
|
||||||
|
ctx.save();
|
||||||
|
// amplitudes are in PROJECTED px, so they have to scale with p.s or they vanish at range —
|
||||||
|
// at 0.035 the whole lurch was sub-pixel out at the far baseline and read as no animation
|
||||||
|
ctx.translate(p.x + Math.sin(w) * p.s * 0.13, p.y);
|
||||||
|
ctx.scale(1 + 0.07 * Math.sin(w), 1 - 0.09 * Math.abs(Math.sin(w * 2)));
|
||||||
|
ctx.translate(-p.x, -p.y);
|
||||||
|
}
|
||||||
|
drawStatic(name, zb.x, zb.z, h, wrecked ? Math.max(0, Math.sin(zb.walkT * 4.8)) * 3 : Math.floor(zb.walkT * 5.2) % 2);
|
||||||
|
if (wrecked) ctx.restore();
|
||||||
if (zb.burnT > 0) drawFlames(zb, h);
|
if (zb.burnT > 0) drawFlames(zb, h);
|
||||||
if (rk) { // HP bar floating above the mini-boss
|
if (rk) { // HP bar floating above the mini-boss
|
||||||
const p = proj(zb.x, 2.05, zb.z), w = Math.max(10, p.s * 0.5), x = p.x - w / 2;
|
const p = proj(zb.x, 2.05, zb.z), w = Math.max(10, p.s * 0.5), x = p.x - w / 2;
|
||||||
@@ -412,15 +427,18 @@ export function render() {
|
|||||||
if (crowdImg) {
|
if (crowdImg) {
|
||||||
const cols = 16, sw = crowdImg.width / cols, dw = W / cols;
|
const cols = 16, sw = crowdImg.width / cols, dw = W / cols;
|
||||||
for (let c = 0; c < cols; c++) {
|
for (let c = 0; c < cols; c++) {
|
||||||
const dy = crowd.cheer > 0 ? -Math.random() * 2 : 0;
|
// A smooth per-column wave, NOT per-frame noise: random jitter here read as TV static
|
||||||
|
// through the scoreboard art's transparent ornate edges, which looked like the HUD flickering.
|
||||||
|
const dy = crowd.cheer > 0 ? -1.6 * (0.5 + 0.5 * Math.sin(G.idleT * 12 + c * 1.7)) : 0;
|
||||||
ctx.drawImage(crowdImg, c * sw, 0, sw, crowdImg.height, c * dw, dy, dw + 0.5, crowdH + 2);
|
ctx.drawImage(crowdImg, c * sw, 0, sw, crowdImg.height, c * dw, dy, dw + 0.5, crowdH + 2);
|
||||||
}
|
}
|
||||||
} else { // fallback until the image loads: the old tier stripes + dots
|
} else { // fallback until the image loads: the old tier stripes + dots
|
||||||
ctx.fillStyle = pal.stands; ctx.fillRect(0, 0, W, standsBottom);
|
ctx.fillStyle = pal.stands; ctx.fillRect(0, 0, W, standsBottom);
|
||||||
for (let r = 0; r < 16; r++) { ctx.fillStyle = r % 2 ? pal.tierA : pal.tierB; ctx.fillRect(0, 6 + r * 5.5, W, 5.5); }
|
for (let r = 0; r < 16; r++) { ctx.fillStyle = r % 2 ? pal.tierA : pal.tierB; ctx.fillRect(0, 6 + r * 5.5, W, 5.5); }
|
||||||
for (const d of crowd.dots) {
|
for (const d of crowd.dots) {
|
||||||
const jx = crowd.cheer > 0 ? (Math.random() * 2 - 1) : 0;
|
const w = G.idleT * 12 + d.x * 0.4; // same smooth wave for the pre-sprite dot fallback
|
||||||
const jy = crowd.cheer > 0 ? -Math.random() * 1.5 : 0;
|
const jx = crowd.cheer > 0 ? Math.sin(w * 0.7) : 0;
|
||||||
|
const jy = crowd.cheer > 0 ? -1.2 * (0.5 + 0.5 * Math.sin(w)) : 0;
|
||||||
ctx.fillStyle = d.c; ctx.fillRect(d.x + jx | 0, d.y + jy | 0, 2, 2);
|
ctx.fillStyle = d.c; ctx.fillRect(d.x + jx | 0, d.y + jy | 0, 2, 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,9 +97,10 @@ export function setupPoint() {
|
|||||||
G.aiServeT = score.server === 'O' ? 1.4 : 0;
|
G.aiServeT = score.server === 'O' ? 1.4 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetch a dead ball — or, with mop, come out to CLEAN: a different sprite sheet and a
|
// fetch a dead ball — or, in splatter, come out to CLEAN: a different sprite sheet and a slower
|
||||||
// slower walk, because mopping is not sprinting. The gore-fading pass is the same either way.
|
// walk, because mopping is not sprinting. Mopping is the DEFAULT once the gore starts; the plain
|
||||||
export function sendBoy(tx = ball.x, tz = ball.z, mop = false) {
|
// running fetch-boy survives for tennis mode, where there is nothing on the grass but a ball.
|
||||||
|
export function sendBoy(tx = ball.x, tz = ball.z, mop = G.mode === 'splatter') {
|
||||||
if (tz < -3 || tz > 27) return; // target left the field lengthwise, let it go
|
if (tz < -3 || tz > 27) return; // target left the field lengthwise, let it go
|
||||||
const dir = tx > 0 ? 1 : -1; // enter from the opposite side so the pickup is mid-run
|
const dir = tx > 0 ? 1 : -1; // enter from the opposite side so the pickup is mid-run
|
||||||
G.boy = { x: dir > 0 ? -13 : 13, z: tz, dir, runT: 0, mop };
|
G.boy = { x: dir > 0 ? -13 : 13, z: tz, dir, runT: 0, mop };
|
||||||
|
|||||||
@@ -251,6 +251,11 @@ export function updateZombies(dt) {
|
|||||||
// hit; at these speeds and radii it doesn't.
|
// hit; at these speeds and radii it doesn't.
|
||||||
const ballSweep = ball.active ? Math.hypot(ball.vx, ball.vz) * dt * 0.5 : 0;
|
const ballSweep = ball.active ? Math.hypot(ball.vx, ball.vz) * dt * 0.5 : 0;
|
||||||
|
|
||||||
|
// The flame ball lights everything it FLIES PAST, not just what it kills. Without this the
|
||||||
|
// fire never actually spread: full pierce meant the ball killed its whole lane outright, and
|
||||||
|
// the only bodies left to catch were the ones it had already removed.
|
||||||
|
if (ball.active && ball.flame && ball.y < 2.6) ignite(ball.x, ball.z, TUNE.burnBallR);
|
||||||
|
|
||||||
for (let i = zombies.length - 1; i >= 0; i--) {
|
for (let i = zombies.length - 1; i >= 0; i--) {
|
||||||
const zb = zombies[i];
|
const zb = zombies[i];
|
||||||
if (!zb) continue; // a bomb detonation splices several out mid-loop and can gut the array under us
|
if (!zb) continue; // a bomb detonation splices several out mid-loop and can gut the array under us
|
||||||
|
|||||||
Reference in new issue
Block a user