The opponent can serve and fall, and there is a bench to judge it on
THE MISSING ART, generated by the whole-sheet restyle method: bouncing the ball at the baseline, the toss with the ball leaving the fingertips, the strike at full stretch — and the fall, which slice 4 has been waiting for: buckling, down on the knees, flat on the court face down, then dragging itself back up. One zombie is now complete as a tennis player rather than as a body that shuffles. (Healthy only so far; the wound variants are the next sheet.) THE SPRITE BENCH, in the back room. No ball, no scoring, no AI — one opponent on an empty court, walking, at the size and speed it is actually played at, because that is the only place a walk cycle can be judged. A sheet that looks fine at 224 px on a contact sheet is a different animal at 30 px crossing a lawn, and every sprite argument this project has had was really about that gap. It steps through every state including the ones the game cannot reach yet, so the serve and the fall could be looked at the day they were drawn instead of the day they get wired up. It prints the FILENAME it is drawing and the hp the wound stage was read from — the two numbers that separate "the art is wrong" from "the wiring picked the wrong art" — and it says HEALTHY ONLY where a wound variant does not exist rather than implying one does. The stride steps off ground covered, like the real thing: a bench animating on a clock would flatter exactly the sprites that foot-slide in play. It owns the court like every other mode and every start* refuses while it runs. FOUND BY IT, ten seconds in: `start()` applies the mode the setup screen picked, so a remembered ZOMBIE TENNIS was taking the court before the back room could — every back-room entry (weapons, bosses, drill, match, bench) had been silently refusing since the mode select landed this morning. The back room passes null. And a third calibration lesson: POSE MOVES A GLOBAL COLOUR STATISTIC. A body face down shows a fraction of the shirt it shows standing, so `redness` fell 14 points across the fall sheet without a colour changing. The measure that survives a pose change is the shorts HUE — mid-tone neutrals, present in every frame in the same proportion — which is why it is the one that caught the drift Oliver actually saw. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ffc9cd3a60
commit
4bbdf6357a
16 files changed
+234
-30
No files matched your search
+84
-7
@@ -7,7 +7,7 @@
|
||||
// (beats <-> gameplay and beats <-> zombies are runtime-only import cycles, same pattern as
|
||||
// input <-> gameplay: the imported bindings are only touched inside functions, never at load.)
|
||||
|
||||
import { TUNE, UI, bossArt, matchRung, COURT, HOME } from './config.js';
|
||||
import { TUNE, UI, bossArt, matchRung, BENCH, COURT, HOME } from './config.js';
|
||||
import { G, P, O, ball, score, setupPoint, sendBoy } from './state.js';
|
||||
import { voice, zombieDuck, aww } from './audio.js';
|
||||
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js';
|
||||
@@ -63,7 +63,7 @@ function startChangeover() {
|
||||
export function startDuel() {
|
||||
// Hard mutual exclusion, HERE, where every caller routes through: a duel must never begin while
|
||||
// another control mode owns the court. Each of these has ended a run when they overlapped.
|
||||
if (G.interlude || G.horde || G.bossFight || G.drill || G.match) return;
|
||||
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return;
|
||||
if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent
|
||||
for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel
|
||||
G.weapon = 0; // tennis rules for the duel: plain balls, no tins
|
||||
@@ -86,7 +86,7 @@ export function startDuel() {
|
||||
// It threatens BOTH channels: its volleys chip your hp, and letting it reach you is the ordinary
|
||||
// instant death. G.bossFight holds the chapter number while it runs.
|
||||
export function startBossFight(chapter) {
|
||||
if (G.bossFight || G.drill || G.match) return;
|
||||
if (G.bossFight || G.drill || G.match || G.bench) return;
|
||||
G.bossFight = chapter;
|
||||
zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage
|
||||
G.horde = null; G.mgT = 0; endBurst(); G.clearing = false;
|
||||
@@ -135,7 +135,7 @@ export function endBossFight() {
|
||||
// was walking around a moment ago is still walking around inside a drill round. The drill's own
|
||||
// clock and rules live in drill.js; only the transition is here, where the rule can be enforced.
|
||||
export function startDrill() {
|
||||
if (G.drill || G.interlude || G.horde || G.bossFight || G.match) return false;
|
||||
if (G.drill || G.interlude || G.horde || G.bossFight || G.match || G.bench) return false;
|
||||
G.drill = { t: 0, spawnT: 0, score: 0, streak: 0, kills: 0, faults: 0, lost: 0, best: 0, msg: null };
|
||||
G.changeover = false; G.clearing = false; G.afterClear = null;
|
||||
// ...and DISARM the ordinary schedule. Taking the court is not enough on its own: a pending
|
||||
@@ -158,6 +158,83 @@ export function endDrill() {
|
||||
ball.active = false; setupPoint();
|
||||
}
|
||||
|
||||
// ---- THE SPRITE BENCH: the back room's art test, and nothing else. No ball, no scoring, no AI —
|
||||
// ONE opponent on an empty court, at the size and the speed it is actually played at, because that
|
||||
// is the only place a walk cycle can be judged. A sheet that looks fine at 224 px on a contact sheet
|
||||
// is a different animal at 30 px moving across a lawn, and every sprite argument this project has
|
||||
// had was really an argument about that gap.
|
||||
//
|
||||
// Each stop is either an ANIMATED state (the walk, driven by ground covered like the real thing) or
|
||||
// ONE NAMED FRAME, so frames the game cannot reach yet — the serve, the fall — can still be looked
|
||||
// at the day they are generated rather than the day they are wired up.
|
||||
const BENCH_HP = 12; // a deep pool so a wound stage is set by FRACTION, cleanly
|
||||
|
||||
export function startBench() {
|
||||
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return false;
|
||||
G.bench = { i: 0, stage: 0, dir: 1 };
|
||||
zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0;
|
||||
// ...and DISARM the ordinary schedule, exactly as the drill does: a pending horde or an owed
|
||||
// chapter is a latch that keeps trying to fire behind a mode that has no bodies for it.
|
||||
G.pendingHorde = false; G.hordeWarn = 0; G.bossDue = 0; G.duelDue = false;
|
||||
G.changeover = false; G.clearing = false; G.afterClear = null;
|
||||
G.weapon = 0; G.mgT = 0; endBurst(); G.boy = null;
|
||||
ball.active = false; setupPoint();
|
||||
G.flash = null; G.card = null; G.modal = null; G.modalQ.length = 0; // nothing the run was
|
||||
// saying belongs over an art test — the bench's own
|
||||
// readout lives in that band
|
||||
spawnRacketZombie();
|
||||
benchApply();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function endBench() {
|
||||
if (!G.bench) return;
|
||||
G.bench = null;
|
||||
zombies.length = 0;
|
||||
ball.active = false; setupPoint();
|
||||
}
|
||||
|
||||
// hp is set as a FRACTION of the pool, never as a literal, because that is how drawZombie reads a
|
||||
// wound stage — the bench must ask the same question the game asks or it is testing something else.
|
||||
function benchApply() {
|
||||
const zb = zombies.find(z => z.variant === 'r'); if (!zb) return;
|
||||
zb.hpMax = BENCH_HP;
|
||||
zb.hp = Math.max(1, Math.round(BENCH_HP * [1, 0.5, 0.2][G.bench.stage]));
|
||||
zb.showFrame = BENCH[G.bench.i].spr || null;
|
||||
}
|
||||
|
||||
// the bench's own controls, taken before anything else reads the key: SPACE / a tap steps to the
|
||||
// next state, left/right change the wound stage. One gesture, one meaning — the rule this project
|
||||
// already holds itself to on touch.
|
||||
export function benchKey(code) {
|
||||
const b = G.bench;
|
||||
if (code === 'ArrowLeft' || code === 'KeyA') b.stage = (b.stage + 2) % 3;
|
||||
else if (code === 'ArrowRight' || code === 'KeyD') b.stage = (b.stage + 1) % 3;
|
||||
else b.i = (b.i + 1) % BENCH.length;
|
||||
benchApply();
|
||||
}
|
||||
|
||||
export function updateBench(dt) {
|
||||
const b = G.bench; if (!b) return;
|
||||
const zb = zombies.find(z => z.variant === 'r'); if (!zb) return;
|
||||
const item = BENCH[b.i];
|
||||
zb.z = 5.5; // parked on the far court, where an opponent stands
|
||||
zb.swingCool = 1; zb.swingT = -1; zb.poseT = 0; // nothing expires a pose out from under the bench
|
||||
if (item.walk) {
|
||||
// THE STRIDE STEPS OFF GROUND COVERED, like the real one — a bench that animated on a clock
|
||||
// would be flattering exactly the sprites that foot-slide in play.
|
||||
const step = TUNE.racketSpeed * 0.55 * dt;
|
||||
zb.x += b.dir * step;
|
||||
zb.dist = (zb.dist || 0) + step;
|
||||
if (Math.abs(zb.x) > 4.0) { b.dir *= -1; zb.x = Math.sign(zb.x) * 4.0; }
|
||||
zb.face = b.dir;
|
||||
zb.pose = 'run';
|
||||
} else {
|
||||
zb.pose = item.pose || 'idle';
|
||||
zb.x = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- THE DEADLOCK WATCHDOG.
|
||||
// A run that hard-locks costs the player everything, and this file has form: `mgT` was once left
|
||||
// above zero by a beat that moved the phase underneath it, and `mgT > 0` blocks serving, so the
|
||||
@@ -217,7 +294,7 @@ export function watchdog(dt) {
|
||||
// it, and killing it outright wins the game early (killZombie fires resolveInterlude('P'), which
|
||||
// lands in matchGameWon via the empty-court check).
|
||||
export function startMatch() {
|
||||
if (G.interlude || G.horde || G.bossFight || G.drill || G.match) return false;
|
||||
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return false;
|
||||
G.match = { opp: 1, ending: false };
|
||||
G.hp = G.hpMax = TUNE.matchPlayerHp; // a real match is longer than four mistakes — the gore
|
||||
// mode's 4 was "too weak" here, felt not calculated
|
||||
@@ -376,7 +453,7 @@ export function resolveInterlude(side) {
|
||||
// anymore". It is an ordinary 'over' beat (no serving, the world keeps walking, the RESUMING clock
|
||||
// counts it down under the flash), and startHorde still owns the transition itself.
|
||||
export function warnHorde() {
|
||||
if (G.horde || G.interlude || G.bossFight || G.drill || G.match) return false; // same court-ownership contract as the rest
|
||||
if (G.horde || G.interlude || G.bossFight || G.drill || G.match || G.bench) return false; // same court-ownership contract as the rest
|
||||
G.hordeWarn = TUNE.hordeWarnT;
|
||||
G.flash = { text: 'THEY KEEP COMING', t: TUNE.hordeWarnT };
|
||||
ball.active = false; G.phase = 'over'; G.overT = TUNE.hordeWarnT;
|
||||
@@ -387,7 +464,7 @@ export function warnHorde() {
|
||||
// Returns false when another mode owns the court — the caller re-arms the latch rather than
|
||||
// silently eating the wave the set paid for.
|
||||
export function startHorde() {
|
||||
if (G.horde || G.interlude || G.bossFight || G.drill || G.match) return false; // same contract as startDuel: one mode owns the court
|
||||
if (G.horde || G.interlude || G.bossFight || G.drill || G.match || G.bench) return false; // same contract as startDuel: one mode owns the court
|
||||
G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked
|
||||
const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse
|
||||
// the gun grows WITH the wave: spawn rate and length both scale by grow, so a flat gun fell
|
||||
|
||||
Reference in new issue
Block a user