Give him a sidestep, and split the messages into two voices
He died at every chapter boss because he could not move, and there were two independent reasons for it. The movement block is 'rally'-only — a settled feel decision the six boss AI styles were written around — and a boss is fought from 'pretoss'. On top of that canAimServe() is true through a boss fight, so his one finger was already spent on the slingshot: there was no gesture left over that could have meant "move". So it is a SIDESTEP, not a walk. One committed step on a cooldown, carrying invulnerability that beats both damage channels — the chip damage in hurtPlayer and the instant-death contact test. The rule about walking stands untouched. The gesture is picked by WHICH WAY the drag leaves the deadzone, decided once and never revisited for that touch: down-ish is the pull the game already teaches, sideways is the step. No timing window, no invisible mode, so "one gesture means one thing" still holds. The finger-steer modes are untouched — stick.aimable is false in a horde, so the drag is the absolute steer it always was. The apron demo now alternates PULL DOWN and SWIPE TO DODGE, and Marvin has a page for it. The rest of the playtest list: - Bangers is bundled beside Silkscreen, and the register IS the category. The quiet face keeps the HUD, the rack, the control hints and RESUMING IN; the loud one takes SET, CHAPTER, EPIC!, PERFECT and every free-floating flash. Floating text gets one shared y and a drop shadow, so it survives crowd, clay and blood. - Modals queue instead of being dropped. That was why the trainer and the weapon had to be crammed into one card after a won duel; the heal gets its own popup with the x_cross tin over it — a sprite loaded since that merge and drawn by nothing. - weaponModal's first-sight gate moved inside the function. The barrels gated themselves and the tin ladder did not, so four weapons re-explained themselves every three games while a barrel weapon explained itself once. - Ten rack slots. Past five it grows a second row upward rather than shrinking ten tiles under the thumb target; slots 0-4 stay welded to the bottom edge. - The boss win holds for its own 3.6s and says EPIC!, with no GAME n NEXT line — the bar under the scoreboard already prints that permanently. That bar went from 6px to 7px, which is the smallest type in the game becoming words. - The turn punches the camera in on him and the ground shakes harder at each beat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
99774ab4be
commit
55c0f75c37
10 files changed
+345
-95
No files matched your search
+97
-34
@@ -310,12 +310,15 @@ function scoreKey() {
|
||||
}
|
||||
|
||||
// ---- the modal: the only thing in the game that stops the world. Play resumes on a tap, not
|
||||
// on a timer, so a popup can be READ. showModal never clobbers an open one (a kill cascade can
|
||||
// try to open two in the same frame) and only ever touches G — never the zombie array it may
|
||||
// be called from mid-iteration.
|
||||
// on a timer, so a popup can be READ. It only ever touches G — never the zombie array it may be
|
||||
// called from mid-iteration.
|
||||
// A second popup opened in the same beat QUEUES rather than being dropped. It used to be dropped
|
||||
// ("if (G.modal) return"), which is why the trainer and the weapon had to be crammed into one card
|
||||
// after a won duel — the second one simply vanished. They are two things, so they are two popups.
|
||||
export function showModal(m) {
|
||||
if (G.modal) return;
|
||||
m.in = 0; G.modal = m; whoosh(); // 0..1 entrance clock, ticked in update() behind the freeze
|
||||
m.in = 0; // 0..1 entrance clock, ticked in update() behind the freeze
|
||||
if (G.modal) { G.modalQ.push(m); return; }
|
||||
G.modal = m; whoosh();
|
||||
}
|
||||
export function dismissModal() {
|
||||
const m = G.modal; if (!m) return false;
|
||||
@@ -323,7 +326,9 @@ export function dismissModal() {
|
||||
// skippable by the same panic tap that was mid-air when it appeared — and returning false here
|
||||
// would let that tap fall through into a serve, which is the swallowed-gesture bug all over again.
|
||||
if (m.in < 1) return true;
|
||||
G.modal = null; if (m.onDismiss) m.onDismiss();
|
||||
G.modal = G.modalQ.shift() || null;
|
||||
if (G.modal) { G.modal.in = 0; whoosh(); } // the next one flies in on its own entrance
|
||||
if (m.onDismiss) m.onDismiss();
|
||||
return true; // the caller must swallow the gesture: dismissing must never also serve
|
||||
}
|
||||
|
||||
@@ -528,24 +533,28 @@ export function spendSlot(i) {
|
||||
// (capped per type at TUNE.wCap) — old stock is kept, so you can bank a bomb for the horde
|
||||
// instead of spending it now. G.weapon picks which tin the next serve throws.
|
||||
const TINS = ['bomb', 'triple', 'fire', 'gun']; // the duel ladder, in the order it hands them out
|
||||
export function deliverTin(healLine = null) {
|
||||
export function deliverTin() {
|
||||
const id = TINS[G.deliveries++ % TINS.length];
|
||||
const wasPlain = G.weapon === 0 && !G.temp; // never yank you off a deliberate PLAIN (banking)
|
||||
grantWeapon(id);
|
||||
if (wasPlain) G.weapon = 0;
|
||||
sendBoy(); voice('new_balls', 0.6);
|
||||
aside('ump_new_balls_dark', 2.0); // "...and a new opponent, apparently."
|
||||
// healLine folds the trainer's visit INTO this popup. `showModal` deliberately never clobbers an
|
||||
// open modal (a kill cascade can try to open two in one frame), so when both happened the heal
|
||||
// modal opened first and the weapon one was lost entirely — Oliver: "the weapon message is lost".
|
||||
weaponModal(id, healLine);
|
||||
// From the fifth delivery on, weaponModal has nothing new to explain and stays quiet — so the tin
|
||||
// needs the same free-floating label a barrel already gets, or a won duel pays out in silence.
|
||||
G.flash = { text: WEAPONS[id].label, t: 1.1 };
|
||||
weaponModal(id);
|
||||
}
|
||||
|
||||
// The one-per-run "what this thing does" popup, shared by the duel ladder and the barrels.
|
||||
export function weaponModal(id, healLine = null) {
|
||||
// THE GATE LIVES HERE, not at the call sites. The barrels gated themselves and the tin ladder did
|
||||
// not, so the four delivered weapons re-explained themselves every three games for a whole run
|
||||
// while a barrel weapon explained itself once — the same popup behaving two different ways.
|
||||
export function weaponModal(id) {
|
||||
if (G.seen['w' + id]) return;
|
||||
G.seen['w' + id] = true;
|
||||
const w = WEAPONS[id];
|
||||
showModal({ title: w.label, body: healLine ? [...w.desc, '', healLine] : w.desc,
|
||||
icon: w.icon, iconH: 40, color: UI.warm });
|
||||
showModal({ title: w.label, body: w.desc, icon: w.icon, iconH: 40, color: UI.warm });
|
||||
}
|
||||
|
||||
// ---- TEST MODE. Triple-tap the scoreboard. A sandbox for trying the new content without playing
|
||||
@@ -621,6 +630,7 @@ export function startBossFight(chapter) {
|
||||
G.seen.boss = true;
|
||||
showModal({ title: 'IT BRINGS A MACHINE', color: UI.bad,
|
||||
body: ['IT THROWS BALLS AT YOU', 'SWING TO RETURN THEM — OR SWISH TO BAT THEM AWAY',
|
||||
'SWIPE SIDEWAYS TO STEP OUT OF THE WAY',
|
||||
'A HIT COSTS BLOOD. WIN A DUEL TO HEAL.'] });
|
||||
}
|
||||
}
|
||||
@@ -632,10 +642,14 @@ export function endBossFight() {
|
||||
zombieDuck(false);
|
||||
shots.length = 0;
|
||||
cheer(3.4, 0.9); voice('unbelievable', 0.3);
|
||||
G.card = { title: 'CHAPTER ' + c + ' DOWN', sub1: 'THE STANDS ARE ON THEIR FEET',
|
||||
sub2: 'GAME ' + (G.runGames + 1) + ' NEXT', color: UI.pick, t: TUNE.setCardT,
|
||||
// The longest fight in the game deserves more than the two seconds a routine set gets. Big word,
|
||||
// long hold — the chapter and the crowd move down to the subs where they belong.
|
||||
// (no 'GAME n NEXT' line: the bar under the scoreboard prints the game number permanently, and a
|
||||
// card that repeats what the HUD already says is the clutter this pass is meant to remove.)
|
||||
G.card = { title: 'EPIC!', sub1: 'CHAPTER ' + c + ' DOWN',
|
||||
sub2: 'THE STANDS ARE ON THEIR FEET', color: UI.pick, t: TUNE.bossWinT,
|
||||
wait: TUNE.bossDeathT }; // ...after the body has finished coming apart
|
||||
ball.active = false; G.phase = 'over'; G.overT = TUNE.setCardT + TUNE.bossDeathT;
|
||||
ball.active = false; G.phase = 'over'; G.overT = TUNE.bossWinT + TUNE.bossDeathT;
|
||||
// hand back the game beat the boss interrupted — same contract resolveInterlude uses
|
||||
if (G.afterDuel) { G.afterClear = G.afterDuel; G.afterDuel = null; proceedAfterClear(); }
|
||||
}
|
||||
@@ -647,16 +661,17 @@ export function resolveInterlude(side) {
|
||||
ball.active = false; G.phase = 'over'; G.overT = 2.2;
|
||||
if (side === 'P') {
|
||||
cheer(3, 0.7);
|
||||
// winning the duel patches you up. THE reason to want a duel rather than merely tolerate it —
|
||||
// it is the only healing in the game, and blood is the only thing you cannot buy back otherwise.
|
||||
let healLine = null;
|
||||
deliverTin(); // win the point, win the next tin
|
||||
// ...and the trainer comes out. THE reason to want a duel rather than merely tolerate it — it is
|
||||
// the only healing in the game, and blood is the only thing you cannot buy back otherwise. It
|
||||
// gets its OWN popup (queued behind the weapon), with the red cross over it: folded in as a line
|
||||
// of body text under a weapon icon it read as a footnote to the tin rather than as the prize.
|
||||
if (G.hp < G.hpMax) {
|
||||
const before = G.hp;
|
||||
G.hp = Math.min(G.hpMax, G.hp + TUNE.healPerDuel);
|
||||
voice('ump_trainer', 0.2, 0.95);
|
||||
healLine = '+ WOUNDS TREATED · NOW ' + G.hp + '/' + G.hpMax;
|
||||
showModal({ title: 'WOUNDS TREATED', icon: 'x_cross', iconH: 52, color: UI.pick,
|
||||
body: ['THE TRAINER PATCHED YOU UP', G.hp + ' OF ' + G.hpMax + ' BACK'] });
|
||||
}
|
||||
deliverTin(healLine); // win the point, win the next tin — and the trainer, in ONE popup
|
||||
} else {
|
||||
voice('game_opponent', 0.4); aww(0.85); // lost the sudden-death duel
|
||||
score.games[1]++; G.runGames++; // it goes on the scoreboard
|
||||
@@ -678,6 +693,30 @@ export function spentBall() {
|
||||
if (!G.changeover) G.overT = TUNE.splatterOverT;
|
||||
}
|
||||
|
||||
// ---- THE SIDESTEP. Free walking is 'rally'-only (see the movement block) and that is staying —
|
||||
// but it left you welded to the spot through a whole boss fight, because a boss is fought from the
|
||||
// serve stance and the one finger is already spent on the slingshot. So: a horizontal swipe is a
|
||||
// committed step, not a joystick. It integrates OUTSIDE the movement block so it works in every
|
||||
// phase, and it carries invulnerability, which is the only reason to make one.
|
||||
export function dodge(dir) {
|
||||
if (G.mode !== 'splatter' || G.modal || G.dodgeCd > 0 || G.dodgeT > 0) return;
|
||||
G.dodgeDir = dir; G.dodgeT = TUNE.dodgeT; G.dodgeCd = TUNE.dodgeCool; G.dodgeI = TUNE.dodgeIFrame;
|
||||
G.idleHintT = 0; // ...and the apron demo goes away, like any other gesture. The touch path nulls
|
||||
// G.aim to fire this, and the demo's own guard only watches G.aim.
|
||||
P.facing = dir;
|
||||
// ...but a swing in flight keeps its pose. You can step out of the way mid-shot and the shot still
|
||||
// lands (swingT is untouched); clobbering the pose only threw away the animation for it.
|
||||
if (P.pose !== 'swing' && P.pose !== 'serve') { P.pose = 'run'; P.poseT = 0; }
|
||||
swish(0.22);
|
||||
}
|
||||
|
||||
// How far out he may stand. P_BOUNDS.x is 6.5 m, but at the baseline in portrait the camera only
|
||||
// shows about ±4 m either side of the centre line, so walking to the bound put him clean off the
|
||||
// edge. Floored at the sideline so every legal tennis ball stays catchable — the court is never out
|
||||
// of bounds. Shared by the movement block and the dodge; both were inline in the former.
|
||||
const xVisible = () => Math.min(P_BOUNDS.x, (W / 2 - 14) / (TUNE.camF / (TUNE.camZ - P.z)));
|
||||
const xLimit = () => Math.max(xVisible(), COURT.W / 2);
|
||||
|
||||
// the swarm keeps coming: no serving, the racket auto-fires, the thumb drag steers you
|
||||
function startHorde() {
|
||||
if (G.horde) return;
|
||||
@@ -725,16 +764,24 @@ function transition(dt) {
|
||||
const t = (G.transT += dt);
|
||||
const once = (key, at, fn) => { if (t >= at && !G.tFlags[key]) { G.tFlags[key] = true; fn(); } };
|
||||
once('quiet', 1.8, () => voice('quiet_dark', 0, 0.82));
|
||||
once('turn', 3.4, () => { spawnZombie(O.x, O.z, 'c'); G.hideO = true; }); // the opponent... turns
|
||||
once('mob', 4.8, () => { spawnZombie(-3.5, -1.6, 'a'); spawnZombie(2.5, -2.1, 'b'); });
|
||||
// The turn is the moment the game tells you what it now is, and it used to happen in a wide
|
||||
// static shot at the far baseline where it was three pixels tall. The camera PUNCHES IN on him
|
||||
// for it (a flat 2D zoom, see render), and the ground goes with it. Each later beat shakes harder.
|
||||
once('turn', 3.4, () => {
|
||||
spawnZombie(O.x, O.z, 'c'); G.hideO = true; // the opponent... turns
|
||||
G.zoom = { x: O.x, z: O.z, k: 1.9, t: 0, T: 1.15 };
|
||||
G.shake = { t: 0.45, mag: 4 };
|
||||
});
|
||||
once('mob', 4.8, () => { spawnZombie(-3.5, -1.6, 'a'); spawnZombie(2.5, -2.1, 'b'); G.shake = { t: 0.5, mag: 6 }; });
|
||||
once('seated', 5.4, () => voice('remain_seated', 0, 0.95));
|
||||
once('go', 8.2, () => {
|
||||
G.mode = 'splatter'; score.server = 'P'; setupPoint(); voice('play', 0.4, 0.9); zombieMusic();
|
||||
G.shake = { t: 0.7, mag: 9 }; // the floor goes out from under the match
|
||||
// the mechanic changed under you — the one popup that gets an illustration, because it has
|
||||
// to TEACH the gesture, not just announce it
|
||||
showModal({ title: 'THE MATCH HAS CHANGED', color: UI.hot, icon: 'x_howto', iconH: 120,
|
||||
body: ['PULL BACK TO AIM — THEN LET GO', 'THE FURTHER YOU PULL, THE HARDER IT HITS',
|
||||
'A WEAK SHOT ONLY STAGGERS THEM'] });
|
||||
'A WEAK SHOT ONLY STAGGERS THEM', 'SWIPE SIDEWAYS TO STEP OUT OF THE WAY'] });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1004,6 +1051,12 @@ function meleeCooldown() {
|
||||
}
|
||||
|
||||
export function keyPressed(code) {
|
||||
// Desktop sidestep: an arrow/A/D EDGE outside 'rally', where the walk axes do nothing anyway.
|
||||
// Inside a rally the same keys still walk, so one key never means two things at one moment.
|
||||
if (code === 'ArrowLeft' || code === 'KeyA' || code === 'ArrowRight' || code === 'KeyD') {
|
||||
if (G.phase !== 'rally') dodge(code === 'ArrowLeft' || code === 'KeyA' ? -1 : 1);
|
||||
return;
|
||||
}
|
||||
if (code !== 'Space') return;
|
||||
audio(); // unlock on first gesture
|
||||
// a swish cleaves adjacent shamblers — but it's on a cooldown now: the panic button, not the
|
||||
@@ -1132,6 +1185,7 @@ export function update(dt) {
|
||||
else { G.card.t -= dt; if (G.card.t <= 0) G.card = null; }
|
||||
}
|
||||
if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null;
|
||||
if (G.zoom && (G.zoom.t += dt) >= G.zoom.T) G.zoom = null; // scripted punch-in (render applies it)
|
||||
if (G.bossBurst && (G.bossBurst.t -= dt) <= 0) G.bossBurst = null;
|
||||
if (G.boomFlash > 0) G.boomFlash -= dt;
|
||||
// A modal freezes the WORLD, not the clock: no zombie steps, no ball flight, no spawn timers,
|
||||
@@ -1173,7 +1227,7 @@ export function update(dt) {
|
||||
// auto-fire steering is ABSOLUTE: the finger x IS the player x — zero lag, and clamped
|
||||
// to what the camera can see (portrait's world bounds are wider than its viewport)
|
||||
const mgMode = G.horde || G.mgT > 0;
|
||||
const xVis = Math.min(P_BOUNDS.x, (W / 2 - 14) / (TUNE.camF / (TUNE.camZ - P.z)));
|
||||
const xVis = xVisible();
|
||||
let mgSteer = false, mgMoving = false;
|
||||
if (mgMode && touchPX() !== null) {
|
||||
mgSteer = true;
|
||||
@@ -1200,17 +1254,14 @@ export function update(dt) {
|
||||
assist = dx;
|
||||
}
|
||||
}
|
||||
// Stay ON SCREEN — always, not just in auto-fire. P_BOUNDS.x is 6.5 m, but at the baseline in
|
||||
// portrait the camera only shows about ±4 m either side of the centre line, so walking to the
|
||||
// bound put the player clean off the edge (x = -6.5 projects to screen -61 of 270). Floored at
|
||||
// the sideline so every legal tennis ball stays catchable — the court is never out of bounds.
|
||||
const xLim = Math.max(xVis, COURT.W / 2);
|
||||
// Stay ON SCREEN — always, not just in auto-fire (x = -6.5 projects to screen -61 of 270).
|
||||
const xLim = xLimit();
|
||||
P.x = Math.max(-xLim, Math.min(xLim, P.x));
|
||||
P.z = Math.max(P_BOUNDS.zMin, Math.min(P_BOUNDS.zMax, P.z));
|
||||
// mgMoving MUST be in here. Finger-steering zeroes mx/mz (they are the keyboard axes), so a
|
||||
// player sliding left and right under auto-fire counted as standing still and never ran —
|
||||
// "when he's moving left and right the run animation is not applied".
|
||||
const moving = mgMoving || mx || mz || assist !== 0;
|
||||
const moving = mgMoving || mx || mz || assist !== 0 || G.dodgeT > 0;
|
||||
P.runT += moving ? dt : 0;
|
||||
// Auto-fire used to be excluded here, which left the pose stuck on 'idle' — and render then
|
||||
// faked a run cycle off G.idleT so he was not a statue. That fake is what read as "this hectic
|
||||
@@ -1223,7 +1274,19 @@ export function update(dt) {
|
||||
// ...and out of 'rally' he STANDS. 'run' is the one pose nothing else expires (expirePose only
|
||||
// handles swing/serve), and P.runT stops advancing outside the block, so a beat that began
|
||||
// mid-stride left him welded to a single run frame — the "stuck mid-animation" report.
|
||||
else if (P.pose === 'run') { P.pose = 'idle'; P.poseT = 0; }
|
||||
else if (P.pose === 'run' && G.dodgeT <= 0) { P.pose = 'idle'; P.poseT = 0; }
|
||||
// The sidestep runs in EVERY phase — that is the entire point of it, since a boss fight is spent
|
||||
// in 'pretoss' where the block above never runs. Same clamp as the walk, so it cannot carry him
|
||||
// off the edge of the screen either.
|
||||
if (G.dodgeCd > 0) G.dodgeCd -= dt;
|
||||
if (G.dodgeI > 0) G.dodgeI -= dt;
|
||||
if (G.dodgeT > 0) {
|
||||
G.dodgeT -= dt;
|
||||
const xLim = xLimit();
|
||||
P.x = Math.max(-xLim, Math.min(xLim, P.x + G.dodgeDir * (TUNE.dodgeDist / TUNE.dodgeT) * dt));
|
||||
P.runT += dt; // the legs move, so the run cycle advances
|
||||
if (G.dodgeT <= 0) G.dodgeDir = 0;
|
||||
}
|
||||
// splatter: you START at the baseline but you're free to advance up-court (bounded at the net by
|
||||
// P_BOUNDS) — hunt the shamblers down, especially when mopping up a won game. Auto-assist still
|
||||
// recovers you toward the baseline between shots on touch; the duel is plain tennis as ever.
|
||||
|
||||
Reference in new issue
Block a user