Fire that looks like fire, a rack you can leave, and a boss you can read
The band work from f352612 rides along; everything else is this round's list.
- proj returns a NEGATIVE scale behind the camera, so four ellipse call sites
could be handed negative radii — ctx.ellipse throws on those, and the throw
killed the rest of the frame (no HUD, no rack, no band). All four now skip
points behind the camera.
- a burst tin is no longer a commitment: tapping another slot aborts it, spends
it and hands back the serve loop. Lives in selectWeapon, so the rack tap and
the 1-0 keys both get it. A horde still locks the rack — that is the one place
your finger is already spoken for. Spending splices the rack, so the tap
resolves to a weapon id and re-finds it rather than trusting the index.
- beacon 1.6 -> 2.4 rounds/s: a lob cannon you could not leave AND could not
feel working was two problems.
- FIRE. Flamer throws fat embers instead of a one-pixel thread; burning bodies
get nine tongues over a heat bloom plus smoke; the fire ball gets a halo and a
real tail; an ash death gets twice the motes at twice the life over a ground
bloom and a column. The flamer had no sfx case at all and was using the gun's
dry tick — it has its own roar now.
- chapter bosses carry an HP bar over their head. The no-HP-bar rule stands for
everything else and the comment says why a boss is the exception.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f352612de3
commit
655b86d27f
7 files changed
+184
-31
No files matched your search
@@ -182,6 +182,36 @@ export function swish(vol = 0.3, f0 = 1600, f1 = 3200, dur = 0.16) {
|
|||||||
}
|
}
|
||||||
export const whoosh = (vol = 0.34) => swish(vol, 4200, 700, 0.3); // a panel flying in and settling
|
export const whoosh = (vol = 0.34) => swish(vol, 4200, 700, 0.3); // a panel flying in and settling
|
||||||
|
|
||||||
|
// THE FLAMETHROWER. It was borrowing pock(0.25, 300) — a short dry bandpassed tick — on every other
|
||||||
|
// round, so the loudest-looking weapon in the game sounded like someone tapping a table. A flame is
|
||||||
|
// broadband ROAR with a low guttural body under it, and it has to survive being retriggered ~15
|
||||||
|
// times a second without turning into a buzz, so each call is a soft-attacked overlapping puff that
|
||||||
|
// sums into a continuous sheet rather than a train of clicks.
|
||||||
|
export function flame(vol = 0.5) {
|
||||||
|
try {
|
||||||
|
const ac = audio(), t = ac.currentTime, dur = 0.26;
|
||||||
|
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac);
|
||||||
|
// the roar: wide noise under a lowpass that opens then closes, which is the breath of it
|
||||||
|
const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.Q.value = 3;
|
||||||
|
lp.frequency.setValueAtTime(700, t);
|
||||||
|
lp.frequency.exponentialRampToValueAtTime(2600, t + dur * 0.25);
|
||||||
|
lp.frequency.exponentialRampToValueAtTime(500, t + dur);
|
||||||
|
const g = ac.createGain(); // soft attack: a hard one is a click, and 15/s of clicks is a buzz
|
||||||
|
g.gain.setValueAtTime(0.001, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(vol, t + 0.03);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
|
||||||
|
src.connect(lp).connect(g).connect(ac.destination); src.start(t, nOff(dur), dur);
|
||||||
|
const o = ac.createOscillator(), og = ac.createGain(); // the guttural body under the hiss
|
||||||
|
o.type = 'sawtooth';
|
||||||
|
o.frequency.setValueAtTime(64 + Math.random() * 22, t);
|
||||||
|
o.frequency.exponentialRampToValueAtTime(38, t + dur);
|
||||||
|
og.gain.setValueAtTime(0.001, t);
|
||||||
|
og.gain.exponentialRampToValueAtTime(vol * 0.35, t + 0.04);
|
||||||
|
og.gain.exponentialRampToValueAtTime(0.001, t + dur);
|
||||||
|
o.connect(og).connect(ac.destination); o.start(t); o.stop(t + dur + 0.02);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
export function pock(vol = 0.8, freq = 900) { // racket contact / bounce thock
|
export function pock(vol = 0.8, freq = 900) { // racket contact / bounce thock
|
||||||
try {
|
try {
|
||||||
const ac = audio(), t = ac.currentTime;
|
const ac = audio(), t = ac.currentTime;
|
||||||
|
|||||||
@@ -568,7 +568,10 @@ export const WEAPONS = {
|
|||||||
// ---- the four that come out of a barrel. Won by shooting one, never delivered.
|
// ---- the four that come out of a barrel. Won by shooting one, never delivered.
|
||||||
// A LOBBED tennis ball twice the weight of yours, thrown on its own beat, changing colour every
|
// A LOBBED tennis ball twice the weight of yours, thrown on its own beat, changing colour every
|
||||||
// shot and dragging its colour behind it. The showiest thing in the game and the one you meet first.
|
// shot and dragging its colour behind it. The showiest thing in the game and the one you meet first.
|
||||||
beacon: { label: 'BEACON', icon: 'w_beacon', tin: 1, kind: 'burst', len: 11, rate: 1.6,
|
// rate was 1.6: about one lob every two thirds of a second, which is a long time to stand still
|
||||||
|
// late on. The abort in selectWeapon is the real answer to "I picked the wrong tin", but a lob
|
||||||
|
// cannon you cannot leave AND cannot feel working was two problems, so it throws faster now.
|
||||||
|
beacon: { label: 'BEACON', icon: 'w_beacon', tin: 1, kind: 'burst', len: 11, rate: 2.4,
|
||||||
r: 1.2, dmg: 2, hp: 4, arc: 1, hue: 1, trail: 1, trailR: 0.035, glow: 3.2, speed: 19,
|
r: 1.2, dmg: 2, hp: 4, arc: 1, hue: 1, trail: 1, trailR: 0.035, glow: 3.2, speed: 19,
|
||||||
sfx: 'thump', // it LOBS — a soft low thump as it goes, not a rifle crack
|
sfx: 'thump', // it LOBS — a soft low thump as it goes, not a rifle crack
|
||||||
desc: ['IT THROWS ITSELF', 'TWICE THE BALL, EVERY COLOUR'] },
|
desc: ['IT THROWS ITSELF', 'TWICE THE BALL, EVERY COLOUR'] },
|
||||||
@@ -577,9 +580,12 @@ export const WEAPONS = {
|
|||||||
// It REACHES THE FAR BASELINE (life 1.15 s x 23 m/s = ~26 m), it PIERCES, and it does no impact
|
// It REACHES THE FAR BASELINE (life 1.15 s x 23 m/s = ~26 m), it PIERCES, and it does no impact
|
||||||
// damage at all: dmg/hp 0. Fire is not a bullet — being hit sets you alight and the burn does the
|
// damage at all: dmg/hp 0. Fire is not a bullet — being hit sets you alight and the burn does the
|
||||||
// killing, so a lit crowd walks a few steps and goes down together. That is the whole weapon.
|
// killing, so a lit crowd walks a few steps and goes down together. That is the whole weapon.
|
||||||
|
// trail 2 -> 4 and a WIDE trailR: an ignite round throws fat drifting embers rather than the
|
||||||
|
// beacon's one-pixel thread (see the pe.ignite branch in the trail block), so the stream reads as
|
||||||
|
// a body of fire with edges instead of a line of dots. glow 2 -> 2.8 to match.
|
||||||
flamer: { label: 'FLAMER', icon: 'w_flamer', tin: 1, kind: 'burst', len: 6, rate: 30,
|
flamer: { label: 'FLAMER', icon: 'w_flamer', tin: 1, kind: 'burst', len: 6, rate: 30,
|
||||||
r: 1.15, dmg: 0, hp: 0, pierce: true, speed: 23, life: 1.15, ignite: true, igniteR: 2.6,
|
r: 1.15, dmg: 0, hp: 0, pierce: true, speed: 23, life: 1.15, ignite: true, igniteR: 2.6,
|
||||||
trail: 2, col: '#ff8420', glow: 2, spread: 3.1, sfx: 'flame',
|
trail: 4, trailR: 0.34, col: '#ff8420', glow: 2.8, spread: 3.1, sfx: 'flame',
|
||||||
desc: ['A WALL OF FIRE', 'AND THE FIRE SPREADS'] },
|
desc: ['A WALL OF FIRE', 'AND THE FIRE SPREADS'] },
|
||||||
// The normal ball gesture, made spectacular: pull, aim, release — and instead of a ball you get a
|
// The normal ball gesture, made spectacular: pull, aim, release — and instead of a ball you get a
|
||||||
// blue plane that widens down the court and cuts through everything standing in it.
|
// blue plane that widens down the court and cuts through everything standing in it.
|
||||||
|
|||||||
+33
-3
@@ -565,7 +565,27 @@ const slotOf = id => { // -1 if you are not carrying it
|
|||||||
};
|
};
|
||||||
export function selectWeapon(i) {
|
export function selectWeapon(i) {
|
||||||
if (G.interlude) return; // the sudden-death duel is played under tennis rules — plain balls only
|
if (G.interlude) return; // the sudden-death duel is played under tennis rules — plain balls only
|
||||||
|
// A HORDE is the one place the rack stays locked: your finger is steering the man, the racket
|
||||||
|
// fires itself, and there is no gesture left to spend on a tin. Everything else can be switched
|
||||||
|
// out of, including mid-burst — see below.
|
||||||
|
if (G.horde) return;
|
||||||
if (slotStock(i) <= 0) return; // dud: nothing in that slot (slot 0 is only ever empty if a spent
|
if (slotStock(i) <= 0) return; // dud: nothing in that slot (slot 0 is only ever empty if a spent
|
||||||
|
// A BURST IS NOT A COMMITMENT. Committing to a burst tin used to lock the rack for its whole
|
||||||
|
// window — eleven seconds of beacon lobs while a late-game crowd walks into you is a death you
|
||||||
|
// can watch coming and do nothing about, which is exactly what Oliver hit. Tapping another slot
|
||||||
|
// ABORTS it: the tin is spent (you did fire it) and the serve loop comes straight back, the same
|
||||||
|
// handover updateZombies does when the window ends on its own. Both input paths land here — the
|
||||||
|
// rack tap and the 1-0 keys — so the abort lives here rather than in either of them.
|
||||||
|
if (G.mgT > 0) {
|
||||||
|
// ...but spending a tin can EMPTY it, and spendSlot splices an empty entry out, which shifts
|
||||||
|
// every slot above it down one. A tap is at a slot; what you meant was the weapon you could see
|
||||||
|
// in it. So resolve to the id first and look it up again afterwards — otherwise aborting a
|
||||||
|
// beacon in slot 1 to reach the rocket in slot 4 hands you whatever slid into slot 4.
|
||||||
|
const want = slotId(i);
|
||||||
|
G.mgT = 0; endBurst(); setupPoint();
|
||||||
|
const j = slotOf(want);
|
||||||
|
i = j >= 0 ? j : 0; // the tin we just spent WAS the one tapped: fall back to BALL
|
||||||
|
}
|
||||||
G.weapon = i; // temp has just been cleared, and then it is BALL again)
|
G.weapon = i; // temp has just been cleared, and then it is BALL again)
|
||||||
sfx('weapon-click2.mp3', 0.45); // a real mechanical click, quiet — switching is audible, not loud
|
sfx('weapon-click2.mp3', 0.45); // a real mechanical click, quiet — switching is audible, not loud
|
||||||
}
|
}
|
||||||
@@ -1527,9 +1547,19 @@ export function update(dt) {
|
|||||||
|
|
||||||
// --- ball physics
|
// --- ball physics
|
||||||
if (ball.active) {
|
if (ball.active) {
|
||||||
if (ball.flame) parts.push({ // fire trail: one ember per frame, brief and rising
|
// fire trail. One brief ember a frame read as a spark, not as a ball that is ON FIRE — this is
|
||||||
x: ball.x, y: ball.y, z: ball.z, t: 0.22, fire: true,
|
// the weapon whose whole pitch is "kills on any touch and sets the others alight", so it now
|
||||||
vx: (Math.random() * 2 - 1) * 0.6, vy: 0.8, vz: (Math.random() * 2 - 1) * 0.6 });
|
// sheds THREE embers a frame with real spread, they live long enough to hang as a tail behind
|
||||||
|
// the arc, and every third leaves a puff of smoke drifting off the top of it.
|
||||||
|
if (ball.flame) {
|
||||||
|
for (let k = 0; k < 3; k++) parts.push({
|
||||||
|
x: ball.x + (Math.random() * 2 - 1) * 0.14, y: ball.y + (Math.random() * 2 - 1) * 0.14,
|
||||||
|
z: ball.z + (Math.random() * 2 - 1) * 0.14, t: 0.34 + Math.random() * 0.26, fire: true,
|
||||||
|
vx: (Math.random() * 2 - 1) * 0.9, vy: 0.9 + Math.random() * 0.7, vz: (Math.random() * 2 - 1) * 0.9 });
|
||||||
|
if (Math.random() < 0.34) parts.push({ x: ball.x, y: ball.y + 0.15, z: ball.z,
|
||||||
|
vx: (Math.random() * 2 - 1) * 0.3, vy: 0.7, vz: (Math.random() * 2 - 1) * 0.3,
|
||||||
|
t: 0.5 + Math.random() * 0.4, smoke: true });
|
||||||
|
}
|
||||||
ball.vy -= (ball.g || TUNE.gravity) * dt;
|
ball.vy -= (ball.g || TUNE.gravity) * dt;
|
||||||
const oldZ = ball.z;
|
const oldZ = ball.z;
|
||||||
ball.x += ball.vx * dt; ball.y += ball.vy * dt; ball.z += ball.vz * dt;
|
ball.x += ball.vx * dt; ball.y += ball.vy * dt; ball.z += ball.vz * dt;
|
||||||
|
|||||||
+1
-1
@@ -359,6 +359,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="main.js?v=137"></script>
|
<script type="module" src="main.js?v=138"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -72,7 +72,7 @@ cv.addEventListener('touchstart', e => {
|
|||||||
const r = cv.getBoundingClientRect();
|
const r = cv.getBoundingClientRect();
|
||||||
for (const t of e.changedTouches) {
|
for (const t of e.changedTouches) {
|
||||||
if (dismissModal()) return; // the popup owns this gesture and nothing else sees it
|
if (dismissModal()) return; // the popup owns this gesture and nothing else sees it
|
||||||
if (!G.horde && !(G.mgT > 0)) { // rack tap selects a tin — but never during finger-steer modes
|
if (!G.horde) { // rack tap selects a tin — a burst can be aborted out of, a horde cannot (selectWeapon)
|
||||||
const slot = rackHit((t.clientX - r.left) / r.width, (t.clientY - r.top) / r.height);
|
const slot = rackHit((t.clientX - r.left) / r.width, (t.clientY - r.top) / r.height);
|
||||||
if (slot >= 0) { selectWeapon(slot); continue; }
|
if (slot >= 0) { selectWeapon(slot); continue; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -424,9 +424,17 @@ function drawBall(x, y, z, glint = false, bomb = false, flame = false) {
|
|||||||
if (Math.floor(G.idleT * 20) % 2) { ctx.fillStyle = '#ff8010'; ctx.fillRect(bp.x - 0.5, bp.y - r - 1, Math.max(1, r * 0.5), Math.max(1, r * 0.6)); }
|
if (Math.floor(G.idleT * 20) % 2) { ctx.fillStyle = '#ff8010'; ctx.fillRect(bp.x - 0.5, bp.y - r - 1, Math.max(1, r * 0.5), Math.max(1, r * 0.6)); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (flame) { // white-hot core in an orange shell, flickering
|
if (flame) { // a burning ball, not an orange one: an additive halo, then shell, then white core
|
||||||
ctx.fillStyle = '#ff8010'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r + (Math.floor(G.idleT * 24) % 2 ? 1 : 0), 0, 7); ctx.fill();
|
const fl = Math.floor(G.idleT * 24) % 2 ? 1 : 0;
|
||||||
ctx.fillStyle = '#ffe8a0'; ctx.beginPath(); ctx.arc(bp.x, bp.y, Math.max(1, r * 0.5), 0, 7); ctx.fill();
|
ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||||||
|
for (const [rf, af] of [[3.1, 0.16], [2.0, 0.26]]) { // the halo is what makes it read as heat
|
||||||
|
ctx.globalAlpha = af; ctx.fillStyle = '#ff7a10';
|
||||||
|
ctx.beginPath(); ctx.arc(bp.x, bp.y, r * rf + fl, 0, 7); ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.restore(); ctx.globalAlpha = 1;
|
||||||
|
ctx.fillStyle = '#ff8010'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r * 1.25 + fl, 0, 7); ctx.fill();
|
||||||
|
ctx.fillStyle = '#ffc030'; ctx.beginPath(); ctx.arc(bp.x, bp.y, Math.max(1, r * 0.8), 0, 7); ctx.fill();
|
||||||
|
ctx.fillStyle = '#fffbe0'; ctx.beginPath(); ctx.arc(bp.x, bp.y, Math.max(1, r * 0.42), 0, 7); ctx.fill();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ctx.fillStyle = UI.ball; ctx.beginPath(); ctx.arc(bp.x, bp.y, r, 0, 7); ctx.fill();
|
ctx.fillStyle = UI.ball; ctx.beginPath(); ctx.arc(bp.x, bp.y, r, 0, 7); ctx.fill();
|
||||||
@@ -655,24 +663,63 @@ function drawZombie(zb) {
|
|||||||
ctx.beginPath(); ctx.arc(Math.round(p.x), Math.round(p.y), r, 0, 7); ctx.fill();
|
ctx.beginPath(); ctx.arc(Math.round(p.x), Math.round(p.y), r, 0, 7); ctx.fill();
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
}
|
}
|
||||||
// No HP bar. It was the only floating UI on a body anywhere in the game, it was on screen for
|
// NO HP BAR ON A SHAMBLER — that one was cut and stays cut: it was the only floating UI on a body
|
||||||
// almost the whole run (a boss respawns every game you win), and nobody knew what it meant —
|
// anywhere in the game, it was up for almost the whole run, and the wound SKINS already encode the
|
||||||
// while the wound SKINS above already encode exactly the same information in the language every
|
// same thing in the language every other multi-hit enemy uses. A CHAPTER BOSS is the exception,
|
||||||
// other multi-hit enemy uses. The first-sight modal in spawnRacketZombie explains him instead.
|
// and for the reason the rule exists: it does NOT re-skin as it takes damage, its pool runs to
|
||||||
|
// dozens of points, and the fight is long enough that "am I getting anywhere" is a real question
|
||||||
|
// with no other answer on screen. One boss, on screen only during its own fight.
|
||||||
|
if (v === 'B' && zb.hpMax) drawBossHp(zb, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The boss's remaining life, over its head, in the scoreboard's own 16-bit language: a dark sunk
|
||||||
|
// well, a bevel, and a fill that runs gold -> orange -> red as the pool empties, so the colour says
|
||||||
|
// how the fight is going without reading the length. Integer-snapped and a FIXED pixel size — this
|
||||||
|
// is HUD, and a bar that grew as the boss walked toward you would read as the boss healing.
|
||||||
|
function drawBossHp(zb, hMeters) {
|
||||||
|
// 1.06 sat ON the hat — a bar touching the sprite reads as part of the costume. It hangs clear
|
||||||
|
// now, and it hangs from its own BOTTOM edge so the gap is the gap you see rather than the gap
|
||||||
|
// minus the bar. The anchor is the body's own height, so it stays put as the boss scales up by
|
||||||
|
// chapter instead of drifting into the crowd.
|
||||||
|
const p = proj(zb.x, hMeters * 1.3, zb.z);
|
||||||
|
if (p.s <= 0) return; // behind the camera: see drawAimMark
|
||||||
|
const k = Math.max(0, Math.min(1, zb.hp / zb.hpMax));
|
||||||
|
const w = 46, h = 5, x = Math.round(p.x - w / 2), y = Math.round(p.y) - h;
|
||||||
|
ctx.fillStyle = 'rgba(6,4,6,0.85)'; ctx.fillRect(x - 1, y - 1, w + 2, h + 2); // frame
|
||||||
|
ctx.fillStyle = '#2a1518'; ctx.fillRect(x, y, w, h); // empty well
|
||||||
|
if (k > 0) {
|
||||||
|
ctx.fillStyle = k > 0.5 ? UI.pick : k > 0.22 ? '#ff8010' : UI.bad;
|
||||||
|
ctx.fillRect(x, y, Math.max(1, Math.round(w * k)), h);
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.28)'; ctx.fillRect(x, y, Math.max(1, Math.round(w * k)), 1); // bevel
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Fire, drawn procedurally instead of as a sprite sheet: a few vertically-stretched quads whose
|
// Fire, drawn procedurally instead of as a sprite sheet: a few vertically-stretched quads whose
|
||||||
// offsets ride each body's own phase (walkT), so no two burning zombies flicker in sync — and it
|
// offsets ride each body's own phase (walkT), so no two burning zombies flicker in sync — and it
|
||||||
// reads at every burn stage, from fresh yellow flare to guttering smoke, for free.
|
// reads at every burn stage, from fresh yellow flare to guttering smoke, for free.
|
||||||
function drawFlames(zb, hMeters) {
|
function drawFlames(zb, hMeters) {
|
||||||
const p = proj(zb.x, 0, zb.z), s = p.s;
|
const p = proj(zb.x, 0, zb.z), s = p.s;
|
||||||
|
if (s <= 0) return;
|
||||||
const k = Math.max(0, Math.min(1, zb.burnT / TUNE.burnTime)); // 1 = just lit, 0 = about to gutter out
|
const k = Math.max(0, Math.min(1, zb.burnT / TUNE.burnTime)); // 1 = just lit, 0 = about to gutter out
|
||||||
const col = k > 0.6 ? '#ffc030' : k > 0.3 ? '#ff8010' : '#6b5a52';
|
const col = k > 0.6 ? '#ffc030' : k > 0.3 ? '#ff8010' : '#6b5a52';
|
||||||
for (let i = 0; i < 5; i++) {
|
// A BODY ON FIRE SHOULD LOOK LIKE ONE. Five thin tongues over the torso read as a pilot light;
|
||||||
|
// this is now a column that engulfs — nine tongues spread across the full silhouette, up to
|
||||||
|
// shoulder height, over an additive heat bloom that is the thing you actually see from across
|
||||||
|
// the court. The bloom dies with k, so a guttering body cools to smoke exactly as before.
|
||||||
|
if (k > 0.15) {
|
||||||
|
ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
||||||
|
const by = p.y - hMeters * s * 0.42, br = hMeters * s * 0.42;
|
||||||
|
for (const [rf, af] of [[1.25, 0.13], [0.72, 0.20]]) {
|
||||||
|
ctx.globalAlpha = af * k; ctx.fillStyle = k > 0.45 ? '#ff9a20' : '#c05a10';
|
||||||
|
ctx.beginPath(); ctx.arc(p.x, by, br * rf, 0, 7); ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.restore(); ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
const ph = G.idleT * (7 + i * 1.7) + zb.walkT * 3.1 + i * 2;
|
const ph = G.idleT * (7 + i * 1.7) + zb.walkT * 3.1 + i * 2;
|
||||||
const fx = p.x + Math.sin(ph) * s * 0.11 + (i - 2) * s * 0.05;
|
const fx = p.x + Math.sin(ph) * s * 0.11 + (i - 4) * s * 0.042;
|
||||||
const fy = p.y - (0.2 + i * 0.15 + 0.25 * k) * hMeters * s - Math.abs(Math.sin(ph * 1.6)) * s * 0.08;
|
const fy = p.y - (0.18 + i * 0.085 + 0.42 * k) * hMeters * s - Math.abs(Math.sin(ph * 1.6)) * s * 0.10;
|
||||||
ctx.fillStyle = i === 0 && k > 0.5 ? '#ffe8a0' : col; // one white-hot core tongue while it rages
|
ctx.fillStyle = i % 4 === 0 && k > 0.5 ? '#fff2c0' : col; // white-hot core tongues while it rages
|
||||||
ctx.fillRect(fx | 0, fy | 0, Math.max(1, s * 0.045), Math.max(2, s * (0.10 + 0.07 * Math.abs(Math.sin(ph * 2)))));
|
ctx.fillRect(fx | 0, fy | 0, Math.max(1, s * 0.05), Math.max(2, s * (0.13 + 0.10 * Math.abs(Math.sin(ph * 2)))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// AIMING and FLYING are two different pictures out of one arc.
|
// AIMING and FLYING are two different pictures out of one arc.
|
||||||
@@ -921,6 +968,12 @@ function drawAimMark(a, u0, flying) {
|
|||||||
const col = a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0';
|
const col = a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0';
|
||||||
const onBody = !!a.zt;
|
const onBody = !!a.zt;
|
||||||
const m = proj(a.tx, a.ty, a.tz); // a.ty is where the ball ARRIVES — mark and impact are one point
|
const m = proj(a.tx, a.ty, a.tz); // a.ty is where the ball ARRIVES — mark and impact are one point
|
||||||
|
// NOTHING BEHIND THE CAMERA DRAWS. proj's scale is camF/(camZ - z), so a point at or past the
|
||||||
|
// camera plane comes back with s <= 0 — x and y are mirrored nonsense and the radii below go
|
||||||
|
// NEGATIVE, which ctx.ellipse throws on (IndexSizeError). A throw here killed the REST of the
|
||||||
|
// frame: no HUD, no rack, no message band, every frame it happened. A solver can hand us such a
|
||||||
|
// point (a target off the back of the world), so the projection is checked, not the aim.
|
||||||
|
if (m.s <= 0) return;
|
||||||
// it goes with the band once fired: a mark still burning on the target is the other half of the
|
// it goes with the band once fired: a mark still burning on the target is the other half of the
|
||||||
// laser look, and the aim it represents is already spent
|
// laser look, and the aim it represents is already spent
|
||||||
ctx.globalAlpha = (onBody ? 0.42 : 0.24) * (flying ? left * left : 1);
|
ctx.globalAlpha = (onBody ? 0.42 : 0.24) * (flying ? left * left : 1);
|
||||||
@@ -1531,6 +1584,7 @@ export function render() {
|
|||||||
// gore decals: what remains of the visiting team (flat ground layer, then far-side mounds)
|
// gore decals: what remains of the visiting team (flat ground layer, then far-side mounds)
|
||||||
for (const d of gore) {
|
for (const d of gore) {
|
||||||
const p = proj(d.x, 0, d.z);
|
const p = proj(d.x, 0, d.z);
|
||||||
|
if (p.s <= 0) continue; // behind the camera: see drawAimMark
|
||||||
ctx.fillStyle = d.scorch ? `rgba(28,22,16,${d.a})`
|
ctx.fillStyle = d.scorch ? `rgba(28,22,16,${d.a})`
|
||||||
: d.ash ? `rgba(58,56,54,${d.a})` // burnt out: dark grey ash, not blood
|
: d.ash ? `rgba(58,56,54,${d.a})` // burnt out: dark grey ash, not blood
|
||||||
: `rgba(140,18,18,${d.a})`; // craters burn black-brown
|
: `rgba(140,18,18,${d.a})`; // craters burn black-brown
|
||||||
@@ -1733,7 +1787,7 @@ export function render() {
|
|||||||
ctx.beginPath(); ctx.arc(pp.x, pp.y, Math.max(0.6, r * (pe.glow ? 0.62 : 0.45)), 0, 7); ctx.fill();
|
ctx.beginPath(); ctx.arc(pp.x, pp.y, Math.max(0.6, r * (pe.glow ? 0.62 : 0.45)), 0, 7); ctx.fill();
|
||||||
}
|
}
|
||||||
// the chapter boss's shockwave: a ring of blood mist blown out through the crowd noise
|
// the chapter boss's shockwave: a ring of blood mist blown out through the crowd noise
|
||||||
if (G.bossBurst) {
|
if (G.bossBurst && proj(G.bossBurst.x, 0.5, G.bossBurst.z).s > 0) { // behind the camera: see drawAimMark
|
||||||
const b = G.bossBurst, k = 1 - b.t / 0.75, p = proj(b.x, 0.5, b.z);
|
const b = G.bossBurst, k = 1 - b.t / 0.75, p = proj(b.x, 0.5, b.z);
|
||||||
ctx.strokeStyle = `rgba(190,26,26,${(1 - k) * 0.8})`;
|
ctx.strokeStyle = `rgba(190,26,26,${(1 - k) * 0.8})`;
|
||||||
ctx.lineWidth = Math.max(1.5, p.s * 0.05 * (1 - k));
|
ctx.lineWidth = Math.max(1.5, p.s * 0.05 * (1 - k));
|
||||||
|
|||||||
+44
-11
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { TUNE, UI, COURT, CONTACT, BOSS_ART, WEAPONS, BARREL_WEAPONS, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js';
|
import { TUNE, UI, COURT, CONTACT, BOSS_ART, WEAPONS, BARREL_WEAPONS, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js';
|
||||||
import { G, P, ball, sendBoy, setupPoint } from './state.js';
|
import { G, P, ball, sendBoy, setupPoint } from './state.js';
|
||||||
import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh } from './audio.js';
|
import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh, flame } from './audio.js';
|
||||||
import { splatPoint, cheer, spentBall, resolveInterlude, endBossFight, aside, grantWeapon, endBurst } from './gameplay.js';
|
import { splatPoint, cheer, spentBall, resolveInterlude, endBossFight, aside, grantWeapon, endBurst } from './gameplay.js';
|
||||||
import { tally, multiKill, endRun } from './stats.js';
|
import { tally, multiKill, endRun } from './stats.js';
|
||||||
|
|
||||||
@@ -459,14 +459,29 @@ function killZombie(i, quiet) {
|
|||||||
if (pl) { pl.size = Math.min(4, pl.size + Math.round(mul / 2 + 0.5)); if (!ash) pl.ash = false; }
|
if (pl) { pl.size = Math.min(4, pl.size + Math.round(mul / 2 + 0.5)); if (!ash) pl.ash = false; }
|
||||||
else { piles.push({ x: zb.x, z: zb.z, size: Math.min(4, Math.round(mul / 2 + 0.5)), seed: Math.random() * 1e4, blobs: null, builtSize: 0, ash });
|
else { piles.push({ x: zb.x, z: zb.z, size: Math.min(4, Math.round(mul / 2 + 0.5)), seed: Math.random() * 1e4, blobs: null, builtSize: 0, ash });
|
||||||
if (piles.length > 30) piles.shift(); }
|
if (piles.length > 30) piles.shift(); }
|
||||||
const n = Math.round((ash ? 18 : 30) * mul); // ash settles rather than sprays: fewer, slower motes
|
// A BURNT BODY COLLAPSING IS ITS OWN MOMENT. It used to be over faster than a wet kill — fewer
|
||||||
|
// motes AND shorter ones — so the most spectacular way to die in the game was the least visible.
|
||||||
|
// Ash still SETTLES rather than sprays (low, slow, no arc), but there is more of it and it hangs
|
||||||
|
// roughly twice as long, which is what sells "he came apart into a heap" instead of "he blinked".
|
||||||
|
const n = Math.round((ash ? 34 : 30) * mul);
|
||||||
for (let k = 0; k < n; k++) { const dark = k >= n * 2 / 3; parts.push({ // ~1/3 dark & long-lived: settling gore
|
for (let k = 0; k < n; k++) { const dark = k >= n * 2 / 3; parts.push({ // ~1/3 dark & long-lived: settling gore
|
||||||
x: zb.x, y: 0.9, z: zb.z,
|
x: zb.x, y: 0.9, z: zb.z,
|
||||||
vx: (Math.random() * 2 - 1) * (ash ? 1.6 : 3.5), vy: (ash ? 0.6 : 1) + Math.random() * (ash ? 1.6 : 3.5),
|
vx: (Math.random() * 2 - 1) * (ash ? 1.6 : 3.5), vy: (ash ? 0.6 : 1) + Math.random() * (ash ? 1.6 : 3.5),
|
||||||
vz: (Math.random() * 2 - 1) * (ash ? 1.6 : 3.5),
|
vz: (Math.random() * 2 - 1) * (ash ? 1.6 : 3.5),
|
||||||
t: dark ? 0.6 + Math.random() * 0.6 : 0.4 + Math.random() * 0.4, dark, ash,
|
t: ash ? (dark ? 1.3 + Math.random() * 1.1 : 0.9 + Math.random() * 0.8)
|
||||||
|
: (dark ? 0.6 + Math.random() * 0.6 : 0.4 + Math.random() * 0.4), dark, ash,
|
||||||
}); }
|
}); }
|
||||||
if (ash) { for (let k = 0; k < 5; k++) smokePuff(zb.x, 0.4 + Math.random() * 1.1, zb.z, 0.45); pock(0.35, 150); }
|
if (ash) {
|
||||||
|
// the dust cloud: a low wide bloom at the feet where the body folded, and a thinner column
|
||||||
|
// lifting off it. Five tight puffs read as a wisp; this is the body's own volume going up.
|
||||||
|
for (let k = 0; k < 10; k++) smokePuff(zb.x, 0.25 + Math.random() * 0.5, zb.z, 1.0); // the ground bloom
|
||||||
|
for (let k = 0; k < 8; k++) smokePuff(zb.x, 0.8 + Math.random() * 1.5, zb.z, 0.5); // the column
|
||||||
|
for (let k = 0; k < 8; k++) parts.push({ // last embers off the collapse, dying as it settles
|
||||||
|
x: zb.x + (Math.random() * 2 - 1) * 0.5, y: 0.3 + Math.random() * 1.0, z: zb.z,
|
||||||
|
vx: (Math.random() * 2 - 1) * 1.1, vy: 1.0 + Math.random() * 1.4, vz: (Math.random() * 2 - 1) * 1.1,
|
||||||
|
t: 0.3 + Math.random() * 0.35, fire: true });
|
||||||
|
pock(0.4, 130);
|
||||||
|
}
|
||||||
else {
|
else {
|
||||||
splat(1, bodySize(zb)); // the burst is pitched by the body too, so a boss comes apart LOW
|
splat(1, bodySize(zb)); // the burst is pitched by the body too, so a boss comes apart LOW
|
||||||
sfx('headshot-' + (Math.random() < 0.5 ? 1 : 2), 0.6); // random headshot crack layered on the gore burst
|
sfx('headshot-' + (Math.random() < 0.5 ? 1 : 2), 0.6); // random headshot crack layered on the gore burst
|
||||||
@@ -514,10 +529,18 @@ function burnTick(zb, i, dt) {
|
|||||||
ignite(zb.x, zb.z, TUNE.burnSpread);
|
ignite(zb.x, zb.z, TUNE.burnSpread);
|
||||||
// fx off: the embers below already ARE the feedback, a wet pop per cooked hp point would be noise
|
// fx off: the embers below already ARE the feedback, a wet pop per cooked hp point would be noise
|
||||||
const dead = wound(zb, TUNE.burnDps * (isBoss(zb) ? TUNE.bossBurnMul : 1) * dt, false);
|
const dead = wound(zb, TUNE.burnDps * (isBoss(zb) ? TUNE.bossBurnMul : 1) * dt, false);
|
||||||
if (parts.length < 110) for (let k = 0; k < 2; k++) parts.push({ // embers off the body
|
// Embers off the body, and SMOKE off the top of them. Two short-lived sparks read as a body
|
||||||
x: zb.x + (Math.random() * 2 - 1) * 0.3, y: 0.5 + Math.random() * 1.4, z: zb.z,
|
// sizzling; a burning man throws a column, so this is three embers with more spread and more
|
||||||
vx: (Math.random() * 2 - 1) * 0.5, vy: 1.2 + Math.random(), vz: (Math.random() * 2 - 1) * 0.5,
|
// life, plus a puff every few frames that rises and goes grey. The cap is what keeps a lit crowd
|
||||||
t: 0.25 + Math.random() * 0.2, fire: true });
|
// from flooding the particle array — raised with the count so the last body still gets its fire.
|
||||||
|
if (parts.length < 200) {
|
||||||
|
for (let k = 0; k < 3; k++) parts.push({
|
||||||
|
x: zb.x + (Math.random() * 2 - 1) * 0.42, y: 0.4 + Math.random() * 1.6,
|
||||||
|
z: zb.z + (Math.random() * 2 - 1) * 0.2,
|
||||||
|
vx: (Math.random() * 2 - 1) * 0.8, vy: 1.5 + Math.random() * 1.2, vz: (Math.random() * 2 - 1) * 0.8,
|
||||||
|
t: 0.34 + Math.random() * 0.3, fire: true });
|
||||||
|
if (Math.random() < 0.25) smokePuff(zb.x, 1.5 + Math.random() * 0.7, zb.z, 0.35);
|
||||||
|
}
|
||||||
if (!dead) return false;
|
if (!dead) return false;
|
||||||
killZombie(i, !!G.horde);
|
killZombie(i, !!G.horde);
|
||||||
return true;
|
return true;
|
||||||
@@ -664,6 +687,10 @@ export function firePellet(w = WEAPONS.gun) {
|
|||||||
// and the gun's rapid tick every other round (at 12+/s every round buzzes the mixer)
|
// and the gun's rapid tick every other round (at 12+/s every round buzzes the mixer)
|
||||||
if (w.sfx === 'thump') boom(0.3);
|
if (w.sfx === 'thump') boom(0.3);
|
||||||
else if (w.sfx === 'swoosh') whoosh(0.3);
|
else if (w.sfx === 'swoosh') whoosh(0.3);
|
||||||
|
// the flamer had no case of its own and fell through to the gun's dry tick. Every THIRD round at
|
||||||
|
// rate 30 = 10 overlapping 0.26 s puffs a second, which sums to a continuous roar; firing one per
|
||||||
|
// round just stacks 30 gains on top of each other and clips.
|
||||||
|
else if (w.sfx === 'flame') { if (++mgTick % 3 === 0) flame(0.5); }
|
||||||
else if (++mgTick % 2 === 0) pock(0.25, w.ignite ? 300 : 1400);
|
else if (++mgTick % 2 === 0) pock(0.25, w.ignite ? 300 : 1400);
|
||||||
}
|
}
|
||||||
const HUES = ['#ff6b6b', '#ffd166', '#7bffa0', '#6bd5ff', '#c08bff', '#ff8bd5'];
|
const HUES = ['#ff6b6b', '#ffd166', '#7bffa0', '#6bd5ff', '#c08bff', '#ff8bd5'];
|
||||||
@@ -1063,13 +1090,19 @@ export function updateZombies(dt) {
|
|||||||
}
|
}
|
||||||
// ...and a FINE thread for the beacon: one small mote a frame, tight to the ball, so the ball
|
// ...and a FINE thread for the beacon: one small mote a frame, tight to the ball, so the ball
|
||||||
// itself is the bright thing and the trail only says where it has been.
|
// itself is the bright thing and the trail only says where it has been.
|
||||||
if (pe.trail && parts.length < 240)
|
// ...and a FINE thread for the beacon: one small mote a frame, tight to the ball. An IGNITE
|
||||||
|
// round (the flamer) throws fat embers instead — same loop, but `fire` motes that are 2px and
|
||||||
|
// warm rather than 1px and flat, they LIFT as they burn out, and they live long enough to leave
|
||||||
|
// a body of flame hanging in the air behind the stream instead of a dotted line.
|
||||||
|
if (pe.trail && parts.length < 320)
|
||||||
for (let k = 0; k < pe.trail; k++) parts.push({
|
for (let k = 0; k < pe.trail; k++) parts.push({
|
||||||
x: pe.x + (Math.random() * 2 - 1) * (pe.trailR ?? 0.12),
|
x: pe.x + (Math.random() * 2 - 1) * (pe.trailR ?? 0.12),
|
||||||
y: pe.y + (Math.random() * 2 - 1) * (pe.trailR ?? 0.12),
|
y: pe.y + (Math.random() * 2 - 1) * (pe.trailR ?? 0.12),
|
||||||
z: pe.z + (Math.random() * 2 - 1) * (pe.trailR ?? 0.12),
|
z: pe.z + (Math.random() * 2 - 1) * (pe.trailR ?? 0.12),
|
||||||
vx: 0, vy: 0.12, vz: 0,
|
vx: (Math.random() * 2 - 1) * (pe.ignite ? 0.5 : 0), vz: (Math.random() * 2 - 1) * (pe.ignite ? 0.5 : 0),
|
||||||
t: 0.16 + Math.random() * 0.16, fine: 1, spark: pe.col || '#fff' });
|
vy: pe.ignite ? 0.9 + Math.random() * 0.8 : 0.12,
|
||||||
|
t: pe.ignite ? 0.30 + Math.random() * 0.34 : 0.16 + Math.random() * 0.16,
|
||||||
|
fine: pe.ignite ? 0 : 1, fire: pe.ignite ? 1 : 0, spark: pe.ignite ? 0 : (pe.col || '#fff') });
|
||||||
for (let j = zombies.length - 1; j >= 0; j--) {
|
for (let j = zombies.length - 1; j >= 0; j--) {
|
||||||
const zb = zombies[j];
|
const zb = zombies[j];
|
||||||
if (!zb) continue;
|
if (!zb) continue;
|
||||||
|
|||||||
Reference in new issue
Block a user