diff --git a/HANDOFF.md b/HANDOFF.md index dfeb6aa..9ad3c41 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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. diff --git a/assets/_regen.py b/assets/_regen.py index d401c7d..c5d8e04 100644 --- a/assets/_regen.py +++ b/assets/_regen.py @@ -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'): diff --git a/assets/_regen_batches.json b/assets/_regen_batches.json index 02f3641..e314e17 100644 --- a/assets/_regen_batches.json +++ b/assets/_regen_batches.json @@ -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 } } \ No newline at end of file diff --git a/assets/z_coach1.png b/assets/z_coach1.png new file mode 100644 index 0000000..e7e213f Binary files /dev/null and b/assets/z_coach1.png differ diff --git a/assets/z_coach2.png b/assets/z_coach2.png new file mode 100644 index 0000000..7976260 Binary files /dev/null and b/assets/z_coach2.png differ diff --git a/assets/z_coacht0.png b/assets/z_coacht0.png new file mode 100644 index 0000000..3eed7c7 Binary files /dev/null and b/assets/z_coacht0.png differ diff --git a/assets/z_coacht1.png b/assets/z_coacht1.png new file mode 100644 index 0000000..7c87027 Binary files /dev/null and b/assets/z_coacht1.png differ diff --git a/assets/z_coacht2.png b/assets/z_coacht2.png new file mode 100644 index 0000000..0d6f169 Binary files /dev/null and b/assets/z_coacht2.png differ diff --git a/assets/z_coacht3.png b/assets/z_coacht3.png new file mode 100644 index 0000000..ae7f220 Binary files /dev/null and b/assets/z_coacht3.png differ diff --git a/assets/z_coachw1.png b/assets/z_coachw1.png new file mode 100644 index 0000000..c3accac Binary files /dev/null and b/assets/z_coachw1.png differ diff --git a/assets/z_coachw2.png b/assets/z_coachw2.png new file mode 100644 index 0000000..3ee180f Binary files /dev/null and b/assets/z_coachw2.png differ diff --git a/assets/z_coachw3.png b/assets/z_coachw3.png new file mode 100644 index 0000000..c303ac2 Binary files /dev/null and b/assets/z_coachw3.png differ diff --git a/assets/z_e1.png b/assets/z_e1.png new file mode 100644 index 0000000..1de4f50 Binary files /dev/null and b/assets/z_e1.png differ diff --git a/assets/z_e2.png b/assets/z_e2.png new file mode 100644 index 0000000..b1ffd12 Binary files /dev/null and b/assets/z_e2.png differ diff --git a/assets/z_e3.png b/assets/z_e3.png new file mode 100644 index 0000000..d815674 Binary files /dev/null and b/assets/z_e3.png differ diff --git a/assets/z_e4.png b/assets/z_e4.png new file mode 100644 index 0000000..604df8b Binary files /dev/null and b/assets/z_e4.png differ diff --git a/assets/z_ew.png b/assets/z_ew.png new file mode 100644 index 0000000..2247e21 Binary files /dev/null and b/assets/z_ew.png differ diff --git a/assets/z_ew2.png b/assets/z_ew2.png new file mode 100644 index 0000000..51ba9d5 Binary files /dev/null and b/assets/z_ew2.png differ diff --git a/assets/z_ew2b.png b/assets/z_ew2b.png new file mode 100644 index 0000000..a5285bf Binary files /dev/null and b/assets/z_ew2b.png differ diff --git a/assets/z_ewb.png b/assets/z_ewb.png new file mode 100644 index 0000000..ccca404 Binary files /dev/null and b/assets/z_ewb.png differ diff --git a/assets/z_h1.png b/assets/z_h1.png new file mode 100644 index 0000000..8fd384d Binary files /dev/null and b/assets/z_h1.png differ diff --git a/assets/z_h2.png b/assets/z_h2.png new file mode 100644 index 0000000..2a6f450 Binary files /dev/null and b/assets/z_h2.png differ diff --git a/assets/z_h3.png b/assets/z_h3.png new file mode 100644 index 0000000..cb9b83d Binary files /dev/null and b/assets/z_h3.png differ diff --git a/assets/z_h4.png b/assets/z_h4.png new file mode 100644 index 0000000..21369fe Binary files /dev/null and b/assets/z_h4.png differ diff --git a/assets/z_hw.png b/assets/z_hw.png new file mode 100644 index 0000000..10c6e4f Binary files /dev/null and b/assets/z_hw.png differ diff --git a/assets/z_hw2.png b/assets/z_hw2.png new file mode 100644 index 0000000..3145798 Binary files /dev/null and b/assets/z_hw2.png differ diff --git a/assets/z_hw2b.png b/assets/z_hw2b.png new file mode 100644 index 0000000..35ae6fd Binary files /dev/null and b/assets/z_hw2b.png differ diff --git a/assets/z_hw3.png b/assets/z_hw3.png new file mode 100644 index 0000000..e7e29bf Binary files /dev/null and b/assets/z_hw3.png differ diff --git a/assets/z_hw3b.png b/assets/z_hw3b.png new file mode 100644 index 0000000..d89530a Binary files /dev/null and b/assets/z_hw3b.png differ diff --git a/assets/z_hwb.png b/assets/z_hwb.png new file mode 100644 index 0000000..cf44cd8 Binary files /dev/null and b/assets/z_hwb.png differ diff --git a/assets/z_k1.png b/assets/z_k1.png new file mode 100644 index 0000000..50e4dc5 Binary files /dev/null and b/assets/z_k1.png differ diff --git a/assets/z_k2.png b/assets/z_k2.png new file mode 100644 index 0000000..f6ca527 Binary files /dev/null and b/assets/z_k2.png differ diff --git a/assets/z_k3.png b/assets/z_k3.png new file mode 100644 index 0000000..e5ab3b4 Binary files /dev/null and b/assets/z_k3.png differ diff --git a/assets/z_k4.png b/assets/z_k4.png new file mode 100644 index 0000000..a4d3f55 Binary files /dev/null and b/assets/z_k4.png differ diff --git a/assets/z_kw.png b/assets/z_kw.png new file mode 100644 index 0000000..f20ff23 Binary files /dev/null and b/assets/z_kw.png differ diff --git a/assets/z_kw2.png b/assets/z_kw2.png new file mode 100644 index 0000000..aa8f3eb Binary files /dev/null and b/assets/z_kw2.png differ diff --git a/assets/z_kw2b.png b/assets/z_kw2b.png new file mode 100644 index 0000000..d7db048 Binary files /dev/null and b/assets/z_kw2b.png differ diff --git a/assets/z_kw3.png b/assets/z_kw3.png new file mode 100644 index 0000000..2374279 Binary files /dev/null and b/assets/z_kw3.png differ diff --git a/assets/z_kw3b.png b/assets/z_kw3b.png new file mode 100644 index 0000000..93f639c Binary files /dev/null and b/assets/z_kw3b.png differ diff --git a/assets/z_kw4.png b/assets/z_kw4.png new file mode 100644 index 0000000..79240f5 Binary files /dev/null and b/assets/z_kw4.png differ diff --git a/assets/z_kw4b.png b/assets/z_kw4b.png new file mode 100644 index 0000000..5266416 Binary files /dev/null and b/assets/z_kw4b.png differ diff --git a/assets/z_kwb.png b/assets/z_kwb.png new file mode 100644 index 0000000..79c48c5 Binary files /dev/null and b/assets/z_kwb.png differ diff --git a/assets/z_mascot1.png b/assets/z_mascot1.png new file mode 100644 index 0000000..8fb4450 Binary files /dev/null and b/assets/z_mascot1.png differ diff --git a/assets/z_mascot2.png b/assets/z_mascot2.png new file mode 100644 index 0000000..dd3b424 Binary files /dev/null and b/assets/z_mascot2.png differ diff --git a/assets/z_mascott0.png b/assets/z_mascott0.png new file mode 100644 index 0000000..f9d39ec Binary files /dev/null and b/assets/z_mascott0.png differ diff --git a/assets/z_mascott1.png b/assets/z_mascott1.png new file mode 100644 index 0000000..b7487cf Binary files /dev/null and b/assets/z_mascott1.png differ diff --git a/assets/z_mascott2.png b/assets/z_mascott2.png new file mode 100644 index 0000000..2530cdd Binary files /dev/null and b/assets/z_mascott2.png differ diff --git a/assets/z_mascott3.png b/assets/z_mascott3.png new file mode 100644 index 0000000..c583c5b Binary files /dev/null and b/assets/z_mascott3.png differ diff --git a/assets/z_mascotw1.png b/assets/z_mascotw1.png new file mode 100644 index 0000000..6682ad4 Binary files /dev/null and b/assets/z_mascotw1.png differ diff --git a/assets/z_mascotw2.png b/assets/z_mascotw2.png new file mode 100644 index 0000000..b6890e9 Binary files /dev/null and b/assets/z_mascotw2.png differ diff --git a/assets/z_mascotw3.png b/assets/z_mascotw3.png new file mode 100644 index 0000000..aac85b5 Binary files /dev/null and b/assets/z_mascotw3.png differ diff --git a/assets/z_physio1.png b/assets/z_physio1.png new file mode 100644 index 0000000..530451b Binary files /dev/null and b/assets/z_physio1.png differ diff --git a/assets/z_physio2.png b/assets/z_physio2.png new file mode 100644 index 0000000..6c26177 Binary files /dev/null and b/assets/z_physio2.png differ diff --git a/assets/z_physiot0.png b/assets/z_physiot0.png new file mode 100644 index 0000000..e469168 Binary files /dev/null and b/assets/z_physiot0.png differ diff --git a/assets/z_physiot1.png b/assets/z_physiot1.png new file mode 100644 index 0000000..8cd6d98 Binary files /dev/null and b/assets/z_physiot1.png differ diff --git a/assets/z_physiot2.png b/assets/z_physiot2.png new file mode 100644 index 0000000..6c7c031 Binary files /dev/null and b/assets/z_physiot2.png differ diff --git a/assets/z_physiot3.png b/assets/z_physiot3.png new file mode 100644 index 0000000..03518f5 Binary files /dev/null and b/assets/z_physiot3.png differ diff --git a/assets/z_physiow1.png b/assets/z_physiow1.png new file mode 100644 index 0000000..c8e28b9 Binary files /dev/null and b/assets/z_physiow1.png differ diff --git a/assets/z_physiow2.png b/assets/z_physiow2.png new file mode 100644 index 0000000..2ea7a8b Binary files /dev/null and b/assets/z_physiow2.png differ diff --git a/assets/z_physiow3.png b/assets/z_physiow3.png new file mode 100644 index 0000000..2ff8dc8 Binary files /dev/null and b/assets/z_physiow3.png differ diff --git a/config.js b/config.js index 28df04c..f1e5188 100644 --- a/config.js +++ b/config.js @@ -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 diff --git a/index.html b/index.html index 55f9d45..42ed132 100644 --- a/index.html +++ b/index.html @@ -179,6 +179,6 @@ - + diff --git a/render.js b/render.js index d01887b..8b48ac4 100644 --- a/render.js +++ b/render.js @@ -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/.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 diff --git a/zombies.js b/zombies.js index e8f191f..d0e42cb 100644 --- a/zombies.js +++ b/zombies.js @@ -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;