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
+68
-2
@@ -317,8 +317,74 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
|
||||
`updateZombies`), never by handing back movement.
|
||||
- **The player must always be ON SCREEN.** `P_BOUNDS.x` is 6.5 m but portrait only shows about
|
||||
±4 m either side of the centre line at the baseline, so the walk bound alone let him leave the
|
||||
frame entirely. `xLim = Math.max(xVis, COURT.W / 2)` — the camera limit, floored at the sideline so
|
||||
every legal ball stays catchable.
|
||||
frame entirely. `xLimit()` in gameplay — the camera limit, floored at the sideline so every legal
|
||||
ball stays catchable. The walk and the sidestep share it.
|
||||
- **The sidestep is how you move outside `rally`, and it is not a walk.** One committed step
|
||||
(`TUNE.dodgeDist`) on a cooldown, carrying `TUNE.dodgeIFrame` seconds that beat BOTH damage
|
||||
channels — the chip damage in `hurtPlayer` and the instant-death contact test. It exists because a
|
||||
boss fight is spent in `pretoss`, so the rule above left you welded to the spot for the whole
|
||||
fight and Oliver died at every chapter boss without ever being able to move.
|
||||
- **Which way you leave the deadzone IS the gesture.** Decided once, at the `DEAD` crossing, and
|
||||
never revisited for that touch: down-ish = the slingshot, sideways = the sidestep. This is how the
|
||||
new gesture stays inside "one gesture means one thing" — there is no timing window and no hidden
|
||||
mode, only the direction you actually moved. The finger-steer modes (horde, burst) are untouched:
|
||||
`stick.aimable` is false there, so the drag is still the absolute steer it always was.
|
||||
- **Two type registers, and the register IS the message's category.** Silkscreen for the HUD, the
|
||||
rack, the control hints and `RESUMING IN` — things you read at leisure. Bangers (`B()` in render)
|
||||
for `SET`, `CHAPTER`, `EPIC!`, `PERFECT` and every free-floating flash — things shouted at you.
|
||||
Free-floating text carries a 1px shadow (`shadowText`) and one shared y (`FLASH_Y`), because it
|
||||
has no panel and has to survive the crowd, sunlit clay and blood behind it.
|
||||
|
||||
## Session log: the sidestep, and two type registers (2026-08-03)
|
||||
|
||||
Oliver's post-ship playtest. One blocker and seven polish items; all eight landed.
|
||||
|
||||
**The blocker: he died at every chapter boss because he could not move.** Two independent causes,
|
||||
and fixing either alone would have left him rooted. The movement block is `rally`-only (a settled
|
||||
decision the six boss AI styles were written around), and a boss fight is spent in `pretoss`. On top
|
||||
of that `canAimServe()` is true through a boss fight, so the one finger was already spent on the
|
||||
slingshot — there was no gesture left over that *could* mean "move". The answer is a **sidestep**,
|
||||
not a walk, so the settled decision stands; and the gesture is picked by the DIRECTION the drag
|
||||
leaves the deadzone in, so nothing about "one gesture means one thing" bends. Both are in the
|
||||
settled-decisions list above.
|
||||
|
||||
Two guards make the dodge worth spending: `hurtPlayer` (the ranged channel) and the contact test in
|
||||
`updateZombies` (the instant-death channel). Verified by pinning a shambler on the player: alive at
|
||||
`dodgeI` 0.3, eaten the frame it hit 0.
|
||||
|
||||
**The message system was seven unrelated looks; it is two registers now.** Bangers is bundled
|
||||
alongside Silkscreen and the split is documented above. `drawCard`, `drawModal`'s title, `G.flash`
|
||||
and the death summary shout; the control hints, the pull/dodge demos, the scoreboard and
|
||||
`RESUMING IN` stay quiet. The types Oliver had not counted, for the record: the bottom control-hint
|
||||
slot at `METER_Y`, the `drawPullHint` apron demo, the death summary, and the scoreboard/rack chrome.
|
||||
|
||||
**Modals queue now.** `showModal` used to *drop* a second popup opened in the same beat, which is
|
||||
why the trainer and the weapon had to be crammed into one card after a won duel. `G.modalQ` is four
|
||||
lines and it retires that hack: the weapon modal, then a real `WOUNDS TREATED` modal with the
|
||||
`x_cross` first-aid tin over it (the sprite had been preloaded and drawn by nothing since the merge).
|
||||
|
||||
**One popup, one rule.** `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 for a whole run while a barrel weapon explained itself once. `deliverTin` gained the label
|
||||
flash a barrel already had, or the fifth delivery onward would pay out in silence.
|
||||
|
||||
**Ten rack slots, second row on demand.** `RACK_MAX` 4 → 9. Ten tiles across 480px would be ~39 CSS
|
||||
px on a phone, under the thumb target `rackHit` was widened for, so past five it grows a second row
|
||||
UPWARD and slots 0-4 stay welded to the bottom edge. `METER_Y` derives from `rackTop()` now, so the
|
||||
hint line and the apron demo lift with it.
|
||||
|
||||
**The apron demo alternates.** `demoSwipe(dx, dy, label)` is the old `drawPullHint` body with the
|
||||
direction pulled out; `drawPullHint` alternates PULL DOWN and SWIPE TO DODGE on the 1.9s cycle, and
|
||||
mimes away from whichever edge the player is standing against.
|
||||
|
||||
**The transition punches in.** `G.zoom` is a flat 2D scale about a projected world point, applied in
|
||||
the same place and for the same reason as `G.shake` — a perspective dolly would want `camZ`,
|
||||
`aimCam` and every projected HUD anchor to agree for the sake of one 1.15s beat. Escalating shake at
|
||||
3.4 / 4.8 / 8.2 s.
|
||||
|
||||
**And the boss win holds.** `TUNE.bossWinT` (3.6s) is its own number so the routine SET card does not
|
||||
drag with it. `EPIC!` in Bangers, chapter and crowd demoted to subs, and no `GAME n NEXT` line —
|
||||
the bar under the scoreboard already prints the game number permanently.
|
||||
|
||||
## Session log: the second pass on the barrel weapons (2026-08-03)
|
||||
|
||||
|
||||
Binary file not shown.
@@ -27,6 +27,15 @@ export const TUNE = {
|
||||
aiQualityMin: 0.45, aiQualityMax: 0.85, aiFlub: 0.09,
|
||||
aiReaction: 0.45, // s before the CPU reads your shot — well-aimed corners are winners
|
||||
autoAssist: matchMedia('(pointer: coarse)').matches ? 1.15 : 0, // auto-run speed factor (mobile)
|
||||
// ---- THE SIDESTEP. Free walking stays 'rally'-only (see the movement block in gameplay), which
|
||||
// left you rooted to the spot through a whole boss fight — the drag is the slingshot and there was
|
||||
// no gesture left over that could mean "move". A dodge is not a walk: it is one committed step
|
||||
// with a cooldown, so it answers a volley without turning the serve stance into a joystick.
|
||||
dodgeDist: 1.9, // m of ground the sidestep covers
|
||||
dodgeT: 0.18, // s it takes — fast enough to be a reaction, slow enough to read as a step
|
||||
dodgeCool: 0.5, // s before another. THE anti-spam knob: shorter and you skate across the court
|
||||
dodgeIFrame: 0.28,// s untouchable, covering the step and a hair after. Beats BOTH damage channels
|
||||
// (a boss volley and the instant-death body), which is the whole point of it
|
||||
zSpeed: 0.7, // zombie shamble speed m/s
|
||||
// ---- THE DIFFICULTY DIRECTOR. Every dial below used to bottom out by about game 12, which is why
|
||||
// a 50-game run stopped changing (games 12..50 were arithmetically identical). They are all keyed
|
||||
@@ -307,6 +316,9 @@ export const TUNE = {
|
||||
// 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)
|
||||
setCardT: 2.0, // s the centered SET card holds (the 'kurze unterbrechung' before the horde)
|
||||
bossWinT: 3.6, // ...and s the EPIC! card holds after a chapter boss. Its OWN number: a boss is
|
||||
// the longest fight in the game and the win was over before it registered, but
|
||||
// stretching setCardT would have made every routine set announcement drag too.
|
||||
modalIn: 0.34, // s the modal swooshes in — icon+title from the right, body cascading from the
|
||||
// left, each on easeOutBack. The gesture that dismisses is swallowed until it
|
||||
// lands, so a panic tap can't skip a popup before it has been read.
|
||||
@@ -527,7 +539,12 @@ export const BARREL_WEAPONS = {
|
||||
laser: { weight: 2, from: 6 },
|
||||
rocket: { weight: 3, from: 12 }, // the late-game answer: it should never turn up in game three
|
||||
};
|
||||
export const RACK_MAX = 4; // gathered weapons the rack holds; a 5th rides slot 0 until it is spent
|
||||
// Gathered weapons the rack holds (plus slot 0 = BALL, so ten tiles). Was 4, and at four you were
|
||||
// throwing away a barrel almost every time one popped — Oliver: "that makes replacing the ball rarer
|
||||
// if not unnecessary at all". The `temp` valve below survives for the eleventh, which nobody will see.
|
||||
export const RACK_MAX = 9;
|
||||
export const RACK_ROW = 5; // tiles per row — past five the rack grows a SECOND row upward, so a tile
|
||||
// never shrinks below the thumb target rackHit was widened for
|
||||
|
||||
export const MACH_H = 1.55; // was 1.15 — waist high read as a prop you could ignore. Still furniture
|
||||
// (a body is PLAYER_H 1.88), but big enough to be a thing on the court.
|
||||
|
||||
+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.
|
||||
|
||||
+6
-1
@@ -20,6 +20,11 @@
|
||||
src: url('assets/fonts/Silkscreen-Regular.ttf') format('truetype'); }
|
||||
@font-face { font-family: 'Silkscreen'; font-weight: 700; font-display: block;
|
||||
src: url('assets/fonts/Silkscreen-Bold.ttf') format('truetype'); }
|
||||
/* the ANNOUNCEMENT face. Silkscreen is the HUD and the instructions — small, even, quiet. The
|
||||
things that shout (SET, CHAPTER, EPIC!, PERFECT) are a second register on purpose, so a message
|
||||
tells you which kind it is before you have read a word of it. Bundled, like the other one. */
|
||||
@font-face { font-family: 'Bangers'; font-weight: 400; font-display: block;
|
||||
src: url('assets/fonts/Bangers-Regular.ttf') format('truetype'); }
|
||||
/* html needs a real HEIGHT: the root element's background paints the viewport, but a GRADIENT is
|
||||
laid out in the root's own box — and with the canvas position:fixed the root has no content and
|
||||
collapses, so the gradient render.js sets had nowhere to go. */
|
||||
@@ -179,6 +184,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="main.js?v=110"></script>
|
||||
<script type="module" src="main.js?v=114"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,7 +6,7 @@ import { cv } from './dom.js';
|
||||
import { audio, pock } from './audio.js';
|
||||
import { TUNE, WEAPONS } from './config.js';
|
||||
import { G, score } from './state.js';
|
||||
import { keyPressed, dragServe, selectWeapon, serveToss, pullQuality, dismissModal, slotId } from './gameplay.js';
|
||||
import { keyPressed, dragServe, selectWeapon, serveToss, pullQuality, dismissModal, slotId, dodge } from './gameplay.js';
|
||||
import { rackHit } from './render.js';
|
||||
import { togglePanel } from './panel.js';
|
||||
|
||||
@@ -15,7 +15,7 @@ addEventListener('keydown', e => {
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(e.key)) e.preventDefault();
|
||||
if (dismissModal()) return; // a modal eats the whole keystroke — dismissing must never also serve
|
||||
if (e.key === 't' || e.key === 'T') togglePanel();
|
||||
if (e.key >= '1' && e.key <= '5') selectWeapon(+e.key - 1); // pick a rack slot
|
||||
if (e.key >= '0' && e.key <= '9') selectWeapon((+e.key + 9) % 10); // pick a rack slot: 1..9 then 0 = the tenth
|
||||
if (!keys[e.code]) keyPressed(e.code);
|
||||
keys[e.code] = true;
|
||||
});
|
||||
@@ -95,6 +95,14 @@ cv.addEventListener('touchmove', e => {
|
||||
const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy);
|
||||
if (dist > DEAD) stick.moved = true;
|
||||
if (stick.aimable) { // slingshot: PULL BACK, launch forward — the shot is the inverse of the drag
|
||||
// WHICH WAY YOU LEFT THE DEADZONE DECIDES WHAT THE GESTURE IS, once, and it never changes its
|
||||
// mind after. Down-ish is the pull the game already teaches; SIDEWAYS is the sidestep — the
|
||||
// only way to move in the serve stance, which is where a whole boss fight is spent. No timing
|
||||
// window and no invisible mode: the direction you moved is the thing you asked for.
|
||||
if (dist > DEAD && !stick.tossed && !stick.dodged && Math.abs(dx) > Math.abs(dy) * 1.6) {
|
||||
stick.dodged = true; G.aim = null; dodge(Math.sign(dx));
|
||||
}
|
||||
if (stick.dodged) continue; // spent: no toss, no reticle, and nothing fires on release
|
||||
if (dist > DEAD) {
|
||||
if (!stick.tossed) { serveToss(); stick.tossed = true; } // the pull kicks off the toss animation
|
||||
G.aim = { fx: -dx / dist, fy: -dy / dist, mag: dist };
|
||||
|
||||
@@ -41,8 +41,9 @@ if (navigator.standalone || matchMedia('(display-mode: standalone)').matches) {
|
||||
requestAnimationFrame(() => { meta.content = real; requestAnimationFrame(fitViewport); });
|
||||
}
|
||||
setupPoint();
|
||||
// warm the pixel HUD font so the canvas never paints a system-serif fallback frame
|
||||
if (document.fonts) { document.fonts.load('700 8px Silkscreen'); document.fonts.load('400 8px Silkscreen'); }
|
||||
// warm both faces so the canvas never paints a system-serif fallback frame
|
||||
if (document.fonts) for (const f of ['700 8px Silkscreen', '400 8px Silkscreen', '400 20px Bangers'])
|
||||
document.fonts.load(f);
|
||||
|
||||
// The overlay is a three-stage machine: splash -> {courts, tutorial}. The title screen IS the menu —
|
||||
// a separate menu stage behind it showed the same logo on the same backdrop and read as the title
|
||||
@@ -89,7 +90,7 @@ const TUT = [
|
||||
{ pose: 2, shot: 'tut_serve', say: ["First it is just <em>TENNIS</em>.",
|
||||
"<em>TAP</em> once to toss, tap again to serve.",
|
||||
"<em>FLICK</em> toward where you want it.",
|
||||
"<em>DRAG</em> to run — though he mostly runs himself."] },
|
||||
"He runs down the rest himself, mostly."] },
|
||||
{ pose: 4, say: ["Then, at some point, the other fellow...",
|
||||
"stops being the other fellow.",
|
||||
"Nobody stops the match. Nobody ever does."] },
|
||||
@@ -97,6 +98,10 @@ const TUT = [
|
||||
"<em>PULL BACK</em> from your man and let go.",
|
||||
"The further you pull, the harder it lands.",
|
||||
"A soft one only makes them cross."] },
|
||||
{ pose: 4, say: ["You will not outrun them. Don't try.",
|
||||
"<em>SWIPE SIDEWAYS</em> and he plants a foot",
|
||||
"and is simply somewhere else.",
|
||||
"Nothing touches him on the way. Nothing."] },
|
||||
{ pose: 2, shot: 'tut_rack', say: ["I bring tins out to the rack down there.",
|
||||
"<em>BOMBS</em>, <em>TRIPLES</em>, <em>FIRE</em>, and the <em>GUN</em>.",
|
||||
"Tap the rack to load one, then serve it.",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
|
||||
|
||||
import { cv, ctx } from './dom.js';
|
||||
import { TUNE, UI, EATEN_GRAB, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS } from './config.js';
|
||||
import { TUNE, UI, EATEN_GRAB, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW } from './config.js';
|
||||
import { P, O, ball, score, crowd, G, court } from './state.js';
|
||||
import { predictLanding, previewServe, slotId, slotStock } from './gameplay.js';
|
||||
import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js';
|
||||
@@ -31,7 +31,22 @@ function palette() {
|
||||
// Every string on the canvas goes through this. Bare `monospace` resolves to Courier — a slab
|
||||
// SERIF — which is what made the HUD look generic; Silkscreen is the bundled pixel face.
|
||||
const F = (sz, b = 1) => `${b ? 700 : 400} ${sz}px Silkscreen, monospace`;
|
||||
// ...and the ANNOUNCEMENT face. Two registers, and which one a message uses is its category:
|
||||
// Silkscreen — the HUD, the rack, the control hints, RESUMING IN. Things you read at leisure.
|
||||
// Bangers — SET, CHAPTER, EPIC!, PERFECT, GAME N. Things shouted at you.
|
||||
// It was one face for everything, so a milestone and a control hint looked like the same object at
|
||||
// two sizes. Falls back to Silkscreen (not monospace) if the TTF ever fails to load.
|
||||
const B = sz => `400 ${sz}px Bangers, Silkscreen, monospace`;
|
||||
const hexA = (h, a) => `rgba(${parseInt(h.slice(1, 3), 16)},${parseInt(h.slice(3, 5), 16)},${parseInt(h.slice(5, 7), 16)},${a})`;
|
||||
// Free-floating text has no panel behind it, so it has to carry its own contrast: over the crowd,
|
||||
// over sunlit clay, over blood. A 1px drop shadow does it and a box would cover the artwork.
|
||||
// (RESUMING IN hand-rolled exactly this; now everything that floats shares it.)
|
||||
function shadowText(txt, x, y, fill) {
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.62)';
|
||||
ctx.fillText(txt, x + 1, y + 1);
|
||||
ctx.fillStyle = fill;
|
||||
ctx.fillText(txt, x, y);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- camera ----
|
||||
const IS_TOUCH = matchMedia('(pointer: coarse)').matches; // hoisted: a matchMedia call per frame churns GC
|
||||
@@ -930,10 +945,17 @@ const RACK_BOT = 0; // px below the rack: none — it sits ON the bottom edge.
|
||||
const RACK_GRAB = 7; // px the TOUCH band extends above the drawn rack (below, it runs to the screen edge)
|
||||
// The bottom text slot. Named for the draw meter that used to live here; the meter is gone (the
|
||||
// arc band carries power now) and the apron below is left to the gesture demo.
|
||||
export const METER_Y = () => H - TUNE.rackH - RACK_BOT - 17 - SAFE.bottom;
|
||||
// The rack holds ten now, and ten tiles across a 480px canvas would be ~39 CSS px on a phone —
|
||||
// under the thumb target rackHit was widened for in the first place. So it grows a SECOND ROW
|
||||
// UPWARD instead, and only once you are actually carrying more than five: the common case is still
|
||||
// one row on the bottom edge, exactly as before.
|
||||
const rackRows = () => (G.rack.length + 1 > RACK_ROW ? 2 : 1);
|
||||
export const rackTop = () => H - TUNE.rackH * rackRows() - RACK_BOT - SAFE.bottom;
|
||||
export const METER_Y = () => rackTop() - 17;
|
||||
function rackGeom() {
|
||||
const h = TUNE.rackH, n = 5, tw = Math.min(64, (W - 4) / n), rw = tw * n;
|
||||
return { x0: Math.round((W - rw) / 2), y0: H - h - RACK_BOT - SAFE.bottom, tw, h, n, rw };
|
||||
const h = TUNE.rackH, tw = Math.min(64, (W - 4) / RACK_ROW), rw = tw * RACK_ROW;
|
||||
const rows = rackRows();
|
||||
return { x0: Math.round((W - rw) / 2), y0: rackTop(), tw, h, rows, n: rows * RACK_ROW, rw };
|
||||
}
|
||||
export function rackHit(nx, ny) { // normalized [0,1] canvas coords -> slot index, or -1
|
||||
if (G.mode !== 'splatter' || G.interlude) return -1; // no rack during the tennis-rules duel
|
||||
@@ -942,26 +964,33 @@ export function rackHit(nx, ny) { // normalized [0,1] canvas coords -> slot inde
|
||||
// the screen edge, reclaiming the dead margin below. At 22px tall the tiles were ~30 CSS px on a
|
||||
// phone — well under the 44px everyone's thumb expects — which is why switching tins felt fiddly.
|
||||
if (cy < g.y0 - RACK_GRAB || cx < g.x0 || cx >= g.x0 + g.rw) return -1;
|
||||
return Math.max(0, Math.min(g.n - 1, ((cx - g.x0) / g.tw) | 0));
|
||||
// The FIRST five never move: they stay welded to the bottom edge and the second five stack above,
|
||||
// so growing the rack doesn't slide the ball out from under a thumb that already knows where it is.
|
||||
// Clamped rather than rejected — the RACK_GRAB overhang sits above the top row.
|
||||
const fromTop = Math.max(0, Math.min(g.rows - 1, ((cy - g.y0) / g.h) | 0));
|
||||
const col = Math.max(0, Math.min(RACK_ROW - 1, ((cx - g.x0) / g.tw) | 0));
|
||||
return (g.rows - 1 - fromTop) * RACK_ROW + col;
|
||||
}
|
||||
function drawRack() {
|
||||
const g = rackGeom(), pad = 3;
|
||||
// Built from what you are CARRYING, not from three fixed arrays. Slot 0 is BALL unless a fifth
|
||||
// gathered weapon is riding it (G.temp), and an empty slot is a blank socket rather than a
|
||||
// Built from what you are CARRYING, not from three fixed arrays. Slot 0 is BALL unless an
|
||||
// overflow weapon is riding it (G.temp), and an empty slot is a blank socket rather than a
|
||||
// permanently dimmed weapon you never had.
|
||||
const W_ = i => WEAPONS[slotId(i)];
|
||||
ctx.save(); ctx.lineWidth = 1;
|
||||
for (let i = 0; i < g.n; i++) {
|
||||
const w = W_(i), stockI = slotStock(i), temp = i === 0 && G.temp;
|
||||
const x = g.x0 + i * g.tw, sel = G.weapon === i, dim = stockI <= 0;
|
||||
const x = g.x0 + (i % RACK_ROW) * g.tw;
|
||||
const y0 = g.y0 + (g.rows - 1 - ((i / RACK_ROW) | 0)) * g.h; // slots 0-4 on the bottom edge, always
|
||||
const sel = G.weapon === i, dim = stockI <= 0;
|
||||
const label = i === 0 && !temp ? 'BALL' : w ? w.label : '';
|
||||
const icon = i === 0 && !temp ? 'w_ball' : w ? w.icon : null;
|
||||
// rounded slots: five separate tiles read as five buttons, which is what they are
|
||||
panel(x + 1.5, g.y0 + 1.5, g.tw - 3, g.h - 3, 4,
|
||||
panel(x + 1.5, y0 + 1.5, g.tw - 3, g.h - 3, 4,
|
||||
sel ? '#123a24' : '#0a2418', // sunk slot
|
||||
sel ? UI.pick : '#05160e'); // gold frame when live
|
||||
// icon: left, vertically centred, padded off the edge
|
||||
const box = g.h - pad * 2, icx = x + pad + box / 2, icy = g.y0 + g.h / 2;
|
||||
const box = g.h - pad * 2, icx = x + pad + box / 2, icy = y0 + g.h / 2;
|
||||
if (!icon) { ctx.globalAlpha = 1; continue; } // an empty socket: nothing gathered for it yet
|
||||
ctx.globalAlpha = dim ? 0.35 : 1;
|
||||
const spr = SPR[icon];
|
||||
@@ -974,12 +1003,12 @@ function drawRack() {
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.globalAlpha = dim ? 0.5 : 1;
|
||||
ctx.font = F(8); ctx.fillStyle = sel ? UI.pick : '#e8f0dc';
|
||||
ctx.fillText(stockI === Infinity ? '∞' : '×' + stockI, tx, g.y0 + g.h * 0.33); // ball = ∞, else ×count
|
||||
ctx.fillText(stockI === Infinity ? '∞' : '×' + stockI, tx, y0 + g.h * 0.33); // ball = ∞, else ×count
|
||||
ctx.font = F(6, 0); ctx.fillStyle = sel ? '#f2ead6' : temp ? UI.pink : '#8fa89a';
|
||||
ctx.fillText(label, tx, g.y0 + g.h * 0.72); // weapon label, under its count
|
||||
ctx.fillText(label, tx, y0 + g.h * 0.72); // weapon label, under its count
|
||||
ctx.globalAlpha = 1;
|
||||
// the burst countdown belongs to whichever slot is holding the armed weapon now, not to slot 4
|
||||
if (G.mgT > 0 && slotId(i) === G.gunId) { ctx.fillStyle = UI.pick; ctx.fillRect(x + 2, g.y0 + g.h - 2, (g.tw - 4) * Math.min(1, G.mgT / (WEAPONS[G.gunId]?.len || TUNE.mgPickupLen)), 1.5); }
|
||||
if (G.mgT > 0 && slotId(i) === G.gunId) { ctx.fillStyle = UI.pick; ctx.fillRect(x + 2, y0 + g.h - 2, (g.tw - 4) * Math.min(1, G.mgT / (WEAPONS[G.gunId]?.len || TUNE.mgPickupLen)), 1.5); }
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -987,12 +1016,14 @@ function drawRack() {
|
||||
// the centered milestone 'block': a real pause, front and center. Shared by the sudden-death duel
|
||||
// and the SET announcement so both read as the same interruption. Assumes textAlign is already set.
|
||||
function drawCard(title, sub1, sub2, color) {
|
||||
ctx.fillStyle = 'rgba(10,6,8,0.66)'; ctx.fillRect(0, H / 2 - 36, W, 64);
|
||||
ctx.fillStyle = 'rgba(10,6,8,0.66)'; ctx.fillRect(0, H / 2 - 42, W, 72);
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = color; ctx.font = F(16); ctx.fillText(title, W / 2, H / 2 - 26);
|
||||
// the title SHOUTS (Bangers, twice the old size); the subs stay Silkscreen, because they are the
|
||||
// detail you read second and a whole card in the loud face is just noise
|
||||
ctx.font = B(30); shadowText(title, W / 2, H / 2 - 34, color);
|
||||
ctx.fillStyle = UI.cream; ctx.font = F(8);
|
||||
if (sub1) ctx.fillText(sub1, W / 2, H / 2 - 2);
|
||||
if (sub2) ctx.fillText(sub2, W / 2, H / 2 + 12);
|
||||
if (sub1) ctx.fillText(sub1, W / 2, H / 2 + 4);
|
||||
if (sub2) ctx.fillText(sub2, W / 2, H / 2 + 18);
|
||||
}
|
||||
|
||||
// Sit in the serve stance doing nothing and the gesture demonstrates itself: a ghost thumb slides
|
||||
@@ -1002,9 +1033,12 @@ function drawCard(title, sub1, sub2, color) {
|
||||
// whole time it matters (the arc band carries power now, up-court where you can see it), while
|
||||
// the demo only ever plays when your thumb is NOT there. So it gets the whole apron and is drawn
|
||||
// at a size you can read at arm's length.
|
||||
function drawPullHint() {
|
||||
// One mimed gesture: a dotted track out of the player's feet, an arrowhead at the far end, the
|
||||
// ghost thumb sliding along it, and the words. (dx, dy) is the unit direction — the only thing that
|
||||
// differs between the two lessons, which is why this is one function and not two near-copies.
|
||||
function demoSwipe(dx, dy, label) {
|
||||
const cyc = 1.9, t = (G.idleHintT - TUNE.pullHintAfter) % cyc;
|
||||
const draw = Math.min(1, t / 1.15); // the pull itself, then a beat held at full
|
||||
const draw = Math.min(1, t / 1.15); // the stroke itself, then a beat held at full
|
||||
const gone = t > 1.45; // released — the thumb lifts off
|
||||
const feet = proj(P.x, 0, P.z).y;
|
||||
const room = Math.max(30, METER_Y() - 10 - feet); // apron between his feet and the text slot
|
||||
@@ -1013,31 +1047,50 @@ function drawPullHint() {
|
||||
const len = travel * draw;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = gone ? 0 : 0.85;
|
||||
// the dotted track the thumb travels, straight down-screen
|
||||
// the dotted track the thumb travels
|
||||
ctx.fillStyle = UI.dim;
|
||||
for (let d = 7; d < travel + 4; d += 7) ctx.fillRect(ax - 1, ay + d, 2, 2);
|
||||
// A DOWN arrowhead at the far end of the track, and nothing pointing up anywhere. The demo used
|
||||
// to grow chevrons UP-court as the pull grew — the shot going the opposite way to the thumb was
|
||||
// meant to be the lesson, but an up arrow on screen is an instruction to swipe up, and that is
|
||||
// the one gesture this does not have. The only direction drawn is the one you actually make.
|
||||
for (let d = 7; d < travel + 4; d += 7) ctx.fillRect(ax + dx * d - 1, ay + dy * d, 2, 2);
|
||||
// An arrowhead at the far end, pointing the way you actually move. The demo used to grow chevrons
|
||||
// UP-court as the pull grew — the shot going the opposite way to the thumb was meant to be the
|
||||
// lesson, but an up arrow on screen is an instruction to swipe up, and that gesture does not exist.
|
||||
ctx.fillStyle = UI.warm;
|
||||
const av = ay + travel + 16, s = 5; // clear of where the thumb lands at full draw
|
||||
const av = travel + 16, s = 5; // clear of where the thumb lands at full stroke
|
||||
const hx = ax + dx * av, hy = ay + dy * av; // arrow tip, and the two barbs behind it
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ax, av + s); ctx.lineTo(ax + s, av - s * 0.7); ctx.lineTo(ax - s, av - s * 0.7);
|
||||
ctx.moveTo(hx + dx * s, hy + dy * s);
|
||||
ctx.lineTo(hx - dx * s * 0.7 + dy * s, hy - dy * s * 0.7 + dx * s);
|
||||
ctx.lineTo(hx - dx * s * 0.7 - dy * s, hy - dy * s * 0.7 - dx * s);
|
||||
ctx.fill();
|
||||
// ...and say it in words. The mime alone was too abstract to read as "drag your thumb down".
|
||||
// ...and say it in words. The mime alone was too abstract to read as an instruction.
|
||||
ctx.fillStyle = UI.warm; ctx.font = F(8);
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
||||
ctx.fillText('PULL DOWN', ax, av + 8);
|
||||
// The label sits under the TRACK, not under the arrowhead — following the arrow sideways would
|
||||
// carry it off the edge of the screen, and clearing the full travel below a horizontal stroke
|
||||
// left it floating in the middle of an empty apron with nothing to attach itself to.
|
||||
ctx.fillText(label, ax, ay + (dy ? travel + 24 : 18));
|
||||
// the ghost thumb
|
||||
ctx.globalAlpha = gone ? 0 : 0.9;
|
||||
ctx.fillStyle = UI.cream;
|
||||
ctx.beginPath(); ctx.arc(ax, ay + len, 6, 0, 7); ctx.fill();
|
||||
ctx.beginPath(); ctx.arc(ax + dx * len, ay + dy * len, 6, 0, 7); ctx.fill();
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.45)';
|
||||
ctx.beginPath(); ctx.arc(ax, ay + len, 2.5, 0, 7); ctx.fill();
|
||||
ctx.beginPath(); ctx.arc(ax + dx * len, ay + dy * len, 2.5, 0, 7); ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// The apron plays the two gestures in alternation — pull, dodge, pull, dodge. Both are one-thumb
|
||||
// moves made from the same stance and the direction is the ONLY thing that tells them apart, so
|
||||
// showing them in the same place, one after the other, is what makes that legible. The sidestep
|
||||
// alternates sides too, or it reads as a move that only goes right.
|
||||
function drawPullHint() {
|
||||
const n = ((G.idleHintT - TUNE.pullHintAfter) / 1.9) | 0;
|
||||
if (n % 2 === 0) { demoSwipe(0, 1, 'PULL DOWN'); return; }
|
||||
// ...and it mimes AWAY from whichever edge he is standing against, or the stroke it is teaching
|
||||
// runs off the side of the screen. Only the alternation gives way; the lesson does not.
|
||||
const px = proj(P.x, 0, P.z).x;
|
||||
const dir = px < W * 0.3 ? 1 : px > W * 0.7 ? -1 : (n % 4 === 1 ? -1 : 1);
|
||||
demoSwipe(dir, 0, 'SWIPE TO DODGE');
|
||||
}
|
||||
|
||||
// The modal: drawn LAST, over everything, and the world behind it is frozen (gameplay.update
|
||||
// early-returns). Unlike drawCard this is a wall — nothing moves until it is tapped away.
|
||||
// easeOutBack: overshoots past the target and settles. The arcade snap — nothing in a cabinet
|
||||
@@ -1065,7 +1118,7 @@ function drawModal(m) {
|
||||
// cap the illustration against the viewport, not just the asset: at 270px tall (landscape) a
|
||||
// portrait-sized icon pushed the TAP prompt down onto the weapon rack
|
||||
const iconH = img ? Math.min(m.iconH || 40, H * 0.28) : 0;
|
||||
const blockH = (iconH ? iconH + 12 : 0) + 22 + body.length * 13;
|
||||
const blockH = (iconH ? iconH + 12 : 0) + 26 + body.length * 13;
|
||||
let y = Math.round(H / 2 - blockH / 2);
|
||||
const headX = dx(0, 1); // icon and title travel together, as one masthead
|
||||
if (img) {
|
||||
@@ -1073,13 +1126,13 @@ function drawModal(m) {
|
||||
ctx.drawImage(img, Math.round(W / 2 - iw / 2) + headX, y, Math.round(iw), iconH);
|
||||
y += iconH + 12;
|
||||
}
|
||||
ctx.font = F(14); ctx.fillStyle = m.color || UI.warm;
|
||||
ctx.font = B(24); ctx.fillStyle = m.color || UI.warm; // an announcement, so the announcement face
|
||||
if (headX) for (const [f, a] of [[0.7, 0.12], [0.35, 0.26]]) { // speed streak: ghosts strung out behind it
|
||||
ctx.save(); ctx.globalAlpha = a;
|
||||
ctx.fillText(m.title, W / 2 + Math.round(headX * (1 + f)), y + 8);
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.fillText(m.title, W / 2 + headX, y + 8); y += 22;
|
||||
ctx.fillText(m.title, W / 2 + headX, y + 8); y += 26;
|
||||
ctx.font = F(8, 0); ctx.fillStyle = UI.cream;
|
||||
body.forEach((l, i) => { ctx.fillText(l, W / 2 + dx(i + 1, -1), y + 6); y += 13; });
|
||||
// the prompt only exists once everything has landed — offering the tap mid-flight would be a lie,
|
||||
@@ -1093,6 +1146,9 @@ function drawModal(m) {
|
||||
|
||||
// ------------------------------------------------------------------ frame ----
|
||||
const AD_H = 14; // px tall the courtside ad strip is drawn — also the ceiling for centred HUD text
|
||||
const FLASH_Y = 68; // the ONE slot every free-floating message uses. Clear of the scoreboard block
|
||||
// (6 + 42 + 15 = 63 at its tallest), which the old 60 was not in portrait —
|
||||
// there the board is over half the width, so a centred flash landed on it.
|
||||
export function render() {
|
||||
ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density
|
||||
// ease the aim camera FIRST, before anything projects — one camera per frame (see proj)
|
||||
@@ -1101,6 +1157,17 @@ export function render() {
|
||||
const m = G.shake.mag * G.shake.t / 0.35;
|
||||
ctx.translate((Math.random() * 2 - 1) * m, (Math.random() * 2 - 1) * m);
|
||||
}
|
||||
// A scripted PUNCH-IN on a world point, in the same place and for the same reason as the shake:
|
||||
// it is a camera move on the drawn frame, not on proj(). A perspective dolly would want camZ,
|
||||
// aimCam and every projected HUD anchor to agree with each other for the sake of one 1.15s beat
|
||||
// in the transition — this is a flat 2D scale about a point, it only ever crops inward (so no
|
||||
// background edge can show), and it costs three lines.
|
||||
if (G.zoom) {
|
||||
const p = Math.min(1, G.zoom.t / G.zoom.T);
|
||||
const k = 1 + (G.zoom.k - 1) * Math.sin(p * Math.PI); // in and back out, no cut at either end
|
||||
const q = proj(G.zoom.x, 0, G.zoom.z);
|
||||
ctx.translate(q.x, q.y); ctx.scale(k, k); ctx.translate(-q.x, -q.y);
|
||||
}
|
||||
|
||||
const pal = palette(), pt = paletteT();
|
||||
// CLEAR THE WHOLE FRAME to the apron colour first. There was no clear at all: the stands cover the
|
||||
@@ -1417,16 +1484,19 @@ export function render() {
|
||||
// unreadable over a sunlit clay crowd or a floodlit blue one, so it gets its own panel — the
|
||||
// same trick the scoreboard art above it already relies on.
|
||||
ctx.lineWidth = 1;
|
||||
panel(0.5, bH + 1.5, bW - 1, 12, 3, 'rgba(8,26,18,0.82)', hexA(bd.hi, 0.35));
|
||||
// 6px was the smallest type in the game and it read as texture rather than as words. Both
|
||||
// strings are 7 and the panel grew to carry them — they are the only permanent readout of how
|
||||
// far into a run you are.
|
||||
panel(0.5, bH + 1.5, bW - 1, 14, 3, 'rgba(8,26,18,0.82)', hexA(bd.hi, 0.35));
|
||||
// which game you're in, and the sets banked, permanently — the 1.1s GAME N flash and the
|
||||
// scoreboard's own per-row sets column were the only places either showed.
|
||||
ctx.textAlign = 'right'; ctx.font = F(6, 0); ctx.fillStyle = bd.dim;
|
||||
ctx.fillText(`GAME ${G.runGames + 1} · ${G.sets[0]} SET${G.sets[0] === 1 ? '' : 'S'}`, bW - 3, bH + 7);
|
||||
ctx.textAlign = 'right'; ctx.font = F(7, 0); ctx.fillStyle = bd.dim;
|
||||
ctx.fillText(`GAME ${G.runGames + 1} · ${G.sets[0]} SET${G.sets[0] === 1 ? '' : 'S'}`, bW - 3, bH + 8);
|
||||
}
|
||||
if (splat && TUNE.killPips && TUNE.killsPerPoint > 1) { // kills-to-next-point pips, on the panel above
|
||||
ctx.textAlign = 'left'; ctx.font = F(6, 0); ctx.fillStyle = bd.dim; ctx.fillText('NEXT', 4, bH + 7);
|
||||
ctx.textAlign = 'left'; ctx.font = F(7, 0); ctx.fillStyle = bd.dim; ctx.fillText('NEXT', 4, bH + 8);
|
||||
for (let k = 0; k < TUNE.killsPerPoint; k++) {
|
||||
ctx.beginPath(); ctx.arc(32 + k * 8, bH + 7, 2.4, 0, 7);
|
||||
ctx.beginPath(); ctx.arc(34 + k * 8, bH + 8, 2.4, 0, 7);
|
||||
if (k < G.killCount) { ctx.fillStyle = bd.gold; ctx.fill(); }
|
||||
else { ctx.strokeStyle = '#5a6a5a'; ctx.lineWidth = 1; ctx.stroke(); }
|
||||
}
|
||||
@@ -1439,20 +1509,22 @@ export function render() {
|
||||
const fade = Math.min(1, (G.eatenT - EATEN_SUMMARY) / 0.6);
|
||||
ctx.save(); ctx.globalAlpha = fade;
|
||||
ctx.fillStyle = 'rgba(10,4,6,0.72)'; ctx.fillRect(0, 0, W, H);
|
||||
ctx.fillStyle = UI.bad; ctx.font = F(14);
|
||||
ctx.fillText('GAME, SET, MASSACRE.', W / 2, H / 2 - 40);
|
||||
// The announcement face, at 26 rather than the card's 30: this is the longest string in the game
|
||||
// and at 30 it ran edge to edge on a 270px-wide portrait canvas.
|
||||
ctx.font = B(26);
|
||||
shadowText('GAME, SET, MASSACRE.', W / 2, H / 2 - 44, UI.bad);
|
||||
ctx.fillStyle = UI.cream; ctx.font = F(9);
|
||||
ctx.fillText('SURVIVED ' + G.runGames + ' GAMES · ' + G.sets[0] + ' SETS', W / 2, H / 2 - 14);
|
||||
if (G.newBest) { // a fresh record crows; otherwise show the mark to beat
|
||||
const pulse = 0.55 + 0.45 * Math.abs(Math.sin(G.eatenT * 4));
|
||||
ctx.fillStyle = hexA(UI.warm, pulse); ctx.font = F(10);
|
||||
ctx.fillText('★ NEW BEST ★', W / 2, H / 2 + 4);
|
||||
ctx.font = B(20);
|
||||
shadowText('★ NEW BEST ★', W / 2, H / 2 + 2, hexA(UI.warm, pulse));
|
||||
} else if (G.best && G.best.games > 0) {
|
||||
ctx.fillStyle = UI.dim; ctx.font = F(8);
|
||||
ctx.fillText('BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS', W / 2, H / 2 + 4);
|
||||
}
|
||||
// gated well past the animation so a panic tap can't skip it (main.js gates the restart the same)
|
||||
if (G.eatenT > EATEN_RESTART) { ctx.fillStyle = UI.warm; ctx.font = F(8); ctx.fillText('TAP TO PLAY AGAIN', W / 2, H / 2 + 26); }
|
||||
if (G.eatenT > EATEN_RESTART) { ctx.fillStyle = UI.warm; ctx.font = F(8); ctx.fillText(IS_TOUCH ? 'TAP TO PLAY AGAIN' : 'PRESS ANY KEY TO PLAY AGAIN', W / 2, H / 2 + 32); }
|
||||
ctx.restore();
|
||||
}
|
||||
// Timed freezes swallow every input until they run out, which reads as the game having hung
|
||||
@@ -1468,15 +1540,15 @@ export function render() {
|
||||
// Kept clear of the ad strip — centred on H alone it landed right on the boards, which is the
|
||||
// busiest band on the screen. The strip's own projected line is the ceiling, so it stays legible
|
||||
// whatever the phone's height does to the layout. A 1px shadow, not a box, carries it over the crowd.
|
||||
// Plain Silkscreen on purpose — a countdown is information, not an announcement.
|
||||
const txt = 'RESUMING IN ' + Math.ceil(waitT);
|
||||
const y = Math.min(Math.round(H * 0.34), Math.round(proj(0, 0, -6).y) - AD_H - 16);
|
||||
ctx.font = F(10);
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillText(txt, W / 2 + 1, y + 1);
|
||||
ctx.fillStyle = hexA(UI.warm, 0.92); ctx.fillText(txt, W / 2, y);
|
||||
shadowText(txt, W / 2, y, hexA(UI.warm, 0.92));
|
||||
}
|
||||
if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0) && !G.aim && waitT <= 0.5) { // while pulling, the draw meter owns this slot
|
||||
ctx.fillStyle = UI.warm; ctx.font = F(8);
|
||||
const touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-5 TO SWITCH';
|
||||
const touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-0 TO SWITCH';
|
||||
const selW = WEAPONS[slotId(G.weapon)], sel = selW ? selW.label : 'PLAIN';
|
||||
// "is the selected weapon one you UNLOAD rather than serve" — it used to be `G.weapon === 4`,
|
||||
// which is meaningless once the slots hold whatever you gathered
|
||||
@@ -1498,9 +1570,12 @@ export function render() {
|
||||
if (txt) ctx.fillText(txt, W / 2, METER_Y()); // same slot the draw meter uses
|
||||
if (demo) drawPullHint(); // show the gesture instead of only naming it
|
||||
}
|
||||
// The free-floating register: no panel, one fixed slot, shouted. PERFECT, GAME N, DOUBLE KILL,
|
||||
// RETURNED, CHANGEOVER, weapon labels — everything that used to be plain white 12px text sitting
|
||||
// directly on the crowd. It keeps its 1px jitter; the shadow is what makes it legible anywhere.
|
||||
if (G.flash) {
|
||||
ctx.font = F(12); ctx.fillStyle = '#ffffff';
|
||||
ctx.fillText(G.flash.text, W / 2, 60 + Math.sin(G.flash.t * 20) * 1);
|
||||
ctx.font = B(24);
|
||||
shadowText(G.flash.text, W / 2, FLASH_Y + Math.sin(G.flash.t * 20) * 1, '#ffffff');
|
||||
}
|
||||
// the SET milestone card (the duel's own announcement is a real modal now, not a timed card)
|
||||
if (G.card && G.card.t > 0 && !(G.card.wait > 0)) // `wait` holds it OFF the screen (a boss dying under it)
|
||||
|
||||
@@ -67,6 +67,12 @@ export const G = {
|
||||
idleHintT: 0, // s the player has sat in pretoss without touching anything — after a beat the
|
||||
// pull gesture demonstrates itself (render.drawPullHint)
|
||||
meleeT: 0, // s of racket-swish cooldown left
|
||||
// ---- the sidestep. The movement block is 'rally'-only on purpose, so this integrates OUTSIDE it
|
||||
// and is the only way to move in the serve stance (which is where a boss fight is spent).
|
||||
dodgeDir: 0, // -1 | 0 | 1 — which way the current step is going, 0 = standing
|
||||
dodgeT: 0, // s of the step left
|
||||
dodgeCd: 0, // s until another is allowed
|
||||
dodgeI: 0, // s of invulnerability left (checked by hurtPlayer AND the eat test)
|
||||
// ---- the SECOND damage channel. Only ranged attacks (ball machine, chapter boss) touch this;
|
||||
// a shambler reaching you is still instant permadeath, deliberately. No HP bar anywhere — the
|
||||
// state is drawn as blood on the man (drawChar's spatter + bleedPlayer's live trickle).
|
||||
@@ -94,7 +100,9 @@ export const G = {
|
||||
// the modal: a real interruption. Unlike card/flash it FREEZES the world (update() early-returns)
|
||||
// and only a tap or key clears it, so a popup can be read without being eaten while you read it.
|
||||
modal: null, // {title, body: [lines], icon, iconH, color, onDismiss}
|
||||
modalQ: [], // popups waiting behind the open one — see showModal
|
||||
seen: {}, // one-shot markers so a first-time-only modal fires once per run
|
||||
zoom: null, // {x, z, k, t, T} — a scripted punch-in on a world point (the transition's turn)
|
||||
best: null, // best run {games, sets}, loaded from localStorage when you get eaten
|
||||
newBest: false, // this run beat the stored best — the eaten summary celebrates it
|
||||
lastScoreT: -9, // idleT of the last score voice — rate-limits umpire during a horde
|
||||
|
||||
+6
-3
@@ -204,8 +204,10 @@ function bossAI(zb, dt) {
|
||||
|
||||
// ---- the player's second health channel. Only these two functions touch it.
|
||||
export function hurtPlayer(n) {
|
||||
if (G.mode !== 'splatter' || G.hurtT > 0 || G.mode === 'eaten') return; // grace window: a volley
|
||||
G.hp -= n; // must not delete you at once
|
||||
// G.hurtT is the grace window: a volley must not delete you at once. G.dodgeI is the SIDESTEP —
|
||||
// it beats this channel and the eat test below, which is the only reason to spend a dodge.
|
||||
if (G.mode !== 'splatter' || G.hurtT > 0 || G.dodgeI > 0 || G.mode === 'eaten') return;
|
||||
G.hp -= n;
|
||||
G.hurtT = TUNE.hurtGrace; G.hurtFlash = 0.3;
|
||||
G.shake = { t: 0.22, mag: 4 };
|
||||
splat(0.7); aww(0.5);
|
||||
@@ -343,7 +345,7 @@ function popBarrel(zb) {
|
||||
grantWeapon(zb.wid);
|
||||
const w = WEAPONS[zb.wid];
|
||||
G.flash = { text: w.label, t: 1.1 };
|
||||
if (!G.seen['w' + zb.wid]) { G.seen['w' + zb.wid] = true; weaponModal(zb.wid); }
|
||||
weaponModal(zb.wid); // first sight only — the gate lives in weaponModal, for both callers
|
||||
}
|
||||
|
||||
// dark smoke: one puff, drifting up and spreading. Not gravity-bound like gore (see the parts loop).
|
||||
@@ -891,6 +893,7 @@ export function updateZombies(dt) {
|
||||
// soften it. Two threats that demand two different responses is the point. The machine ('m') is
|
||||
// furniture and cannot eat you.
|
||||
if (!held && G.mode === 'splatter' && !'mo'.includes(zb.variant) // furniture and barrels cannot eat you
|
||||
&& !(G.dodgeI > 0) // ...and a sidestep gets you out from under one. It is the ONE escape from this
|
||||
&& Math.hypot(zb.x - P.x, (zb.z - P.z) * 0.75) < TUNE.eatenR + (zb.variant === 'g' ? 0.2 : 0)) // z weighted down: depth is foreshortened, so a head-on shambler eats you when it LOOKS on top of you
|
||||
killPlayer();
|
||||
if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) {
|
||||
|
||||
Reference in new issue
Block a user