Make the bosses move, and put six of them and three new bodies on court

A chapter boss had no AI at all: it matched no branch in the variant chain and
fell through to the crowd's aimless wobble, so "the boss AI owns its x" was a
comment describing code that did not exist. Movement is now data on the BOSS_ART
row -- pace, shadow, surge, charge, bounce -- and bossAI is the one place it
happens. Every behaviour field is optional, so a row carrying only art behaves
exactly as a boss did before.

Three earlier drafts of those styles failed the same way: written as "walk toward
the player's x", which in splatter means arrive in four seconds and stand still,
because the player is rooted in pretoss for nearly all of a boss fight. Every
style now moves under its own power and only biases toward him. Measured over a
simulated minute each: 50-136 m travelled, none static.

Three new designs -- THE HEAD COACH (a 5-shot drill 0.14s apart), THE PHYSIO
(charges, and withdraws less than it charges, so it reaches you at 38s and that
is the fight's clock), THE SPONSOR MASCOT (gap 0, so the whole wall of five
leaves in ONE frame and is dodged rather than parried). Harder to kill: 16 -> 20
hp, 9 -> 10 per chapter, plus per-design multipliers.

Three new bodies: the ball boy (3 hp, 2 skins, from game 3), the bouncer (6/3,
game 8) and the roller man (9/4, game 15), each with a 4-frame walk and a drawn
two-frame lurch for every wound stage.

Three wound rules became one. The brute had a hardcoded stage array, everything
else a single skin behind 'abd'.includes(...), and the boss a fraction of hpMax.
The boss's rule reproduces the other two exactly -- verified: the brute still
draws z_gw/gw2/gw3/gw4 at hp 4/3/2/1 -- and keeps working when hp is not
one-per-skin, which all three new bodies need. So a multi-hit body is now a row
in config.ZWOUND and no code. bleed() reads the same call, which also gave the
steward the trickle his wound skin had always implied.

Fixes a real crash on the way past: killPlayer empties shots[] while the shots
loop is mid-iteration, so every remaining index read undefined and the frame
threw. The mascot's five-at-once volley makes it easy to hit; the shipped 3-shot
volley could always have hit it.

_regen.py gains two style blocks and a check tier. One constant used to say
"keep every colour exactly as the reference" -- right for a remaster, and it
fights every word of the subject otherwise. newart is for a new character, state
for the same character wounded or mid-throw; using newart for the wound sheets
licensed a repaint and got one, five came back with living tan skin. And the
detail gate ratchets against the file it replaces, so check: any exists for
re-rolling already-fine art to fix something the gate cannot see, such as colour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-03 11:45:35 +02:00
1 parent 0774a2a31d
commit 4cda6bde76
64 files changed
+694 -78

No files matched your search

