The colour goes on the court, and the dead go off like everyone else
"the colored zombies should also explode like they did before and make the normal splash sound on destruction.. no bleeps please.. can we make the color as a color circle the walk on? the sign they're holding is not nice. also remove the ball slicing in this mode.. this leads to unwanted hits". The silent deaths were my bug: the hook in killZombie did drillKill(zb) and then RETURNED, and everything below that return is the death — decal, pile, 36-particle burst, splat, headshot crack. It resolves after the splice and without returning now. Both halves are load-bearing and each was a bug in turn: splice first or a freshly drawn combination names the corpse; don't return, or the body simply vanishes. The colour is a disc on the court the body walks on, drawn before the sprite. The bib was a flat quad pinned to a walking sprite — UI stuck onto a character, covering the art it sat on. On the ground it takes the court's own perspective, measures 12.4px across at the spawn line against a chest patch's few, and hides none of the sprite. A hard dark rim plus a bright inner line is what keeps it from reading as another gore decal. No slicing in the drill: a cut ball leaves the corpse and strikes whatever it finds next, which in a mode where the wrong body is the failure is the game choosing your target for you, badly. And the mode has no sound of its own now, deliberately. The umpire reading the list was vetoed, the arpeggio that replaced him was vetoed. blip() is deleted. A kill bursts and splats like every other kill in this game; the strip carries the list. 28 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
abd6d4f789
commit
d775ac40ec
7 files changed
+96
-72
No files matched your search
+34
-6
@@ -647,13 +647,41 @@ survivors; an emptied batch brings in the next one) and it draws from whatever i
|
||||
round asking for a colour which is not on court: precisely the un-winnable state this pass exists to
|
||||
make impossible. **Splice first, resolve second.**
|
||||
|
||||
**SEVENTH PASS — the colour comes off the body, and the mode stops making noises.** Oliver: *"the
|
||||
colored zombies should also explode like they did before and make the normal splash sound on
|
||||
destruction.. no bleeps please.. can we make the color as a color circle the walk on? the sign
|
||||
they're holding is not nice. also remove the ball slicing in this mode.. this leads to unwanted
|
||||
hits"*. Four things, and one of them was a straight bug of mine.
|
||||
|
||||
- **THE BUG: bibbed bodies were dying silently and cleanly.** My hook in `killZombie` did
|
||||
`drillKill(zb); return;` — and everything below that return is THE DEATH: the blood decal, the
|
||||
pile, the 36-particle burst, the splat and the headshot crack. The drill resolves after the splice
|
||||
and **without returning** now. Both halves are load-bearing and each was a bug in turn: splice
|
||||
first or a freshly drawn combination can name the corpse; don't return, or the body just vanishes.
|
||||
- **The colour is a DISC ON THE COURT that the body walks on**, drawn BEFORE the sprite. The bib was
|
||||
a flat quad pinned to a walking sprite — UI stuck onto a character rather than part of the world,
|
||||
and it covered the artwork. On the ground it sits in the court's own perspective, it is far larger
|
||||
in pixels than a chest patch at the same depth (measured 12.4 x 4.2 logical px at the spawn line,
|
||||
24 px wide by the time it reaches you), and it hides none of the sprite. The risk was reading as
|
||||
another gore decal; the answer is the RING — gore is a soft flat blob, this has a hard dark rim and
|
||||
a bright inner line, so it reads as painted court marking. `TUNE.drillRingR` sizes it.
|
||||
**Weakest case is GREEN on grass** — legible thanks to the rim, but it is the one to watch.
|
||||
- **NO SLICING IN THE DRILL.** A cut ball leaves the corpse and strikes whatever it finds next; in a
|
||||
mode where hitting the wrong body IS the failure, that is the game choosing your target for you and
|
||||
choosing it badly. One guard on the `ball.chain` branch. This also retires the old open question
|
||||
about a ball completing two steps of a combination in one shot.
|
||||
- **THE MODE NOW HAS NO SOUND OF ITS OWN, deliberately.** Two attempts have been vetoed: the recorded
|
||||
umpire reading the list ("maximum annoying") and the arpeggio that replaced him ("no bleeps
|
||||
please"). `blip()` is deleted from audio.js, `drillTones` from TUNE, the correct-kill tick from
|
||||
`drillKill`. What is left is the game's own vocabulary — a kill bursts and splats like every other
|
||||
kill — and the strip carries the list. **Do not add a third noise here without a reason**: both
|
||||
attempts read as the mode talking over itself.
|
||||
|
||||
**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** — measured: one serve
|
||||
killed bib 1 and then bib 2 standing behind it, banking two correct steps in one shot. Had the
|
||||
second body been out of order that is an instant fault the player could not have intended. Either
|
||||
the lane is part of the puzzle (it is the same occlusion the `column` shape is built on) or the
|
||||
pierce has to stop on a drill kill. Do not patch it silently — it is a design fork, not a bug.
|
||||
**More likely to come up now** that `column` deliberately stacks bodies in one lane.
|
||||
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
|
||||
happened. A straight ball can still plow through a body standing directly behind another
|
||||
(`TUNE.zPierce`), which is honest occlusion rather than the game aiming for you. Watch it.
|
||||
2. ~~**The tap aims itself.**~~ CLOSED by the second pass above — the snap is gone, you aim.
|
||||
3. ~~Difficulty climbs on sequence LENGTH.~~ CLOSED by the stream — one ramp on time survived now
|
||||
drives walk speed, arrival rate and round length together. What is still NOT ramped is how often
|
||||
|
||||
+1
-1
@@ -399,6 +399,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="src/main.js?v=157"></script>
|
||||
<script type="module" src="src/main.js?v=158"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -266,22 +266,6 @@ export function flame(vol = 0.5) {
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// A short pitched note — the drill's colour arpeggio, and nothing else so far. Deliberately a plain
|
||||
// triangle with a fast decay rather than anything designed: it has to be readable as a PITCH at a
|
||||
// tenth of a second and it has to survive being played every few seconds forever, which is exactly
|
||||
// what the recorded umpire could not do.
|
||||
export function blip(freq, delay = 0, vol = 0.22) {
|
||||
try {
|
||||
const ac = audio(), t = ac.currentTime + delay;
|
||||
const o = ac.createOscillator(), g = ac.createGain();
|
||||
o.type = 'triangle'; o.frequency.setValueAtTime(freq, t);
|
||||
g.gain.setValueAtTime(0.0001, t);
|
||||
g.gain.exponentialRampToValueAtTime(vol, t + 0.012); // a hard attack clicks; 12 ms does not
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.17);
|
||||
o.connect(g).connect(ac.destination); o.start(t); o.stop(t + 0.19);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
export function pock(vol = 0.8, freq = 900) { // racket contact / bounce thock
|
||||
try {
|
||||
const ac = audio(), t = ac.currentTime;
|
||||
|
||||
+2
-1
@@ -365,8 +365,9 @@ export const TUNE = {
|
||||
drillEaterMax: 4, // 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
|
||||
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
|
||||
drillTones: 1, // volume of the colour arpeggio (0 = silent). It replaced the spoken call
|
||||
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,
|
||||
// flippable live from the T panel. DEFAULT ON since 2026-08-08: Oliver judged
|
||||
|
||||
+18
-18
@@ -21,14 +21,16 @@
|
||||
import { TUNE } from './config.js';
|
||||
import { G } from './state.js';
|
||||
import { zombies, spawnZombie } from './zombies.js';
|
||||
import { voice, blip, sfx } from './audio.js';
|
||||
import { voice } from './audio.js';
|
||||
|
||||
// The bib colours. Six, and a colour is on court at most once, so a colour always names exactly one
|
||||
// body. `tone` is its PITCH — see the arpeggio in newRound. The recorded umpire reading the list
|
||||
// aloud is deleted (Oliver: "daniel announcing is maximum annoying"): at ~0.8 s a word he needed
|
||||
// four seconds to get through a list of four, and with rounds running continuously that is a man
|
||||
// talking over the entire mode. A rising figure says the same thing in half a second and does not
|
||||
// wear out. (`drill_*.m4a` stay in assets — cheap to keep, and a slower variant could want them.)
|
||||
// The colours. Six, and a colour is on court at most once, so a colour always names exactly one body.
|
||||
//
|
||||
// THE MODE HAS NO SOUND OF ITS OWN, and that is now deliberate. Two attempts have been vetoed: the
|
||||
// recorded umpire reading the list aloud ("daniel announcing is maximum annoying" — at ~0.8 s a word
|
||||
// he needed four seconds for a list of four, over and over), and the arpeggio that replaced him
|
||||
// ("no bleeps please"). What is left is the game's own vocabulary: a kill bursts and splats like
|
||||
// every other kill in the game, and the strip carries the list. Do not put a third noise here
|
||||
// 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
|
||||
// oversight. Die-face dots rode on the bib and the HUD chip so the mode could be played without
|
||||
@@ -37,12 +39,12 @@ import { voice, blip, sfx } from './audio.js';
|
||||
// Oliver's call. If the mode ships, the answer is bibs that differ by SHAPE or marking at sprite
|
||||
// scale, not dots: a channel that fails at the far baseline is not a channel.
|
||||
export const DRILL_COLS = [
|
||||
{ key: 'red', col: '#e5342c', tone: 392, name: 'RED' },
|
||||
{ key: 'blue', col: '#2f6bff', tone: 440, name: 'BLUE' },
|
||||
{ key: 'yellow', col: '#f2c318', tone: 494, name: 'YELLOW' },
|
||||
{ key: 'green', col: '#27bd4a', tone: 587, name: 'GREEN' },
|
||||
{ key: 'purple', col: '#9b3fd8', tone: 659, name: 'PURPLE' },
|
||||
{ key: 'white', col: '#f2f0e8', tone: 784, name: 'WHITE' },
|
||||
{ key: 'red', col: '#e5342c', name: 'RED' },
|
||||
{ key: 'blue', col: '#2f6bff', name: 'BLUE' },
|
||||
{ key: 'yellow', col: '#f2c318', name: 'YELLOW' },
|
||||
{ key: 'green', col: '#27bd4a', name: 'GREEN' },
|
||||
{ key: 'purple', col: '#9b3fd8', name: 'PURPLE' },
|
||||
{ key: 'white', col: '#f2f0e8', name: 'WHITE' },
|
||||
];
|
||||
|
||||
// ---- THE RAMP. One number, 0..1 off time survived; everything else is a lerp on it. Time, not
|
||||
@@ -111,10 +113,7 @@ function newRound(d) {
|
||||
const pool = bibs();
|
||||
d.step = 0; d.faults = 0;
|
||||
d.seq = pool.length ? shuffle(pool.map(z => z.bib)) : [];
|
||||
// THE CALL, without the man: one short note per colour, in the called order. Half a second for the
|
||||
// whole figure. TUNE.drillTones 0 plays it silent.
|
||||
if (TUNE.drillTones > 0)
|
||||
d.seq.forEach((b, i) => blip(DRILL_COLS[b].tone, i * 0.13, 0.22 * TUNE.drillTones));
|
||||
// (no sound: the strip IS the call — see the note on DRILL_COLS)
|
||||
}
|
||||
|
||||
// Called by beats.startDrill once the court is its own.
|
||||
@@ -146,7 +145,8 @@ export function drillKill(zb) {
|
||||
const d = G.drill; if (!d || zb.bib === undefined) return;
|
||||
if (zb.bib === d.seq[d.step]) {
|
||||
d.step++;
|
||||
sfx('pock', 0.7);
|
||||
// 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
|
||||
if (d.step >= d.seq.length) { // round complete — and the next one is already here
|
||||
d.rounds++;
|
||||
d.msg = { text: 'GOOD', t: 0.9 };
|
||||
|
||||
+28
-23
@@ -662,8 +662,8 @@ function drawZombie(zb) {
|
||||
// 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
|
||||
// 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));
|
||||
if (zb.bib !== undefined) drawBib(zb, h, bob);
|
||||
if (zb.burnT > 0) drawFlames(zb, h);
|
||||
if (zb.windT > 0 && zb.variant === 'B') { // the boss telegraphs too: it rears up, glowing, then throws
|
||||
const k = Math.max(0, 1 - zb.windT / (zb.windMax || TUNE.bossWind)), p = proj(zb.x, h * 0.62, zb.z); // fade over the design's own wind
|
||||
@@ -681,32 +681,37 @@ function drawZombie(zb) {
|
||||
if (v === 'B' && zb.hpMax) drawBossHp(zb, h);
|
||||
}
|
||||
|
||||
// THE TRAINING BIB — the drill's colour, worn. ponytail: this is a DRAWN OVERLAY, and it knowingly
|
||||
// breaks the house rule that says add sprite frames rather than fake them (see PROJECT.md). It is
|
||||
// here because the mode is a feel-test and six bib sheets x every walk frame is a week of art to
|
||||
// answer a question a rectangle answers today. If the drill survives its playtest, this function is
|
||||
// the thing to delete: the shipping version is a real bib sprite per colour, drawn like a wound skin.
|
||||
// THE COLOUR IS A DISC ON THE COURT, and the body walks on it. It was a bib worn on the chest and
|
||||
// Oliver's verdict was "the sign they're holding is not nice" — which it was: a flat quad pinned to
|
||||
// a walking sprite reads as UI stuck onto a character rather than as part of the world, and it fought
|
||||
// the artwork it was covering.
|
||||
//
|
||||
// It is a TRAPEZOID, not a rectangle — a flat bar over a walking body reads as a UI element stuck to
|
||||
// the screen; narrowing at the shoulders reads as cloth on a torso. And it carries PIPS as well as a
|
||||
// hue: about 8% of men cannot separate the red from the green, and a mode whose entire input is
|
||||
// "which colour is that" owes them a second channel.
|
||||
function drawBib(zb, hMeters, bob) {
|
||||
// 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 h = hMeters * p.s;
|
||||
const y0 = p.y - h * 0.68 - bob, y1 = p.y - h * 0.42 - bob; // chest to waist
|
||||
// SHOULDERS WIDER THAN WAIST. The first cut flared the other way and every body was wearing a
|
||||
// sandwich board — a shape that widens as it goes down is furniture, not cloth on a torso.
|
||||
const wTop = h * 0.185, wBot = h * 0.155;
|
||||
const cx = Math.round(p.x);
|
||||
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.moveTo(cx - wTop, y0); ctx.lineTo(cx + wTop, y0);
|
||||
ctx.lineTo(cx + wBot, y1); ctx.lineTo(cx - wBot, y1);
|
||||
ctx.closePath(); ctx.fill();
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.30)'; // a seam, so it sits ON the body
|
||||
ctx.fillRect(cx - wTop, Math.round(y0), wTop * 2, Math.max(1, h * 0.012));
|
||||
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 —
|
||||
|
||||
+13
-7
@@ -477,12 +477,6 @@ function killZombie(i, quiet) {
|
||||
const zb = zombies[i];
|
||||
// The drill's ONE rule, read at the single point every weapon routes through — hooking the ball
|
||||
// path instead would have the swish, the pellets and the blast silently exempt from the order.
|
||||
// SPLICE FIRST, THEN RESOLVE. drillKill can draw a new combination on the spot (a double fault
|
||||
// re-calls the survivors, an emptied batch brings in the next one) and it draws from whatever is
|
||||
// standing in `zombies` — so running it before the splice let the new combination name the body
|
||||
// that was in the middle of dying. That is a round asking for a colour which is not on court,
|
||||
// i.e. exactly the un-winnable state this mode is built to make impossible.
|
||||
if (G.drill && zb.bib !== undefined) { zombies.splice(i, 1); drillKill(zb); return; }
|
||||
const mul = GORE_MUL[zb.variant] || 1;
|
||||
// BEFORE the early returns below: the ball machine is furniture but it is still a thing you put
|
||||
// down, so it earns a row. The barrel is loot and scores nothing anywhere else in the game —
|
||||
@@ -491,6 +485,15 @@ function killZombie(i, quiet) {
|
||||
if (zb.variant !== 'o')
|
||||
tally(zb.variant === 'B' ? 'B' + Math.max(0, BOSS_ART.indexOf(zb.art)) : zb.variant, CAUSE);
|
||||
zombies.splice(i, 1);
|
||||
// THE DRILL RESOLVES HERE — after the splice, and WITHOUT returning. Both halves are load-bearing:
|
||||
// splice first, because drillKill can draw a new combination on the spot (a double fault
|
||||
// re-calls the survivors, an emptied batch brings in the next one) off whatever is standing in
|
||||
// `zombies` — resolving first let the new combination name the body that was in the middle of
|
||||
// dying, i.e. a round asking for a colour that is not on court;
|
||||
// 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
|
||||
// bodies died silently and cleanly, which is not what this game does to anybody.
|
||||
if (G.drill && zb.bib !== undefined) drillKill(zb);
|
||||
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 === 'm') { // furniture, not a body: no blood, no mound, no mop boy
|
||||
@@ -704,7 +707,10 @@ function splatter(i, fanfare = true) { // the MAIN ball hit a zombie
|
||||
// steward or boss ever carries one on. See sliceCarom for why this is a single strike and not a
|
||||
// steer. The new arc is solved from the CORPSE's position, not the ball's, because at this point
|
||||
// in the frame the ball is still a hair short of the body it just burst.
|
||||
if (ball.chain > 0) {
|
||||
// NOT IN THE DRILL. A slice leaves the corpse and strikes whatever it finds next — in a mode where
|
||||
// hitting the wrong body IS the failure that is the game choosing your target for you, and choosing
|
||||
// it badly. Oliver: "remove the ball slicing in this mode.. this leads to unwanted hits".
|
||||
if (ball.chain > 0 && !G.drill) {
|
||||
const nx = nearestTo(zx, zz, TUNE.sliceR, ball, TUNE.sliceArc * Math.PI / 180);
|
||||
if (nx) {
|
||||
ball.chain--;
|
||||
|
||||
Reference in new issue
Block a user