diff --git a/PROJECT.md b/PROJECT.md index 609d03b..7bf9f6b 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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 diff --git a/index.html b/index.html index 835746c..a9f7087 100644 --- a/index.html +++ b/index.html @@ -399,6 +399,6 @@ - + diff --git a/src/audio.js b/src/audio.js index 0e2a264..d4aa51e 100644 --- a/src/audio.js +++ b/src/audio.js @@ -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; diff --git a/src/config.js b/src/config.js index 014c2c4..4cc8bce 100644 --- a/src/config.js +++ b/src/config.js @@ -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 diff --git a/src/drill.js b/src/drill.js index cb28535..81be078 100644 --- a/src/drill.js +++ b/src/drill.js @@ -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 }; diff --git a/src/render.js b/src/render.js index 0cd9783..fe8a544 100644 --- a/src/render.js +++ b/src/render.js @@ -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 — diff --git a/src/zombies.js b/src/zombies.js index e4503ba..647b07f 100644 --- a/src/zombies.js +++ b/src/zombies.js @@ -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--;