+88 -4
View File
@@ -79,6 +79,29 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
and only then extend, or you have silently nerfed the part of the game that was already fine.
The raw dials do run out around game 25 by design; bosses and ranged attacks (steps 4-5) are what
carry escalation past that, not more bodies.
- **`woundStage(zb, n)` in config.js is the ONE rule for how damage READS.** 0 = unhurt, 1..n = the
skins, spread evenly over `hpMax`. There used to be three rules: the brute's hardcoded stage array
indexed by absolute hp, a single skin behind `'abd'.includes(...)`, and the boss's fraction-of-hpMax.
The boss's rule reproduces the other two EXACTLY (5 hp over 4 skins is one skin per hit; 2 hp over
1 skin is the shambler) and keeps working when hp is not one-per-skin — which the ball boy (3 hp,
2 skins), bouncer (6/3) and roller man (9/4) all need. So a new multi-hit body is now one row in
`config.ZWOUND` (healthiest first, `…b` stride twin looked up automatically) and NO code.
`bleed()`'s anchors are read through the same call and are deliberately the same length as that
variant's ZWOUND row, so the blood and the drawing it comes out of cannot drift apart — they used
to be written by hand against different constants. **'r' and 'B' keep their own skins** (the boss's
on its design row) but go through the same `woundStage`.
- **A chapter boss's MOVEMENT is data on its `BOSS_ART` row** (`move`, plus optional `hpMul`,
`speedMul`, `volley`, `gap`, `spread`, `cycle`, `wind`, `shot`), and `bossAI` in zombies.js is the
only place it moves. Every field is optional, so a row carrying nothing but art behaves exactly as a
boss did before there were any. Three things that are load-bearing and were each a bug first:
**(a)** the generic forward step at the top of the body loop SKIPS `'B'` — bossAI owns both axes,
and leaving it in advanced the boss twice per frame; **(b)** the stride steps off `zb.dist`
(ground covered on both axes), not `zb.z`, or a boss strafing sideways slides with its feet frozen;
**(c)** a boss holds a z BAND (`bossZMin..bossZMax`) except while charging — uncapped, a fight ended
by the boss simply arriving after about a minute, and every extra hp made that MORE likely, not less.
**No style may rely on the player moving to create its own motion**: in splatter he is rooted in
`pretoss` for nearly all of a boss fight, so "close on the player's x" arrives, stops, and is static
for the rest of the fight. Every style moves under its own power and only BIASES toward him.
- **`wound(zb, amount, fx)` in zombies.js is the ONE place damage is applied to a multi-hit body.**
Fractional amounts are legal (a pellet chips `TUNE.dmgPellet` = 0.34 of a point); they bank on
`zb.chip` and only WHOLE points come off `zb.hp`, because the wound skins and `bleed()` anchors
@@ -384,6 +407,24 @@ Clear the intro first: `document.getElementById('intro')?.remove()`.
survives as a rim on a green court.
12. **Two-row "style reference / redraw this" input does NOT work.** The model returns both rows and
invents a background. Describe the target detail level in words instead.
12b. **`_regen.py` has THREE style blocks now, and picking the wrong one costs rolls.** The shared
block used to be one constant that always said *"keep every pose, every colour EXACTLY as in the
reference"* — correct for a remaster, and it fights every word of the subject for anything else.
- default (`STYLE`): remastering a sprite that already exists.
- `"newart": true` (`NEW_STYLE`): a NEW character drawn in the reference's style. Drops the
fidelity clause and says explicitly not to copy the reference's body or outfit.
- `"state": true` (`STATE_STYLE`): the SAME character in a new state — wounded, mid-throw. This is
the one that is easy to get wrong, because `newart` looks right and isn't: it licenses a repaint,
and it got one. Five wound sheets came back with living TAN skin and a redesigned shirt while the
healthy frame stayed a grey-green corpse, and the coach's navy tracksuit came back mid-blue
(torso RGB blue channel 111 -> 138). `STATE_STYLE` pins colours, clothing and build to the
reference and states outright that he is a corpse, never pink or tan.
**A brand-new sprite name must exist on disk before its batch runs** — `run()` measures `density()`
of the file it is about to replace, and `colour_match` opens it. Seed each new name by copying a
body of the right size and facing, and use `check: same`/`loose` so `colour_match` is skipped and
the seed's palette is NOT inherited. The trap in that: a batch that silently fails leaves a perfect
copy of another character behind, which renders as working art. Assert every new PNG actually
differs from its seed before shipping (the scratchpad `check_seeds.py` does exactly this).
13. **`assets/_derive.py` is where a frame that must MATCH another frame is made.** Three of them
live there now: `p_bounce2` (a per-column shear of `p_bounce` — the free arm bends, nothing else
moves), `p_toss` (the drawn-in ball cut out, canvas height KEPT so drawChar keeps his scale) and
@@ -409,6 +450,47 @@ Known generator quirks, all still true:
- **Colour-match any regenerated frame** of an existing pair before shipping: fit a per-channel
linear map onto the original's mean and sd, then verify with a mean-difference number.
## Session log: the roster grows — moving bosses, six of them, three new bodies (2026-08-03)
Oliver asked for five things. This is the first half: **enemies first, weapons second**, his call, so
the arsenal could be tuned against the harder game rather than the other way round.
**Bosses move now, and the style is data.** A boss previously matched NO branch in the variant chain
and fell through to the crowd's aimless ±0.15 m/s wobble — "the boss AI owns its x" was a comment
describing code that did not exist. Six styles, one per design: `pace` (groundskeeper, and the coach
at 1.9x), `shadow` (line judge — paces a band centred on YOU), `surge` (chair umpire — rolls up and
down the whole depth band), `charge` (physio), `bounce` (mascot). Measured over a simulated minute
each: 50–136 m travelled, none static. **Three earlier drafts of these styles failed the same way and
it is worth knowing why: they were written as "walk toward the player's x", which in splatter means
arrive in four seconds and stand still for the rest of the fight, because the player is rooted in
`pretoss` and never moves.** Measure `xSpan` over 3600 steps before believing any movement change.
**Three new bosses**: THE HEAD COACH (rapid 5-shot drill, 0.14 s apart), THE PHYSIO (charges — it
withdraws LESS than it charges, so it is a couple of metres nearer after each rush and reaches you at
about 38 s, which is the fight's clock), THE SPONSOR MASCOT (a `gap: 0` volley — the whole wall of
five leaves in ONE frame, so it is dodged, not parried; the salvo loop is a `while` for exactly this).
Harder to kill: `bossHp` 16 -> 20, `bossHpPerChapter` 9 -> 10, plus per-design `hpMul`.
**Chapter 6 is now 91 hp — the number most likely to want tuning after a real playtest.**
**Three new bodies**: ball boy `'e'` (3 hp, 2 skins, from game 3), bouncer `'h'` (6/3, game 8), roller
man `'k'` (9/4, game 15). Named "roller man" and not "groundsman" so he cannot be confused with the
GROUNDSKEEPER boss.
**One real bug found, and it was pre-existing.** `killPlayer()` empties `shots[]` while the shots loop
is mid-iteration, so every remaining index reads `undefined` and the frame throws. The mascot's
five-at-once volley makes it easy to hit; the shipped 3-shot volley could always have hit it. Fixed
with the same `if (!s) continue` guard the body loop above it already carries, and for the same reason.
**Verification that actually caught things** (all via `window.T`, RAF halted):
- `drawImage` instrumentation per design: all six bosses and all three new bodies draw their OWN
frames, both `…w`/`…wb` stride twins included. Nothing silently falls back to design 1.
- Differential check on the refactor: the brute still draws `z_gw/gw2/gw3/gw4` at hp 4/3/2/1 — the
new shared `woundStage` is byte-identical to the array it replaced.
- Foot-gap spread: ball boy 76% (the shipped shambler is 36%, the brute 39%). Boss stride-pair
difference 34–54%, inside the shipped range of 30–83%.
- 26-game soak: no exceptions, no stall, `mgT` back to 0, gore capped 40, piles capped 30, and
`e`/`h`/`k`/`B` all appeared in it.
## Session log: court types (2026-08-01)
Three courts, picked on a second start-screen stage. A court is pure DATA — `COURTS` in config.js
@@ -746,11 +828,13 @@ Nothing here is broken; these are known gaps and unverified risks.
scale but gained no detail in the remaster, and they are the two most-seen poses in the game.
Fix by hand-slicing `_sheet_player2/3` and `_sheet_opp2/3` at `IDLE_TARGET_H` 224 rather than going
through the signature matcher, which assigned those frames elsewhere.
4. **Boss designs are asymmetric.** Design 1 (GROUNDSKEEPER) has 3 wound stages and 4 throw frames;
designs 2 (LINE JUDGE) and 3 (CHAIR UMPIRE) have 1 wound skin and 2 throw frames each. Legal — the
4. **Boss designs are asymmetric — still, for two of the six.** Designs 1 (GROUNDSKEEPER), 4 (HEAD
COACH), 5 (PHYSIO) and 6 (SPONSOR MASCOT) have 3 wound stages and 4 throw frames each; designs 2
(LINE JUDGE) and 3 (CHAIR UMPIRE) still have 1 wound skin and 2 throw frames. Legal — the
`BOSS_ART` indices are clamped — but a chapter-2 boss degrades in one visible step instead of three.
5. **Only 3 boss designs, cycling.** Chapter 4 repeats the groundskeeper at 43 hp and 3.67 m. A fourth
design is a sheet plus one row in `BOSS_ART`.
Fixing them is now just two batches in the shape of `boss_coach_wounds` / `boss_coach_throw`.
5. ~~**Only 3 boss designs, cycling.**~~ CLOSED 2026-08-03 — six now, so a chapter does not repeat a
design until chapter 7. A seventh is still a sheet plus one row in `BOSS_ART`.
6. **Landscape is under-maintained.** The readability pass (camZ/camY/camF/horizon), the adaptive
height and the safe-area work are all portrait-only; landscape still runs the old camera and a fixed
480x270. Portrait is the stated primary target, but landscape has drifted and is untested.
+39 -7
View File
@@ -21,21 +21,47 @@ SCRATCH = os.environ.get('SPLAT_SCRATCH', '/tmp/splatter-regen')
GEN = os.path.expanduser('~/.claude/skills/generate-image/scripts/generate_image.py')
PY = os.environ.get('SPLAT_PY', sys.executable)
STYLE = (
# The style block is in three parts because the middle one is a REDRAW instruction and only a redraw
# wants it. "Keep every pose and colour exactly as in the reference" is right when the job is
# remastering a sprite that already exists, and it fights every word of the subject when the job is a
# NEW character drawn in the reference's style — set "newart": true on the batch to drop it.
RES = (
"Redraw them as HIGH-RESOLUTION pixel art. The art pixels must be SMALL: build each figure out "
"of roughly 90 to 110 pixels of height instead of the reference's 40, so there is room for real "
"detail, and spend it — a face with teeth, sunken eyes and a jawline; individual fingers; "
"clothing with visible seams, hems, folds and frayed torn edges; laced shoes; four or five "
"distinct shades per material with fine hatched shading where the light falls away. Keep every "
"pose, every body proportion, every height, every facing and every wound EXACTLY as in the "
"reference — only the drawing resolution and the amount of fine detail change. Keep the same "
"colours. Hard black outline around each figure. The background must be ONE FLAT solid magenta "
"distinct shades per material with fine hatched shading where the light falls away. "
)
FIDELITY = (
"Keep every pose, every body proportion, every height, every facing and every wound EXACTLY as "
"in the reference — only the drawing resolution and the amount of fine detail change. Keep the "
"same colours. "
)
CANVAS = (
"Hard black outline around each figure. The background must be ONE FLAT solid magenta "
"#FF00FF across the entire canvas — no scenery, no gradient, no vignette. Keep at least 200 "
"pixels of empty magenta between figures and at least 150 pixels of empty magenta at every edge "
"of the canvas: no figure or limb may touch or run off an edge. No shadow, no ground line, no text. "
"The canvas is a WIDE LANDSCAPE STRIP and every figure stands side by side in ONE SINGLE HORIZONTAL "
"ROW on the same ground line — never a grid, never two rows, never stacked."
)
STYLE = RES + FIDELITY + CANVAS
NEW_STYLE = RES + (
"Match the reference's DRAWING STYLE, its overall figure height and its facing — but the "
"character, the clothes, the colours and the poses are the ones described above, not the "
"reference's. Do not copy the reference's body or outfit. "
) + CANVAS
# The third case, and the one that is easy to miss: the SAME character in a different state (wounded,
# mid-throw). "newart" is wrong for it — it licenses the generator to repaint the character, and it
# did: wound frames came back with living tan skin and a redesigned shirt while the healthy frame
# stayed a grey-green corpse. Set "state": true for those.
STATE_STYLE = RES + (
"This is the SAME character as the reference, not a new one. His colours, his clothing, his "
"build, his proportions and his height must be IDENTICAL to the reference — the only things "
"that change are the injuries and the pose described above. His skin stays exactly the same "
"dead grey-green as the reference: he is a walking corpse, never pink, never tan, never living "
"flesh. Do not redesign or recolour any garment. "
) + CANVAS
def batches():
with open(os.path.join(HERE, '_regen_batches.json')) as f:
@@ -147,7 +173,7 @@ def run(name, spec, dry=False):
ref = ref_sheet(shown, f'{SCRATCH}/{name}_ref.png', spec.get('scale', 2.0),
frames=len(sprites) + (1 if lead else 0))
sheet = f'{SCRATCH}/{name}_hi.png'
prompt = f"{spec['subject']} {STYLE}"
prompt = f"{spec['subject']} {STATE_STYLE if spec.get('state') else NEW_STYLE if spec.get('newart') else STYLE}"
if dry:
print(f'[{name}] would generate {len(sprites)} frames: {", ".join(sprites)}'); return True
print(f'[{name}] generating {len(sprites)} frames...')
@@ -174,7 +200,13 @@ def run(name, spec, dry=False):
# that already has blood on it starts high, so a genuinely finer wounded body gains less). A
# REPOSE — turning the player around, pointing the runner at the camera, matching a wound between
# two frames — is already fine art and only has to come back no worse.
bar = {'finer': 1.25, 'same': 0.85, 'loose': 0.7}[spec.get('check', 'finer')]
# 'any' turns the detail gate OFF, and there is exactly one job it is for: RE-ROLLING art that is
# already fine to fix something the gate cannot see, such as a colour. The bar is measured against
# the file being replaced, so once a good sheet installs it RATCHETS — a second roll of equal
# quality now has to beat the first, and three colour-fix re-rolls were rejected for being 0.39
# against a 0.46 that was itself only there because the first roll happened to be dense. Magenta
# and edge checks still apply; verify the thing you were actually fixing by measuring it.
bar = {'finer': 1.25, 'same': 0.85, 'loose': 0.7, 'any': 0.0}[spec.get('check', 'finer')]
if sum(after) / len(after) < sum(before) / len(before) * bar:
print(f'[{name}] REJECTED: not finer than what it replaces {before} -> {after}'); return False
if spec.get('palette_ref'):
+305 -4
View File
@@ -27,7 +27,8 @@
"z_bwb"
],
"subject": "Four frames of two WOUNDED walking zombies, drawn as coarse low-resolution pixel art. The first two are the same grey-shirted zombie whose right arm ends in a ragged bloody stump, in two stages of one stride. The last two are the same brown-coated zombie with an open red gash across his skull, in two stages of one stride. Keep each wound exactly where it is.",
"anchor": 0
"anchor": 0,
"state": true
},
"brute_walk": {
"sprites": [
@@ -65,7 +66,8 @@
"z_dwb"
],
"subject": "Two frames of the same badly wounded zombie stadium steward in a torn high-visibility ORANGE vest \u2014 one arm mangled, a deep bloody gash across the chest, head lolling \u2014 walking, two stages of one stride, drawn as coarse low-resolution pixel art.",
"anchor": 0
"anchor": 0,
"state": true
},
"cpu_walk": {
"sprites": [
@@ -85,7 +87,8 @@
"z_cw2b"
],
"subject": "Four frames of the same wounded zombie tennis player in a red shirt holding a wooden racket, drawn as coarse low-resolution pixel art. The first two are him wounded and bleeding, in two stages of one stride. The last two are the same character HEADLESS, a ragged red stump where the neck was, still holding the racket, in two stages of one stride.",
"anchor": 0
"anchor": 0,
"state": true
},
"player_stance": {
"sprites": [
@@ -268,7 +271,8 @@
],
"anchor": 0,
"subject": "Four frames of the same enormous fat zombie brute, drawn as coarse low-resolution pixel art. The FIRST TWO are the same damage stage \u2014 his LEFT arm torn off at the shoulder leaving a ragged bloody stump, the right arm dragging a rusty pole \u2014 shown as two stages of one stride. The LAST TWO are the next damage stage, BOTH arms gone at the shoulders, again two stages of one stride. Within each pair the injuries must be IDENTICAL and on the same side of the body: only the legs change.",
"check": "same"
"check": "same",
"state": true
},
"brute_all_wounds2": {
"sprites": [
@@ -453,5 +457,302 @@
"anchor": 0,
"check": "loose",
"subject": "The reference is ONE young ball boy in a WHITE polo shirt and WHITE shorts with brown hair, seen from the FRONT, holding a floor mop. Draw FOUR frames of that SAME boy \u2014 same face, same brown hair, same white kit, same white shoes, same size, same front-facing view \u2014 with a gormless, dim-witted, slightly cross-eyed expression in every frame. THE FOUR FRAMES ARE FOUR DIFFERENT POSES: (1) WAVING hello with his free hand raised beside his head, big goofy open-mouthed grin, the mop standing upright in his other hand; (2) POINTING away to the side with one arm stuck straight out and his mouth open explaining something, mop upright in the other hand; (3) SHRUGGING with both palms turned up and his shoulders hunched, utterly baffled, eyebrows up, the mop tucked under one arm; (4) TERRIFIED \u2014 eyes wide as saucers, mouth open in a yell, both hands clutching the mop up across his chest like a weapon. Each figure stands upright at the same height with both feet on the ground. The whole boy and the whole mop are fully inside the canvas with clear space around them. The canvas contains EXACTLY FOUR figures and no others."
},
"ballboy_walk": {
"sprites": [
"z_e1",
"z_e2",
"z_e3",
"z_e4"
],
"ref_only": [
"z_a1"
],
"anchor": 0,
"check": "same",
"subject": "The reference is one walking zombie seen from the SIDE and facing LEFT. Draw FOUR frames of a walk cycle, seen from the SIDE and facing LEFT, of a dead teenage TOURNAMENT BALL BOY: short-sleeved white and navy polo shirt with a small sponsor patch, navy shorts, white knee socks and white trainers, a tennis ball still clutched in one hand. Green-grey dead skin, sunken eyes, slack jaw, hair matted flat. An ordinary-sized body, the same height as the reference. The four frames are the four stages of ONE shambling stride: front leg reaching forward with the GAP BETWEEN THE FEET WIDE; then the feet together and passing; then the other leg reaching forward with the gap wide the other way; then the feet together again. The gap between his feet must visibly open and close, and the arms swing opposite the legs. Output exactly four figures in one row, evenly spaced.",
"state": true
},
"ballboy_w1": {
"sprites": [
"z_ew",
"z_ewb"
],
"ref_only": [
"z_e1"
],
"anchor": 0,
"check": "same",
"subject": "The reference is the dead tournament ball boy in his white and navy polo, seen from the SIDE and facing LEFT. Draw TWO frames of that SAME boy, same size, same clothes, still walking, now WOUNDED: his right arm is torn off at the elbow leaving a ragged bloody stump, the polo shredded and soaked dark down that side, the tennis ball gone. The two frames are the two halves of ONE stride: in the first the front foot is far forward and the GAP BETWEEN THE FEET IS WIDE, in the second the feet are together and passing. Output exactly two figures in one row.",
"state": true
},
"ballboy_w2": {
"sprites": [
"z_ew2",
"z_ew2b"
],
"ref_only": [
"z_e1"
],
"anchor": 0,
"check": "same",
"subject": "The reference is the dead tournament ball boy in his white and navy polo, seen from the SIDE and facing LEFT. Draw TWO frames of that SAME boy, same size, same clothes, still walking, now HORRIFICALLY WOUNDED: BOTH arms torn off at the shoulders leaving two bloody stumps, and his lower jaw ripped away so the throat is open, the polo soaked black-red to the hem. The two frames are the two halves of ONE stride: in the first the front foot is far forward and the GAP BETWEEN THE FEET IS WIDE, in the second the feet are together and passing. Output exactly two figures in one row.",
"state": true
},
"bouncer_walk": {
"sprites": [
"z_h1",
"z_h2",
"z_h3",
"z_h4"
],
"ref_only": [
"z_d1"
],
"anchor": 0,
"check": "same",
"subject": "The reference is one big walking zombie seen from the SIDE and facing LEFT. Draw FOUR frames of a walk cycle, seen from the SIDE and facing LEFT, of a dead SECURITY STEWARD working the tournament gate: a big heavy-set man with a shaven head and a thick neck, wearing an open BLACK bomber jacket over a bright YELLOW high-visibility vest with SECURITY in black block letters, a white shirt collar showing at the throat, a red accreditation lanyard, grey-green cargo trousers with deep pockets and visible seams, a chunky leather belt with a silver buckle, tan boots, tattoos down one bare forearm, and a coiled clear earpiece wire running up to one ear. Green-grey dead skin, jaw hanging, teeth bared. He is broad and slab-shouldered \u2014 noticeably heavier than the reference, but still a normal human silhouette \u2014 and he walks with his arms held out from his body, shoving forward. Draw him at the same overall height as the reference. The four frames are the four stages of ONE shambling stride: front leg reaching forward with the GAP BETWEEN THE FEET WIDE; then the feet together and passing; then the other leg reaching forward with the gap wide the other way; then the feet together again. The gap between his feet must visibly open and close, and the arms swing opposite the legs. Output exactly four figures in one row, evenly spaced.",
"state": true
},
"bouncer_w1": {
"sprites": [
"z_hw",
"z_hwb"
],
"ref_only": [
"z_h1"
],
"anchor": 0,
"check": "any",
"subject": "The reference is the dead SECURITY steward in his black bomber jacket over a bright yellow hi-vis vest, seen from the SIDE and facing LEFT. Draw TWO frames of that SAME man, the same size and bulk and the same clothes, still walking, now WOUNDED: his left arm is mangled and hanging by a thread of muscle, and the hi-vis vest is torn open across the chest with a deep bloody gash beneath it. Do not slim him down. The two frames are the two halves of ONE stride: in the first the front foot is far forward and the GAP BETWEEN THE FEET IS WIDE, in the second the feet are together and passing. Output exactly two figures in one row.",
"state": true
},
"bouncer_w2": {
"sprites": [
"z_hw2",
"z_hw2b"
],
"ref_only": [
"z_h1"
],
"anchor": 0,
"check": "same",
"subject": "The reference is the dead SECURITY steward in his black bomber jacket over a bright yellow hi-vis vest, seen from the SIDE and facing LEFT. Draw TWO frames of that SAME man, the same size and bulk and the same clothes, still walking, now WOUNDED: BOTH arms are torn off at the shoulders leaving two bloody stumps, and the hi-vis vest hangs in shredded ribbons over an opened chest. Do not slim him down. The two frames are the two halves of ONE stride: in the first the front foot is far forward and the GAP BETWEEN THE FEET IS WIDE, in the second the feet are together and passing. Output exactly two figures in one row.",
"state": true
},
"bouncer_w3": {
"sprites": [
"z_hw3",
"z_hw3b"
],
"ref_only": [
"z_h1"
],
"anchor": 0,
"check": "same",
"subject": "The reference is the dead SECURITY steward in his black bomber jacket over a bright yellow hi-vis vest, seen from the SIDE and facing LEFT. Draw TWO frames of that SAME man, the same size and bulk and the same clothes, still walking, now WOUNDED: both arms are gone at the shoulders AND he has no head at all \u2014 the neck ends in a ragged red stump at shoulder height, no skull, no face \u2014 the earpiece wire dangling loose into the wound. Do not slim him down. The two frames are the two halves of ONE stride: in the first the front foot is far forward and the GAP BETWEEN THE FEET IS WIDE, in the second the feet are together and passing. Output exactly two figures in one row.",
"state": true
},
"roller_walk": {
"sprites": [
"z_k1",
"z_k2",
"z_k3",
"z_k4"
],
"ref_only": [
"z_g1"
],
"anchor": 0,
"check": "same",
"subject": "The reference is one enormous walking zombie brute seen from the SIDE and facing LEFT. Draw FOUR frames of a heavy walk cycle, seen from the SIDE and facing LEFT, of a dead GROUNDSMAN hauling a heavy court roller: a colossal man in filthy green overalls with the sleeves cut off, a leather tool belt, mud-caked rubber boots, leaning hard into a two-handed steel T-handle. The roller itself is a squat heavy cylinder on a short frame close behind his heels \u2014 it must stay small and entirely INSIDE his own frame, never reaching toward the next figure. Green-grey dead skin, one eye gone, teeth bared with effort. He is even bigger and heavier than the reference, hunched forward under the load. The four frames are the four stages of ONE shambling stride: front leg reaching forward with the GAP BETWEEN THE FEET WIDE; then the feet together and passing; then the other leg reaching forward with the gap wide the other way; then the feet together again. The gap between his feet must visibly open and close, and the arms swing opposite the legs. Output exactly four figures in one row, evenly spaced.",
"state": true
},
"roller_w1": {
"sprites": [
"z_kw",
"z_kwb"
],
"ref_only": [
"z_k1"
],
"anchor": 0,
"check": "any",
"subject": "The reference is the dead groundsman in filthy green overalls, seen from the SIDE and facing LEFT. Draw TWO frames of that SAME man, the same colossal size and bulk and the same overalls, still walking, now WOUNDED: the arm on the right-hand side of the image is COMPLETELY GONE \u2014 severed at the shoulder joint, no upper arm, no forearm, no hand, nothing below it but a ragged red stump of bone and muscle at the shoulder \u2014 while his other arm still grips the roller handle. He must stay exactly as enormous \u2014 do not slim him down as the damage grows. The two frames are the two halves of ONE stride: in the first the front foot is far forward and the GAP BETWEEN THE FEET IS WIDE, in the second the feet are together and passing. Output exactly two figures in one row.",
"state": true
},
"roller_w2": {
"sprites": [
"z_kw2",
"z_kw2b"
],
"ref_only": [
"z_k1"
],
"anchor": 0,
"check": "any",
"subject": "The reference is the dead groundsman in filthy green overalls, seen from the SIDE and facing LEFT. Draw TWO frames of that SAME man, the same colossal size and bulk and the same overalls, still walking, now WOUNDED: BOTH arms are torn off at the shoulders leaving two bloody stumps and the roller is gone entirely. He must stay exactly as enormous \u2014 do not slim him down as the damage grows. The two frames are the two halves of ONE stride: in the first the front foot is far forward and the GAP BETWEEN THE FEET IS WIDE, in the second the feet are together and passing. Output exactly two figures in one row.",
"state": true
},
"roller_w3": {
"sprites": [
"z_kw3",
"z_kw3b"
],
"ref_only": [
"z_k1"
],
"anchor": 0,
"check": "same",
"subject": "The reference is the dead groundsman in filthy green overalls, seen from the SIDE and facing LEFT. Draw TWO frames of that SAME man, the same colossal size and bulk and the same overalls, still walking, now WOUNDED: both arms are gone AND his chest and belly are torn wide open with the ribs showing through, the overalls hanging in bloody strips. He must stay exactly as enormous \u2014 do not slim him down as the damage grows. The two frames are the two halves of ONE stride: in the first the front foot is far forward and the GAP BETWEEN THE FEET IS WIDE, in the second the feet are together and passing. Output exactly two figures in one row.",
"state": true
},
"roller_w4": {
"sprites": [
"z_kw4",
"z_kw4b"
],
"ref_only": [
"z_k1"
],
"anchor": 0,
"check": "same",
"subject": "The reference is the dead groundsman in filthy green overalls, seen from the SIDE and facing LEFT. Draw TWO frames of that SAME man, the same colossal size and bulk and the same overalls, still walking, now WOUNDED: both arms are gone, the chest is torn open with ribs showing, AND he has no head at all \u2014 the neck ends in a ragged red stump at shoulder height. He must stay exactly as enormous \u2014 do not slim him down as the damage grows. The two frames are the two halves of ONE stride: in the first the front foot is far forward and the GAP BETWEEN THE FEET IS WIDE, in the second the feet are together and passing. Output exactly two figures in one row.",
"state": true
},
"boss_coach_walk": {
"sprites": [
"z_coach1",
"z_coach2"
],
"ref_only": [
"z_boss1"
],
"anchor": 0,
"check": "same",
"newart": true,
"subject": "The reference is a huge chapter-boss zombie drawn FACING THE VIEWER. Draw TWO frames of a DIFFERENT boss in that same front-facing style and at that same enormous size: THE HEAD COACH \u2014 a towering dead tennis coach facing the viewer, in a zipped navy and white tracksuit with a national-team crest on the chest and white piping down the sleeves, a steel whistle on a cord round his neck, wraparound sunglasses pushed up on his forehead, and a wire ball-hopper basket full of tennis balls gripped in one huge fist. Green-grey dead skin, hollow cheeks, teeth bared in a shout. In both frames his chest and face point straight at the viewer. The two frames are two stages of one heavy stride straight toward the viewer, one knee lifted in each. Output exactly two figures in one row."
},
"boss_coach_wounds": {
"sprites": [
"z_coachw1",
"z_coachw2",
"z_coachw3"
],
"ref_only": [
"z_coach1"
],
"anchor": 0,
"check": "loose",
"subject": "The reference is THE HEAD COACH \u2014 a towering dead tennis coach facing the viewer, in a zipped navy and white tracksuit with a national-team crest on the chest and white piping down the sleeves, a steel whistle on a cord round his neck, wraparound sunglasses pushed up on his forehead, and a wire ball-hopper basket full of tennis balls gripped in one huge fist. Green-grey dead skin, hollow cheeks, teeth bared in a shout, facing the viewer. Draw THREE frames of that SAME boss, the same enormous size and the same costume, standing and facing the viewer, as three worsening stages of damage: (1) the arm on the left-hand side of the image is torn off at the shoulder leaving a ragged bloody stump \u2014 the OTHER arm is untouched and still holding what it held; (2) his chest and belly are torn wide open with the ribs showing through and the clothing hanging in bloody strips \u2014 one arm is still whole and still holding what it held; (3) his chest is torn open with ribs showing AND he has no head at all, the neck ending in a ragged red stump at shoulder height \u2014 one arm is still whole and still holding what it held. He must stay the same enormous size in all three \u2014 do not slim him down as the damage grows. Output exactly three figures in one row.",
"state": true
},
"boss_coach_throw": {
"sprites": [
"z_coacht0",
"z_coacht1",
"z_coacht2",
"z_coacht3"
],
"ref_only": [
"z_coach1",
"z_coachw1",
"z_coachw2",
"z_coachw3"
],
"anchor": 0,
"check": "loose",
"subject": "The reference shows the SAME boss in four states: undamaged, then three worsening stages of damage. Draw all FOUR of those exact states again, in the same order, same size, same costume, same wounds \u2014 but each one caught MID-THROW: the whole body wound up and twisted, the surviving arm drawn back high over the shoulder about to hurl a tennis ball straight at the viewer, weight on the back foot. The wounds must stay clearly visible in every frame. Output exactly four figures in one row.",
"state": true
},
"boss_physio_walk": {
"sprites": [
"z_physio1",
"z_physio2"
],
"ref_only": [
"z_boss1"
],
"anchor": 0,
"check": "same",
"newart": true,
"subject": "The reference is a huge chapter-boss zombie drawn FACING THE VIEWER. Draw TWO frames of a DIFFERENT boss in that same front-facing style and at that same enormous size: THE PHYSIO \u2014 a towering dead sports physiotherapist facing the viewer, in a white medical polo with a red cross on the breast and blood to the elbows, torn blue latex gloves, a bandolier of rolled strapping tape across the chest, a folding massage table strapped flat to his back, an ice pack gripped in one huge fist. Green-grey dead skin, wire-framed glasses cracked across one lens, jaw slack. In both frames his chest and face point straight at the viewer. The two frames are two stages of one heavy stride straight toward the viewer, one knee lifted in each. Output exactly two figures in one row."
},
"boss_physio_wounds": {
"sprites": [
"z_physiow1",
"z_physiow2",
"z_physiow3"
],
"ref_only": [
"z_physio1"
],
"anchor": 0,
"check": "loose",
"subject": "The reference is THE PHYSIO \u2014 a towering dead sports physiotherapist facing the viewer, in a white medical polo with a red cross on the breast and blood to the elbows, torn blue latex gloves, a bandolier of rolled strapping tape across the chest, a folding massage table strapped flat to his back, an ice pack gripped in one huge fist. Green-grey dead skin, wire-framed glasses cracked across one lens, jaw slack, facing the viewer. Draw THREE frames of that SAME boss, the same enormous size and the same costume, standing and facing the viewer, as three worsening stages of damage: (1) the arm on the left-hand side of the image is torn off at the shoulder leaving a ragged bloody stump \u2014 the OTHER arm is untouched and still holding what it held; (2) his chest and belly are torn wide open with the ribs showing through and the clothing hanging in bloody strips \u2014 one arm is still whole and still holding what it held; (3) his chest is torn open with ribs showing AND he has no head at all, the neck ending in a ragged red stump at shoulder height \u2014 one arm is still whole and still holding what it held. He must stay the same enormous size in all three \u2014 do not slim him down as the damage grows. Output exactly three figures in one row.",
"state": true
},
"boss_physio_throw": {
"sprites": [
"z_physiot0",
"z_physiot1",
"z_physiot2",
"z_physiot3"
],
"ref_only": [
"z_physio1",
"z_physiow1",
"z_physiow2",
"z_physiow3"
],
"anchor": 0,
"check": "loose",
"subject": "The reference shows the SAME boss in four states: undamaged, then three worsening stages of damage. Draw all FOUR of those exact states again, in the same order, same size, same costume, same wounds \u2014 but each one caught MID-THROW: the whole body wound up and twisted, the surviving arm drawn back high over the shoulder about to hurl a tennis ball straight at the viewer, weight on the back foot. The wounds must stay clearly visible in every frame. Output exactly four figures in one row.",
"state": true
},
"boss_mascot_walk": {
"sprites": [
"z_mascot1",
"z_mascot2"
],
"ref_only": [
"z_boss1"
],
"anchor": 0,
"check": "same",
"newart": true,
"subject": "The reference is a huge chapter-boss zombie drawn FACING THE VIEWER. Draw TWO frames of a DIFFERENT boss in that same front-facing style and at that same enormous size: THE SPONSOR MASCOT \u2014 a towering dead tournament mascot facing the viewer, wearing a giant fuzzy tennis-ball costume: an enormous yellow-green foam sphere for a head and body with the white curved seam painted across it, stubby green sleeves and leggings, oversized cartoon shoes, white gloves. Through a dark mesh mouth-hole in the ball a rotting green face is just visible. He grips a tennis ball in one glove. In both frames his chest and face point straight at the viewer. The two frames are two stages of one heavy stride straight toward the viewer, one knee lifted in each. Output exactly two figures in one row."
},
"boss_mascot_wounds": {
"sprites": [
"z_mascotw1",
"z_mascotw2",
"z_mascotw3"
],
"ref_only": [
"z_mascot1"
],
"anchor": 0,
"check": "loose",
"subject": "The reference is THE SPONSOR MASCOT \u2014 a towering dead tournament mascot facing the viewer, wearing a giant fuzzy tennis-ball costume: an enormous yellow-green foam sphere for a head and body with the white curved seam painted across it, stubby green sleeves and leggings, oversized cartoon shoes, white gloves. Through a dark mesh mouth-hole in the ball a rotting green face is just visible. He grips a tennis ball in one glove, facing the viewer. Draw THREE frames of that SAME boss, the same enormous size and the same costume, standing and facing the viewer, as three worsening stages of damage: (1) the arm on the left-hand side of the image is torn off at the shoulder leaving a ragged bloody stump \u2014 the OTHER arm is untouched and still holding what it held; (2) his chest and belly are torn wide open with the ribs showing through and the clothing hanging in bloody strips \u2014 one arm is still whole and still holding what it held; (3) his chest is torn open with ribs showing AND he has no head at all, the neck ending in a ragged red stump at shoulder height \u2014 one arm is still whole and still holding what it held. He must stay the same enormous size in all three \u2014 do not slim him down as the damage grows. Output exactly three figures in one row.",
"state": true
},
"boss_mascot_throw": {
"sprites": [
"z_mascott0",
"z_mascott1",
"z_mascott2",
"z_mascott3"
],
"ref_only": [
"z_mascot1",
"z_mascotw1",
"z_mascotw2",
"z_mascotw3"
],
"anchor": 0,
"check": "loose",
"subject": "The reference shows the SAME boss in four states: undamaged, then three worsening stages of damage. Draw all FOUR of those exact states again, in the same order, same size, same costume, same wounds \u2014 but each one caught MID-THROW: the whole body wound up and twisted, the surviving arm drawn back high over the shoulder about to hurl a tennis ball straight at the viewer, weight on the back foot. The wounds must stay clearly visible in every frame. Output exactly four figures in one row.",
"state": true
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+81 -6
View File
@@ -101,9 +101,11 @@ export const TUNE = {
// ---- chapter bosses: one every bossEvery games, escalating. The racket zombie is demoted to an
// occasional opponent (racketEvery) so the boss is the only thing that reads as a wall.
bossEvery: 10, // games between chapter bosses
bossHp: 16, // hp of the FIRST one; each later chapter adds bossHpPerChapter. 10 was far too
// soft — three banked flame balls took chapter 1 down (2 impact + 2.4 burn each)
bossHpPerChapter: 9,
bossHp: 20, // hp of the FIRST one; each later chapter adds bossHpPerChapter. 10 was far too
// soft — three banked flame balls took chapter 1 down (2 impact + 2.4 burn each).
// 16 -> 20 alongside the movement below: a boss that holds its ground and dodges
// is only harder if it also lives long enough for the dodging to matter.
bossHpPerChapter: 10,
bossScale: 1.5, // body height multiplier at chapter 1 (a shambler is 1.8 m)
bossScalePer: 0.18, // ...growing per chapter, so chapter 4 towers
bossSpeed: 0.42, // m/s it advances — slow, deliberate, unstoppable
@@ -121,6 +123,20 @@ export const TUNE = {
bossSlowT: 1.2, // s of REAL time the world runs slow while a boss comes apart
bossSlowMo: 0.35, // ...and how slow
bossShotSpeed: 15.5, // m/s a boss throws at (the machine's is machSpeed 13)
// ---- boss MOVEMENT. A boss used to have no AI at all: it fell through to the crowd's aimless
// ±0.15 m/s wobble and crawled forward until it simply arrived on top of you. Each design now owns
// a movement style (BOSS_ART.move) and these are the shared numbers it spends.
bossStrafe: 1.7, // m/s sideways — bossSpeed above is only the FORWARD crawl
bossPaceX: 3.4, // m either side of the centre line a pacing boss turns at
bossShadowX: 1.7, // m either side of YOUR lane a 'shadow' boss turns at — its beat follows you
bossZMin: -1.0, // the band a boss holds. Without a clamp a fight ends by the boss walking into
bossZMax: 5.6, // you (~57 s at bossSpeed) rather than by you killing it — and every extra hp
// above just made that MORE likely, not less. Only 'charge' breaks the band.
bossChargeV: 4.2, // m/s the physio rushes at once it commits
bossChargeT: 1.5, // s a charge lasts before it withdraws
bossChargeRest: 2.2, // s of held ground between charges — the window you get to hit it
bossHopT: 0.55, // s per mascot hop (it moves in arcs, not in a slide)
bossHopX: 2.0, // m of ground one hop covers (the eased arc lands it at about 2/pi of this)
racketEvery: 3, // games between racket-zombie (plain opponent) spawns — was EVERY won game, which
// is why he read as "the boss" and why a real boss would not have stood out
asideEvery: 55, // s between the umpire's sarcastic asides in splatter (jittered +-40%). He never
@@ -223,6 +239,14 @@ export const TUNE = {
// as decoration; at 5 he is a brute's worth of hp on a faster, ordinary-looking
// walker, and he has a wound skin (z_dw) so the second half of that shows
hordeSteward: 0.2, // share of horde spawns that come out as stewards (from game 4)
// ---- the late roster. Three bodies that arrive as the run goes on, filling the gap between the
// 2 hp shamblers and the 5 hp brute and then extending past it. Each has one wound skin per hit it
// can still take, so the damage is readable without a bar (the rule the boss's missing bar set).
ballboyHp: 3, // the ball boy ('e') — a stock-sized body that takes three, with two wound skins
bouncerHp: 6, // the bouncer ('h') — 2.4 m, shoves through the crowd instead of shambling round it
rollerHp: 9, // the roller man ('k') — 3.2 m, hauling a court roller. The heaviest thing on court
rollerStride: 0.62, // m he covers per lurch frame — longer than the brute's 0.5: a bigger body,
// a longer stride, or his feet slide the way the brute's used to
// fire: the flame ball no longer just kills what it touches, it starts an epidemic.
burnTime: 3.0, // s a body burns before the fire goes out
burnDps: 2.0, // hp lost per second while burning — burnTime*burnDps must exceed bruteHp or tanks walk out of the fire
@@ -324,16 +348,66 @@ export const PLAYER_H = 1.88;
// throws: the same states mid-throw, used while windT > 0. Index is clamped, so a design with
// fewer throw frames than wound stages simply reuses its last one.
// Every name must be ALL LOWERCASE and a whole word — see the case-collision note in the pipeline.
//
// BEHAVIOUR is data too, and every field below is OPTIONAL — anything left out falls back to the
// shared TUNE.boss* number, so a row with only art in it behaves exactly like a boss did before:
// move 'pace' | 'mirror' | 'advance' | 'charge' | 'bounce' (spawnChapterBoss -> bossAI)
// hpMul / speedMul scale this design's hp and its forward crawl
// volley shots per attack · gap s between them (0 = the whole volley leaves in ONE frame,
// which is a WALL to dodge rather than a rhythm to parry) · spread m of scatter
// cycle s between attacks · wind s of telegraph · shot m/s it throws at
export const BOSS_ART = [
{ name: 'THE GROUNDSKEEPER', walk: ['z_boss1', 'z_boss2'],
wounds: ['z_bossw1', 'z_bossw2', 'z_bossw3'],
throws: ['z_bosst0', 'z_bosst1', 'z_bosst2', 'z_bosst3'] },
throws: ['z_bosst0', 'z_bosst1', 'z_bosst2', 'z_bosst3'],
move: 'pace', speedMul: 0.7 },
{ name: 'THE LINE JUDGE', walk: ['z_judge1', 'z_judge2'],
wounds: ['z_judgew'], throws: ['z_judget', 'z_judgewt'] },
wounds: ['z_judgew'], throws: ['z_judget', 'z_judgewt'],
move: 'shadow' }, // he walks the line, and the line is wherever you are
{ name: 'THE CHAIR UMPIRE', walk: ['z_chair1', 'z_chair2'],
wounds: ['z_chairw'], throws: ['z_chairt', 'z_chairwt'] },
wounds: ['z_chairw'], throws: ['z_chairt', 'z_chairwt'],
move: 'surge', hpMul: 1.1 }, // the immovable one — he just keeps coming, and coming back
{ name: 'THE HEAD COACH', walk: ['z_coach1', 'z_coach2'],
wounds: ['z_coachw1', 'z_coachw2', 'z_coachw3'],
throws: ['z_coacht0', 'z_coacht1', 'z_coacht2', 'z_coacht3'],
move: 'pace', paceMul: 1.9, hpMul: 0.9, // never where he was: drills you while running the line
volley: 5, gap: 0.14, cycle: 2.2, wind: 0.4, shot: 19 },
{ name: 'THE PHYSIO', walk: ['z_physio1', 'z_physio2'],
wounds: ['z_physiow1', 'z_physiow2', 'z_physiow3'],
throws: ['z_physiot0', 'z_physiot1', 'z_physiot2', 'z_physiot3'],
move: 'charge', hpMul: 0.8, // fast and fragile: the BODY is the weapon, and contact
volley: 1, cycle: 3.4, wind: 0.35, shot: 21, spread: 0.15 }, // is still instant death
{ name: 'THE SPONSOR MASCOT', walk: ['z_mascot1', 'z_mascot2'],
wounds: ['z_mascotw1', 'z_mascotw2', 'z_mascotw3'],
throws: ['z_mascott0', 'z_mascott1', 'z_mascott2', 'z_mascott3'],
move: 'bounce', hpMul: 1.3, speedMul: 0.6, // soaks, and answers with a WALL: five at once, so
volley: 5, gap: 0, spread: 1.6, cycle: 3.2, wind: 0.65, shot: 14 }, // there is nothing to parry
];
// Which wound stage a body is in: 0 = unhurt, 1..n = the skins, spread evenly over its full health.
// This is the rule the chapter boss already used, and it turns out to reproduce the brute's old
// hardcoded thresholds EXACTLY (5 hp over 4 skins = one skin per hit) and the shamblers' single skin
// too — so there is now one rule for damage readout instead of three, and it is the one that keeps
// working when a body's hp is not one-per-skin. Every caller passes its own stage count.
export const woundStage = (zb, n) => {
const f = zb.hp / (zb.hpMax || 1);
return f >= 1 ? 0 : Math.min(n, Math.max(1, Math.ceil((1 - f) * n)));
};
// Wound skins by variant, HEALTHIEST FIRST — the same ordering BOSS_ART.wounds uses, indexed by
// woundStage above. This one table replaced two branches in drawZombie that did the same job against
// different constants (the brute had a hardcoded stage array, everything else a single skin behind
// `'abd'.includes(...)`), which is why a new multi-stage body is a row here and no code at all.
// Each name's `…b` twin — the stride swapped — is looked up automatically, so a wounded body keeps
// WALKING instead of freezing and sliding. 'r' and 'B' carry their skins elsewhere ('B' on its
// design row) but read them through the very same woundStage call.
export const ZWOUND = {
a: ['z_aw'], b: ['z_bw'], d: ['z_dw'],
e: ['z_ew', 'z_ew2'],
h: ['z_hw', 'z_hw2', 'z_hw3'],
g: ['z_gw', 'z_gw2', 'z_gw3', 'z_gw4'],
k: ['z_kw', 'z_kw2', 'z_kw3', 'z_kw4'],
};
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.
@@ -341,6 +415,7 @@ export const MACH_H = 1.55; // was 1.15 — waist high read as a prop you could
// fraction of it, so the two cannot drift — a shot at a brute has to arrive at the brute's chest,
// not at the height a shambler's chest happens to be.
export const zombieH = zb => zb.variant === 'g' ? 2.7 : zb.variant === 'd' ? 2.15
: zb.variant === 'k' ? 3.2 : zb.variant === 'h' ? 2.4
: zb.variant === 'r' ? 1.85 : zb.variant === 'm' ? MACH_H
: zb.variant === 'B' ? 1.8 * (zb.scale || 1.5) : 1.8;
// Where on that body the ball is aimed, and where the aim mark is drawn. Solving the shot to the
+1 -1
View File
@@ -179,6 +179,6 @@
</div>
</div>
</div>
<script type="module" src="main.js?v=96"></script>
<script type="module" src="main.js?v=97"></script>
</body>
</html>
+43 -30
View File
@@ -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, BOUNCE_IDLE, SPRITE_H, zombieH } 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 } from './config.js';
import { P, O, ball, score, crowd, G, court } from './state.js';
import { predictLanding, previewServe } from './gameplay.js';
import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js';
@@ -181,7 +181,7 @@ function drawRunOff() {
// ---------------------------------------------------------------- sprites ----
// assets/<name>.png; game falls back to code-drawn figures until sprites load.
const SPRV = 79; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
const SPRV = 80; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
const SPR = {};
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_run3','p_run4','p_swing1','p_swing2','p_die1','p_die2','p_die3','p_die4','p_die5',
'p_pull', // the serve wind-up: the racket dropping behind him while the pull is drawn
@@ -196,8 +196,17 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','
'z_boss1','z_boss2','z_bossw1','z_bossw2','z_bossw3', // the CHAPTER BOSS: 2-frame stride + three wound stages (arm off -> ribs out -> headless)
'z_bosst0','z_bosst1','z_bosst2','z_bosst3', // ...and each of those four states mid-THROW (windT)
'z_mach1','z_mach2','z_machw1','z_machw2', // the ball machine: idle, firing, dented, split open
'z_judge1','z_judge2','z_judgew','z_judget','z_judgewt', // boss design 2 (chapter 2, 5, ...)
'z_chair1','z_chair2','z_chairw','z_chairt','z_chairwt', // boss design 3 (chapter 3, 6, ...)
'z_judge1','z_judge2','z_judgew','z_judget','z_judgewt', // boss design 2 (chapter 2, 8, ...)
'z_chair1','z_chair2','z_chairw','z_chairt','z_chairwt', // boss design 3 (chapter 3, 9, ...)
// designs 4-6, each with the full set the groundskeeper has: 2 stride, 3 wounds, 4 throws
'z_coach1','z_coach2','z_coachw1','z_coachw2','z_coachw3','z_coacht0','z_coacht1','z_coacht2','z_coacht3',
'z_physio1','z_physio2','z_physiow1','z_physiow2','z_physiow3','z_physiot0','z_physiot1','z_physiot2','z_physiot3',
'z_mascot1','z_mascot2','z_mascotw1','z_mascotw2','z_mascotw3','z_mascott0','z_mascott1','z_mascott2','z_mascott3',
// the late roster: ball boy (3 hp, 2 skins), bouncer (6 hp, 3), roller man (9 hp, 4).
// Every skin has its …b stride twin, so a wounded body keeps walking (see ZWOUND).
'z_e1','z_e2','z_e3','z_e4','z_ew','z_ewb','z_ew2','z_ew2b',
'z_h1','z_h2','z_h3','z_h4','z_hw','z_hwb','z_hw2','z_hw2b','z_hw3','z_hw3b',
'z_k1','z_k2','z_k3','z_k4','z_kw','z_kwb','z_kw2','z_kw2b','z_kw3','z_kw3b','z_kw4','z_kw4b',
'z_a1','z_a2','z_a3','z_a4','z_b1','z_b2','z_b3','z_b4','z_c1','z_c2',
'z_d1','z_d2','z_d3','z_d4', // the steward: bigger body, hi-vis vest, 4-frame walk
'z_awb','z_bwb','z_cw1b','z_cw2b','z_dw','z_dwb', // ...and every wound skin's second frame: wounded bodies walk too
@@ -307,7 +316,10 @@ function drawBoy(b) {
// for 'z_g-1' — a sprite that does not exist, and drawStatic silently draws nothing. That is the
// blinking Oliver saw on the way in, on exactly the bodies that step off distance: brutes and bosses.
const stepFrame = (v, n) => ((Math.floor(v) % n) + n) % n; // 0..n-1, whatever the sign of v
const ZFRAMES = { a: 4, b: 4, f: 4, d: 4, g: 4, s: 4, c: 4 }; // 4-frame walk cycles where sheets exist; the rest stay 2
const ZFRAMES = { a: 4, b: 4, e: 4, f: 4, d: 4, g: 4, h: 4, k: 4, s: 4, c: 4 }; // 4-frame walk cycles where sheets exist; the rest stay 2
// Bodies whose stride steps off GROUND COVERED instead of the clock. They are the slow heavy ones,
// and a time-driven cycle on them is pure foot-slide (the defect the brute's rule was written for).
const ZSTRIDE = { g: TUNE.bruteStride, k: TUNE.rollerStride };
// The rogue ball machine, on its own generated sheet: a dented hopper cart on wheels with a barrel
// aimed down-court, and a second frame with the muzzle flashing. Oliver called the first, procedural
// version "naively painted" — it was four rects, which is fine for fire but not for furniture the
@@ -347,9 +359,9 @@ function drawZombie(zb) {
// the brute's — standing still is standing still — and his swing keeps its own frame.
let frame = rk && zb.pose === 'swing' ? 2
: rk ? stepFrame((zb.dist || 0) / TUNE.racketStride, N) + 1
: zb.variant === 'g' ? stepFrame(zb.z / (TUNE.bruteStride * 2 / N), N) + 1 // heaviest body: step off DISTANCE
// WALKED, like its wound lurch below,
// or the feet slide (same cycle length)
: ZSTRIDE[v] ? stepFrame(zb.z / (ZSTRIDE[v] * 2 / N), N) + 1 // heaviest bodies: step off DISTANCE
// WALKED, like their wound lurch below,
// or the feet slide (same cycle length)
: Math.floor(zb.walkT * (zb.variant === 'f' ? 6 : 2.6) * N / 2) % N + 1;
const chairDown = zb.variant === 's' && zb.shieldT > 0; // chair dropped — hit it NOW. Its own sprite
// now (z_sdown); it used to be walk frame 3,
@@ -362,25 +374,23 @@ function drawZombie(zb) {
// shambling). The blood is NOT painted in — open wounds gush live particles (see bleed() in
// zombies.js). Fall back to the healthy sprite until the wounded PNG has loaded.
let name = chairDown && SPR.z_sdown ? 'z_sdown' : 'z_' + v + frame;
if (zb.variant === 'g' && zb.hp < TUNE.bruteHp) { // one arm → both → head gone → ribs out
const stage = ['z_gw4', 'z_gw3', 'z_gw2', 'z_gw'][Math.max(0, Math.min(3, zb.hp - 1))];
// ONE lookup for every multi-hit body that reads damage against absolute hp. config.ZWOUND is
// worst-first and indexed by REMAINING hp − 1, which is the brute's old hardcoded stage array
// generalised; a single-skin body clamps to index 0 and falls out of the same line. This replaced
// two branches that did the same job against different constants, which is why the ball boy, the
// bouncer and the roller man each cost a table row here and no code at all.
const stages = ZWOUND[zb.variant], sIdx = stages ? woundStage(zb, stages.length) : 0;
if (sIdx) {
const stage = stages[sIdx - 1];
if (SPR[stage]) {
// Every wound stage has its OWN two-frame lurch (…w / …wb = the stride swapped). Stepped by
// DISTANCE WALKED, not time — the brute is the slowest thing on court, so a time-driven cycle
// was pure foot-slide. This replaced a procedural squash-and-sway that read as exactly what
// it was: fake. The comedy is in the gait, and the gait has to be drawn.
const b = stage + 'b';
name = SPR[b] && stepFrame(zb.z / TUNE.bruteStride, 2) ? b : stage;
} else if (SPR.z_gw) name = 'z_gw'; // a missing asset degrades to the last skin that loaded
}
else if ('abd'.includes(zb.variant) && zb.hp < (zb.hpMax || 0) && SPR['z_' + zb.variant + 'w']) {
// A wounded body used to freeze on ONE frame and slide across the court — the wound skins were
// single stills. Every skin has its …b twin now (the stride swapped), stepped off the same clock
// as the healthy walk, so losing an arm changes how he walks and not whether he walks.
const w = 'z_' + zb.variant + 'w';
// Same CADENCE as the healthy walk (which runs four frames at 5.2/s), or a wounded body reads
// as frozen next to the crowd it is walking in — it was stepping at half the rate on two frames.
name = SPR[w + 'b'] && stepFrame(zb.walkT * 5.2, 2) ? w + 'b' : w; // gashed shambler, still lurching
// Every wound stage has its OWN two-frame lurch (…w / …wb = the stride swapped). The heavy
// bodies step it by DISTANCE WALKED (a time-driven cycle on them was pure foot-slide, and
// replaced a procedural squash-and-sway that read as exactly what it was: fake); everything
// else steps at the SAME cadence as its healthy walk, or a wounded body reads as frozen next
// to the crowd it is walking in. The comedy is in the gait, and the gait has to be drawn.
const b = stage + 'b', phase = ZSTRIDE[v] ? zb.z / ZSTRIDE[v] : zb.walkT * 5.2;
name = SPR[b] && stepFrame(phase, 2) ? b : stage;
} else if (SPR[stages[0]]) name = stages[0]; // a missing asset degrades to the lightest skin that loaded
}
// The boss's damage is read as a FRACTION of full health — his hp scales with the run
// (TUNE.racketHpPerGame), so the old `hp === 2` / `hp <= 1` thresholds would have left a late-run
@@ -404,14 +414,17 @@ function drawZombie(zb) {
// writing z_B1.png silently OVERWROTE the 'b' shambler's z_b1.png and a boss turned up in the crowd.
if (zb.variant === 'B') {
const A = zb.art || BOSS_ART[0];
const f = zb.hp / (zb.hpMax || 1), nStage = A.wounds.length;
const idx = f >= 1 ? 0 : Math.min(nStage, Math.max(1, Math.ceil((1 - f) * nStage)));
const idx = woundStage(zb, A.wounds.length); // the shared rule — see config.woundStage
// The stride steps off zb.dist — GROUND COVERED on BOTH axes, which zb.z alone is not: a boss
// that paces or hops sideways would slide across the court with its feet frozen. (zb.z is the
// fallback so a boss spawned by an older path still animates.)
const walked = (zb.dist ?? zb.z) / TUNE.bruteStride;
const wanted = zb.windT > 0 ? A.throws[Math.min(idx, A.throws.length - 1)]
: idx ? A.wounds[idx - 1]
: A.walk[stepFrame(zb.z / TUNE.bruteStride, 2)];
: A.walk[stepFrame(walked, 2)];
// a design whose sheet has not been generated (or has not loaded) degrades to design 1 rather
// than vanishing, since drawStatic silently draws nothing for a missing sprite
const stage = SPR[wanted] ? wanted : BOSS_ART[0].walk[stepFrame(zb.z / TUNE.bruteStride, 2)];
const stage = SPR[wanted] ? wanted : BOSS_ART[0].walk[stepFrame(walked, 2)];
if (SPR[stage]) name = stage;
}
// Every frame is drawn at ITS OWN height. drawStatic forces the height it is given, so a crouched
+137 -26
View File
@@ -3,7 +3,7 @@
// your baseline — one crossing it is a point for THEM.
// (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.)
import { TUNE, UI, COURT, VOLLEY, CONTACT, BOSS_ART, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js';
import { TUNE, UI, COURT, VOLLEY, CONTACT, BOSS_ART, woundStage, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js';
import { G, P, ball, sendBoy, setupPoint } from './state.js';
import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx } from './audio.js';
import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight, aside } from './gameplay.js';
@@ -41,12 +41,15 @@ export function spawnZombie(x, z, variant) {
if (variant === 'a' || variant === 'b') zb.hp = TUNE.shamblerHp; // classic shamblers take a wound before they drop — only the runner is one-shot glass
if (variant === 'g') { zb.v *= 0.55; zb.hp = TUNE.bruteHp; } // slow, but soaks hits
if (variant === 'd') { zb.v *= 0.8; zb.hp = TUNE.stewardHp; } // the steward: a head taller and several hits tougher than the crowd, nothing more exotic than that
if (variant === 'e') zb.hp = TUNE.ballboyHp; // the ball boy: shambler-sized, three hits deep
if (variant === 'h') { zb.v *= 0.75; zb.hp = TUNE.bouncerHp; } // the bouncer: between the steward and the brute
if (variant === 'k') { zb.v *= 0.45; zb.hp = TUNE.rollerHp; } // the roller man: slower and heavier than the brute
// Every body with hp remembers its FULL hp. The wound skins used to compare against the variant's
// TUNE constant, which meant every new body needed its own branch to look hurt; one field here and
// the rule is "hp below full = show the wound", for anything that has a wound skin.
if (zb.hp) zb.hpMax = zb.hp;
if (variant === 's') zb.shieldT = 0; // chair up; > 0 = seconds of stagger left
if ('fgsd'.includes(variant) && !G.seen['v' + variant]) { G.seen['v' + variant] = true; aside('ump_dress_code', 0.8); }
if ('fgsdehk'.includes(variant) && !G.seen['v' + variant]) { G.seen['v' + variant] = true; aside('ump_dress_code', 0.8); }
if (variant === 'm') { // the rogue ball machine: furniture, not a body. It never SHAMBLES...
zb.v = 0; zb.hp = zb.hpMax = TUNE.machHp; zb.fireT = TUNE.machCycle; zb.windT = 0;
zb.repoT = TUNE.machRepo * (0.4 + Math.random()); // ...but it wheels itself somewhere else now and then
@@ -71,11 +74,94 @@ export function spawnMachine() {
// reach you is the ordinary instant death. Reuses the whole existing damage model — it is just a
// body with a lot of hp (see the wound() invariant in HANDOFF), so no weapon needs special-casing.
export function spawnChapterBoss(chapter) {
const hp = TUNE.bossHp + (chapter - 1) * TUNE.bossHpPerChapter;
zombies.push({ x: 0, z: -1.2, v: TUNE.bossSpeed, walkT: 0, homeOff: 0, variant: 'B',
const art = BOSS_ART[(chapter - 1) % BOSS_ART.length]; // which of the boss designs this chapter is
// Every behaviour field on the design is OPTIONAL and falls back to the shared TUNE number, so a
// design row that carries nothing but art behaves exactly the way a boss did before there were any.
const hp = Math.round((TUNE.bossHp + (chapter - 1) * TUNE.bossHpPerChapter) * (art.hpMul ?? 1));
zombies.push({ x: 0, z: -1.2, v: TUNE.bossSpeed * (art.speedMul ?? 1), walkT: 0, homeOff: 0, variant: 'B',
chapter, hp, hpMax: hp, scale: TUNE.bossScale + (chapter - 1) * TUNE.bossScalePer,
fireT: TUNE.bossCycle, windT: 0, salvo: 0, salvoT: 0,
art: BOSS_ART[(chapter - 1) % BOSS_ART.length] }); // which of the boss designs this chapter is
fireT: art.cycle ?? TUNE.bossCycle, windT: 0, salvo: 0, salvoT: 0,
dist: 0, dir: Math.random() < 0.5 ? -1 : 1, moveT: 0, // dist drives the stride: see drawZombie
art });
}
// ---- BOSS MOVEMENT. Before this the boss had no AI at all — it matched no branch in the variant
// chain below and fell through to the crowd's aimless ±0.15 m/s wobble, so "the boss moves" was a
// comment and nothing else. Each design owns a style (BOSS_ART.move) and they differ in the axis
// they threaten on: pace/bounce make you lead a moving target, mirror takes your lane away, charge
// trades its ranged game for closing the distance. Returns nothing; it moves zb.x/zb.z directly.
//
// Two rules hold for all of them:
// - IT PLANTS ITS FEET TO THROW. While winding up or mid-volley it does not move, which is the same
// fairness contract as the wind-up glow: the telegraph has to be worth reading.
// - IT STAYS IN THE BAND (bossZMin..bossZMax) unless it is charging. Uncapped, a boss ended the
// fight by simply arriving on top of you after about a minute — and every extra hp made that
// outcome MORE likely, not less. Only 'charge' is allowed through, and that is its whole threat.
// A NOTE THAT DECIDED THREE OF THESE FIVE: in splatter the player is rooted in `pretoss` for nearly
// the whole of a boss fight (he only walks during `rally` — a settled decision). So any style whose
// motion is "close on the player's x" arrives, stops, and is static for the rest of the fight, which
// is the exact complaint this work exists to fix. Every style below therefore moves under its OWN
// power and only BIASES toward the player. Measured: each of the six covers 5-14 m a minute.
function bossAI(zb, dt) {
if (zb.windT > 0 || zb.salvo > 0) return; // planted: throwing is not a moving action
const x0 = zb.x, z0 = zb.z, style = zb.art?.move || 'advance';
const strafe = TUNE.bossStrafe * (zb.art?.paceMul ?? 1);
if (style === 'charge') {
zb.moveT -= dt;
if (zb.charging) {
const dx = P.x - zb.x;
zb.x += Math.sign(dx) * Math.min(Math.abs(dx), TUNE.bossStrafe * dt);
zb.z = Math.min(P.z, zb.z + TUNE.bossChargeV * dt); // AT you and NO FURTHER: unclamped it ran
if (zb.moveT <= 0) { zb.charging = false; zb.moveT = TUNE.bossChargeRest; } // off the back of the court
} else {
// It withdraws LESS than it charged, so the fight has a visible clock: it is a couple of metres
// nearer after every rush and reaches you in about half a minute. A withdrawal that undid the
// whole charge made "the body is the weapon" untrue — it never once got near you.
zb.z = Math.max(TUNE.bossZMin, zb.z - TUNE.bossChargeV * 0.45 * dt);
if (zb.moveT <= 0) { zb.charging = true; zb.moveT = TUNE.bossChargeT; }
}
} else if (style === 'shadow') {
// paces — but the ground it paces over is centred on YOU, so it never stops moving and it takes
// your lane away the moment you take one
const c = Math.max(-TUNE.bossPaceX, Math.min(TUNE.bossPaceX, P.x));
zb.x += zb.dir * strafe * dt;
if (zb.x > c + TUNE.bossShadowX) zb.dir = -1; else if (zb.x < c - TUNE.bossShadowX) zb.dir = 1;
zb.z += zb.v * dt;
} else if (style === 'bounce') {
// hops, not a slide: it covers bossHopX per hop and is nearly still between them, so it reads as
// a costume with someone inside it rather than a sprite being lerped across the court. It flips
// every hop and only steers when it has drifted a long way off your lane.
zb.moveT -= dt;
if (zb.moveT <= 0) {
zb.moveT = TUNE.bossHopT;
// keep going the SAME way until it runs out of court or has overshot your lane, then turn.
// Flipping every hop kept it oscillating inside one metre — motion with no travel in it.
if (Math.abs(zb.x) > TUNE.bossPaceX) zb.dir = -Math.sign(zb.x);
else if (Math.abs(P.x - zb.x) > 2.5) zb.dir = Math.sign(P.x - zb.x) || zb.dir;
}
const k = Math.max(0, Math.sin(Math.PI * (1 - zb.moveT / TUNE.bossHopT))); // fast at mid-hop
zb.x += zb.dir * (TUNE.bossHopX / TUNE.bossHopT) * k * dt;
zb.z += zb.v * dt;
} else if (style === 'surge') {
// rolls up the whole band and back down it, over and over, drifting slowly into your lane. Its
// DEPTH is the threat, not its x — the one that simply keeps coming, and keeps coming back.
zb.z += zb.dir * zb.v * 3 * dt;
if (zb.z > TUNE.bossZMax) zb.dir = -1; else if (zb.z < TUNE.bossZMin) zb.dir = 1;
const dx = P.x - zb.x;
zb.x += Math.sign(dx) * Math.min(Math.abs(dx), strafe * 0.3 * dt);
} else if (style === 'pace') {
zb.x += zb.dir * strafe * dt;
if (Math.abs(zb.x) > TUNE.bossPaceX) { zb.dir = -Math.sign(zb.x); zb.x = Math.sign(zb.x) * TUNE.bossPaceX; }
zb.z += zb.v * dt;
} else { // 'advance': the old crawl, with a slow drift.
zb.x += Math.sin(zb.walkT * 0.6) * 0.4 * dt; // Unused by any design — it is what a row with
zb.z += zb.v * dt; // no `move` field falls back to.
}
if (style !== 'charge') zb.z = Math.max(TUNE.bossZMin, Math.min(TUNE.bossZMax, zb.z));
zb.x = Math.max(-TUNE.bossPaceX - 0.6, Math.min(TUNE.bossPaceX + 0.6, zb.x)); // never off the sides
// The stride steps off GROUND COVERED (the brute's rule). A strafing boss read off zb.z alone
// would slide sideways with both feet frozen — the exact defect the distance rule exists to stop.
zb.dist = (zb.dist || 0) + Math.hypot(zb.x - x0, zb.z - z0);
}
// ---- the player's second health channel. Only these two functions touch it.
@@ -147,6 +233,11 @@ function pickVariant() {
// to learn — so he can arrive EARLY and thicken the ordinary crowd, which the shambler mix alone
// never did. He is also the only advanced body that shows up in a horde (see spawnHordeZombie).
if (g >= 4) pool.push('d');
// The late roster, in the order the crowd should learn it: a stock-sized body that no longer dies
// to two (game 3), then a bigger one (game 8), then something heavier than the brute (game 15).
if (g >= 3) pool.push('e');
if (g >= 8) pool.push('h');
if (g >= 15) pool.push('k');
const nasty = Math.min(TUNE.nastyMax, g * TUNE.nastyPerGame);
if (pool.length && Math.random() < nasty) return pool[Math.floor(Math.random() * pool.length)];
return Math.random() < 0.5 ? 'a' : 'b';
@@ -158,6 +249,7 @@ function pickVariant() {
// deliberately NOT one — melee/bomb/fire one-shotting a brute is a settled "power exception".
const isBoss = zb => zb.variant === 'r' || zb.variant === 'B';
const hitR = zb => zb.variant === 'g' ? 0.75 : zb.variant === 'd' ? 0.64 : zb.variant === 'r' ? 0.7
: zb.variant === 'k' ? 0.88 : zb.variant === 'h' ? 0.68 // the roller man is wider than the brute
: zb.variant === 'B' ? 0.55 * (zb.scale || 1) * 1.4 // a chapter boss is a barn door, as it should be
: zb.variant === 'm' ? 0.8 // the machine is a wide low box
: 0.55;
@@ -165,7 +257,7 @@ const hitR = zb => zb.variant === 'g' ? 0.75 : zb.variant === 'd' ? 0.64 : zb.va
// core kill: remove the zombie, spawn its gore/pile/particles, score. No ball coupling —
// shared by ball hits, machine-gun pellets, melee swipes, and extra (multi-serve) balls.
// how absurdly a variant dies: classics splash, the advanced roster DETONATES
const GORE_MUL = { a: 1, b: 1, f: 1.8, s: 2.5, g: 4, r: 3, m: 1.5, B: 6 }; // a chapter boss comes apart like nothing else on the court
const GORE_MUL = { a: 1, b: 1, e: 1, f: 1.8, s: 2.5, h: 2.5, g: 4, k: 5, r: 3, m: 1.5, B: 6 }; // a chapter boss comes apart like nothing else on the court
// dark smoke: one puff, drifting up and spreading. Not gravity-bound like gore (see the parts loop).
function smokePuff(x, y, z, spread = 0.5) {
@@ -464,25 +556,34 @@ export function explode(x, z) {
// wounded zombies bleed from every open wound — a fine, sparse trickle (a small spurt from the
// neck stump on the headless boss). Anchors are approximate body offsets [x, y, rise]; most
// frames a wound drips nothing, and the tight spread keeps each thread thin. Bounded low.
// Where each body opens up, one anchor per WOUND STAGE and cumulative: at stage n it bleeds from
// the first n. Read through the same config.woundStage call the skins are, and deliberately the same
// length as that variant's ZWOUND row, so the blood and the drawing it comes out of cannot drift —
// the two used to be written by hand against different constants. (The brute's old cumulative
// if-chain is what this replaces, and it reproduces it exactly. The steward gained a trickle here
// that his wound skin had always implied and never had.)
const BLEED = {
a: [[0.28, 1.35, 0.85]],
b: [[0.28, 1.35, 0.85]],
d: [[0.30, 1.62, 0.90]],
e: [[0.28, 1.35, 0.85], [0.05, 1.55, 1.60]], // gashed, then throat open
h: [[0.32, 1.72, 0.95], [-0.30, 1.68, 0.90], [0.05, 2.05, 1.80]],
g: [[-0.30, 1.95, 1.10], [0.42, 1.90, 1.00], [0.05, 2.35, 2.00], [0.00, 1.20, 0.70]], // arm, both, head, gut
k: [[-0.36, 2.30, 1.15], [0.50, 2.24, 1.05], [0.06, 2.80, 2.10], [0.00, 1.50, 0.75]], // the same, a head higher
};
function bleed(zb) {
if (parts.length > 90) return;
let wounds;
if (zb.variant === 'g' && zb.hp < TUNE.bruteHp) {
// the brute opens up stage by stage: one arm, then both, then the head. Still sparse —
// more ANCHORS, not a heavier flow (the settled feel note: blood stays thin).
wounds = [[-0.3, 1.95, 1.1]];
if (zb.hp <= TUNE.bruteHp - 2) wounds.push([0.42, 1.9, 1.0]);
if (zb.hp <= TUNE.bruteHp - 3) wounds.push([0.05, 2.35, 2.0]);
if (zb.hp <= 1) wounds.push([0.0, 1.2, 0.7]);
}
const anchors = BLEED[zb.variant];
// the boss reads as a FRACTION of full health, not against hardcoded hp numbers: his hp scales
// with the run now (racketHpPerGame), so `hp === 2` would leave a late-run boss bleeding from
// nothing until the very last hit. Must stay in step with drawZombie's skin thresholds.
else if (zb.variant === 'r' && zb.hp / (zb.hpMax || TUNE.racketHp) <= 0.34)
if (zb.variant === 'r' && zb.hp / (zb.hpMax || TUNE.racketHp) <= 0.34)
wounds = [[0.05, 1.5, 2.4], [0.4, 1.15, 0.9]]; // headless: neck spurt + torn arm
else if (zb.variant === 'r' && zb.hp / (zb.hpMax || TUNE.racketHp) <= 0.67)
wounds = [[0.4, 1.2, 1.0]]; // racket: torn-off arm
else if ((zb.variant === 'a' || zb.variant === 'b') && zb.hp < TUNE.shamblerHp) wounds = [[0.28, 1.35, 0.85]]; // shambler: gashed shoulder
// Still sparse as the damage stacks — more ANCHORS, not a heavier flow (blood stays thin).
else if (anchors && woundStage(zb, anchors.length)) wounds = anchors.slice(0, woundStage(zb, anchors.length));
else return;
for (const w of wounds) {
if (Math.random() > 0.4) continue; // sparse: most frames this wound emits nothing
@@ -551,7 +652,8 @@ export function updateZombies(dt) {
zb.walkT += dt;
const faceX0 = zb.x; // which way he is walking, for the sprite mirror in drawZombie
if (zb.burnT > 0 && burnTick(zb, i, dt)) continue; // the fire finished it — the array moved under us
if (!held) zb.z += zb.v * (zb.burnT > 0 ? TUNE.burnPanic : 1) * dt;
// a chapter boss is excluded: bossAI owns BOTH of its axes, and leaving this in advanced it twice
if (!held && zb.variant !== 'B') zb.z += zb.v * (zb.burnT > 0 ? TUNE.burnPanic : 1) * dt;
bleed(zb); // open wounds bleed every frame they're standing
if (zb.shieldT > 0) zb.shieldT -= dt; // staggered chair comes back up when this runs out
@@ -560,21 +662,25 @@ export function updateZombies(dt) {
// charge glow) because a shot you cannot see coming is not a fairness contract, it is a gotcha.
if (!held && !G.interlude && (zb.variant === 'm' || zb.variant === 'B') && G.mode === 'splatter'
&& zb.rollTo === undefined) { // a cart on the roll cannot aim — that is the window to close on it
const boss = zb.variant === 'B';
const boss = zb.variant === 'B', A = zb.art;
if (zb.salvo > 0) { // mid-volley: the rest of the balls are still coming
if ((zb.salvoT -= dt) <= 0) {
zb.salvo--; zb.salvoT = TUNE.bossSalvoGap;
fireAtPlayer(zb.x, zb.z, (Math.random() * 2 - 1) * 0.35, TUNE.bossShotSpeed); // aimed at where
zb.salvoT -= dt;
// A gap of 0 means the WHOLE volley leaves in this one frame — the mascot's wall of five,
// which you dodge rather than parry. The loop still terminates: salvo counts down either way.
const gap = A?.gap ?? TUNE.bossSalvoGap, spread = A?.spread ?? 0.35;
while (zb.salvo > 0 && zb.salvoT <= 0) {
zb.salvo--; zb.salvoT += gap;
fireAtPlayer(zb.x, zb.z, (Math.random() * 2 - 1) * spread, A?.shot ?? TUNE.bossShotSpeed); // aimed at where
} // you are NOW, with only a hand's width of scatter
} else if (zb.windT > 0) {
zb.windT -= dt;
if (zb.windT <= 0) {
if (boss) { zb.salvo = TUNE.bossVolley; zb.salvoT = 0; } // the volley starts next frame
if (boss) { zb.salvo = A?.volley ?? TUNE.bossVolley; zb.salvoT = 0; } // the volley starts next frame
else fireAtPlayer(zb.x, zb.z, 0, TUNE.machSpeed);
}
} else if ((zb.fireT -= dt) <= 0) {
zb.fireT = boss ? TUNE.bossCycle : TUNE.machCycle;
zb.windT = boss ? TUNE.bossWind : TUNE.machWind;
zb.fireT = boss ? (A?.cycle ?? TUNE.bossCycle) : TUNE.machCycle;
zb.windT = boss ? (A?.wind ?? TUNE.bossWind) : TUNE.machWind;
pock(0.3, 1500); // the tell: a mechanical click as it loads
}
}
@@ -622,7 +728,8 @@ export function updateZombies(dt) {
}
if (zb.hp < zb.hpMax * 0.5 && Math.random() < dt * 6) smokePuff(zb.x, 1.1, zb.z, 0.25); // wrecked: it smokes where it stands
}
else if (held || zb.variant === 'r') { /* boss AI owns its x; a duelling opponent owns his */ }
else if (held || zb.variant === 'r') { /* nothing moves on a changeover; a duelling opponent owns his x */ }
else if (zb.variant === 'B') { if (G.mode === 'splatter' && !G.interlude) bossAI(zb, dt); }
else if (G.mode === 'splatter' && zb.z > COURT.netZ) { // on YOUR half they close in — each on its own lane, not a column
const tx = P.x + zb.homeOff;
zb.x += Math.sign(tx - zb.x) * Math.min(Math.abs(tx - zb.x), zb.v * 0.7 * dt)
@@ -726,6 +833,10 @@ export function updateZombies(dt) {
// swish (meleeSwipe, above) merely bats it away, and reaching you unanswered costs a point of hp.
for (let i = shots.length - 1; i >= 0; i--) {
const s = shots[i];
// A shot landing the KILLING blow routes through killPlayer, which empties this array from
// under us — every remaining index then reads undefined and the frame throws. Same guard, and
// the same reason, as the `if (!zb) continue` in the body loop above.
if (!s) continue;
s.t -= dt; s.vy -= s.g * dt;
s.x += s.vx * dt; s.y += s.vy * dt; s.z += s.vz * dt;
let done = s.t <= 0 || s.y < -0.25 || Math.abs(s.x) > 8 || s.z > COURT.L + 2;