The roster is the alphabet: the sequence is the zombies themselves
Oliver's insight, and it retires a whole subsystem: "we don't need colors at
all! we already have different zombie types.. so the sequence is the zombies
themselves".
The bib and then the ground disc were both inventions bolted on to give a
body an identity it already had. A brute does not look like a runner, the
game is built on reading those silhouettes at speed, and the strip can show
the body's own walk frame instead of a swatch — no legend, no mapping, no new
art ever. It also deletes the accessibility problem outright rather than
deferring it, because a silhouette is not a hue. DRILL_COLS, drawColourRing,
drawBib, PIP_FACES, blip(), drillRingR and drillTones are all gone.
Two rules keep a type unambiguous: the plain shambler is never in the
alphabet (it is what the eaters are, so "not the puzzle" needs no marking),
and one of each type is on court at a time.
The new failure mode this created is instructive. Types move at different
speeds and drill bodies hold their lane, so one in a lane you are not in
walks the whole court and off the back untouched — stranding the sequence
asking for a body that no longer existed, i.e. the un-winnable state the mode
exists to prevent, reintroduced by a change that looked cosmetic. A batch
body crossing your baseline counts as caught now ("GOT PAST YOU"), which is
also just better design, and the speed spread is narrowed to 1.35..0.70 so a
batch stays coherent. Their real difference is HP and that is untouched: a
runner is one shot, a roller is seven.
Verified: 14400 frames of simulation, zero frames where the sequence named a
body that was not on court.
29 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3379e83efc
commit
5b2f1d55ce
7 files changed
+197
-145
No files matched your search
+35
@@ -694,6 +694,41 @@ not so close to the color gang"*.
|
|||||||
They still converge on you afterwards, which is what they are for; this only decides where they
|
They still converge on you afterwards, which is what they are for; this only decides where they
|
||||||
come in, so they stop screening the bodies you are trying to read.
|
come in, so they stop screening the bodies you are trying to read.
|
||||||
|
|
||||||
|
**NINTH PASS — THE ROSTER IS THE ALPHABET, and it deletes a whole subsystem.** Oliver: *"we don't
|
||||||
|
need colors at all! we already have different zombie types.. so the sequence is the zombies
|
||||||
|
themselves"*. He is right, and it is better on every axis the colour version was losing on.
|
||||||
|
|
||||||
|
The bib, then the ground disc, were both inventions bolted on to give a body an identity **it
|
||||||
|
already had**. A brute does not look like a runner, the game is built on reading those silhouettes
|
||||||
|
at speed, and the strip can show the body's own walk frame instead of a swatch — no legend, no
|
||||||
|
mapping, no new art ever. It also **deletes the accessibility problem outright** instead of
|
||||||
|
deferring it, because a silhouette is not a hue. `DRILL_COLS`, `drawColourRing`, `drawBib`,
|
||||||
|
`PIP_FACES`, `blip()`, `drillRingR`, `drillTones` and the seven `drill_*.m4a` call recordings are
|
||||||
|
all now gone or unused — the mode is materially smaller than it was three passes ago.
|
||||||
|
|
||||||
|
TWO RULES keep a type unambiguous and both are load-bearing:
|
||||||
|
1. **the plain shambler is never in the alphabet** — 'a'/'b' is what the EATERS are, so "not part of
|
||||||
|
the puzzle" is legible at a glance and needs no marking on anything;
|
||||||
|
2. **one of each type on court at a time**, guaranteed by the batch dealing distinct types and by
|
||||||
|
eaters only ever being shamblers. "The brute" always names exactly one body.
|
||||||
|
|
||||||
|
**The new failure mode this created, and it is a good example of the class.** Types move at
|
||||||
|
different speeds, and drill bodies hold their lane — so a body in a lane you are not standing in
|
||||||
|
walks the full court and off the back untouched. Left as the ordinary despawn that stranded the
|
||||||
|
sequence asking for a body which **no longer existed**: precisely the un-winnable state the mode
|
||||||
|
exists to prevent, reintroduced by a change that looked purely cosmetic. Two fixes, both needed:
|
||||||
|
- **a batch body crossing your baseline counts as CAUGHT** ('GOT PAST YOU'), which is also just
|
||||||
|
better — the drill is "don't let them through" and now the lanes mean something;
|
||||||
|
- **the speed spread is narrowed to 1.35..0.70**, not the roster's own 3.2x..0.45x, so a batch stays
|
||||||
|
coherent. The types still read as different because their real difference is **HP, untouched**: a
|
||||||
|
runner is one shot and a roller is seven, so the called order still decides how early you must
|
||||||
|
start on what. Verified: four minutes of simulation, 14400 frames, **zero frames** in which the
|
||||||
|
sequence named a body that was not on court.
|
||||||
|
|
||||||
|
Watch: **a roller in the middle of a sequence is seven aimed serves.** Oliver asked for the roster's
|
||||||
|
robustness to be kept, so it is — but if a round ever feels like grinding rather than reading, a
|
||||||
|
drill-only HP scale is the knob, not a change to the roster.
|
||||||
|
|
||||||
**Open, and every one of them is Oliver's call after he plays it:**
|
**Open, and every one of them is Oliver's call after he plays it:**
|
||||||
1. ~~**A ball that carries through the lane can complete two steps at once.**~~ Largely CLOSED by
|
1. ~~**A ball that carries through the lane can complete two steps at once.**~~ Largely CLOSED by
|
||||||
the slice removal (seventh pass) — a cut ball picking its own next victim was the main way this
|
the slice removal (seventh pass) — a cut ball picking its own next victim was the main way this
|
||||||
|
|||||||
+1
-1
@@ -399,6 +399,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="src/main.js?v=159"></script>
|
<script type="module" src="src/main.js?v=160"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+10
-12
@@ -345,19 +345,19 @@ export const TUNE = {
|
|||||||
barrelRoll: 2.6, // m/s it trundles across the far court — fast enough to have to be led
|
barrelRoll: 2.6, // m/s it trundles across the far court — fast enough to have to be led
|
||||||
barrelHp: 1, // anything pops it: ball, pellet, bomb, flame, swish. It is a target, not a body
|
barrelHp: 1, // anything pops it: ball, pellet, bomb, flame, swish. It is a target, not a body
|
||||||
barrelH: 1.15, // drawn height (a body is 1.8)
|
barrelH: 1.15, // drawn height (a body is 1.8)
|
||||||
// ---- THE COACHING DRILL (src/drill.js) — the cognitive mode. THIRD DESIGN, and the one that
|
// ---- THE COACHING DRILL (src/drill.js) — the cognitive mode. THE SEQUENCE IS THE ZOMBIES
|
||||||
// makes it winnable: a BATCH of bibbed bodies is born on the far side, its combination is called
|
// THEMSELVES: a BATCH of distinct roster types is born on the far side and the combination is
|
||||||
// off them immediately, and every colour on court is in that combination — no coloured decoys, so
|
// called off them at once, so everything the strip asks for is already in front of you. There are
|
||||||
// the strip can never name something you cannot see. Kill the last of a batch and the next walks
|
// no colours, signs or markers anywhere — a brute does not look like a runner, and that is the
|
||||||
// in on the same frame. The danger is a separate channel: unbibbed EATERS on their own clock that
|
// whole point. The danger is a separate channel: plain shambler EATERS on their own clock, never
|
||||||
// converge on you the way ordinary shamblers do.
|
// in the alphabet, converging on you the way ordinary shamblers do.
|
||||||
drillBatch: 3, // colours in a batch at the bottom of the ramp...
|
drillBatch: 3, // TYPES in a batch at the bottom of the ramp...
|
||||||
drillBatchMax: 6, // ...and at the top. Capped by DRILL_COLS — a colour is on court at most once
|
drillBatchMax: 6, // ...and at the top. Capped by DRILL_TYPES — one of each type on court at most
|
||||||
drillBatchDepth: 3.0, // m of depth a batch is staggered over. All of it still on the far side,
|
drillBatchDepth: 3.0, // m of depth a batch is staggered over. All of it still on the far side,
|
||||||
// which is the promise: you see every colour before you are asked for it
|
// which is the promise: you see every colour before you are asked for it
|
||||||
drillSpawnZ: 0.6, // z they enter at (the far baseline is 0)
|
drillSpawnZ: 0.6, // z they enter at (the far baseline is 0)
|
||||||
drillSpread: 3.4, // m either side of the centre line the lanes fan across
|
drillSpread: 3.4, // m either side of the centre line the lanes fan across
|
||||||
drillSpeed: 0.80, // m/s a bibbed body walks at the bottom of the ramp...
|
drillSpeed: 0.80, // m/s a batch body walks at the bottom of the ramp (x its type's vMul)...
|
||||||
drillSpeedMax: 1.9,// ...and at the top. Stamped per body AT SPAWN, so the ramp shows up as new
|
drillSpeedMax: 1.9,// ...and at the top. Stamped per body AT SPAWN, so the ramp shows up as new
|
||||||
// arrivals outpacing the ones already walking, not the court accelerating
|
// arrivals outpacing the ones already walking, not the court accelerating
|
||||||
// The eaters are RARE on purpose. At every 7 s / 2.2 s they were a second crowd rather than an
|
// The eaters are RARE on purpose. At every 7 s / 2.2 s they were a second crowd rather than an
|
||||||
@@ -365,13 +365,11 @@ export const TUNE = {
|
|||||||
// with only works if breaking off is occasional. They also enter wide of the colour gang
|
// with only works if breaking off is occasional. They also enter wide of the colour gang
|
||||||
// (spawnEater), because the one body out there that is neither the puzzle nor worth anything has
|
// (spawnEater), because the one body out there that is neither the puzzle nor worth anything has
|
||||||
// no business standing in front of the puzzle.
|
// no business standing in front of the puzzle.
|
||||||
drillEaterEvery: 12.0, // s between unbibbed eaters at the bottom of the ramp...
|
drillEaterEvery: 12.0, // s between plain-shambler eaters at the bottom of the ramp...
|
||||||
drillEaterMin: 5.0, // ...and at the top. Still THE difficulty dial, just a slower one
|
drillEaterMin: 5.0, // ...and at the top. Still THE difficulty dial, just a slower one
|
||||||
drillEaterMax: 3, // eaters allowed on court at once — past this the puzzle is unreadable
|
drillEaterMax: 3, // eaters allowed on court at once — past this the puzzle is unreadable
|
||||||
drillEaterSpeed: 1.15,// they walk a touch faster than the batch: the batch is a list, this is a threat
|
drillEaterSpeed: 1.15,// they walk a touch faster than the batch: the batch is a list, this is a threat
|
||||||
drillRampT: 210, // s to go from the bottom of every curve above to the top
|
drillRampT: 210, // s to go from the bottom of every curve above to the top
|
||||||
drillRingR: 0.62, // m — radius of the colour disc a bibbed body walks on. Big enough to read at
|
|
||||||
// the far baseline, which is the whole reason the colour left the chest
|
|
||||||
drillCaughtBack: 45, // s of ramp handed back when one reaches you. A real setback, not a reset
|
drillCaughtBack: 45, // s of ramp handed back when one reaches you. A real setback, not a reset
|
||||||
fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped)
|
fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped)
|
||||||
lowPower: 1, // >= 0.5 caps RES at 3 (−44% of pixels painted per frame) — the battery saver,
|
lowPower: 1, // >= 0.5 caps RES at 3 (−44% of pixels painted per frame) — the battery saver,
|
||||||
|
|||||||
+58
-47
@@ -23,29 +23,40 @@ import { G } from './state.js';
|
|||||||
import { zombies, spawnZombie } from './zombies.js';
|
import { zombies, spawnZombie } from './zombies.js';
|
||||||
import { voice } from './audio.js';
|
import { voice } from './audio.js';
|
||||||
|
|
||||||
// The colours. Six, and a colour is on court at most once, so a colour always names exactly one body.
|
// THE ROSTER IS THE ALPHABET. Oliver's insight, and it retires an entire subsystem: *"we don't need
|
||||||
|
// colors at all! we already have different zombie types.. so the sequence is the zombies themselves"*.
|
||||||
//
|
//
|
||||||
// THE MODE HAS NO SOUND OF ITS OWN, and that is now deliberate. Two attempts have been vetoed: the
|
// He is right, and it is better on every axis the colour version was struggling on. The chest sign
|
||||||
// recorded umpire reading the list aloud ("daniel announcing is maximum annoying" — at ~0.8 s a word
|
// and then the ground disc were both inventions bolted on to give a body an identity it ALREADY HAD
|
||||||
// he needed four seconds for a list of four, over and over), and the arpeggio that replaced him
|
// — a brute does not look like a runner, and the whole game is built on reading those silhouettes at
|
||||||
// ("no bleeps please"). What is left is the game's own vocabulary: a kill bursts and splats like
|
// speed. So: no colours, no signs, no discs, and no new art of any kind. It also deletes the
|
||||||
// every other kill in the game, and the strip carries the list. Do not put a third noise here
|
// accessibility problem outright rather than deferring it, because a silhouette is not a hue.
|
||||||
// without a reason — the two that were tried both read as the mode talking over itself.
|
|
||||||
//
|
//
|
||||||
// THERE IS NO REDUNDANT CHANNEL ANY MORE, and that is a known accessibility gap rather than an
|
// TWO RULES keep a type unambiguous, and both are load-bearing:
|
||||||
// oversight. Die-face dots rode on the bib and the HUD chip so the mode could be played without
|
// 1. **the plain shambler is never in the alphabet.** 'a'/'b' is what the EATERS are, so "not part
|
||||||
// separating red from green (~8% of men cannot) — but they were noise to everyone who can, and they
|
// of the puzzle" is legible at a glance and needs no marking of any kind.
|
||||||
// shrank to nothing on a body at distance, which is exactly where the channel had to work. Cut on
|
// 2. **one of each type on court at a time** — guaranteed by the batch dealing distinct types and
|
||||||
// Oliver's call. If the mode ships, the answer is bibs that differ by SHAPE or marking at sprite
|
// by eaters only ever being shamblers. "The brute" always names exactly one body.
|
||||||
// scale, not dots: a channel that fails at the far baseline is not a channel.
|
//
|
||||||
export const DRILL_COLS = [
|
// `vMul` is the drill's own speed character per type, NOT spawnZombie's (which is keyed on the gore
|
||||||
{ key: 'red', col: '#e5342c', name: 'RED' },
|
// mode's ramp): the roller is a slow tank you have to start early, the runner is gone if you dither.
|
||||||
{ key: 'blue', col: '#2f6bff', name: 'BLUE' },
|
// Their HP is left at the roster's own values on purpose — Oliver: "the existing robustness of
|
||||||
{ key: 'yellow', col: '#f2c318', name: 'YELLOW' },
|
// zombies remains (some are easier, some more difficult to kill)" — which means a roller in the
|
||||||
{ key: 'green', col: '#27bd4a', name: 'GREEN' },
|
// middle of a sequence is genuinely several aimed serves. See the open questions.
|
||||||
{ key: 'purple', col: '#9b3fd8', name: 'PURPLE' },
|
// The spread is DELIBERATELY NARROW (1.35 down to 0.70, not the roster's own 3.2x-to-0.45x). A batch
|
||||||
{ key: 'white', col: '#f2f0e8', name: 'WHITE' },
|
// has to stay roughly coherent: at the wide spread the runner crossed the baseline while the roller
|
||||||
|
// was still in the far third, so a sequence naming the runner last could not be finished by anyone.
|
||||||
|
// The types still read as different — and their real difference is HP, which is untouched: a runner
|
||||||
|
// is one shot, a roller is seven, so the order you are given still decides how early you must start.
|
||||||
|
export const DRILL_TYPES = [
|
||||||
|
{ v: 'f', name: 'RUNNER', vMul: 1.35 },
|
||||||
|
{ v: 'e', name: 'BALL BOY', vMul: 1.10 },
|
||||||
|
{ v: 'd', name: 'STEWARD', vMul: 1.00 },
|
||||||
|
{ v: 'h', name: 'BOUNCER', vMul: 0.90 },
|
||||||
|
{ v: 'g', name: 'BRUTE', vMul: 0.80 },
|
||||||
|
{ v: 'k', name: 'ROLLER', vMul: 0.70 },
|
||||||
];
|
];
|
||||||
|
export const drillType = v => DRILL_TYPES.find(t => t.v === v);
|
||||||
|
|
||||||
// ---- THE RAMP. One number, 0..1 off time survived; everything else is a lerp on it. Time, not
|
// ---- THE RAMP. One number, 0..1 off time survived; everything else is a lerp on it. Time, not
|
||||||
// rounds won: a round-counted ramp cannot escalate while you stand still.
|
// rounds won: a round-counted ramp cannot escalate while you stand still.
|
||||||
@@ -56,7 +67,7 @@ const batchSize = d => Math.round(lerp(TUNE.drillBatch, TUNE.drillBatchMax, ramp
|
|||||||
const eaterGap = d => lerp(TUNE.drillEaterEvery, TUNE.drillEaterMin, ramp(d));
|
const eaterGap = d => lerp(TUNE.drillEaterEvery, TUNE.drillEaterMin, ramp(d));
|
||||||
|
|
||||||
const shuffle = a => { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };
|
const shuffle = a => { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };
|
||||||
const bibs = () => zombies.filter(z => z.bib !== undefined);
|
const gang = () => zombies.filter(z => z.drill); // the batch: a member's identity IS its variant
|
||||||
|
|
||||||
// ---- THE BATCH IS THE ROUND, AND IT IS WHAT MAKES THE MODE WINNABLE. Oliver: "make the game that
|
// ---- THE BATCH IS THE ROUND, AND IT IS WHAT MAKES THE MODE WINNABLE. Oliver: "make the game that
|
||||||
// it is actually winable meaning that all required colors are visible on the far side before when a
|
// it is actually winable meaning that all required colors are visible on the far side before when a
|
||||||
@@ -72,47 +83,47 @@ const bibs = () => zombies.filter(z => z.bib !== undefined);
|
|||||||
// The pressure moved to the EATERS below instead, which is a better place for it — the puzzle stays
|
// The pressure moved to the EATERS below instead, which is a better place for it — the puzzle stays
|
||||||
// fair and the danger is a separate thing arriving on its own clock.
|
// fair and the danger is a separate thing arriving on its own clock.
|
||||||
function spawnBatch(d) {
|
function spawnBatch(d) {
|
||||||
const k = Math.min(DRILL_COLS.length, batchSize(d));
|
const k = Math.min(DRILL_TYPES.length, batchSize(d));
|
||||||
// distinct colours, distinct lanes, fanned across the width and shuffled so the called order has
|
// distinct TYPES, distinct lanes, fanned across the width and shuffled so the called order has
|
||||||
// nothing to do with the left-to-right order
|
// nothing to do with the left-to-right order
|
||||||
const cols = shuffle(DRILL_COLS.map((_, i) => i)).slice(0, k);
|
const types = shuffle(DRILL_TYPES.slice()).slice(0, k);
|
||||||
const lanes = shuffle(cols.map((_, i) => -TUNE.drillSpread + (2 * TUNE.drillSpread) * (i / Math.max(1, k - 1))));
|
const lanes = shuffle(types.map((_, i) => -TUNE.drillSpread + (2 * TUNE.drillSpread) * (i / Math.max(1, k - 1))));
|
||||||
cols.forEach((bib, i) => {
|
types.forEach((t, i) => {
|
||||||
// staggered in depth as well as across, so the batch arrives as a loose group rather than as a
|
// staggered in depth as well as across, so the batch arrives as a loose group rather than as a
|
||||||
// rank walking in abreast — but every one of them still starts on the far side, which is the
|
// rank walking in abreast — but every one still starts on the far side, which is the promise.
|
||||||
// promise the mode now makes.
|
|
||||||
spawnZombie(lanes[i] + (Math.random() - 0.5) * 0.4,
|
spawnZombie(lanes[i] + (Math.random() - 0.5) * 0.4,
|
||||||
TUNE.drillSpawnZ + Math.random() * TUNE.drillBatchDepth, Math.random() < 0.25 ? 'b' : 'a');
|
TUNE.drillSpawnZ + Math.random() * TUNE.drillBatchDepth, t.v);
|
||||||
const zb = zombies[zombies.length - 1];
|
const zb = zombies[zombies.length - 1];
|
||||||
zb.bib = bib;
|
zb.drill = true; // marks it as the puzzle AND holds its lane (see updateZombies)
|
||||||
zb.drill = true; // holds its lane (see updateZombies) — the lane is the puzzle
|
zb.v = drillSpeed(d) * t.vMul * (0.92 + Math.random() * 0.16);
|
||||||
zb.v = drillSpeed(d) * (0.92 + Math.random() * 0.16);
|
|
||||||
});
|
});
|
||||||
newRound(d);
|
newRound(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- THE EATERS. Plain, unbibbed shamblers on their own clock: no colour, no place in any
|
// ---- THE EATERS. Plain shamblers on their own clock: never in the alphabet, no place in any
|
||||||
// combination, nothing to remember about them. Killing one is never a fault (drillKill ignores a
|
// combination, nothing to remember about them. Killing one is never a fault (drillKill ignores a
|
||||||
// body with no bib) and letting one reach you is the ordinary caught. They exist because with the
|
// body that is not flagged) and letting one reach you is the ordinary caught. They exist because with the
|
||||||
// puzzle made fair the mode needed its danger back from somewhere, and a threat you simply have to
|
// puzzle made fair the mode needed its danger back from somewhere, and a threat you simply have to
|
||||||
// DEAL WITH is a cleaner source of it than a puzzle that cheats.
|
// DEAL WITH is a cleaner source of it than a puzzle that cheats.
|
||||||
// Deliberately NOT flagged `drill`, so they keep the gore mode's homing: they converge on you while
|
// Deliberately NOT flagged `drill`, so they keep the gore mode's homing: they converge on you while
|
||||||
// the bibbed batch walks its lanes, which is exactly the "stop what you are doing" interruption the
|
// the bibbed batch walks its lanes, which is exactly the "stop what you are doing" interruption the
|
||||||
// mode was missing.
|
// mode was missing.
|
||||||
function spawnEater(d) {
|
function spawnEater(d) {
|
||||||
if (zombies.filter(z => z.bib === undefined).length >= TUNE.drillEaterMax) return;
|
if (zombies.filter(z => !z.drill).length >= TUNE.drillEaterMax) return;
|
||||||
// THEY ENTER WIDE OF THE COLOUR GANG. An eater walking in on top of the batch screens the very
|
// THEY ENTER WIDE OF THE COLOUR GANG. An eater walking in on top of the batch screens the very
|
||||||
// bodies you are trying to read, and it is the one body on court that is neither the puzzle nor
|
// bodies you are trying to read, and it is the one body on court that is neither the puzzle nor
|
||||||
// worth points — so it has no business standing in front of the puzzle. Best-of-N: sample lanes
|
// worth points — so it has no business standing in front of the puzzle. Best-of-N: sample lanes
|
||||||
// across the width and take the one furthest from every coloured body. They still converge on you
|
// across the width and take the one furthest from every coloured body. They still converge on you
|
||||||
// afterwards (that is what they are for); this only decides where they come IN.
|
// afterwards (that is what they are for); this only decides where they come IN.
|
||||||
const gang = bibs();
|
const g = gang();
|
||||||
let bx = 0, bs = -1;
|
let bx = 0, bs = -1;
|
||||||
for (let k = 0; k < 9; k++) {
|
for (let k = 0; k < 9; k++) {
|
||||||
const x = -TUNE.drillSpread + (2 * TUNE.drillSpread) * (k / 8) + (Math.random() - 0.5) * 0.4;
|
const x = -TUNE.drillSpread + (2 * TUNE.drillSpread) * (k / 8) + (Math.random() - 0.5) * 0.4;
|
||||||
const s = gang.length ? Math.min(...gang.map(z => Math.abs(z.x - x))) : 1e9;
|
const s = g.length ? Math.min(...g.map(z => Math.abs(z.x - x))) : 1e9;
|
||||||
if (s > bs) { bs = s; bx = x; }
|
if (s > bs) { bs = s; bx = x; }
|
||||||
}
|
}
|
||||||
|
// ALWAYS A PLAIN SHAMBLER — rule 1 above doing its work: the alphabet never contains 'a'/'b', so
|
||||||
|
// an eater can never be mistaken for a body the sequence is asking for, with no marking on either.
|
||||||
spawnZombie(bx, TUNE.drillSpawnZ + Math.random() * 0.8, Math.random() < 0.3 ? 'b' : 'a');
|
spawnZombie(bx, TUNE.drillSpawnZ + Math.random() * 0.8, Math.random() < 0.3 ? 'b' : 'a');
|
||||||
const zb = zombies[zombies.length - 1];
|
const zb = zombies[zombies.length - 1];
|
||||||
zb.v = drillSpeed(d) * TUNE.drillEaterSpeed * (0.9 + Math.random() * 0.2);
|
zb.v = drillSpeed(d) * TUNE.drillEaterSpeed * (0.9 + Math.random() * 0.2);
|
||||||
@@ -121,10 +132,10 @@ function spawnEater(d) {
|
|||||||
// ---- THE ROUND: the order the batch has to be taken in. Drawn from every bibbed body on court,
|
// ---- THE ROUND: the order the batch has to be taken in. Drawn from every bibbed body on court,
|
||||||
// which — see rule 1 above — is exactly the batch and nothing else.
|
// which — see rule 1 above — is exactly the batch and nothing else.
|
||||||
function newRound(d) {
|
function newRound(d) {
|
||||||
const pool = bibs();
|
const pool = gang();
|
||||||
d.step = 0; d.faults = 0;
|
d.step = 0; d.faults = 0;
|
||||||
d.seq = pool.length ? shuffle(pool.map(z => z.bib)) : [];
|
d.seq = pool.length ? shuffle(pool.map(z => z.variant)) : [];
|
||||||
// (no sound: the strip IS the call — see the note on DRILL_COLS)
|
// (no sound: the strip IS the call — see the note on DRILL_TYPES)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called by beats.startDrill once the court is its own.
|
// Called by beats.startDrill once the court is its own.
|
||||||
@@ -146,15 +157,15 @@ export function updateDrill(dt) {
|
|||||||
// the same frame with its combination already called. No pause, no deal, no announcement beat —
|
// the same frame with its combination already called. No pause, no deal, no announcement beat —
|
||||||
// and because a batch is born on the far side, "all the colours are visible before you are asked
|
// and because a batch is born on the far side, "all the colours are visible before you are asked
|
||||||
// for them" holds by construction rather than by a check that could be wrong.
|
// for them" holds by construction rather than by a check that could be wrong.
|
||||||
if (!bibs().length) { spawnBatch(d); return; }
|
if (!gang().length) { spawnBatch(d); return; }
|
||||||
if (!d.seq.length) newRound(d); // survivors after a double fault: same bodies, fresh order
|
if (!d.seq.length) newRound(d); // survivors after a double fault: same bodies, fresh order
|
||||||
}
|
}
|
||||||
|
|
||||||
// A drill body went down. Called from killZombie — the single kill funnel — so no weapon path can
|
// A drill body went down. Called from killZombie — the single kill funnel — so no weapon path can
|
||||||
// miss it (the bug this codebase has had twice).
|
// miss it (the bug this codebase has had twice).
|
||||||
export function drillKill(zb) {
|
export function drillKill(zb) {
|
||||||
const d = G.drill; if (!d || zb.bib === undefined) return;
|
const d = G.drill; if (!d || !zb.drill) return;
|
||||||
if (zb.bib === d.seq[d.step]) {
|
if (zb.variant === d.seq[d.step]) {
|
||||||
d.step++;
|
d.step++;
|
||||||
// no tick here either — killZombie is already bursting the body and playing the splat, and that
|
// no tick here either — killZombie is already bursting the body and playing the splat, and that
|
||||||
// burst is a far better "yes, that one" than a beep laid over the top of it
|
// burst is a far better "yes, that one" than a beep laid over the top of it
|
||||||
@@ -169,7 +180,7 @@ export function drillKill(zb) {
|
|||||||
// rather than leaving a slot nobody can fill: an un-winnable round reads as a broken game, and the
|
// rather than leaving a slot nobody can fill: an un-winnable round reads as a broken game, and the
|
||||||
// fault below is the punishment already.
|
// fault below is the punishment already.
|
||||||
d.faults++;
|
d.faults++;
|
||||||
d.seq = d.seq.filter((c, i) => i < d.step || c !== zb.bib);
|
d.seq = d.seq.filter((c, i) => i < d.step || c !== zb.variant);
|
||||||
if (d.faults >= 2) {
|
if (d.faults >= 2) {
|
||||||
d.lost++;
|
d.lost++;
|
||||||
voice('double_fault', 0.1);
|
voice('double_fault', 0.1);
|
||||||
@@ -185,15 +196,15 @@ export function drillKill(zb) {
|
|||||||
// Contact costs the POINT and a cleared court, never the run: permadeath is settled for the gore
|
// Contact costs the POINT and a cleared court, never the run: permadeath is settled for the gore
|
||||||
// mode, and a memory game where one lapse deletes a long run punishes the skill it is teaching.
|
// mode, and a memory game where one lapse deletes a long run punishes the skill it is teaching.
|
||||||
// This is the ONE thing that interrupts the stream, which is exactly what makes it read as a setback.
|
// This is the ONE thing that interrupts the stream, which is exactly what makes it read as a setback.
|
||||||
export function drillCaught() {
|
export function drillCaught(text = 'CAUGHT') {
|
||||||
const d = G.drill;
|
const d = G.drill;
|
||||||
d.lost++;
|
d.lost++;
|
||||||
voice('game_opponent', 0.1);
|
voice('game_opponent', 0.1);
|
||||||
d.msg = { text: 'CAUGHT', t: 1.8, bad: true };
|
d.msg = { text, t: 1.8, bad: true };
|
||||||
zombies.length = 0;
|
zombies.length = 0;
|
||||||
d.seq = []; d.step = 0; d.faults = 0; d.spawnT = 0;
|
d.seq = []; d.step = 0; d.faults = 0; d.spawnT = 0;
|
||||||
d.t = Math.max(0, d.t - TUNE.drillCaughtBack); // ...and the ramp backs off a little
|
d.t = Math.max(0, d.t - TUNE.drillCaughtBack); // ...and the ramp backs off a little
|
||||||
}
|
}
|
||||||
|
|
||||||
// Which body is owed right now — the HUD highlights it. NOT used for aiming: the auto-aim is gone.
|
// Which body is owed right now — the HUD highlights it. NOT used for aiming: the auto-aim is gone.
|
||||||
export const drillTarget = () => zombies.find(z => G.drill && z.bib === G.drill.seq[G.drill.step]) || null;
|
export const drillTarget = () => zombies.find(z => G.drill && z.drill && z.variant === G.drill.seq[G.drill.step]) || null;
|
||||||
+23
-48
@@ -5,7 +5,6 @@ import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, P
|
|||||||
import { P, O, ball, score, crowd, G, court, tour } from './state.js';
|
import { P, O, ball, score, crowd, G, court, tour } from './state.js';
|
||||||
import { predictLanding, previewServe, slotId, slotStock } from './gameplay.js';
|
import { predictLanding, previewServe, slotId, slotStock } from './gameplay.js';
|
||||||
import { zombies, gore, piles, parts, pellets, extras, shots, pops } from './zombies.js';
|
import { zombies, gore, piles, parts, pellets, extras, shots, pops } from './zombies.js';
|
||||||
import { DRILL_COLS } from './drill.js';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- palette ----
|
// ---------------------------------------------------------------- palette ----
|
||||||
// tennis palette -> drained splatter palette; the transition lerps between them.
|
// tennis palette -> drained splatter palette; the transition lerps between them.
|
||||||
@@ -662,7 +661,6 @@ function drawZombie(zb) {
|
|||||||
// Every side-on body is DRAWN facing left, so one walking right was moonwalking — obvious the
|
// Every side-on body is DRAWN facing left, so one walking right was moonwalking — obvious the
|
||||||
// moment they close on you across your own half. The front-facing ones (the turned CPU, the
|
// moment they close on you across your own half. The front-facing ones (the turned CPU, the
|
||||||
// runner, the machine, a chapter boss) have no left or right to turn and are left alone.
|
// runner, the machine, a chapter boss) have no left or right to turn and are left alone.
|
||||||
if (zb.bib !== undefined) drawColourRing(zb); // BEFORE the sprite: he stands ON it
|
|
||||||
drawStatic(name, zb.x, zb.z, h, bob, zb.face > 0 && !'cfmB'.includes(v));
|
drawStatic(name, zb.x, zb.z, h, bob, zb.face > 0 && !'cfmB'.includes(v));
|
||||||
if (zb.burnT > 0) drawFlames(zb, h);
|
if (zb.burnT > 0) drawFlames(zb, h);
|
||||||
if (zb.windT > 0 && zb.variant === 'B') { // the boss telegraphs too: it rears up, glowing, then throws
|
if (zb.windT > 0 && zb.variant === 'B') { // the boss telegraphs too: it rears up, glowing, then throws
|
||||||
@@ -681,54 +679,34 @@ function drawZombie(zb) {
|
|||||||
if (v === 'B' && zb.hpMax) drawBossHp(zb, h);
|
if (v === 'B' && zb.hpMax) drawBossHp(zb, h);
|
||||||
}
|
}
|
||||||
|
|
||||||
// THE COLOUR IS A DISC ON THE COURT, and the body walks on it. It was a bib worn on the chest and
|
// THE CALLED LIST, on screen — and it is the ROSTER now, not a palette. Each chip is the body's own
|
||||||
// Oliver's verdict was "the sign they're holding is not nice" — which it was: a flat quad pinned to
|
// walk frame, which is the same picture you are reading on the court: no legend to learn, no colour
|
||||||
// a walking sprite reads as UI stuck onto a character rather than as part of the world, and it fought
|
// to map, and no new art. Done ones dim and the next wears the cream frame, so the strip doubles as
|
||||||
// the artwork it was covering.
|
// "where am I" and never has to be re-read from the start mid-round.
|
||||||
//
|
|
||||||
// On the ground it is better on every count. It sits in the court's own perspective, so it shrinks
|
|
||||||
// and squashes with distance like everything else; it is far LARGER in pixels than a chest patch at
|
|
||||||
// the same depth, which is what fixes the far-baseline readability the die-face dots could never
|
|
||||||
// solve; and it hides none of the sprite. The obvious risk — a coloured disc reading as another gore
|
|
||||||
// decal — is answered by the ring: gore is a soft flat blob, this has a hard bright rim and a lit
|
|
||||||
// centre, so it reads as painted court marking rather than as something spilled.
|
|
||||||
//
|
|
||||||
// ponytail: still procedural rather than art, and that is right here — this is a court MARKING, the
|
|
||||||
// same family as the tramlines, not a character. It is not the sprite rule being bent twice.
|
|
||||||
function drawColourRing(zb) {
|
|
||||||
const C = DRILL_COLS[zb.bib]; if (!C) return;
|
|
||||||
const p = proj(zb.x, 0, zb.z); if (p.s <= 0) return;
|
|
||||||
const rx = p.s * TUNE.drillRingR, ry = rx * 0.34; // squashed by the camera, like every ground oval
|
|
||||||
ctx.save();
|
|
||||||
ctx.globalAlpha = 0.92;
|
|
||||||
ctx.fillStyle = C.col;
|
|
||||||
ctx.beginPath(); ctx.ellipse(p.x, p.y, rx, ry, 0, 0, 7); ctx.fill();
|
|
||||||
// a dark rim, then a bright inner line: two edges are what stop it reading as a puddle, and they
|
|
||||||
// survive being drawn over clay, grass and blood alike
|
|
||||||
ctx.globalAlpha = 0.75;
|
|
||||||
ctx.lineWidth = Math.max(1, p.s * 0.012);
|
|
||||||
ctx.strokeStyle = 'rgba(0,0,0,0.55)';
|
|
||||||
ctx.beginPath(); ctx.ellipse(p.x, p.y, rx, ry, 0, 0, 7); ctx.stroke();
|
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
|
|
||||||
ctx.beginPath(); ctx.ellipse(p.x, p.y, rx * 0.72, ry * 0.72, 0, 0, 7); ctx.stroke();
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
// THE CALLED LIST, on screen. Oliver's call: the umpire reads it aloud AND it shows as a pattern —
|
|
||||||
// audio is the channel that makes the mode this game's own, the strip is what stops a missed word
|
|
||||||
// costing a round. Chips left to right, the done ones struck through and dimmed, so the strip
|
|
||||||
// doubles as "where am I" and never has to be read from the start again mid-round.
|
|
||||||
function drawDrillHud() {
|
function drawDrillHud() {
|
||||||
const d = G.drill; if (!d) return;
|
const d = G.drill; if (!d) return;
|
||||||
const n = d.seq.length; if (!n) return;
|
const n = d.seq.length; if (!n) return;
|
||||||
const w = 26, gap = 4, totalW = n * w + (n - 1) * gap;
|
// Sized to the widest case the ramp can deal: six chips at 30 + five 3px gaps = 195 px, inside a
|
||||||
|
// 240 px portrait canvas with room either side. A silhouette is the whole readout here, so the
|
||||||
|
// chips take every pixel the band can spare — at 26 the steward, the ball boy and the bouncer were
|
||||||
|
// three similar green figures.
|
||||||
|
const w = 30, gap = 3, totalW = n * w + (n - 1) * gap;
|
||||||
let x = Math.round(W / 2 - totalW / 2);
|
let x = Math.round(W / 2 - totalW / 2);
|
||||||
const y = CARD_TOP + 6, h = 22;
|
const y = CARD_TOP + 4, h = 34;
|
||||||
for (let i = 0; i < n; i++, x += w + gap) {
|
for (let i = 0; i < n; i++, x += w + gap) {
|
||||||
const C = DRILL_COLS[d.seq[i]], done = i < d.step;
|
const done = i < d.step;
|
||||||
ctx.globalAlpha = done ? 0.28 : 1;
|
ctx.globalAlpha = done ? 0.3 : 1;
|
||||||
ctx.fillStyle = 'rgba(6,4,6,0.8)'; ctx.fillRect(x - 1, y - 1, w + 2, h + 2);
|
ctx.fillStyle = 'rgba(8,6,10,0.82)'; ctx.fillRect(x - 1, y - 1, w + 2, h + 2);
|
||||||
ctx.fillStyle = C.col; ctx.fillRect(x, y, w, h);
|
ctx.fillStyle = 'rgba(30,26,34,0.9)'; ctx.fillRect(x, y, w, h);
|
||||||
|
// the body itself, fitted into the chip on its own aspect — a roller is wide, a runner is not,
|
||||||
|
// and that difference is exactly the thing being read
|
||||||
|
const img = SPR[SK('z_' + d.seq[i] + '1')];
|
||||||
|
if (img) {
|
||||||
|
const pad = 2, bw = w - pad * 2, bh = h - pad * 2;
|
||||||
|
const sc = Math.min(bw / img.width, bh / img.height);
|
||||||
|
const iw = Math.round(img.width * sc), ih = Math.round(img.height * sc);
|
||||||
|
ctx.drawImage(img, Math.round(x + (w - iw) / 2), Math.round(y + h - pad - ih), iw, ih);
|
||||||
|
}
|
||||||
if (i === d.step) { // the one you owe them right now
|
if (i === d.step) { // the one you owe them right now
|
||||||
ctx.strokeStyle = UI.cream; ctx.lineWidth = 2;
|
ctx.strokeStyle = UI.cream; ctx.lineWidth = 2;
|
||||||
ctx.strokeRect(x - 2, y - 2, w + 4, h + 4);
|
ctx.strokeRect(x - 2, y - 2, w + 4, h + 4);
|
||||||
@@ -737,9 +715,6 @@ function drawDrillHud() {
|
|||||||
}
|
}
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
if (d.msg && d.msg.t > 0) { ctx.font = B(20); shadowText(d.msg.text, W / 2, y + h + 22, d.msg.bad ? UI.bad : UI.pick); }
|
if (d.msg && d.msg.t > 0) { ctx.font = B(20); shadowText(d.msg.text, W / 2, y + h + 22, d.msg.bad ? UI.bad : UI.pick); }
|
||||||
// the round tally, quiet register, TUCKED UNDER THE STRIP — floated lower it sat in the middle
|
|
||||||
// of the crowd with nothing to belong to. (No 'IN ORDER, PLEASE' any more: there is no
|
|
||||||
// announcement beat to caption — the rounds run continuously and the strip is always live.)
|
|
||||||
ctx.font = F(8);
|
ctx.font = F(8);
|
||||||
shadowText(d.rounds + ' - ' + d.lost, W / 2, y + h + 5, UI.dim);
|
shadowText(d.rounds + ' - ' + d.lost, W / 2, y + h + 5, UI.dim);
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-2
@@ -493,7 +493,7 @@ function killZombie(i, quiet) {
|
|||||||
// and no early return, because everything below is the DEATH — the blood decal, the pile, the
|
// and no early return, because everything below is the DEATH — the blood decal, the pile, the
|
||||||
// particle burst, the splat and the headshot crack. The first cut returned here and bibbed
|
// particle burst, the splat and the headshot crack. The first cut returned here and bibbed
|
||||||
// bodies died silently and cleanly, which is not what this game does to anybody.
|
// bodies died silently and cleanly, which is not what this game does to anybody.
|
||||||
if (G.drill && zb.bib !== undefined) drillKill(zb);
|
if (G.drill && zb.drill) drillKill(zb); // `drill` marks a batch member; eaters are never flagged
|
||||||
if (zb.variant === 'B') bossDeath(zb); // before endBossFight below, so the card can wait on it
|
if (zb.variant === 'B') bossDeath(zb); // before endBossFight below, so the card can wait on it
|
||||||
if (zb.variant === 'o') { popBarrel(zb); return; } // a container: it pays out and scores NOTHING
|
if (zb.variant === 'o') { popBarrel(zb); return; } // a container: it pays out and scores NOTHING
|
||||||
if (zb.variant === 'm') { // furniture, not a body: no blood, no mound, no mop boy
|
if (zb.variant === 'm') { // furniture, not a body: no blood, no mound, no mop boy
|
||||||
@@ -1196,7 +1196,16 @@ export function updateZombies(dt) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (Math.abs(zb.x - faceX0) > 0.004) zb.face = Math.sign(zb.x - faceX0); // sticky: a body standing still keeps facing where it last walked
|
if (Math.abs(zb.x - faceX0) > 0.004) zb.face = Math.sign(zb.x - faceX0); // sticky: a body standing still keeps facing where it last walked
|
||||||
if (zb.z >= COURT.L + 0.7) { aside('ump_unsportsmanlike', 0.5); zombies.splice(i, 1); if (G.mode !== 'eaten' && !G.horde && !G.clearing) splatPoint('O'); } // it reached your baseline (during a mop-up, a straggler that reaches the line just despawns — no point for them)
|
if (zb.z >= COURT.L + 0.7) {
|
||||||
|
// A BATCH BODY THAT GETS PAST YOU IS A FAILURE, not a free despawn. Drill bodies hold their
|
||||||
|
// lane, so one in a lane you are not standing in walks the whole court and off the back
|
||||||
|
// without ever touching you — and since the sequence names it, the round is left asking for a
|
||||||
|
// body that no longer exists. That is the un-winnable state the whole mode is built to
|
||||||
|
// prevent, and it only appeared once the roster became the alphabet: types move at different
|
||||||
|
// speeds, so the runner leaves long before the roller. It counts as caught.
|
||||||
|
if (G.drill && zb.drill) { zombies.splice(i, 1); drillCaught('GOT PAST YOU'); continue; }
|
||||||
|
aside('ump_unsportsmanlike', 0.5); zombies.splice(i, 1); if (G.mode !== 'eaten' && !G.horde && !G.clearing) splatPoint('O');
|
||||||
|
} // it reached your baseline (during a mop-up, a straggler that reaches the line just despawns — no point for them)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = pellets.length - 1; i >= 0; i--) { // gun rounds flat and fast; lobbed ones ride an arc
|
for (let i = pellets.length - 1; i >= 0; i--) { // gun rounds flat and fast; lobbed ones ride an arc
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ const { update, forceZombies, splatPoint, restartRun, flickTarget } = await impo
|
|||||||
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill } = await import('./src/beats.js');
|
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill } = await import('./src/beats.js');
|
||||||
const { zombies, spawnZombie, pellets } = await import('./src/zombies.js');
|
const { zombies, spawnZombie, pellets } = await import('./src/zombies.js');
|
||||||
const { killPlayer } = await import('./src/zombies.js');
|
const { killPlayer } = await import('./src/zombies.js');
|
||||||
const { drillTarget } = await import('./src/drill.js');
|
const { drillTarget, DRILL_TYPES } = await import('./src/drill.js');
|
||||||
const { TUNE, bossArt, COURT } = await import('./src/config.js');
|
const { TUNE, bossArt, COURT } = await import('./src/config.js');
|
||||||
|
|
||||||
const DT = 1 / 60;
|
const DT = 1 / 60;
|
||||||
@@ -282,8 +282,13 @@ function drillPlaying(seconds = 1) {
|
|||||||
// kill a body the way every weapon does — through the shared funnel, via the ball's contact test.
|
// kill a body the way every weapon does — through the shared funnel, via the ball's contact test.
|
||||||
// Returns false when it is already down: a ball carries through the lane, so a shot at one body can
|
// Returns false when it is already down: a ball carries through the lane, so a shot at one body can
|
||||||
// take the one behind it (see PROJECT.md's open question).
|
// take the one behind it (see PROJECT.md's open question).
|
||||||
const drillKillBib = bib => {
|
// the gang's mean speed — per-TYPE vMul means a single body is not a fair sample of the ramp
|
||||||
const zb = zombies.find(z => z.bib === bib);
|
const avgGangSpeed = () => {
|
||||||
|
const g = zombies.filter(z => z.drill);
|
||||||
|
return g.reduce((a, z) => a + z.v / (DRILL_TYPES.find(t => t.v === z.variant) || { vMul: 1 }).vMul, 0) / Math.max(1, g.length);
|
||||||
|
};
|
||||||
|
const drillKillType = v => {
|
||||||
|
const zb = zombies.find(z => z.drill && z.variant === v);
|
||||||
if (!zb) return false;
|
if (!zb) return false;
|
||||||
for (let i = 0; i < 40 && zombies.includes(zb); i++) {
|
for (let i = 0; i < 40 && zombies.includes(zb); i++) {
|
||||||
ball.active = true; ball.lastHit = 'P'; ball.y = 1; ball.pow = 1; ball.bounces = 0;
|
ball.active = true; ball.lastHit = 'P'; ball.y = 1; ball.pow = 1; ball.bounces = 0;
|
||||||
@@ -312,34 +317,36 @@ test('a drill refuses to start while another mode owns the court', () => {
|
|||||||
assert.equal(G.drill, null, 'and leave no half-built round behind');
|
assert.equal(G.drill, null, 'and leave no half-built round behind');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the puzzle is WINNABLE: every called colour is on the far side, and nothing else wears one', () => {
|
test('the puzzle is WINNABLE: every called TYPE is on the far side, and nothing else is in the gang', () => {
|
||||||
startDrill();
|
startDrill();
|
||||||
const called = G.drill.seq;
|
const called = G.drill.seq;
|
||||||
assert.ok(called.length >= 2, 'a combination is called at once');
|
assert.ok(called.length >= 2, 'a combination is called at once');
|
||||||
const bibbed = zombies.filter(z => z.bib !== undefined);
|
const gang = zombies.filter(z => z.drill);
|
||||||
assert.equal(bibbed.length, called.length, 'exactly as many bibs on court as colours called');
|
assert.equal(gang.length, called.length, 'exactly as many bodies in the gang as types called');
|
||||||
assert.deepEqual(bibbed.map(z => z.bib).sort(), called.slice().sort(),
|
assert.deepEqual(gang.map(z => z.variant).sort(), called.slice().sort(),
|
||||||
'the bibs on court ARE the combination — no coloured decoys');
|
'the gang on court IS the combination — nothing called that is not there');
|
||||||
for (const z of bibbed)
|
for (const z of gang)
|
||||||
assert.ok(z.z < COURT.netZ, 'every called body starts on the far side, z=' + z.z.toFixed(1));
|
assert.ok(z.z < COURT.netZ, 'every called body starts on the far side, z=' + z.z.toFixed(1));
|
||||||
assert.equal(new Set(bibbed.map(z => z.bib)).size, bibbed.length, 'a colour names exactly one body');
|
assert.equal(new Set(gang.map(z => z.variant)).size, gang.length, 'a type names exactly one body');
|
||||||
// ...and it stays true as the batch walks in: nothing new ever puts an uncalled colour on court
|
assert.ok(gang.every(z => !'ab'.includes(z.variant)), 'the plain shambler is never in the alphabet');
|
||||||
|
// ...and it stays true as the batch walks in: no new body ever joins the gang mid-round
|
||||||
for (let i = 0; i < 25 * 60; i++) {
|
for (let i = 0; i < 25 * 60; i++) {
|
||||||
update(DT);
|
update(DT);
|
||||||
const live = zombies.filter(z => z.bib !== undefined).map(z => z.bib);
|
for (const z of zombies) if (z.drill)
|
||||||
for (const b of live) assert.ok(G.drill.seq.includes(b), 'an uncalled colour appeared on court');
|
assert.ok(G.drill.seq.includes(z.variant), 'an uncalled type joined the gang');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the eaters are the danger: unbibbed, homing, and killing one is never a fault', () => {
|
test('the eaters are the danger: plain shamblers, homing, and killing one is never a fault', () => {
|
||||||
const eaten = TUNE.eatenR; TUNE.eatenR = 0; // the soak trick: don't get caught mid-measurement
|
const eaten = TUNE.eatenR; TUNE.eatenR = 0; // the soak trick: don't get caught mid-measurement
|
||||||
try {
|
try {
|
||||||
startDrill();
|
startDrill();
|
||||||
for (let i = 0; i < 40 * 60; i++) update(DT);
|
for (let i = 0; i < 40 * 60; i++) update(DT);
|
||||||
const eaters = zombies.filter(z => z.bib === undefined);
|
const eaters = zombies.filter(z => !z.drill);
|
||||||
assert.ok(eaters.length > 0, 'plain shamblers arrive on their own clock');
|
assert.ok(eaters.length > 0, 'plain shamblers arrive on their own clock');
|
||||||
assert.ok(eaters.length <= TUNE.drillEaterMax, 'and are capped at ' + TUNE.drillEaterMax);
|
assert.ok(eaters.length <= TUNE.drillEaterMax, 'and are capped at ' + TUNE.drillEaterMax);
|
||||||
assert.ok(eaters.every(z => !z.drill), 'they are NOT lane-holders — they converge on the player');
|
assert.ok(eaters.every(z => 'ab'.includes(z.variant)),
|
||||||
|
'an eater is ALWAYS a plain shambler — that is what keeps a called type unambiguous');
|
||||||
// killing one must not touch the combination
|
// killing one must not touch the combination
|
||||||
const seq = G.drill.seq.slice(), faults = G.drill.faults;
|
const seq = G.drill.seq.slice(), faults = G.drill.faults;
|
||||||
const e = eaters[0];
|
const e = eaters[0];
|
||||||
@@ -358,15 +365,14 @@ test('the ramp raises the batch, the walk and the eater rate', () => {
|
|||||||
const eaten = TUNE.eatenR; TUNE.eatenR = 0;
|
const eaten = TUNE.eatenR; TUNE.eatenR = 0;
|
||||||
try {
|
try {
|
||||||
startDrill();
|
startDrill();
|
||||||
const earlyBatch = G.drill.seq.length, earlyV = zombies.find(z => z.bib !== undefined).v;
|
const earlyBatch = G.drill.seq.length;
|
||||||
let earlyEaters = 0;
|
const earlyV = avgGangSpeed();
|
||||||
for (let i = 0; i < 40 * 60; i++) { update(DT); }
|
for (let i = 0; i < 40 * 60; i++) { update(DT); }
|
||||||
earlyEaters = zombies.filter(z => z.bib === undefined).length;
|
|
||||||
G.drill.t = TUNE.drillRampT; // jump to the top of the ramp
|
G.drill.t = TUNE.drillRampT; // jump to the top of the ramp
|
||||||
zombies.length = 0; update(DT); // force a fresh batch at full difficulty
|
zombies.length = 0; update(DT); // force a fresh batch at full difficulty
|
||||||
const lateBatch = G.drill.seq.length, lateV = zombies.find(z => z.bib !== undefined).v;
|
const lateBatch = G.drill.seq.length, lateV = avgGangSpeed();
|
||||||
assert.ok(lateBatch > earlyBatch, 'the batch grows: ' + earlyBatch + ' -> ' + lateBatch);
|
assert.ok(lateBatch > earlyBatch, 'the batch grows: ' + earlyBatch + ' -> ' + lateBatch);
|
||||||
assert.ok(lateV > earlyV * 1.5, 'and it walks faster: ' + earlyV.toFixed(2) + ' -> ' + lateV.toFixed(2));
|
assert.ok(lateV > earlyV * 1.4, 'and it walks faster: ' + earlyV.toFixed(2) + ' -> ' + lateV.toFixed(2));
|
||||||
assert.ok(TUNE.drillEaterMin < TUNE.drillEaterEvery, 'and the eaters come sooner');
|
assert.ok(TUNE.drillEaterMin < TUNE.drillEaterEvery, 'and the eaters come sooner');
|
||||||
} finally { TUNE.eatenR = eaten; }
|
} finally { TUNE.eatenR = eaten; }
|
||||||
});
|
});
|
||||||
@@ -374,20 +380,20 @@ test('the ramp raises the batch, the walk and the eater rate', () => {
|
|||||||
test('finishing a batch rolls straight into the next one — no pause, no deal', () => {
|
test('finishing a batch rolls straight into the next one — no pause, no deal', () => {
|
||||||
drillPlaying();
|
drillPlaying();
|
||||||
const seq = G.drill.seq.slice();
|
const seq = G.drill.seq.slice();
|
||||||
for (const bib of seq) drillKillBib(bib);
|
for (const bib of seq) drillKillType(bib);
|
||||||
assert.ok(G.drill.rounds >= 1, 'the round is banked');
|
assert.ok(G.drill.rounds >= 1, 'the round is banked');
|
||||||
update(DT);
|
update(DT);
|
||||||
const bibbed = zombies.filter(z => z.bib !== undefined);
|
const gang = zombies.filter(z => z.drill);
|
||||||
assert.ok(bibbed.length >= 2, 'a fresh batch is already walking in');
|
assert.ok(gang.length >= 2, 'a fresh batch is already walking in');
|
||||||
assert.ok(G.drill.seq.length >= 2, 'with its combination already called');
|
assert.ok(G.drill.seq.length >= 2, 'with its combination already called');
|
||||||
assert.equal(G.drill.step, 0, 'from the top');
|
assert.equal(G.drill.step, 0, 'from the top');
|
||||||
for (const z of bibbed) assert.ok(z.z < COURT.netZ, 'and it starts on the far side, as promised');
|
for (const z of gang) assert.ok(z.z < COURT.netZ, 'and it starts on the far side, as promised');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the drill keeps its own count; the tennis ladder stays out of it', () => {
|
test('the drill keeps its own count; the tennis ladder stays out of it', () => {
|
||||||
drillPlaying();
|
drillPlaying();
|
||||||
const games = score.games.slice(), pts = score.pts.slice();
|
const games = score.games.slice(), pts = score.pts.slice();
|
||||||
for (const bib of G.drill.seq.slice()) drillKillBib(bib);
|
for (const bib of G.drill.seq.slice()) drillKillType(bib);
|
||||||
assert.deepEqual(score.games, games, 'the drill does not bank tennis games');
|
assert.deepEqual(score.games, games, 'the drill does not bank tennis games');
|
||||||
assert.deepEqual(score.pts, pts, '...nor tennis points');
|
assert.deepEqual(score.pts, pts, '...nor tennis points');
|
||||||
});
|
});
|
||||||
@@ -396,16 +402,16 @@ test('two out of turn is a double fault, and the survivors are re-called', () =>
|
|||||||
startDrill();
|
startDrill();
|
||||||
if (G.drill.seq.length < 3) return; // needs a batch with room to be wrong twice
|
if (G.drill.seq.length < 3) return; // needs a batch with room to be wrong twice
|
||||||
const wrong1 = G.drill.seq[G.drill.seq.length - 1]; // the LAST call, killed first
|
const wrong1 = G.drill.seq[G.drill.seq.length - 1]; // the LAST call, killed first
|
||||||
drillKillBib(wrong1);
|
drillKillType(wrong1);
|
||||||
assert.equal(G.drill.faults, 1, 'out of turn is a fault');
|
assert.equal(G.drill.faults, 1, 'out of turn is a fault');
|
||||||
assert.equal(G.drill.lost, 0, 'but not the round');
|
assert.equal(G.drill.lost, 0, 'but not the round');
|
||||||
const wrong2 = G.drill.seq[G.drill.seq.length - 1];
|
const wrong2 = G.drill.seq[G.drill.seq.length - 1];
|
||||||
if (wrong2 !== undefined && G.drill.seq.length > 1) {
|
if (wrong2 !== undefined && G.drill.seq.length > 1) {
|
||||||
drillKillBib(wrong2);
|
drillKillType(wrong2);
|
||||||
assert.equal(G.drill.lost, 1, 'the second one takes the round off you');
|
assert.equal(G.drill.lost, 1, 'the second one takes the round off you');
|
||||||
assert.equal(G.drill.faults, 0, 'and the survivors are re-called with a clean slate');
|
assert.equal(G.drill.faults, 0, 'and the survivors are re-called with a clean slate');
|
||||||
assert.deepEqual(G.drill.seq.slice().sort(),
|
assert.deepEqual(G.drill.seq.slice().sort(),
|
||||||
zombies.filter(z => z.bib !== undefined).map(z => z.bib).sort(),
|
zombies.filter(z => z.drill).map(z => z.variant).sort(),
|
||||||
'the new combination is exactly what is still standing');
|
'the new combination is exactly what is still standing');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -415,22 +421,40 @@ test('THE AUTO-AIM IS GONE: the shot goes where you point it', () => {
|
|||||||
// and the fault unreachable — Oliver: "no skills involved at all... there is no penalty".
|
// and the fault unreachable — Oliver: "no skills involved at all... there is no penalty".
|
||||||
drillPlaying();
|
drillPlaying();
|
||||||
const called = G.drill.seq[G.drill.step];
|
const called = G.drill.seq[G.drill.step];
|
||||||
const wrong = zombies.find(z => z.bib !== called);
|
const wrong = zombies.find(z => z.drill && z.variant !== called);
|
||||||
assert.ok(wrong, 'setup: something else is on court');
|
assert.ok(wrong, 'setup: something else is on court');
|
||||||
const ft = flickTarget(P, { fx: Math.max(-1, Math.min(1, (wrong.x - P.x) / 9)), fy: -0.95, mag: 140 });
|
const ft = flickTarget(P, { fx: Math.max(-1, Math.min(1, (wrong.x - P.x) / 9)), fy: -0.95, mag: 140 });
|
||||||
assert.notEqual(ft.zt && ft.zt.bib, called, 'pointing away from the called body must not hit it');
|
assert.notEqual(ft.zt && ft.zt.variant, called, 'pointing away from the called body must not hit it');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a wrong kill never leaves an un-winnable round', () => {
|
test('a wrong kill never leaves an un-winnable round', () => {
|
||||||
drillPlaying();
|
drillPlaying();
|
||||||
const later = G.drill.seq[G.drill.seq.length - 1]; // kill the LAST call first
|
const later = G.drill.seq[G.drill.seq.length - 1]; // kill the LAST call first
|
||||||
if (G.drill.seq.length > 1 && drillKillBib(later)) {
|
if (G.drill.seq.length > 1 && drillKillType(later)) {
|
||||||
assert.ok(!G.drill.seq.includes(later), 'the dead colour leaves the list rather than blocking it');
|
assert.ok(!G.drill.seq.includes(later), 'the dead type leaves the list rather than blocking it');
|
||||||
assert.ok(G.drill.seq.every(b => zombies.some(z => z.bib === b)),
|
assert.ok(G.drill.seq.every(v => zombies.some(z => z.drill && z.variant === v)),
|
||||||
'every colour still on the list is still on the court');
|
'every type still on the list is still on the court');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a batch body that walks past you counts as caught, never as a free escape', () => {
|
||||||
|
// Types move at different speeds now, and drill bodies hold their lane — so one in a lane you are
|
||||||
|
// not standing in can cross the baseline untouched. Left as a plain despawn that stranded the
|
||||||
|
// sequence asking for a body which no longer existed: the un-winnable state the mode forbids.
|
||||||
|
drillPlaying();
|
||||||
|
const runaway = zombies.find(z => z.drill);
|
||||||
|
assert.ok(runaway, 'setup: a batch is on court');
|
||||||
|
runaway.x = P.x + 5; // a lane the player is nowhere near
|
||||||
|
runaway.z = COURT.L + 1; // ...and over the line
|
||||||
|
const lost = G.drill.lost;
|
||||||
|
update(DT);
|
||||||
|
assert.equal(G.drill.lost, lost + 1, 'it costs the point');
|
||||||
|
assert.ok(!zombies.includes(runaway), 'and it is gone');
|
||||||
|
for (let i = 0; i < 5 * 60; i++) update(DT);
|
||||||
|
assert.ok(G.drill.seq.every(v => zombies.some(z => z.drill && z.variant === v)),
|
||||||
|
'the sequence never names a body that is not on court');
|
||||||
|
});
|
||||||
|
|
||||||
test('being caught costs the point and the court, not the run', () => {
|
test('being caught costs the point and the court, not the run', () => {
|
||||||
drillPlaying();
|
drillPlaying();
|
||||||
const t0 = G.drill.t;
|
const t0 = G.drill.t;
|
||||||
|
|||||||
Reference in new issue
Block a user