Make the parry reachable, and stop a boss dying to one flame ball
Six reports from Oliver's first playtest of the boss build. Two mattered. A boss sprite turned up in the shambler crowd because z_B1.png and z_b1.png are the same file on a case-insensitive filesystem — writing the boss frames silently overwrote the 'b' shambler's first two frames, and `ls` only ever showed the original lowercase name. Restored from HEAD~1, boss frames renamed z_boss*, and the rule is now in the asset pipeline notes. The parry was unreachable, which killed the entire new mechanic: P.swingT is only set in keyPressed's `phase === 'rally'` branch, and splatter sits in pretoss almost all the time, so every tap aimed at an incoming shot tossed the serve ball instead of swinging. An incoming shot inside parryArm now owns the tap in every phase, with its own generous reach and window — this is a phone, one thumb, against something aimed at you. The three fail cases (too early, too late, no tap) are what keep it a timing skill. The swish no longer eats shots: it runs first in keyPressed and was swallowing the very ball the parry was about to return. One flame ball killed a 16 hp boss, and that was a bug rather than tuning. A flame ball is not consumed on contact (full pierce, so it can cut a lane) but a boss is not removed when hit, so the same ball re-wounded it every frame. Boss bodies now take a contact cooldown and flame spends itself on them. Chapter 1 costs 4 flame balls or 16 clean smashes, and bossHp goes 10 to 16. The boss had no wounded state while throwing — correct, the wound skins were standing poses only. New sheet gives all four damage states their own overarm pitch. The ball machine's four painted rects are replaced with a real sheet, idle and firing, keeping only the muzzle glow procedural because the telegraph has to read at 20px tall. And the player's blood no longer sits on him as a painted layer: live drip and the screen-edge wash, nothing else. Tennis untouched: 30 tapped points, 50 hits / 0 whiffs. 45-game soak clean, all four chapters firing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4a066e5cc6
commit
295470b73c
21 files changed
+137
-85
No files matched your search
@@ -1,7 +1,7 @@
|
||||
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
|
||||
|
||||
import { cv, ctx } from './dom.js';
|
||||
import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, PLAYER_H, BOY_H, UMP_H, UMP_X, BOUNCE_IDLE, SPRITE_H } from './config.js';
|
||||
import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOUNCE_IDLE, SPRITE_H } from './config.js';
|
||||
import { P, O, ball, score, crowd, G } from './state.js';
|
||||
import { predictLanding, flickTarget } from './gameplay.js';
|
||||
import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js';
|
||||
@@ -79,7 +79,9 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','
|
||||
'x_boymop1','x_boymop2','x_boymop3','x_boymop4', // the push cycle, used only while he is stopped and working
|
||||
'x_boycarry1','x_boycarry2','x_boycarry3','x_boycarry4','x_howto', // walking with the mop shouldered, and the slingshot tutorial panel
|
||||
'z_gw2','z_gw3','z_gw4','z_gwb','z_gw2b','z_gw3b','z_gw4b', // the brute comes apart in stages (4/5 hp = z_gw .. 1/5 = z_gw4), each with a 2-frame lurch (…b)
|
||||
'z_B1','z_B2','z_Bw1','z_Bw2','z_Bw3', // the CHAPTER BOSS: 2-frame stride + three wound stages (arm off -> ribs out -> headless)
|
||||
'z_boss1','z_boss2','z_bossw1','z_bossw2','z_bossw3', // the CHAPTER BOSS: 2-frame stride + three wound stages (arm off -> ribs out -> headless)
|
||||
'z_bosst0','z_bosst1','z_bosst2','z_bosst3', // ...and each of those four states mid-THROW (windT)
|
||||
'z_mach1','z_mach2', // the ball machine: idle, and firing with a muzzle flash
|
||||
'z_a1','z_a2','z_a3','z_a4','z_b1','z_b2','z_b3','z_b4','z_c1','z_c2',
|
||||
'z_f1','z_f2','z_f3','z_f4','z_g1','z_g2','z_gw','z_s1','z_s2','z_s3',
|
||||
'z_cw1','z_cw2','z_aw','z_bw', // wounded skins for multi-hit enemies (racket boss, brute, and now the classic shamblers)
|
||||
@@ -165,26 +167,23 @@ function drawBoy(b) {
|
||||
ctx.restore();
|
||||
}
|
||||
const ZFRAMES = { a: 4, b: 4, f: 4 }; // 4-frame walk cycles where sheets exist; the rest stay 2
|
||||
// The rogue ball machine: real tennis furniture, so it is drawn as furniture — a squat hopper on
|
||||
// wheels with a barrel pointed down-court. Procedural like drawFlames, and it stays that way unless
|
||||
// a sheet turns up: a box is four rects, and the readable part is the BARREL and the charge glow.
|
||||
// The rogue ball machine, on its own generated sheet: a dented hopper cart on wheels with a barrel
|
||||
// aimed down-court, and a second frame with the muzzle flashing. Oliver called the first, procedural
|
||||
// version "naively painted" — it was four rects, which is fine for fire but not for furniture the
|
||||
// player has to recognise and prioritise.
|
||||
function drawMachine(zb) {
|
||||
const p = proj(zb.x, 0, zb.z), u = p.s * 0.16; // u = one "pixel" at this depth
|
||||
const R = (x, y, w, h, c) => { ctx.fillStyle = c; ctx.fillRect(Math.round(p.x + x * u), Math.round(p.y + y * u), Math.max(1, w * u), Math.max(1, h * u)); };
|
||||
drawShadow(p.x, p.y, u * 4.4, u * 1.3);
|
||||
const dead = zb.hp / TUNE.machHp;
|
||||
R(-4, -7, 8, 7, dead > 0.5 ? '#4a5460' : '#3a3038'); // hopper, scorched as it takes damage
|
||||
R(-4, -7, 8, 1, '#6b7684'); // top light
|
||||
R(-4, -1, 8, 1, '#20262e'); // bottom shadow
|
||||
R(-2.2, -9.6, 4.4, 2.6, '#2b3138'); // ball basket
|
||||
R(-1.6, -9.2, 3.6, 1.8, UI.ball); // ...full of tennis balls
|
||||
R(1.2, -6.2, 4.6, 2.2, '#565f6b'); // the BARREL, pointed at you
|
||||
R(4.4, -6.2, 1.4, 2.2, '#161a1f'); // muzzle
|
||||
R(-3.4, -1.2, 2, 1.6, '#15181c'); R(1.4, -1.2, 2, 1.6, '#15181c'); // wheels
|
||||
if (zb.windT > 0) { // the TELEGRAPH: the muzzle glows hotter the closer the shot is
|
||||
const k = 1 - zb.windT / TUNE.machWind;
|
||||
ctx.globalAlpha = 0.35 + k * 0.65;
|
||||
R(4.2, -6.4, 2 + k * 1.6, 2.6, k > 0.6 ? '#fff2c0' : UI.warm);
|
||||
const firing = zb.windT > 0;
|
||||
const name = firing && SPR.z_mach2 ? 'z_mach2' : 'z_mach1';
|
||||
const img = SPR[name];
|
||||
if (!img) return; // sheet not loaded yet: draw nothing, not a box
|
||||
const h = MACH_H * (img.height / SPRITE_H);
|
||||
drawStatic(name, zb.x, zb.z, h);
|
||||
if (firing) { // the TELEGRAPH still needs to read at 20px tall, so the muzzle glow stays procedural
|
||||
const k = 1 - zb.windT / TUNE.machWind, p = proj(zb.x, h * 0.62, zb.z);
|
||||
ctx.globalAlpha = 0.3 + k * 0.7;
|
||||
ctx.fillStyle = k > 0.6 ? '#fff2c0' : UI.warm;
|
||||
const r = Math.max(1.5, p.s * 0.05 * (0.5 + k));
|
||||
ctx.beginPath(); ctx.arc(Math.round(p.x + p.s * 0.1), Math.round(p.y), r, 0, 7); ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
@@ -231,8 +230,15 @@ function drawZombie(zb) {
|
||||
// rule the brute uses) — it is the slowest thing on court and a time-driven cycle would foot-slide.
|
||||
if (zb.variant === 'B') {
|
||||
const f = zb.hp / (zb.hpMax || 1);
|
||||
const stage = f <= 0.25 ? 'z_Bw3' : f <= 0.5 ? 'z_Bw2' : f <= 0.75 ? 'z_Bw1'
|
||||
: (Math.floor(zb.z / TUNE.bruteStride) % 2 ? 'z_B2' : 'z_B1');
|
||||
// NEVER name a sprite so it differs from another only by case: macOS is case-insensitive, so
|
||||
// writing z_B1.png silently OVERWROTE the 'b' shambler's z_b1.png and a boss showed up in the
|
||||
// shambler crowd. All lowercase from here.
|
||||
const wounded = f <= 0.25 ? 3 : f <= 0.5 ? 2 : f <= 0.75 ? 1 : 0;
|
||||
// mid-throw it rears up — and the wounded state has to stay visible while it does, which is what
|
||||
// the z_bosst<n> set is for. Oliver flagged the throwing animation as the one place damage vanished.
|
||||
const stage = zb.windT > 0 && SPR['z_bosst' + wounded] ? 'z_bosst' + wounded
|
||||
: wounded ? 'z_bossw' + wounded
|
||||
: (Math.floor(zb.z / TUNE.bruteStride) % 2 ? 'z_boss2' : 'z_boss1');
|
||||
if (SPR[stage]) { name = stage; h *= SPR[stage].height / SPRITE_H; } // height ALWAYS off the frame, or the size pops
|
||||
}
|
||||
drawStatic(name, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2);
|
||||
@@ -347,30 +353,11 @@ function drawChar(ch) {
|
||||
ctx.drawImage(img, -w / 2, -h, w, h);
|
||||
ctx.restore();
|
||||
} else drawFallbackChar(ch, p, hpx);
|
||||
if (ch.side === 'P') drawPlayerWounds(p, hpx);
|
||||
// No blood painted on the player. Oliver: "the player blood looks odd, appears as a painted layer
|
||||
// on top" — and he is right, and it also broke the settled rule that blood is never painted into
|
||||
// the man. His health reads as bleedPlayer()'s live drip plus the screen-edge wash on impact.
|
||||
}
|
||||
|
||||
// The player's health, drawn ON HIM and nowhere else — there is no bar and there will not be one
|
||||
// (same rule that removed the boss's). Blood is never painted into the sprite (settled feel
|
||||
// decision), so this is a procedural overlay plus bleedPlayer()'s live trickle: fixed anchors, so it
|
||||
// does not crawl frame to frame, and it grows with every hit. The white kit is the meter.
|
||||
const WOUND_AT = [[-0.18, -0.62], [0.22, -0.55], [-0.05, -0.72], [0.14, -0.42], [-0.26, -0.48], [0.06, -0.33]];
|
||||
function drawPlayerWounds(p, hpx) {
|
||||
if (G.hp >= G.hpMax) return;
|
||||
const lost = G.hpMax - G.hp, n = Math.min(WOUND_AT.length, lost * 2);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const [ox, oy] = WOUND_AT[i];
|
||||
const s = Math.max(1, hpx * (0.055 + (i % 2) * 0.02));
|
||||
ctx.fillStyle = i % 3 ? 'rgba(150,16,16,0.92)' : 'rgba(86,10,10,0.95)';
|
||||
ctx.fillRect(Math.round(p.x + ox * hpx * 0.5), Math.round(p.y + oy * hpx), s, s);
|
||||
}
|
||||
if (G.hurtFlash > 0) { // the impact itself: he goes red for a moment
|
||||
ctx.save(); ctx.globalAlpha = Math.min(1, G.hurtFlash / 0.3) * 0.5;
|
||||
ctx.fillStyle = UI.bad;
|
||||
ctx.fillRect(Math.round(p.x - hpx * 0.2), Math.round(p.y - hpx * 0.82), hpx * 0.4, hpx * 0.6);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
function drawFallbackChar(ch, p, h) { // shown only until sprites load
|
||||
const you = ch.side === 'P';
|
||||
const u = h / 16, x = p.x, y = p.y;
|
||||
|
||||
Reference in new issue
Block a user