Aesthetics: 3x render res, flatter lens, 4-frame walk cycles, tiered gore
- RES 2 -> 3: phone screens now DOWNSCALE the canvas (crisp pixels) instead of upscaling it (soft). Umpire-tap coords un-hardcoded from RES=2. - Flatter lens: camZ 36 -> 42 with camF scaled per orientation (406/580) so the PLAYER stays the same size — far-court zombies render ~25% bigger and finally read as individuals from the baseline. - Smoother animation: 4-frame walk cycles for both classic shamblers and the runner's sprint (new sheets, same style anchor; z_a1-4, z_b1-4, z_f1-4). Same cycle frequency — frames subdivide the stride. Render falls back to 2 frames per variant until its sheet loads. - Tiered gore (GORE_MUL): the more advanced the zombie, the more absurd the mess — classics 1x (base bumped 24 -> 30 particles), runner 1.8x, shield 2.5x, racket boss 3x, brute 4x (120 particles, a scatter of stains, an instant size-3 mound). Verified: fresh origin, canvas 1440px, umpire triple-tap works at RES 3, all 4 anim frames cycle at the old stride rate, brute kill = 120 parts / 2 stains / size-3 pile, far-court lineup screenshot clearly readable, portrait layout numbers correct (810x1440, camF 580), tennis 11/12, no console errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
No files matched your search
|
After Width: | Height: | Size: 910 KiB |
|
After Width: | Height: | Size: 919 KiB |
|
After Width: | Height: | Size: 937 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -2,7 +2,7 @@
|
||||
// the exported consts below are structural values that code shares but the panel doesn't show.
|
||||
|
||||
export const TUNE = {
|
||||
camZ: 36, camY: 9, camF: 280, horizon: 40,
|
||||
camZ: 42, camY: 9, camF: 406, horizon: 40, // camF/horizon are per-orientation, set in fitViewport
|
||||
gravity: 9.2, // slightly floaty vs 9.8 for readability
|
||||
spinFactor: 0.9, // extra dive on quality shots (fake topspin)
|
||||
bounceY: 0.58, bounceXZ: 0.78,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { TUNE, COURT, UMP_H } from './config.js';
|
||||
import { P, O, ball, score, G, setupPoint } from './state.js';
|
||||
import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint } from './gameplay.js';
|
||||
import { fitViewport, render, proj } from './render.js';
|
||||
import { fitViewport, render, proj, RES } from './render.js';
|
||||
import { voice } from './audio.js';
|
||||
import { zombies, spawnZombie, piles, gore, parts, pellets, extras, explode } from './zombies.js';
|
||||
import { cv } from './dom.js';
|
||||
@@ -40,8 +40,8 @@ addEventListener('keydown', maybeRestart);
|
||||
let umpTaps = [];
|
||||
cv.addEventListener('pointerdown', e => {
|
||||
const r = cv.getBoundingClientRect();
|
||||
const x = (e.clientX - r.left) / r.width * (cv.width / 2); // logical coords (RES = 2)
|
||||
const y = (e.clientY - r.top) / r.height * (cv.height / 2);
|
||||
const x = (e.clientX - r.left) / r.width * (cv.width / RES); // logical coords
|
||||
const y = (e.clientY - r.top) / r.height * (cv.height / RES);
|
||||
if (x >= 6 && x <= 138 && y >= 6 && y <= 36) { G.logShow = !G.logShow; return; } // scoreboard tap = miss-log overlay
|
||||
const u = proj(7.1, 0, COURT.netZ);
|
||||
if (Math.abs(x - u.x) > 16 || y > u.y + 6 || y < u.y - UMP_H * u.s - 8) return;
|
||||
|
||||
@@ -30,7 +30,7 @@ function palette() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- camera ----
|
||||
export const RES = 2; // physical pixels per logical pixel — sprites keep 2x detail
|
||||
export const RES = 3; // physical pixels per logical pixel — 3x so phone screens downscale (crisp) instead of upscaling (soft)
|
||||
export let W = 480, H = 270;
|
||||
|
||||
export function fitViewport() { // landscape 480x270, portrait 270x480 with a longer lens
|
||||
@@ -39,7 +39,9 @@ export function fitViewport() { // landscape 480x270, portrait 270x480 with a lo
|
||||
if (cv.width !== w * RES) {
|
||||
W = w; H = h;
|
||||
cv.width = w * RES; cv.height = h * RES;
|
||||
TUNE.camF = portrait ? 400 : 280;
|
||||
// flatter lens: camZ pushed back (config 42, was 36) with camF scaled to keep the PLAYER
|
||||
// the same size — far-court zombies render ~25% bigger and actually read as zombies
|
||||
TUNE.camF = portrait ? 580 : 406;
|
||||
TUNE.horizon = portrait ? 90 : 40;
|
||||
buildCrowd();
|
||||
}
|
||||
@@ -60,8 +62,8 @@ const SPR = {};
|
||||
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_swing1','p_swing2',
|
||||
'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2',
|
||||
'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4',
|
||||
'z_a1','z_a2','z_b1','z_b2','z_c1','z_c2',
|
||||
'z_f1','z_f2','z_g1','z_g2','z_s1','z_s2','z_s3']) {
|
||||
'z_a1','z_a2','z_a3','z_a4','z_b1','z_b2','z_b3','z_b4','z_c1','z_c2',
|
||||
'z_f1','z_f2','z_f3','z_f4','z_g1','z_g2','z_s1','z_s2','z_s3']) {
|
||||
const img = new Image();
|
||||
img.onload = () => SPR[n] = img;
|
||||
img.src = 'assets/' + n + '.png';
|
||||
@@ -129,10 +131,14 @@ function drawBoy(b) {
|
||||
ctx.drawImage(img, -w / 2, -h, w, h);
|
||||
ctx.restore();
|
||||
}
|
||||
const ZFRAMES = { a: 4, b: 4, f: 4 }; // 4-frame walk cycles where sheets exist; the rest stay 2
|
||||
function drawZombie(zb) {
|
||||
const rk = zb.variant === 'r';
|
||||
const v = rk ? 'c' : zb.variant; // the racket boss reuses the turned-CPU sprite (already holds a racket)
|
||||
let frame = rk && zb.pose === 'swing' ? 2 : Math.floor(zb.walkT * (zb.variant === 'f' ? 6 : 2.6)) % 2 + 1;
|
||||
let N = ZFRAMES[v] || 2;
|
||||
if (!SPR['z_' + v + N]) N = 2; // frames still loading (or sheet rejected): 2-frame fallback
|
||||
// same CYCLE frequency as before — more frames subdivide the stride, they don't speed it up
|
||||
let frame = rk && zb.pose === 'swing' ? 2 : Math.floor(zb.walkT * (zb.variant === 'f' ? 6 : 2.6) * N / 2) % N + 1;
|
||||
if (zb.variant === 's' && zb.shieldT > 0) frame = 3; // chair dropped — hit it NOW
|
||||
const h = zb.variant === 'g' ? 2.7 : rk ? 1.85 : 1.8; // the brute towers half again over the crowd
|
||||
drawStatic('z_' + v + frame, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2);
|
||||
|
||||
@@ -39,18 +39,25 @@ function pickVariant() {
|
||||
|
||||
// core kill: remove the zombie, spawn its gore/pile/particles, score. No ball coupling —
|
||||
// shared by ball hits, machine-gun pellets, melee swipes, and extra (multi-serve) balls.
|
||||
// how absurdly a variant dies: classics splash, the advanced roster DETONATES
|
||||
const GORE_MUL = { a: 1, b: 1, f: 1.8, s: 2.5, g: 4, r: 3 };
|
||||
|
||||
function killZombie(i, quiet) {
|
||||
const zb = zombies[i];
|
||||
const big = zb.variant === 'g'; // a brute goes down like a building — twice the gore
|
||||
const mul = GORE_MUL[zb.variant] || 1;
|
||||
zombies.splice(i, 1);
|
||||
if (G.interlude && zb.variant === 'r') resolveInterlude('P'); // regicide ends the duel your way — any weapon counts
|
||||
gore.push({ x: zb.x, z: zb.z, r: (big ? 0.8 : 0.45) + Math.random() * 0.35, a: 0.85 });
|
||||
if (gore.length > 40) gore.shift();
|
||||
const stains = Math.round(Math.sqrt(mul)); // advanced kills splash a scatter of stains, not one puddle
|
||||
for (let k = 0; k < stains; k++) {
|
||||
gore.push({ x: zb.x + (k ? (Math.random() * 2 - 1) * 0.9 : 0), z: zb.z + (k ? (Math.random() * 2 - 1) * 0.9 : 0),
|
||||
r: (0.45 + Math.random() * 0.35) * Math.sqrt(mul), a: 0.85 });
|
||||
if (gore.length > 40) gore.shift();
|
||||
}
|
||||
const pl = piles.find(p => Math.hypot(p.x - zb.x, p.z - zb.z) < 0.6); // stack up where kills cluster
|
||||
if (pl) pl.size = Math.min(4, pl.size + (big ? 2 : 1));
|
||||
else { piles.push({ x: zb.x, z: zb.z, size: big ? 2 : 1, seed: Math.random() * 1e4, blobs: null, builtSize: 0 });
|
||||
if (pl) pl.size = Math.min(4, pl.size + Math.round(mul / 2 + 0.5));
|
||||
else { piles.push({ x: zb.x, z: zb.z, size: Math.min(4, Math.round(mul / 2 + 0.5)), seed: Math.random() * 1e4, blobs: null, builtSize: 0 });
|
||||
if (piles.length > 30) piles.shift(); }
|
||||
const n = big ? 48 : 24;
|
||||
const n = Math.round(30 * mul); // base bumped 24 -> 30, then the multiplier goes to work
|
||||
for (let k = 0; k < n; k++) { const dark = k >= n * 2 / 3; parts.push({ // ~1/3 dark & long-lived: settling gore
|
||||
x: zb.x, y: 0.9, z: zb.z,
|
||||
vx: (Math.random() * 2 - 1) * 3.5, vy: 1 + Math.random() * 3.5, vz: (Math.random() * 2 - 1) * 3.5,
|
||||
|
||||