diff --git a/assets/player_death_sheet.png b/assets/player_death_sheet.png new file mode 100644 index 0000000..f1afcae Binary files /dev/null and b/assets/player_death_sheet.png differ diff --git a/js/enemies.js b/js/enemies.js index 408f012..911d353 100644 --- a/js/enemies.js +++ b/js/enemies.js @@ -10,6 +10,7 @@ const ENEMY_TYPES = { }; const MAX_ENEMIES = 72; const SPAWN_WARN = 0.5; // seconds a wave-spawned enemy materializes, frozen + harmless +const HP_BAR_SHOW = 2.5; function makeEnemy(type, x, y, floor, elite) { const t = ENEMY_TYPES[type]; @@ -28,7 +29,7 @@ function makeEnemy(type, x, y, floor, elite) { xp: t.xp * (elite ? 3 : 1), sprite: t.sprite, elite: !!elite, - hitT: 0, flash: 0, burnT: 0, burnDps: 0, bleedT: 0, bleedDps: 0, + hitT: 0, flash: 0, hpBarT: 0, burnT: 0, burnDps: 0, bleedT: 0, bleedDps: 0, slowT: 0, stunT: 0, rootT: 0, fireT: rand(1, 2.5), fuse: -1, wob: rand(0, TAU), animSeed: rand(0, TAU), animT: rand(0, 1), attackT: 0, actionT: 0, @@ -110,6 +111,7 @@ function damageEnemy(e, dmg, ang, knockback, opts) { dmg *= e.dmgTakenMul === undefined ? 1 : e.dmgTakenMul; e.hp -= dmg; e.flash = 0.12; + e.hpBarT = HP_BAR_SHOW; spawnText(e.x, e.y, Math.max(1, Math.round(dmg)), mitigated ? '#d0b887' : '#ffb0a0'); if (e.plates > 0) { e.plateDmg = (e.plateDmg || 0) + dmg; @@ -342,6 +344,7 @@ function updateEnemies(dt) { if (e.hp <= 0) { G.enemies.splice(i, 1); continue; } e.spawnT = Math.min((e.spawnT || 0) + dt, 1); e.animT = (e.animT || 0) + dt; + if (e.hpBarT > 0) e.hpBarT = Math.max(0, e.hpBarT - dt); if (e.attackT > 0) { e.attackT -= dt; e.actionT += dt; } // materializing spawns are frozen and harmless until the telegraph ends @@ -356,12 +359,14 @@ function updateEnemies(dt) { if (e.orbT > 0) e.orbT -= dt; if (e.burnT > 0) { e.burnT -= dt; + e.hpBarT = HP_BAR_SHOW; tickEnemyDamage(e, e.burnDps * dt); if (chance(0.2)) addParticle({ type: 'flame', x: e.x + rand(-6, 6), y: e.y + rand(-6, 6), vx: 0, vy: -40, life: 0.3, t: 0, r: 3 }); if (e.hp <= 0) { G.enemies.splice(i, 1); continue; } } if (e.bleedT > 0) { e.bleedT -= dt; + e.hpBarT = HP_BAR_SHOW; tickEnemyDamage(e, e.bleedDps * dt); if (e.hp <= 0) { G.enemies.splice(i, 1); continue; } } @@ -497,13 +502,19 @@ function drawEnemies(ctx) { ctx.beginPath(); ctx.arc(e.x, e.y, e.r + 3, 0, TAU); ctx.stroke(); ctx.globalAlpha = 1; } - // boss & elite hp pip - if (e.boss || e.elite) { - const w = e.boss ? 60 : 26; - ctx.fillStyle = '#400a10'; - ctx.fillRect(e.x - w / 2, e.y - e.r - 10, w, 4); - ctx.fillStyle = '#d92038'; - ctx.fillRect(e.x - w / 2, e.y - e.r - 10, w * clamp(e.hp / e.maxHp, 0, 1), 4); + // Damaged normal monsters show a compact timed pip; elites/bosses stay visible. + if (!e.phased && (e.boss || e.elite || (e.hpBarT > 0 && e.hp < e.maxHp))) { + const w = e.boss ? 60 : (e.elite ? 26 : (e.type === 'mini' ? 14 : 18)); + const h = e.boss || e.elite ? 4 : 3; + const alpha = e.boss || e.elite ? 1 : clamp(e.hpBarT / 0.5, 0, 1); + ctx.save(); ctx.globalAlpha = alpha; + ctx.fillStyle = '#24070b'; + ctx.fillRect(e.x - w / 2 - 1, e.y - e.r - 11, w + 2, h + 2); + ctx.fillStyle = '#5a0d18'; + ctx.fillRect(e.x - w / 2, e.y - e.r - 10, w, h); + ctx.fillStyle = '#e22b46'; + ctx.fillRect(e.x - w / 2, e.y - e.r - 10, w * clamp(e.hp / e.maxHp, 0, 1), h); + ctx.restore(); } } } diff --git a/js/main.js b/js/main.js index 4362f09..6068715 100644 --- a/js/main.js +++ b/js/main.js @@ -142,6 +142,8 @@ function confirmAction(name, action) { function gameOver() { G.mode = 'gameover'; + G.deathLockT = DEATH_LOCK; + spawnPlayerGore(G.player.x, G.player.y); Sfx.playerDeath(); if (G.score > (G.best || 0)) { G.best = G.score; @@ -161,6 +163,7 @@ function loop(now) { function update(dt) { Music.update(dt); + if (G.deathLockT > 0) G.deathLockT = Math.max(0, G.deathLockT - dt); if (G.confirmT > 0) { G.confirmT = Math.max(0, G.confirmT - dt); if (G.confirmT <= 0) G.confirmAction = null; @@ -254,7 +257,7 @@ function update(dt) { } case 'gameover': updateParticles(dt); - if (keyPressed('r', 'enter', ' ') || Input.mpressed) startRun(); + if (G.deathLockT <= 0 && (keyPressed('r', 'enter', ' ') || Input.mpressed)) startRun(); break; } @@ -503,6 +506,9 @@ function drawPause(ctx) { } function drawGameOver(ctx) { + const reveal = clamp((DEATH_LOCK - G.deathLockT) / 0.9, 0, 1); + ctx.save(); + ctx.globalAlpha = reveal; ctx.fillStyle = 'rgba(4,1,3,0.86)'; ctx.fillRect(0, 0, W, H); drawPixelPanel(ctx, W / 2 - 230, 142, 460, 350, { accent: '#e02945', border: '#772638', fill: 'rgba(12,4,7,0.97)', @@ -529,10 +535,12 @@ function drawGameOver(ctx) { drawPixelTag(ctx, 'NEW BEST CUT', W / 2 - 68, 367, { width: 136, height: 26, color: '#f1cb53', accent: '#b38b2e' }); } drawPixelPanel(ctx, W / 2 - 140, 414, 280, 50, { - cut: 5, shadow: false, accent: '#cf2942', border: '#8c293c', fill: '#1a080e', + cut: 5, shadow: false, accent: G.deathLockT > 0 ? '#77552a' : '#cf2942', + border: G.deathLockT > 0 ? '#604727' : '#8c293c', fill: '#1a080e', }); - ctx.fillStyle = '#f1e4d5'; ctx.font = 'bold 12px monospace'; - ctx.fillText('[ R / ENTER ] CUT AGAIN', W / 2, 444); + ctx.fillStyle = G.deathLockT > 0 ? '#d4ad3d' : '#f1e4d5'; ctx.font = 'bold 12px monospace'; + ctx.fillText(G.deathLockT > 0 ? '[ INPUT LOCKED ยท ' + G.deathLockT.toFixed(1) + ' ]' : '[ R / ENTER ] CUT AGAIN', W / 2, 444); + ctx.restore(); } window.addEventListener('load', init); diff --git a/js/particles.js b/js/particles.js index 08e578c..51c88c6 100644 --- a/js/particles.js +++ b/js/particles.js @@ -48,7 +48,7 @@ function onGore(x, y) { return false; } -function spawnGibs(x, y, n, big) { +function spawnGibs(x, y, n, big, important) { for (let i = 0; i < n; i++) { const a = rand(0, TAU), sp = rand(60, big ? 290 : 220); const bone = chance(big ? 0.3 : 0.18); @@ -59,7 +59,7 @@ function spawnGibs(x, y, n, big) { life: rand(0.5, big ? 1.35 : 1.1), t: 0, r, w: r * rand(1.3, 2.6), h: r * rand(0.65, 1.25), bone, shade: i % 3, rot: rand(0, TAU), vr: rand(-10, 10), - }); + }, important); } } @@ -97,11 +97,11 @@ function spawnSmoke(x, y, ang, n, color) { } } -function spawnShockwave(x, y, radius, color, alpha) { +function spawnShockwave(x, y, radius, color, alpha, important) { addParticle({ type: 'shockwave', x, y, vx: 0, vy: 0, life: 0.42, t: 0, r: 5, maxR: radius, color: color || '#ffb06a', alpha: alpha === undefined ? 0.8 : alpha, - }); + }, important); } function spawnMuzzleFx(x, y, ang, behavior) { @@ -144,6 +144,19 @@ function spawnExplosionFx(x, y, r) { addShake(6); } +function spawnPlayerGore(x, y) { + spawnExplosionFx(x, y, 110); + spawnShockwave(x, y, 138, '#ff2947', 0.95, true); + for (let i = 0; i < 4; i++) spawnBlood(x, y, i * TAU / 4, 10, true); + spawnGibs(x, y, 26, true, true); + for (let i = 0; i < 18; i++) { + const a = rand(0, TAU), sp = rand(90, 350); + addParticle({ type: 'flame', x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, + life: rand(0.22, 0.68), t: 0, r: rand(4, 10) }, true); + } + addShake(16); +} + function spawnCorpse(e) { G.corpses.push({ sprite: e.sprite, x: e.x, y: e.y, diff --git a/js/player.js b/js/player.js index 6dd72a6..6d51b31 100644 --- a/js/player.js +++ b/js/player.js @@ -411,8 +411,7 @@ function drawPlayer(ctx) { const kick = p.recoil * 3; Sprites.shadow(ctx, p.x, p.y + 17, 27 + p.moveBlend * 2, 9, 0.46); if (p.hp <= 0) { - // Preserve the authored full-body fall while the live character stays layered. - Sprites.actor(ctx, 'player', p.x, p.y, p.bodyFacing, 'death', p.deathT || 0, 96); + Sprites.strip(ctx, 'player_death_sheet', p.x, p.y, p.deathT || 0, 12, 14, 128, 144, true); } else { Sprites.legs(ctx, p.x, p.y + 4, p.bodyFacing, p.step, 124); const torsoOffset = (w.torsoFwd || 18) - kick; diff --git a/js/sprites.js b/js/sprites.js index 3e8e1fb..16891b5 100644 --- a/js/sprites.js +++ b/js/sprites.js @@ -48,7 +48,7 @@ const SPRITE_MANIFEST = [ 'a_bonenova', 'a_offalbomb', 'a_bloodtransfusion', 'a_cleaverstorm', 'a_butchersbell', 'a_marrowdraught', 'a_slaughtertime', 'a_panicroom', 'a_skinnerscoin', 'a_gutreroll', // full 64px/128px eight-direction action atlases - 'player_sheet', 'player_legs_sheet', + 'player_sheet', 'player_legs_sheet', 'player_death_sheet', 'enemy_shambler_sheet', 'enemy_runner_sheet', 'enemy_spitter_sheet', 'enemy_splitter_sheet', 'enemy_mini_sheet', 'enemy_exploder_sheet', 'boss_bonesaw_sheet', 'boss_gorecrown_sheet', 'boss_knifecrawl_sheet', @@ -111,6 +111,20 @@ const Sprites = { ctx.restore(); }, + // Non-directional one-row effects such as the player's authored gore burst. + strip(ctx, name, x, y, time, frames, fps, frameSize, targetW, hold) { + const sheet = this.imgs[name]; + if (!sheet) { + this.actor(ctx, 'player', x, y, 0, 'death', time, targetW || frameSize); + return; + } + const raw = Math.floor(Math.max(0, time) * fps); + const frame = hold ? Math.min(frames - 1, raw) : raw % frames; + const tw = targetW || frameSize; + ctx.drawImage(sheet, frame * frameSize, 0, frameSize, frameSize, + x - tw / 2, y - tw / 2, tw, tw); + }, + actor(ctx, name, x, y, facing, action, time, targetW, alpha, scaleX, scaleY) { const sheet = this.imgs[name + '_sheet']; const def = ACTOR_ANIMS[action] || ACTOR_ANIMS.idle; diff --git a/js/state.js b/js/state.js index ab54ab1..8270498 100644 --- a/js/state.js +++ b/js/state.js @@ -33,6 +33,7 @@ const G = { recentHits: [], confirmAction: null, confirmT: 0, + deathLockT: 0, transition: 0, // room transition fade muted: false, pauseHelp: false, @@ -53,6 +54,7 @@ const PRESSURE_MIN = 0.75, PRESSURE_MAX = 1.60; const PRESSURE_UNIT = 0.01; // one "point" on the dial scale const PRESSURE_GAIN = PRESSURE_UNIT; // retained: dial 0 == 1 point const PRESSURE_DROP_BASE = 3; // dial 0 == 0.03 relief base +const DEATH_LOCK = 3; const PRESSURE_DIAL_MIN = -5, PRESSURE_DIAL_MAX = 5; // -5 -> 0 units, 0 -> 1 unit, +5 -> 5 units (piecewise linear) diff --git a/test/smoke.js b/test/smoke.js index 20f60f2..0dfc30e 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -953,6 +953,19 @@ console.log('== bigger gore-red minis =='); check('mini sprite grew', ctx.ENEMY_TYPES.mini.drawSize === 48); } +console.log('== damaged monster health pips =='); +{ + const target = ctx.makeEnemy('shambler', 160, 160, 1, false); + target.warmT = 0; + check('undamaged normal monster starts without a timed health pip', target.hpBarT === 0); + ctx.damageEnemy(target, 1, 0, false, { noProc: true, noCrit: true }); + check('damaging a normal monster reveals its health pip', target.hpBarT === 2.5 && target.hp < target.maxHp); + ctx.G.enemies = [target]; + ctx.updateEnemies(2.6); + check('normal monster health pip expires after the tracking window', target.hpBarT === 0); + ctx.G.enemies = []; +} + console.log('== 500ms wave spawn telegraph =='); { ctx.startRun(); step(3, 16); @@ -1426,10 +1439,15 @@ console.log('== performance and duplicate-hit regressions =='); console.log('== death and restart =='); ctx.G.player.invT = 0; ctx.hurtPlayer(9999, 0); +check('death starts a three-second input lock', ctx.G.deathLockT === 3); +check('player death creates a layered gore burst', ctx.G.parts.some(p => p.type === 'gib') && ctx.G.parts.some(p => p.type === 'shockwave')); step(5, 16); check('game over', ctx.G.mode === 'gameover'); check('death animation advances once per game-over frame', Math.abs(ctx.G.player.deathT - 0.08) < 0.001); tap('r'); +check('restart input is ignored during death lock', ctx.G.mode === 'gameover'); +step(190, 16); +tap('r'); step(5, 16); check('restart works', ctx.G.mode === 'play' && ctx.G.floor === 1); diff --git a/tools/draw_sprites.py b/tools/draw_sprites.py index 2e4d497..c807539 100644 --- a/tools/draw_sprites.py +++ b/tools/draw_sprites.py @@ -1456,6 +1456,71 @@ def render_legs_sheet(): return sheet +def render_player_death_sheet(): + """Derive a gore burst from the shipped AI player still without redrawing it.""" + source_path = OUT / "player.png" + if not source_path.exists(): + raise FileNotFoundError("assets/player.png is required for the derived death sheet") + source = _fit_actor(Image.open(source_path).convert("RGBA"), "player", 128) + frame_size, frame_count = 128, 12 + sheet = Image.new("RGBA", (frame_size * frame_count, frame_size), (0, 0, 0, 0)) + rng = random.Random(0x5A17CE) + fragments = [] + cols = rows = 4 + for gy in range(rows): + y0 = round(gy * source.height / rows) + y1 = round((gy + 1) * source.height / rows) + for gx in range(cols): + x0 = round(gx * source.width / cols) + x1 = round((gx + 1) * source.width / cols) + piece = source.crop((x0, y0, x1, y1)) + if not piece.getbbox(): + continue + relx = (x0 + x1) / 2 - source.width / 2 + rely = (y0 + y1) / 2 - source.height / 2 + a = math.atan2(rely, relx) + rng.uniform(-0.7, 0.7) + fragments.append((piece, relx, rely, a, rng.uniform(30, 68), rng.uniform(-105, 105))) + + for index in range(frame_count): + p = index / max(1, frame_count - 1) + burst = p * p * (3 - 2 * p) + frame = Image.new("RGBA", (frame_size, frame_size), (0, 0, 0, 0)) + d = ImageDraw.Draw(frame) + if index in (1, 2, 3): + flash = (1 - abs(index - 2) / 2) * 0.9 + rr = 8 + index * 9 + d.ellipse([64 - rr, 64 - rr, 64 + rr, 64 + rr], fill=(255, 223, 164, round(255 * flash))) + for dot in range(20): + da = (dot * 2.399963 + 0.37) % math.tau + dr = burst * (18 + (dot * 17) % 47) + rr = max(1, round((3 + dot % 4) * (1 - p * 0.55))) + dx = round(64 + math.cos(da) * dr) + dy = round(64 + math.sin(da) * dr) + alpha = round(230 * max(0, 1 - p * 0.75)) + color = (174, 15 + (dot % 3) * 8, 38, alpha) + d.ellipse([dx - rr, dy - rr, dx + rr, dy + rr], fill=color) + + origin_x = (frame_size - source.width) / 2 + origin_y = (frame_size - source.height) / 2 + for piece, relx, rely, ang, speed, spin in fragments: + work = piece.copy() + fade = 1 if p < 0.62 else max(0, 1 - (p - 0.62) / 0.38) + if fade < 1: + work.putalpha(work.getchannel("A").point(lambda alpha: round(alpha * fade))) + rotation = spin * burst + if rotation: + work = work.rotate(rotation, Image.Resampling.NEAREST, expand=True) + cx = origin_x + source.width / 2 + relx + cy = origin_y + source.height / 2 + rely + travel = speed * burst + cx += math.cos(ang) * travel + relx * burst * 0.45 + cy += math.sin(ang) * travel + rely * burst * 0.45 + frame.alpha_composite(work, (round(cx - work.width / 2), round(cy - work.height / 2))) + sheet.alpha_composite(frame, (index * frame_size, 0)) + sheet.save(OUT / "player_death_sheet.png", optimize=True) + return sheet + + def main(): filters = [a for a in sys.argv[1:] if not a.startswith("--")] OUT.mkdir(exist_ok=True) @@ -1468,9 +1533,12 @@ def main(): for name in actor_names: sheet = render_actor_sheet(name) print(f"drew assets/{name}_sheet.png ({sheet.width}x{sheet.height})") - if not filters or any("player_legs" in f or "player" in f for f in filters): + if not filters or any("player_legs" in f or f == "player" for f in filters): sheet = render_legs_sheet() print(f"drew assets/player_legs_sheet.png ({sheet.width}x{sheet.height})") + if any("player_death" in f for f in filters): + sheet = render_player_death_sheet() + print(f"drew assets/player_death_sheet.png ({sheet.width}x{sheet.height})") print(f"\n{len(names)} sprites and {len(actor_names)} animation sheets rendered.")